diff --git a/Cargo.lock b/Cargo.lock index 87bcfeb7..6a9b9849 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -938,6 +938,14 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "milestone-arbitration" +version = "0.0.1" +dependencies = [ + "learnvault-shared", + "soroban-sdk", +] + [[package]] name = "milestone-escrow" version = "0.0.1" diff --git a/contracts/milestone_arbitration/Cargo.toml b/contracts/milestone_arbitration/Cargo.toml new file mode 100644 index 00000000..e6d98302 --- /dev/null +++ b/contracts/milestone_arbitration/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "milestone-arbitration" +description = "Staked-juror commit-reveal arbitration for disputed milestone rejections" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +learnvault-shared = { workspace = true } +soroban-sdk = { workspace = true } + +[dev-dependencies] +learnvault-shared = { workspace = true, features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/milestone_arbitration/src/lib.rs b/contracts/milestone_arbitration/src/lib.rs new file mode 100644 index 00000000..e735dfef --- /dev/null +++ b/contracts/milestone_arbitration/src/lib.rs @@ -0,0 +1,1122 @@ +#![no_std] + +//! # MilestoneArbitration +//! +//! On-chain, staked-juror commit-reveal arbitration for disputed milestone +//! rejections. A rejected scholar escalates to a panel of LRN-staking jurors +//! instead of a single off-chain admin; the panel votes blind (commit, then +//! reveal), the majority outcome is the source of truth [`milestone_escrow`] +//! can rely on for [`milestone_escrow::MilestoneEscrow::release_tranche`]-style +//! disbursement, and jurors who vote against the outcome — or never reveal at +//! all — are slashed. +//! +//! ## Lifecycle +//! +//! 1. [`MilestoneArbitration::join_panel`] — a would-be juror stakes LRN and +//! joins the eligible pool. Anyone in the pool can be drawn onto a panel. +//! 2. [`MilestoneArbitration::open_dispute`] — the scholar stakes +//! [`SCHOLAR_DISPUTE_STAKE`] and opens a dispute. A panel of +//! [`PANEL_SIZE`] jurors is drawn immediately, weighted by stake and seeded +//! from ledger data the caller does not control. This starts the commit +//! window. +//! 3. [`MilestoneArbitration::commit_vote`] — each panel member submits +//! `sha256(dispute_id ++ vote_byte ++ salt)` before `commit_deadline`. +//! 4. [`MilestoneArbitration::reveal_vote`] — after `commit_deadline` and +//! before `reveal_deadline`, each committed juror reveals `(vote, salt)`; +//! the contract recomputes the hash and rejects (does not silently drop) a +//! mismatched reveal. +//! 5. [`MilestoneArbitration::resolve`] — callable by anyone once every panel +//! member has revealed, or once `reveal_deadline` has passed. Tallies the +//! revealed votes, slashes the minority and every non-revealer, and +//! redistributes their stake to the majority and the winning party. +//! +//! ## Policy parameters +//! +//! [`PANEL_SIZE`] = 5, [`QUORUM`] = 3 (strict majority of the panel). Ties +//! among revealed votes favor the status quo (rejection upheld, nothing +//! moves) rather than releasing funds on an inconclusive result. +//! +//! [`SCHOLAR_DISPUTE_STAKE`] and [`MIN_JUROR_STAKE`] are both denominated in +//! LRN's 7-decimal base units, matching every other LRN-denominated contract +//! in this workspace (`learn_token`, `governance_token`, `lrn_staking`). +//! +//! ### Quorum-failure fallback +//! +//! If fewer than [`QUORUM`] jurors reveal by `reveal_deadline`, the dispute +//! resolves with `outcome = None`: the rejection stands (no escrow release is +//! ever authorized for a `None` outcome — see +//! [`MilestoneArbitration::get_release_outcome`]), the scholar's stake is +//! refunded in full (a quorum failure is the panel's fault, not theirs), +//! jurors who *did* reveal keep their stake untouched, and every juror who +//! never revealed is slashed [`NON_PARTICIPATION_SLASH_BPS`] to the treasury. +//! A dispute can never hang forever: `resolve` is callable by anyone the +//! moment `reveal_deadline` passes, regardless of participation. +//! +//! ### Why a whale can't dominate a panel +//! +//! Selection weight per juror is capped at [`MAX_JUROR_SELECTION_WEIGHT`] +//! LRN, so staking beyond that buys no further odds. Selection is also +//! without replacement — a single address can only occupy one of the +//! [`PANEL_SIZE`] seats on a given dispute, so buying more stake raises the +//! *chance* of being drawn but never the number of seats a whale can hold on +//! one panel. Combined with a five-member panel and a three-vote quorum, a +//! whale would need to simultaneously win multiple independent weighted draws +//! to control an outcome, and each draw is reseeded per dispute from ledger +//! data unavailable to the caller ahead of the transaction (see +//! [`MilestoneArbitration::open_dispute`]'s use of the ledger sequence and +//! timestamp at panel-selection time — not a caller-supplied value). +//! +//! ### Why a scholar can't dispute indefinitely +//! +//! Each rejected milestone can be disputed exactly once +//! ([`DataKey::DisputeByMilestone`] is a one-shot key), disputing costs a real +//! stake that is forfeited outright on a losing outcome, and +//! [`DISPUTE_WINDOW_SECONDS`] bounds how long after rejection a dispute can +//! even be opened. Repeated frivolous disputes across different milestones +//! are self-limiting: every loss is a real, non-recoverable cost. +//! +//! ### A known, documented trust gap +//! +//! The underlying milestone-rejection decision itself is recorded off-chain +//! today (`server/src/controllers/milestone-appeal.controller.ts`); this +//! contract has no on-chain rejection record to check `open_dispute` against. +//! `rejected_at` is therefore a caller-supplied timestamp, bounded only by +//! "not in the future" and "within the dispute window" — it rate-limits +//! staleness, it does not prove a rejection actually happened. Closing this +//! gap fully requires the admin rejection path to publish an on-chain event +//! this contract can verify against, which is out of scope for this change +//! (see the PR description for the full discussion). +//! +//! ## Commit-reveal salting +//! +//! The commitment preimage is `dispute_id (8 bytes, BE) ++ vote_byte (0/1) ++ +//! salt (32 bytes)`. It deliberately does not embed the juror's address: +//! storage already partitions each commitment by `(dispute_id, juror)`, so a +//! commitment can never be checked against the wrong juror's reveal. The +//! juror is responsible for generating a fresh random salt per dispute; reuse +//! across disputes risks correlation once the first is revealed, but cannot +//! forge or redirect another juror's vote. +//! +//! ## Evidence +//! +//! `evidence_hash` is a 32-byte hash, not the evidence itself — evidence is +//! pinned to IPFS off-chain (`server/src/services/pinata.service.ts`) and +//! only its hash is ever written on-chain. + +use soroban_sdk::{ + Address, Bytes, BytesN, Env, String, Symbol, Vec, contract, contracterror, contractevent, + contractimpl, contracttype, panic_with_error, symbol_short, token, +}; + +use learnvault_shared::upgrade; + +pub use upgrade::ContractUpgraded; + +// --------------------------------------------------------------------------- +// Storage TTL constants (ledger-count convention shared with lrn_staking) +// --------------------------------------------------------------------------- + +const DAY_IN_LEDGERS: u32 = 17_280; +const INSTANCE_BUMP_THRESHOLD: u32 = DAY_IN_LEDGERS; +const INSTANCE_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30; +const PERSISTENT_BUMP_THRESHOLD: u32 = DAY_IN_LEDGERS; +const PERSISTENT_EXTEND_TO: u32 = DAY_IN_LEDGERS * 365; + +// --------------------------------------------------------------------------- +// Policy parameters — see the module docs above for the rationale behind +// each of these. All amounts are in LRN base units (7 decimals). +// --------------------------------------------------------------------------- + +/// Jurors drawn per dispute. Odd, so a plain majority never ties outright. +pub const PANEL_SIZE: u32 = 5; + +/// Minimum revealed votes for a ruling to bind the escrow. Strict majority of +/// [`PANEL_SIZE`]. +pub const QUORUM: u32 = 3; + +// The digit grouping below is deliberate: the underscore separates the whole +// LRN amount from its 7-decimal suffix, e.g. `1_000` LRN `_0000000` base +// units, rather than grouping by thousands throughout. +/// Minimum stake to join the eligible-juror pool: 1,000 LRN. +#[allow(clippy::inconsistent_digit_grouping)] +pub const MIN_JUROR_STAKE: i128 = 1_000_0000000; + +/// Cap on a single juror's selection weight: 10,000 LRN. Staking beyond this +/// buys no further odds of being drawn onto a panel. +#[allow(clippy::inconsistent_digit_grouping)] +pub const MAX_JUROR_SELECTION_WEIGHT: i128 = 10_000_0000000; + +/// Stake a scholar must post to open a dispute: 500 LRN. Forfeited outright +/// if the panel upholds the rejection. +#[allow(clippy::inconsistent_digit_grouping)] +pub const SCHOLAR_DISPUTE_STAKE: i128 = 500_0000000; + +/// A dispute must be opened within this many seconds of `rejected_at`. +pub const DISPUTE_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60; + +/// Seconds panel members have to commit a vote after a dispute opens. +pub const COMMIT_WINDOW_SECONDS: u64 = 3 * 24 * 60 * 60; + +/// Seconds panel members have to reveal after the commit window closes. +pub const REVEAL_WINDOW_SECONDS: u64 = 2 * 24 * 60 * 60; + +/// Portion of a minority (revealed-but-outvoted) juror's stake slashed, in +/// basis points. +pub const MINORITY_SLASH_BPS: u32 = 5_000; + +/// Portion of a non-participating (never revealed) juror's stake slashed. +/// Silence is treated as the worse offense: it is what allows a quorum +/// failure and holds the escrow hostage, so it costs the whole stake. +pub const NON_PARTICIPATION_SLASH_BPS: u32 = 10_000; + +/// Of the slashed pool, the share redistributed evenly across the majority +/// jurors; the remainder goes to the winning party (the scholar on release, +/// the treasury on an upheld rejection). +pub const MAJORITY_REWARD_SHARE_BPS: u32 = 7_000; + +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// Upper bound on the eligible-juror pool, so panel selection (which walks +/// the whole pool once per dispute) stays within a predictable CPU budget. +pub const MAX_POOL_SIZE: u32 = 500; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ArbitrationError { + AlreadyInitialized = 1, + NotInitialized = 2, + AlreadyJuror = 3, + NotJuror = 4, + JurorHasActiveDisputes = 5, + InvalidStake = 6, + PoolFull = 7, + InsufficientPool = 8, + DisputeExists = 9, + DisputeNotFound = 10, + DisputeWindowExpired = 11, + RejectedAtInFuture = 12, + WrongPhase = 13, + NotPanelMember = 14, + AlreadyCommitted = 15, + AlreadyRevealed = 16, + NoCommitment = 17, + CommitWindowClosed = 18, + RevealWindowNotOpen = 19, + RevealWindowClosed = 20, + CommitmentMismatch = 21, + NotYetResolvable = 22, + ArithmeticOverflow = 23, +} + +// --------------------------------------------------------------------------- +// Storage +// --------------------------------------------------------------------------- + +const CONFIG_KEY: Symbol = symbol_short!("CONFIG"); +const NEXT_ID_KEY: Symbol = symbol_short!("NEXTID"); +const JUROR_POOL_KEY: Symbol = symbol_short!("JPOOL"); + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Config { + pub admin: Address, + pub lrn_token: Address, + pub treasury: Address, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Dispute(u64), + DisputeByMilestone(u32, u32), + JurorStake(Address), + JurorActiveCount(Address), + Vote(u64, Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputePhase { + /// Commit and/or reveal is in progress; which one is derived from + /// `commit_deadline`/`reveal_deadline`, not stored separately. + Active, + /// Quorum was met; `outcome` is authoritative. + Resolved, + /// Quorum was not met by `reveal_deadline`; `outcome` is always `None`. + QuorumFailed, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Dispute { + pub id: u64, + pub scholar: Address, + pub proposal_id: u32, + pub milestone_id: u32, + pub evidence_hash: BytesN<32>, + pub scholar_stake: i128, + pub opened_at: u64, + pub panel: Vec
, + pub commit_deadline: u64, + pub reveal_deadline: u64, + pub phase: DisputePhase, + pub votes_for: u32, + pub votes_against: u32, + pub revealed_count: u32, + /// `Some(true)` releases the tranche, `Some(false)` upholds the + /// rejection, `None` means quorum failed (nothing is authorized). + pub outcome: Option, + pub resolved_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoteRecord { + pub commitment: BytesN<32>, + pub revealed: bool, + /// Meaningful only once `revealed` is `true`. + pub vote: bool, +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +#[contractevent(topics = ["juror_joined"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JurorJoined { + #[topic] + pub juror: Address, + pub stake: i128, +} + +#[contractevent(topics = ["juror_left"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JurorLeft { + #[topic] + pub juror: Address, + pub stake_returned: i128, +} + +#[contractevent(topics = ["dispute_opened"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeOpened { + #[topic] + pub dispute_id: u64, + #[topic] + pub scholar: Address, + pub proposal_id: u32, + pub milestone_id: u32, + pub evidence_hash: BytesN<32>, + pub panel: Vec
, + pub commit_deadline: u64, + pub reveal_deadline: u64, +} + +#[contractevent(topics = ["vote_committed"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoteCommitted { + #[topic] + pub dispute_id: u64, + #[topic] + pub juror: Address, +} + +#[contractevent(topics = ["vote_revealed"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoteRevealed { + #[topic] + pub dispute_id: u64, + #[topic] + pub juror: Address, + pub vote: bool, +} + +#[contractevent(topics = ["dispute_resolved"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeResolved { + #[topic] + pub dispute_id: u64, + #[topic] + pub proposal_id: u32, + pub milestone_id: u32, + pub outcome: Option, + pub votes_for: u32, + pub votes_against: u32, + pub revealed_count: u32, + pub quorum_met: bool, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct MilestoneArbitration; + +#[contractimpl] +impl MilestoneArbitration { + pub fn initialize(env: Env, admin: Address, lrn_token: Address, treasury: Address) { + if env.storage().instance().has(&CONFIG_KEY) { + panic_with_error!(&env, ArbitrationError::AlreadyInitialized); + } + admin.require_auth(); + + let config = Config { + admin, + lrn_token, + treasury, + }; + env.storage().instance().set(&CONFIG_KEY, &config); + env.storage().instance().set(&NEXT_ID_KEY, &0u64); + upgrade::init(&env); + Self::extend_instance(&env); + } + + // ----------------------------------------------------------------------- + // Juror pool + // ----------------------------------------------------------------------- + + /// Stake `stake_amount` LRN (>= [`MIN_JUROR_STAKE`]) to join the eligible + /// juror pool. A given address may only be in the pool once at a time. + pub fn join_panel(env: Env, juror: Address, stake_amount: i128) { + juror.require_auth(); + Self::extend_instance(&env); + let config = Self::config(&env); + + if stake_amount < MIN_JUROR_STAKE { + panic_with_error!(&env, ArbitrationError::InvalidStake); + } + + let stake_key = DataKey::JurorStake(juror.clone()); + if env.storage().persistent().has(&stake_key) { + panic_with_error!(&env, ArbitrationError::AlreadyJuror); + } + + let mut pool = Self::juror_pool(&env); + if pool.len() >= MAX_POOL_SIZE { + panic_with_error!(&env, ArbitrationError::PoolFull); + } + + Self::token(&env, &config).transfer(&juror, env.current_contract_address(), &stake_amount); + + env.storage().persistent().set(&stake_key, &stake_amount); + Self::extend_persistent(&env, &stake_key); + + pool.push_back(juror.clone()); + Self::put_juror_pool(&env, &pool); + + let active_key = DataKey::JurorActiveCount(juror.clone()); + env.storage().persistent().set(&active_key, &0u32); + Self::extend_persistent(&env, &active_key); + + JurorJoined { + juror, + stake: stake_amount, + } + .publish(&env); + } + + /// Leave the eligible pool and withdraw the full stake. Only possible + /// with no active panel assignments, so a juror can never walk away from + /// a dispute mid-vote to dodge a slash. + pub fn leave_panel(env: Env, juror: Address) { + juror.require_auth(); + Self::extend_instance(&env); + let config = Self::config(&env); + + let stake_key = DataKey::JurorStake(juror.clone()); + let stake: i128 = env + .storage() + .persistent() + .get(&stake_key) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::NotJuror)); + + let active_key = DataKey::JurorActiveCount(juror.clone()); + let active: u32 = env.storage().persistent().get(&active_key).unwrap_or(0); + if active > 0 { + panic_with_error!(&env, ArbitrationError::JurorHasActiveDisputes); + } + + env.storage().persistent().remove(&stake_key); + env.storage().persistent().remove(&active_key); + + let mut pool = Self::juror_pool(&env); + if let Some(index) = pool.first_index_of(juror.clone()) { + pool.remove(index); + } + Self::put_juror_pool(&env, &pool); + + if stake > 0 { + Self::token(&env, &config).transfer(&env.current_contract_address(), &juror, &stake); + } + + JurorLeft { + juror, + stake_returned: stake, + } + .publish(&env); + } + + // ----------------------------------------------------------------------- + // Dispute lifecycle + // ----------------------------------------------------------------------- + + /// Open a dispute over a rejected milestone. Callable only by the scholar + /// themself (`scholar.require_auth()`), within [`DISPUTE_WINDOW_SECONDS`] + /// of the caller-supplied `rejected_at`. Draws a panel of [`PANEL_SIZE`] + /// jurors immediately and returns the new dispute id. + pub fn open_dispute( + env: Env, + scholar: Address, + proposal_id: u32, + milestone_id: u32, + evidence_hash: BytesN<32>, + rejected_at: u64, + ) -> u64 { + scholar.require_auth(); + Self::extend_instance(&env); + let config = Self::config(&env); + + let now = env.ledger().timestamp(); + if rejected_at > now { + panic_with_error!(&env, ArbitrationError::RejectedAtInFuture); + } + if now.saturating_sub(rejected_at) > DISPUTE_WINDOW_SECONDS { + panic_with_error!(&env, ArbitrationError::DisputeWindowExpired); + } + + let milestone_key = DataKey::DisputeByMilestone(proposal_id, milestone_id); + if env.storage().persistent().has(&milestone_key) { + panic_with_error!(&env, ArbitrationError::DisputeExists); + } + + let dispute_id: u64 = env.storage().instance().get(&NEXT_ID_KEY).unwrap_or(0); + let next_id = dispute_id + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::ArithmeticOverflow)); + env.storage().instance().set(&NEXT_ID_KEY, &next_id); + + let pool = Self::juror_pool(&env); + let panel = Self::select_panel(&env, &pool, dispute_id); + + Self::token(&env, &config).transfer( + &scholar, + env.current_contract_address(), + &SCHOLAR_DISPUTE_STAKE, + ); + + for member in panel.iter() { + let key = DataKey::JurorActiveCount(member.clone()); + let count: u32 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(count + 1)); + Self::extend_persistent(&env, &key); + } + + let commit_deadline = now + .checked_add(COMMIT_WINDOW_SECONDS) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::ArithmeticOverflow)); + let reveal_deadline = commit_deadline + .checked_add(REVEAL_WINDOW_SECONDS) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::ArithmeticOverflow)); + + let dispute = Dispute { + id: dispute_id, + scholar: scholar.clone(), + proposal_id, + milestone_id, + evidence_hash: evidence_hash.clone(), + scholar_stake: SCHOLAR_DISPUTE_STAKE, + opened_at: now, + panel: panel.clone(), + commit_deadline, + reveal_deadline, + phase: DisputePhase::Active, + votes_for: 0, + votes_against: 0, + revealed_count: 0, + outcome: None, + resolved_at: 0, + }; + + let dispute_key = DataKey::Dispute(dispute_id); + env.storage().persistent().set(&dispute_key, &dispute); + Self::extend_persistent(&env, &dispute_key); + env.storage().persistent().set(&milestone_key, &dispute_id); + Self::extend_persistent(&env, &milestone_key); + + DisputeOpened { + dispute_id, + scholar, + proposal_id, + milestone_id, + evidence_hash, + panel, + commit_deadline, + reveal_deadline, + } + .publish(&env); + + dispute_id + } + + /// Submit `sha256(dispute_id ++ vote_byte ++ salt)`. Must come from a + /// selected panel member, before `commit_deadline`, exactly once. + pub fn commit_vote(env: Env, dispute_id: u64, juror: Address, commitment: BytesN<32>) { + juror.require_auth(); + Self::extend_instance(&env); + + let dispute_key = DataKey::Dispute(dispute_id); + let dispute = Self::get_dispute_or_panic(&env, &dispute_key); + + if dispute.phase != DisputePhase::Active { + panic_with_error!(&env, ArbitrationError::WrongPhase); + } + let now = env.ledger().timestamp(); + if now > dispute.commit_deadline { + panic_with_error!(&env, ArbitrationError::CommitWindowClosed); + } + if dispute.panel.first_index_of(juror.clone()).is_none() { + panic_with_error!(&env, ArbitrationError::NotPanelMember); + } + + let vote_key = DataKey::Vote(dispute_id, juror.clone()); + if env.storage().persistent().has(&vote_key) { + panic_with_error!(&env, ArbitrationError::AlreadyCommitted); + } + + let record = VoteRecord { + commitment, + revealed: false, + vote: false, + }; + env.storage().persistent().set(&vote_key, &record); + Self::extend_persistent(&env, &vote_key); + + VoteCommitted { dispute_id, juror }.publish(&env); + } + + /// Reveal `(vote, salt)` for a prior commitment. Must fall strictly after + /// `commit_deadline` and at or before `reveal_deadline`. A reveal that + /// does not match the stored commitment is rejected outright, never + /// silently ignored. + pub fn reveal_vote(env: Env, dispute_id: u64, juror: Address, vote: bool, salt: BytesN<32>) { + juror.require_auth(); + Self::extend_instance(&env); + + let dispute_key = DataKey::Dispute(dispute_id); + let mut dispute = Self::get_dispute_or_panic(&env, &dispute_key); + + if dispute.phase != DisputePhase::Active { + panic_with_error!(&env, ArbitrationError::WrongPhase); + } + let now = env.ledger().timestamp(); + if now <= dispute.commit_deadline { + panic_with_error!(&env, ArbitrationError::RevealWindowNotOpen); + } + if now > dispute.reveal_deadline { + panic_with_error!(&env, ArbitrationError::RevealWindowClosed); + } + + let vote_key = DataKey::Vote(dispute_id, juror.clone()); + let mut record: VoteRecord = env + .storage() + .persistent() + .get(&vote_key) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::NoCommitment)); + if record.revealed { + panic_with_error!(&env, ArbitrationError::AlreadyRevealed); + } + + let expected = Self::compute_commitment(&env, dispute_id, vote, &salt); + if expected != record.commitment { + panic_with_error!(&env, ArbitrationError::CommitmentMismatch); + } + + record.revealed = true; + record.vote = vote; + env.storage().persistent().set(&vote_key, &record); + Self::extend_persistent(&env, &vote_key); + + if vote { + dispute.votes_for = dispute + .votes_for + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::ArithmeticOverflow)); + } else { + dispute.votes_against = dispute + .votes_against + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::ArithmeticOverflow)); + } + dispute.revealed_count = dispute + .revealed_count + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, ArbitrationError::ArithmeticOverflow)); + env.storage().persistent().set(&dispute_key, &dispute); + Self::extend_persistent(&env, &dispute_key); + + VoteRevealed { + dispute_id, + juror, + vote, + } + .publish(&env); + } + + /// Finalize a dispute. Callable by anyone once every panel member has + /// revealed, or once `reveal_deadline` has passed — a dispute can never + /// hang forever. See the module docs for the full quorum/slashing model. + pub fn resolve(env: Env, dispute_id: u64) { + Self::extend_instance(&env); + let config = Self::config(&env); + let dispute_key = DataKey::Dispute(dispute_id); + let mut dispute = Self::get_dispute_or_panic(&env, &dispute_key); + + if dispute.phase != DisputePhase::Active { + panic_with_error!(&env, ArbitrationError::WrongPhase); + } + let now = env.ledger().timestamp(); + let panel_size = dispute.panel.len(); + let all_revealed = dispute.revealed_count >= panel_size; + if !all_revealed && now <= dispute.reveal_deadline { + panic_with_error!(&env, ArbitrationError::NotYetResolvable); + } + + let token = Self::token(&env, &config); + let quorum_met = dispute.revealed_count >= QUORUM; + + if quorum_met { + Self::resolve_with_quorum(&env, &token, &config, &mut dispute); + } else { + Self::resolve_quorum_failed(&env, &token, &config, &mut dispute); + } + + env.storage().persistent().set(&dispute_key, &dispute); + Self::extend_persistent(&env, &dispute_key); + + DisputeResolved { + dispute_id, + proposal_id: dispute.proposal_id, + milestone_id: dispute.milestone_id, + outcome: dispute.outcome, + votes_for: dispute.votes_for, + votes_against: dispute.votes_against, + revealed_count: dispute.revealed_count, + quorum_met, + } + .publish(&env); + } + + // ----------------------------------------------------------------------- + // Read functions + // ----------------------------------------------------------------------- + + pub fn get_dispute(env: Env, dispute_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Dispute(dispute_id)) + } + + pub fn get_dispute_for_milestone(env: Env, proposal_id: u32, milestone_id: u32) -> Option { + env.storage() + .persistent() + .get(&DataKey::DisputeByMilestone(proposal_id, milestone_id)) + } + + pub fn get_vote(env: Env, dispute_id: u64, juror: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::Vote(dispute_id, juror)) + } + + pub fn get_juror_stake(env: Env, juror: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::JurorStake(juror)) + .unwrap_or(0) + } + + pub fn get_juror_active_count(env: Env, juror: Address) -> u32 { + env.storage() + .persistent() + .get(&DataKey::JurorActiveCount(juror)) + .unwrap_or(0) + } + + pub fn is_juror(env: Env, juror: Address) -> bool { + env.storage().persistent().has(&DataKey::JurorStake(juror)) + } + + pub fn get_juror_pool(env: Env) -> Vec
{ + Self::juror_pool(&env) + } + + pub fn get_config(env: Env) -> Config { + Self::config(&env) + } + + /// `(panel_size, quorum, min_juror_stake, max_juror_selection_weight, + /// scholar_dispute_stake, dispute_window_seconds, commit_window_seconds, + /// reveal_window_seconds)`. + pub fn get_params(_env: Env) -> (u32, u32, i128, i128, i128, u64, u64, u64) { + ( + PANEL_SIZE, + QUORUM, + MIN_JUROR_STAKE, + MAX_JUROR_SELECTION_WEIGHT, + SCHOLAR_DISPUTE_STAKE, + DISPUTE_WINDOW_SECONDS, + COMMIT_WINDOW_SECONDS, + REVEAL_WINDOW_SECONDS, + ) + } + + /// The escrow-release authorization surface: `Some((proposal_id, + /// milestone_id, release))` once resolved with quorum, `None` if the + /// dispute doesn't exist, hasn't resolved, or resolved via quorum + /// failure. `milestone_escrow` should reject anything but + /// `Some((matching_proposal_id, _, true))`. + pub fn get_release_outcome(env: Env, dispute_id: u64) -> Option<(u32, u32, bool)> { + let dispute: Option = env + .storage() + .persistent() + .get(&DataKey::Dispute(dispute_id)); + dispute.and_then(|d| { + d.outcome + .map(|release| (d.proposal_id, d.milestone_id, release)) + }) + } + + pub fn get_version(env: Env) -> String { + String::from_str(&env, "1.0.0") + } + + // ----------------------------------------------------------------------- + // Admin + // ----------------------------------------------------------------------- + + /// Replace the current contract WASM with a new uploaded hash. Admin only. + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + let admin = Self::config(&env).admin; + admin.require_auth(); + upgrade::apply(&env, &admin, &new_wasm_hash); + } + + // ----------------------------------------------------------------------- + // Internal: resolution + // ----------------------------------------------------------------------- + + fn resolve_quorum_failed( + env: &Env, + token: &token::Client, + config: &Config, + dispute: &mut Dispute, + ) { + let now = env.ledger().timestamp(); + dispute.phase = DisputePhase::QuorumFailed; + dispute.outcome = None; + dispute.resolved_at = now; + + if dispute.scholar_stake > 0 { + token.transfer( + &env.current_contract_address(), + &dispute.scholar, + &dispute.scholar_stake, + ); + } + + let mut slashed_total: i128 = 0; + for juror in dispute.panel.iter() { + let revealed = Self::did_reveal(env, dispute.id, &juror); + Self::release_assignment(env, &juror); + if revealed { + continue; + } + let stake_key = DataKey::JurorStake(juror.clone()); + let stake: i128 = env.storage().persistent().get(&stake_key).unwrap_or(0); + let slash = Self::bps_of(env, stake, NON_PARTICIPATION_SLASH_BPS); + if slash > 0 { + env.storage().persistent().set(&stake_key, &(stake - slash)); + slashed_total += slash; + } + } + if slashed_total > 0 { + token.transfer( + &env.current_contract_address(), + &config.treasury, + &slashed_total, + ); + } + } + + fn resolve_with_quorum( + env: &Env, + token: &token::Client, + config: &Config, + dispute: &mut Dispute, + ) { + let now = env.ledger().timestamp(); + let release = dispute.votes_for > dispute.votes_against; + let is_tie = dispute.votes_for == dispute.votes_against; + dispute.phase = DisputePhase::Resolved; + dispute.outcome = Some(release); + dispute.resolved_at = now; + + // The value a *minority* voter would have cast. Meaningless on a tie. + let minority_vote = !release; + + let mut majority_jurors: Vec
= Vec::new(env); + let mut slashed_total: i128 = 0; + + for juror in dispute.panel.iter() { + let revealed = Self::did_reveal(env, dispute.id, &juror); + let stake_key = DataKey::JurorStake(juror.clone()); + let stake: i128 = env.storage().persistent().get(&stake_key).unwrap_or(0); + + let slash = if !revealed { + Self::bps_of(env, stake, NON_PARTICIPATION_SLASH_BPS) + } else if !is_tie && Self::juror_vote(env, dispute.id, &juror) == minority_vote { + Self::bps_of(env, stake, MINORITY_SLASH_BPS) + } else { + 0 + }; + + if slash > 0 { + env.storage().persistent().set(&stake_key, &(stake - slash)); + slashed_total += slash; + } else if revealed { + majority_jurors.push_back(juror.clone()); + } + Self::release_assignment(env, &juror); + } + + // The scholar's own stake joins the slashed pool only when the + // rejection is upheld (a losing dispute is, by this design, treated + // as frivolous); otherwise it is returned in full. + let mut pool = slashed_total; + if release { + if dispute.scholar_stake > 0 { + token.transfer( + &env.current_contract_address(), + &dispute.scholar, + &dispute.scholar_stake, + ); + } + } else { + pool += dispute.scholar_stake; + } + + if pool > 0 { + let majority_share = Self::bps_of(env, pool, MAJORITY_REWARD_SHARE_BPS); + let winner_share = pool - majority_share; + let juror_count = majority_jurors.len(); + + if majority_share > 0 && juror_count > 0 { + let each = majority_share / (juror_count as i128); + let dust = majority_share - each * (juror_count as i128); + if each > 0 { + for juror in majority_jurors.iter() { + let stake_key = DataKey::JurorStake(juror.clone()); + let stake: i128 = env.storage().persistent().get(&stake_key).unwrap_or(0); + env.storage().persistent().set(&stake_key, &(stake + each)); + } + } + if dust > 0 { + token.transfer(&env.current_contract_address(), &config.treasury, &dust); + } + } else if majority_share > 0 { + token.transfer( + &env.current_contract_address(), + &config.treasury, + &majority_share, + ); + } + + if winner_share > 0 { + let winner = if release { + dispute.scholar.clone() + } else { + config.treasury.clone() + }; + token.transfer(&env.current_contract_address(), &winner, &winner_share); + } + } + } + + // ----------------------------------------------------------------------- + // Internal: panel selection + // ----------------------------------------------------------------------- + + /// Draw [`PANEL_SIZE`] jurors from `pool` without replacement, weighted + /// by each juror's stake (capped at [`MAX_JUROR_SELECTION_WEIGHT`]) and + /// seeded from the dispute id plus the ledger's sequence and timestamp at + /// the moment of the draw — neither of which the caller controls at the + /// time they submit the `open_dispute` transaction. + fn select_panel(env: &Env, pool: &Vec
, dispute_id: u64) -> Vec
{ + if pool.len() < PANEL_SIZE { + panic_with_error!(env, ArbitrationError::InsufficientPool); + } + + let mut candidates: Vec
= Vec::new(env); + let mut weights: Vec = Vec::new(env); + for juror in pool.iter() { + let stake: i128 = env + .storage() + .persistent() + .get(&DataKey::JurorStake(juror.clone())) + .unwrap_or(0); + let weight = stake.min(MAX_JUROR_SELECTION_WEIGHT); + if weight > 0 { + candidates.push_back(juror.clone()); + weights.push_back(weight); + } + } + if candidates.len() < PANEL_SIZE { + panic_with_error!(env, ArbitrationError::InsufficientPool); + } + + let mut selected: Vec
= Vec::new(env); + let mut draw: u32 = 0; + while selected.len() < PANEL_SIZE { + let mut total_weight: i128 = 0; + for w in weights.iter() { + total_weight += w; + } + + let ticket = (Self::draw_u128(env, dispute_id, draw) % (total_weight as u128)) as i128; + let mut cumulative: i128 = 0; + let mut pick_index: u32 = 0; + for i in 0..weights.len() { + cumulative += weights.get(i).unwrap(); + if ticket < cumulative { + pick_index = i; + break; + } + } + + let picked = candidates.get(pick_index).unwrap(); + selected.push_back(picked); + candidates.remove(pick_index); + weights.remove(pick_index); + draw = draw + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(env, ArbitrationError::ArithmeticOverflow)); + } + selected + } + + fn draw_u128(env: &Env, dispute_id: u64, counter: u32) -> u128 { + let mut bytes = Bytes::new(env); + bytes.append(&Bytes::from_array(env, &dispute_id.to_be_bytes())); + bytes.append(&Bytes::from_array( + env, + &env.ledger().sequence().to_be_bytes(), + )); + bytes.append(&Bytes::from_array( + env, + &env.ledger().timestamp().to_be_bytes(), + )); + bytes.append(&Bytes::from_array(env, &counter.to_be_bytes())); + + let hash: BytesN<32> = env.crypto().sha256(&bytes).into(); + let arr = hash.to_array(); + let mut buf = [0u8; 16]; + buf.copy_from_slice(&arr[0..16]); + u128::from_be_bytes(buf) + } + + fn compute_commitment(env: &Env, dispute_id: u64, vote: bool, salt: &BytesN<32>) -> BytesN<32> { + let mut bytes = Bytes::new(env); + bytes.append(&Bytes::from_array(env, &dispute_id.to_be_bytes())); + bytes.append(&Bytes::from_array(env, &[if vote { 1u8 } else { 0u8 }])); + bytes.append(&Bytes::from(salt.clone())); + env.crypto().sha256(&bytes).into() + } + + // ----------------------------------------------------------------------- + // Internal: storage helpers + // ----------------------------------------------------------------------- + + fn config(env: &Env) -> Config { + env.storage() + .instance() + .get(&CONFIG_KEY) + .unwrap_or_else(|| panic_with_error!(env, ArbitrationError::NotInitialized)) + } + + fn token<'a>(env: &Env, config: &Config) -> token::Client<'a> { + token::Client::new(env, &config.lrn_token) + } + + fn juror_pool(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&JUROR_POOL_KEY) + .unwrap_or_else(|| Vec::new(env)) + } + + fn put_juror_pool(env: &Env, pool: &Vec
) { + env.storage().instance().set(&JUROR_POOL_KEY, pool); + } + + fn get_dispute_or_panic(env: &Env, key: &DataKey) -> Dispute { + env.storage() + .persistent() + .get(key) + .unwrap_or_else(|| panic_with_error!(env, ArbitrationError::DisputeNotFound)) + } + + fn did_reveal(env: &Env, dispute_id: u64, juror: &Address) -> bool { + let key = DataKey::Vote(dispute_id, juror.clone()); + env.storage() + .persistent() + .get::<_, VoteRecord>(&key) + .map(|r| r.revealed) + .unwrap_or(false) + } + + fn juror_vote(env: &Env, dispute_id: u64, juror: &Address) -> bool { + let key = DataKey::Vote(dispute_id, juror.clone()); + env.storage() + .persistent() + .get::<_, VoteRecord>(&key) + .map(|r| r.vote) + .unwrap_or(false) + } + + fn release_assignment(env: &Env, juror: &Address) { + let key = DataKey::JurorActiveCount(juror.clone()); + let count: u32 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage() + .persistent() + .set(&key, &count.saturating_sub(1)); + } + + fn bps_of(env: &Env, amount: i128, bps: u32) -> i128 { + amount + .checked_mul(bps as i128) + .unwrap_or_else(|| panic_with_error!(env, ArbitrationError::ArithmeticOverflow)) + / BPS_DENOMINATOR + } + + fn extend_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_EXTEND_TO); + } + + fn extend_persistent(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, PERSISTENT_BUMP_THRESHOLD, PERSISTENT_EXTEND_TO); + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/milestone_arbitration/src/test.rs b/contracts/milestone_arbitration/src/test.rs new file mode 100644 index 00000000..4b981ae2 --- /dev/null +++ b/contracts/milestone_arbitration/src/test.rs @@ -0,0 +1,844 @@ +extern crate std; + +use soroban_sdk::{ + Address, Bytes, BytesN, Env, IntoVal, + testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, + token::{StellarAssetClient, TokenClient}, +}; + +use crate::{ + ArbitrationError, BPS_DENOMINATOR, DISPUTE_WINDOW_SECONDS, MAX_JUROR_SELECTION_WEIGHT, + MIN_JUROR_STAKE, MINORITY_SLASH_BPS, MilestoneArbitration, MilestoneArbitrationClient, + NON_PARTICIPATION_SLASH_BPS, PANEL_SIZE, SCHOLAR_DISPUTE_STAKE, +}; + +const START_TS: u64 = 1_700_000_000; +const START_SEQ: u32 = 1_000; + +struct Fixture<'a> { + env: Env, + client: MilestoneArbitrationClient<'a>, + contract_id: Address, + token: TokenClient<'a>, + admin: Address, + treasury: Address, + scholar: Address, +} + +fn set_ledger(env: &Env, timestamp: u64, sequence_number: u32) { + env.ledger().set(LedgerInfo { + timestamp, + protocol_version: 23, + sequence_number, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 16, + max_entry_ttl: 6_312_000, + }); +} + +fn setup<'a>() -> Fixture<'a> { + let env = Env::default(); + env.mock_all_auths(); + set_ledger(&env, START_TS, START_SEQ); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let scholar = Address::generate(&env); + + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + let token_address = sac.address(); + StellarAssetClient::new(&env, &token_address).mint(&scholar, &(SCHOLAR_DISPUTE_STAKE * 10)); + + let contract_id = env.register(MilestoneArbitration, ()); + let client = MilestoneArbitrationClient::new(&env, &contract_id); + client.initialize(&admin, &token_address, &treasury); + + Fixture { + token: TokenClient::new(&env, &token_address), + env, + client, + contract_id, + admin, + treasury, + scholar, + } +} + +fn contract_error( + error: ArbitrationError, +) -> Option> { + Some(Ok(soroban_sdk::Error::from_contract_error(error as u32))) +} + +fn evidence_hash(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +fn salt(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +/// Mirrors the contract's private `compute_commitment` so tests can produce +/// valid commitments without reaching into contract internals. +fn make_commitment(env: &Env, dispute_id: u64, vote: bool, salt: &BytesN<32>) -> BytesN<32> { + let mut bytes = Bytes::new(env); + bytes.append(&Bytes::from_array(env, &dispute_id.to_be_bytes())); + bytes.append(&Bytes::from_array(env, &[if vote { 1u8 } else { 0u8 }])); + bytes.append(&Bytes::from(salt.clone())); + env.crypto().sha256(&bytes).into() +} + +fn join_juror(f: &Fixture, stake: i128) -> Address { + let juror = Address::generate(&f.env); + StellarAssetClient::new(&f.env, &f.token.address).mint(&juror, &(stake + MIN_JUROR_STAKE)); + f.client.join_panel(&juror, &stake); + juror +} + +fn take_vec(env: &Env, vec: &soroban_sdk::Vec
, n: u32) -> soroban_sdk::Vec
{ + let mut result = soroban_sdk::Vec::new(env); + for i in 0..n.min(vec.len()) { + result.push_back(vec.get(i).unwrap()); + } + result +} + +fn join_default_pool(f: &Fixture) -> std::vec::Vec
{ + let mut jurors = std::vec::Vec::new(); + for _ in 0..PANEL_SIZE { + jurors.push(join_juror(f, MIN_JUROR_STAKE)); + } + jurors +} + +fn open_dispute(f: &Fixture, proposal_id: u32, milestone_id: u32) -> u64 { + let hash = evidence_hash(&f.env, 7); + f.client + .open_dispute(&f.scholar, &proposal_id, &milestone_id, &hash, &START_TS) +} + +/// Commits and reveals every panel member, voting `true` (release) for +/// `votes_for` of them and `false` for the rest, in panel order. +fn commit_and_reveal( + f: &Fixture, + dispute_id: u64, + panel: &soroban_sdk::Vec
, + votes_for: u32, +) { + let salts: std::vec::Vec> = (0..panel.len()) + .map(|i| salt(&f.env, i as u8 + 1)) + .collect(); + let votes: std::vec::Vec = (0..panel.len()).map(|i| i < votes_for).collect(); + + for (i, juror) in panel.iter().enumerate() { + let commitment = make_commitment(&f.env, dispute_id, votes[i], &salts[i]); + f.client.commit_vote(&dispute_id, &juror, &commitment); + } + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + 1, + START_SEQ + 1, + ); + + for (i, juror) in panel.iter().enumerate() { + f.client + .reveal_vote(&dispute_id, &juror, &votes[i], &salts[i]); + } +} + +// --------------------------------------------------------------------------- +// initialization +// --------------------------------------------------------------------------- + +#[test] +fn initialize_stores_config() { + let f = setup(); + let config = f.client.get_config(); + assert_eq!(config.admin, f.admin); + assert_eq!(config.treasury, f.treasury); + assert_eq!(config.lrn_token, f.token.address); +} + +#[test] +fn double_initialize_reverts() { + let f = setup(); + let result = f + .client + .try_initialize(&f.admin, &f.token.address, &f.treasury); + assert_eq!( + result.err(), + contract_error(ArbitrationError::AlreadyInitialized) + ); +} + +// --------------------------------------------------------------------------- +// juror pool +// --------------------------------------------------------------------------- + +#[test] +fn join_panel_transfers_stake_and_records_juror() { + let f = setup(); + let juror = join_juror(&f, MIN_JUROR_STAKE); + assert!(f.client.is_juror(&juror)); + assert_eq!(f.client.get_juror_stake(&juror), MIN_JUROR_STAKE); + assert_eq!(f.token.balance(&f.contract_id), MIN_JUROR_STAKE); +} + +#[test] +fn join_panel_below_minimum_stake_fails() { + let f = setup(); + let juror = Address::generate(&f.env); + StellarAssetClient::new(&f.env, &f.token.address).mint(&juror, &MIN_JUROR_STAKE); + let result = f.client.try_join_panel(&juror, &(MIN_JUROR_STAKE - 1)); + assert_eq!(result.err(), contract_error(ArbitrationError::InvalidStake)); +} + +#[test] +fn double_join_rejected() { + let f = setup(); + let juror = join_juror(&f, MIN_JUROR_STAKE); + StellarAssetClient::new(&f.env, &f.token.address).mint(&juror, &MIN_JUROR_STAKE); + let result = f.client.try_join_panel(&juror, &MIN_JUROR_STAKE); + assert_eq!(result.err(), contract_error(ArbitrationError::AlreadyJuror)); +} + +#[test] +fn leave_panel_refunds_stake() { + let f = setup(); + let juror = join_juror(&f, MIN_JUROR_STAKE); + f.client.leave_panel(&juror); + assert!(!f.client.is_juror(&juror)); + assert_eq!(f.token.balance(&f.contract_id), 0); +} + +#[test] +fn leave_panel_with_active_dispute_fails() { + let f = setup(); + let panel = join_default_pool(&f); + open_dispute(&f, 1, 1); + + let result = f.client.try_leave_panel(&panel[0]); + assert_eq!( + result.err(), + contract_error(ArbitrationError::JurorHasActiveDisputes) + ); +} + +// --------------------------------------------------------------------------- +// open_dispute +// --------------------------------------------------------------------------- + +#[test] +fn open_dispute_draws_full_panel_and_starts_commit_window() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + assert_eq!(dispute.panel.len(), PANEL_SIZE); + assert_eq!(dispute.scholar, f.scholar); + assert_eq!(dispute.scholar_stake, SCHOLAR_DISPUTE_STAKE); + assert_eq!( + dispute.commit_deadline, + START_TS + crate::COMMIT_WINDOW_SECONDS + ); + assert_eq!( + dispute.reveal_deadline, + START_TS + crate::COMMIT_WINDOW_SECONDS + crate::REVEAL_WINDOW_SECONDS + ); + assert_eq!( + f.token.balance(&f.contract_id), + MIN_JUROR_STAKE * (PANEL_SIZE as i128) + SCHOLAR_DISPUTE_STAKE + ); +} + +#[test] +fn open_dispute_with_insufficient_pool_fails() { + let f = setup(); + for _ in 0..(PANEL_SIZE - 1) { + join_juror(&f, MIN_JUROR_STAKE); + } + let hash = evidence_hash(&f.env, 1); + let result = f + .client + .try_open_dispute(&f.scholar, &1, &1, &hash, &START_TS); + assert_eq!( + result.err(), + contract_error(ArbitrationError::InsufficientPool) + ); +} + +#[test] +fn open_dispute_twice_for_same_milestone_fails() { + let f = setup(); + join_default_pool(&f); + open_dispute(&f, 1, 1); + + let hash = evidence_hash(&f.env, 2); + let result = f + .client + .try_open_dispute(&f.scholar, &1, &1, &hash, &START_TS); + assert_eq!( + result.err(), + contract_error(ArbitrationError::DisputeExists) + ); +} + +#[test] +fn open_dispute_outside_window_rejected() { + let f = setup(); + join_default_pool(&f); + + let rejected_at = START_TS; + set_ledger(&f.env, START_TS + DISPUTE_WINDOW_SECONDS + 1, START_SEQ); + + let hash = evidence_hash(&f.env, 3); + let result = f + .client + .try_open_dispute(&f.scholar, &1, &1, &hash, &rejected_at); + assert_eq!( + result.err(), + contract_error(ArbitrationError::DisputeWindowExpired) + ); +} + +#[test] +fn open_dispute_with_future_rejected_at_fails() { + let f = setup(); + join_default_pool(&f); + let hash = evidence_hash(&f.env, 4); + let result = f + .client + .try_open_dispute(&f.scholar, &1, &1, &hash, &(START_TS + 1)); + assert_eq!( + result.err(), + contract_error(ArbitrationError::RejectedAtInFuture) + ); +} + +#[test] +fn only_the_scholar_can_open_their_own_dispute() { + let env = Env::default(); + set_ledger(&env, START_TS, START_SEQ); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let scholar = Address::generate(&env); + let attacker = Address::generate(&env); + + env.mock_all_auths(); + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + let token_address = sac.address(); + StellarAssetClient::new(&env, &token_address).mint(&scholar, &(SCHOLAR_DISPUTE_STAKE * 2)); + + let contract_id = env.register(MilestoneArbitration, ()); + let client = MilestoneArbitrationClient::new(&env, &contract_id); + client.initialize(&admin, &token_address, &treasury); + + for _ in 0..PANEL_SIZE { + let juror = Address::generate(&env); + StellarAssetClient::new(&env, &token_address).mint(&juror, &(MIN_JUROR_STAKE * 2)); + client.join_panel(&juror, &MIN_JUROR_STAKE); + } + + let hash = evidence_hash(&env, 5); + + // The attacker signs, but names `scholar` as the dispute's scholar. + env.mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "open_dispute", + args: (scholar.clone(), 1u32, 1u32, hash.clone(), START_TS).into_val(&env), + sub_invokes: &[], + }, + }]); + + let result = client.try_open_dispute(&scholar, &1, &1, &hash, &START_TS); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// commit / reveal +// --------------------------------------------------------------------------- + +#[test] +fn commit_vote_requires_panel_membership() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + + let outsider = join_juror(&f, MIN_JUROR_STAKE); + let commitment = make_commitment(&f.env, dispute_id, true, &salt(&f.env, 1)); + let result = f + .client + .try_commit_vote(&dispute_id, &outsider, &commitment); + assert_eq!( + result.err(), + contract_error(ArbitrationError::NotPanelMember) + ); +} + +#[test] +fn double_vote_rejected() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + + let juror = dispute.panel.get(0).unwrap(); + let commitment = make_commitment(&f.env, dispute_id, true, &salt(&f.env, 1)); + f.client.commit_vote(&dispute_id, &juror, &commitment); + + let result = f.client.try_commit_vote(&dispute_id, &juror, &commitment); + assert_eq!( + result.err(), + contract_error(ArbitrationError::AlreadyCommitted) + ); +} + +#[test] +fn commit_after_deadline_fails() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let juror = dispute.panel.get(0).unwrap(); + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + 1, + START_SEQ, + ); + let commitment = make_commitment(&f.env, dispute_id, true, &salt(&f.env, 1)); + let result = f.client.try_commit_vote(&dispute_id, &juror, &commitment); + assert_eq!( + result.err(), + contract_error(ArbitrationError::CommitWindowClosed) + ); +} + +#[test] +fn reveal_before_commit_deadline_fails() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let juror = dispute.panel.get(0).unwrap(); + + let s = salt(&f.env, 1); + let commitment = make_commitment(&f.env, dispute_id, true, &s); + f.client.commit_vote(&dispute_id, &juror, &commitment); + + let result = f.client.try_reveal_vote(&dispute_id, &juror, &true, &s); + assert_eq!( + result.err(), + contract_error(ArbitrationError::RevealWindowNotOpen) + ); +} + +#[test] +fn reveal_after_reveal_deadline_fails() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let juror = dispute.panel.get(0).unwrap(); + + let s = salt(&f.env, 1); + let commitment = make_commitment(&f.env, dispute_id, true, &s); + f.client.commit_vote(&dispute_id, &juror, &commitment); + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + crate::REVEAL_WINDOW_SECONDS + 1, + START_SEQ, + ); + let result = f.client.try_reveal_vote(&dispute_id, &juror, &true, &s); + assert_eq!( + result.err(), + contract_error(ArbitrationError::RevealWindowClosed) + ); +} + +#[test] +fn reveal_with_wrong_salt_is_rejected_not_ignored() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let juror = dispute.panel.get(0).unwrap(); + + let s = salt(&f.env, 1); + let commitment = make_commitment(&f.env, dispute_id, true, &s); + f.client.commit_vote(&dispute_id, &juror, &commitment); + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + 1, + START_SEQ, + ); + let wrong_salt = salt(&f.env, 99); + let result = f + .client + .try_reveal_vote(&dispute_id, &juror, &true, &wrong_salt); + assert_eq!( + result.err(), + contract_error(ArbitrationError::CommitmentMismatch) + ); + + // The vote must still be unrevealed -- a mismatch does not silently pass. + let vote = f.client.get_vote(&dispute_id, &juror).unwrap(); + assert!(!vote.revealed); +} + +#[test] +fn double_reveal_rejected() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let juror = dispute.panel.get(0).unwrap(); + + let s = salt(&f.env, 1); + let commitment = make_commitment(&f.env, dispute_id, true, &s); + f.client.commit_vote(&dispute_id, &juror, &commitment); + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + 1, + START_SEQ, + ); + f.client.reveal_vote(&dispute_id, &juror, &true, &s); + + let result = f.client.try_reveal_vote(&dispute_id, &juror, &true, &s); + assert_eq!( + result.err(), + contract_error(ArbitrationError::AlreadyRevealed) + ); +} + +// --------------------------------------------------------------------------- +// resolve: happy paths +// --------------------------------------------------------------------------- + +#[test] +fn resolve_releases_on_majority_favorable_vote() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + + // 4 of 5 vote to release. + commit_and_reveal(&f, dispute_id, &panel, 4); + + let scholar_balance_before = f.token.balance(&f.scholar); + f.client.resolve(&dispute_id); + + let resolved = f.client.get_dispute(&dispute_id).unwrap(); + assert_eq!(resolved.outcome, Some(true)); + assert_eq!( + f.client.get_release_outcome(&dispute_id), + Some((1u32, 1u32, true)) + ); + + // Scholar gets their own stake back. + assert!(f.token.balance(&f.scholar) >= scholar_balance_before + SCHOLAR_DISPUTE_STAKE); +} + +#[test] +fn resolve_upholds_rejection_on_majority_against() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + + // Only 1 of 5 votes to release; rejection is upheld. + commit_and_reveal(&f, dispute_id, &panel, 1); + + let treasury_balance_before = f.token.balance(&f.treasury); + f.client.resolve(&dispute_id); + + let resolved = f.client.get_dispute(&dispute_id).unwrap(); + assert_eq!(resolved.outcome, Some(false)); + assert_eq!( + f.client.get_release_outcome(&dispute_id), + Some((1u32, 1u32, false)) + ); + + // The scholar's forfeited stake's non-majority share lands at the + // treasury (the "winning party" when the rejection is upheld). + assert!(f.token.balance(&f.treasury) > treasury_balance_before); +} + +#[test] +fn resolve_slashes_minority_and_rewards_majority() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + + // 4-for-release, 1-against: the single "against" voter is the minority. + commit_and_reveal(&f, dispute_id, &panel, 4); + let minority_juror = panel.get(4).unwrap(); + + f.client.resolve(&dispute_id); + + let expected_slash = (MIN_JUROR_STAKE * (MINORITY_SLASH_BPS as i128)) / BPS_DENOMINATOR; + assert_eq!( + f.client.get_juror_stake(&minority_juror), + MIN_JUROR_STAKE - expected_slash + ); + + // Majority jurors end up with at least their original stake back (their + // share of the redistributed slash pool is >= 0). + let majority_juror = panel.get(0).unwrap(); + assert!(f.client.get_juror_stake(&majority_juror) >= MIN_JUROR_STAKE); +} + +#[test] +fn resolve_slashes_non_revealer_in_full() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + + // 4 jurors commit and reveal (3-for, 1-against); the 5th never commits. + let silent = panel.get(4).unwrap(); + let voting_panel = take_vec(&f.env, &panel, 4); + commit_and_reveal(&f, dispute_id, &voting_panel, 3); + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + crate::REVEAL_WINDOW_SECONDS + 1, + START_SEQ, + ); + f.client.resolve(&dispute_id); + + let expected_slash = + (MIN_JUROR_STAKE * (NON_PARTICIPATION_SLASH_BPS as i128)) / BPS_DENOMINATOR; + assert_eq!( + f.client.get_juror_stake(&silent), + MIN_JUROR_STAKE - expected_slash + ); +} + +#[test] +fn resolve_before_deadline_without_full_reveal_fails() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + + let s = salt(&f.env, 1); + let commitment = make_commitment(&f.env, dispute_id, true, &s); + f.client + .commit_vote(&dispute_id, &panel.get(0).unwrap(), &commitment); + + let result = f.client.try_resolve(&dispute_id); + assert_eq!( + result.err(), + contract_error(ArbitrationError::NotYetResolvable) + ); +} + +#[test] +fn resolve_early_when_every_panel_member_has_revealed() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + + commit_and_reveal(&f, dispute_id, &panel, 5); + // Still well before reveal_deadline. + assert!(f.env.ledger().timestamp() < dispute.reveal_deadline); + + f.client.resolve(&dispute_id); + assert_eq!( + f.client.get_dispute(&dispute_id).unwrap().outcome, + Some(true) + ); +} + +// --------------------------------------------------------------------------- +// resolve: quorum-failure fallback +// --------------------------------------------------------------------------- + +#[test] +fn quorum_failure_upholds_status_quo_and_refunds_scholar() { + let f = setup(); + join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + + // Only 2 of 5 reveal -- below QUORUM (3). + let revealing = take_vec(&f.env, &panel, 2); + commit_and_reveal(&f, dispute_id, &revealing, 2); + + let scholar_balance_before = f.token.balance(&f.scholar); + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + crate::REVEAL_WINDOW_SECONDS + 1, + START_SEQ, + ); + f.client.resolve(&dispute_id); + + let resolved = f.client.get_dispute(&dispute_id).unwrap(); + assert_eq!(resolved.outcome, None); + assert_eq!(f.client.get_release_outcome(&dispute_id), None); + + // Scholar refunded in full -- a quorum failure is not their fault. + assert_eq!( + f.token.balance(&f.scholar), + scholar_balance_before + SCHOLAR_DISPUTE_STAKE + ); + + // The 2 revealers kept their stake untouched. + for juror in revealing.iter() { + assert_eq!(f.client.get_juror_stake(&juror), MIN_JUROR_STAKE); + } + // The 3 silent jurors were slashed in full. + for juror in panel.iter().skip(2) { + assert_eq!(f.client.get_juror_stake(&juror), 0); + } +} + +#[test] +fn tie_vote_favors_status_quo() { + let f = setup(); + // 6 jurors so a 3-3 tie is possible on a panel this large... PANEL_SIZE is + // fixed at 5, so instead build a panel where reveals land 2-2 with one + // non-revealer, keeping the tie among revealed votes. + let jurors = join_default_pool(&f); + let dispute_id = open_dispute(&f, 1, 1); + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + assert_eq!(panel.len(), jurors.len() as u32); + + let voting_panel = take_vec(&f.env, &panel, 4); + commit_and_reveal(&f, dispute_id, &voting_panel, 2); + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + crate::REVEAL_WINDOW_SECONDS + 1, + START_SEQ, + ); + f.client.resolve(&dispute_id); + + let resolved = f.client.get_dispute(&dispute_id).unwrap(); + // 4 revealed (quorum met), 2-2 tie -> status quo (uphold, no release). + assert_eq!(resolved.outcome, Some(false)); +} + +// --------------------------------------------------------------------------- +// panel selection +// --------------------------------------------------------------------------- + +#[test] +fn panel_selection_depends_on_ledger_state_not_caller_input() { + // Same pool, same open_dispute arguments, different ledger sequence at + // the moment of the draw -> a different panel. Nothing about the + // `open_dispute` call itself can force a specific outcome. + let f = setup(); + join_default_pool(&f); + for _ in 0..5 { + join_juror(&f, MIN_JUROR_STAKE); + } + + let dispute_a = open_dispute(&f, 1, 1); + let panel_a = f.client.get_dispute(&dispute_a).unwrap().panel; + + set_ledger(&f.env, START_TS + 1, START_SEQ + 1); + let dispute_b = open_dispute(&f, 2, 2); + let panel_b = f.client.get_dispute(&dispute_b).unwrap().panel; + + assert_ne!(panel_a, panel_b); +} + +#[test] +fn selection_weight_is_capped_so_a_whale_cannot_dominate() { + let f = setup(); + join_default_pool(&f); + // A juror staking far beyond MAX_JUROR_SELECTION_WEIGHT still only ever + // occupies a single seat, same as everyone else. + join_juror(&f, MAX_JUROR_SELECTION_WEIGHT * 100); + + let dispute_id = open_dispute(&f, 1, 1); + let panel = f.client.get_dispute(&dispute_id).unwrap().panel; + assert_eq!(panel.len(), PANEL_SIZE); + + let mut seen: std::vec::Vec
= std::vec::Vec::new(); + for juror in panel.iter() { + assert!( + !seen.contains(&juror), + "a whale occupied more than one seat" + ); + seen.push(juror); + } +} + +// --------------------------------------------------------------------------- +// benchmarks +// --------------------------------------------------------------------------- + +#[test] +fn benchmark_costs() { + let f = setup(); + join_default_pool(&f); + + f.env.cost_estimate().budget().reset_unlimited(); + let dispute_id = open_dispute(&f, 1, 1); + let open_instr = f.env.cost_estimate().budget().cpu_instruction_cost(); + + let dispute = f.client.get_dispute(&dispute_id).unwrap(); + let panel = dispute.panel.clone(); + let s = salt(&f.env, 1); + let commitment = make_commitment(&f.env, dispute_id, true, &s); + + f.env.cost_estimate().budget().reset_unlimited(); + f.client + .commit_vote(&dispute_id, &panel.get(0).unwrap(), &commitment); + let commit_instr = f.env.cost_estimate().budget().cpu_instruction_cost(); + + for (i, juror) in panel.iter().enumerate().skip(1) { + let s_i = salt(&f.env, i as u8 + 1); + let commitment_i = make_commitment(&f.env, dispute_id, i % 2 == 0, &s_i); + f.client.commit_vote(&dispute_id, &juror, &commitment_i); + } + + set_ledger( + &f.env, + START_TS + crate::COMMIT_WINDOW_SECONDS + 1, + START_SEQ, + ); + f.env.cost_estimate().budget().reset_unlimited(); + f.client + .reveal_vote(&dispute_id, &panel.get(0).unwrap(), &true, &s); + let reveal_instr = f.env.cost_estimate().budget().cpu_instruction_cost(); + + for (i, juror) in panel.iter().enumerate().skip(1) { + let s_i = salt(&f.env, i as u8 + 1); + f.client + .reveal_vote(&dispute_id, &juror, &(i % 2 == 0), &s_i); + } + + f.env.cost_estimate().budget().reset_unlimited(); + f.client.resolve(&dispute_id); + let resolve_instr = f.env.cost_estimate().budget().cpu_instruction_cost(); + + std::println!("BENCHMARK_RESULTS: milestone_arbitration"); + std::println!("open_dispute: instr={}", open_instr); + std::println!("commit_vote: instr={}", commit_instr); + std::println!("reveal_vote: instr={}", reveal_instr); + std::println!("resolve: instr={}", resolve_instr); +} diff --git a/contracts/milestone_escrow/src/lib.rs b/contracts/milestone_escrow/src/lib.rs index 26508f1d..ecd8ceb5 100644 --- a/contracts/milestone_escrow/src/lib.rs +++ b/contracts/milestone_escrow/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] use soroban_sdk::{ - Address, BytesN, Env, String, Symbol, contract, contracterror, contractevent, contractimpl, - contracttype, panic_with_error, symbol_short, + Address, BytesN, Env, String, Symbol, contract, contractclient, contracterror, contractevent, + contractimpl, contracttype, panic_with_error, symbol_short, }; use learnvault_shared::upgrade; @@ -10,6 +10,18 @@ use learnvault_shared::upgrade; pub use upgrade::ContractUpgraded; const CONFIG_KEY: Symbol = symbol_short!("CONFIG"); +const ARBITRATION_KEY: Symbol = symbol_short!("ARBCFG"); +/// Stored separately from `ArbitrationConfig`: a custom struct cannot be +/// nested inside an `Option<_>` field of another `#[contracttype]` struct, +/// but storage's own `get()` already returns `Option` for a missing key, +/// so a dedicated key sidesteps the limitation entirely. +const PENDING_ARBITRATION_KEY: Symbol = symbol_short!("ARBPEND"); + +/// Default delay between queuing and executing an arbitration-contract +/// address change, matching `upgrade_timelock_vault`'s default upgrade +/// timelock. Changing which contract can force a release is exactly as +/// sensitive as changing the WASM itself, so it gets the same waiting period. +const DEFAULT_ARBITRATION_TIMELOCK: u64 = 48 * 60 * 60; #[derive(Clone)] #[contracttype] @@ -19,6 +31,35 @@ pub struct Config { pub inactivity_window: u64, } +/// A queued replacement arbitration-contract address and the timestamp it +/// becomes executable. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct PendingArbitrationChange { + pub new_arbitration: Address, + pub ready_at: u64, +} + +/// Authorization path for `release_tranche_via_arbitration`. Deliberately +/// separate from `Config` so wiring up arbitration is opt-in and never +/// changes the shape of the existing admin-gated config. +#[derive(Clone)] +#[contracttype] +pub struct ArbitrationConfig { + /// The `milestone_arbitration` contract authorized to force a release. + /// `None` until `set_arbitration_contract` bootstraps it once. + pub address: Option
, + pub timelock_duration: u64, +} + +/// A trait-only view of `milestone_arbitration::MilestoneArbitration`'s read +/// surface, avoiding a crate dependency between the two contracts -- this +/// only needs to know the function's name and shape to build a client. +#[contractclient(name = "ArbitrationClient")] +pub trait ArbitrationInterface { + fn get_release_outcome(env: Env, dispute_id: u64) -> Option<(u32, u32, bool)>; +} + #[derive(Clone)] #[contracttype] pub struct EscrowRecord { @@ -36,6 +77,10 @@ pub struct EscrowRecord { #[contracttype] pub enum DataKey { Escrow(u32), + /// Marks a dispute id as already consumed by + /// `release_tranche_via_arbitration`, so a single arbitration outcome can + /// never authorize more than one release. + ArbitrationDisputeUsed(u64), } #[contracterror] @@ -53,6 +98,13 @@ pub enum Error { InactivityNotReached = 9, NothingToReclaim = 10, ArithmeticOverflow = 11, + ArbitrationAlreadySet = 12, + ArbitrationNotSet = 13, + ArbitrationChangeAlreadyQueued = 14, + ArbitrationChangeNotFound = 15, + ArbitrationTimelockNotExpired = 16, + ArbitrationNotFavorable = 17, + DisputeAlreadyConsumed = 18, } #[contract] @@ -85,6 +137,43 @@ pub struct EscrowReclaimed { pub amount_reclaimed: i128, } +#[contractevent(topics = ["arb_released"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrancheReleasedViaArbitration { + #[topic] + pub scholar: Address, + #[topic] + pub proposal_id: u32, + pub dispute_id: u64, + pub amount: i128, +} + +#[contractevent(topics = ["arb_set"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArbitrationContractSet { + pub arbitration: Address, +} + +#[contractevent(topics = ["arb_queue"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArbitrationChangeQueued { + pub new_arbitration: Address, + pub ready_at: u64, +} + +#[contractevent(topics = ["arb_exec"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArbitrationChangeExecuted { + pub old_arbitration: Option
, + pub new_arbitration: Address, +} + +#[contractevent(topics = ["arb_cncl"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArbitrationChangeCancelled { + pub cancelled_arbitration: Address, +} + #[contractimpl] impl MilestoneEscrow { pub fn initialize(env: Env, admin: Address, treasury: Address, inactivity_window_seconds: u64) { @@ -173,6 +262,177 @@ impl MilestoneEscrow { .publish(&env); } + /// Release the next tranche on the authority of a resolved on-chain + /// arbitration dispute, instead of the escrow admin's signature. + /// + /// Permissionless by design -- like `milestone_arbitration::resolve`, the + /// correctness of this call rests entirely on independently-verified + /// on-chain state (the arbitration contract's own `get_release_outcome`), + /// never on who happens to submit the transaction. `dispute_id` can only + /// ever authorize one release: it is marked consumed before the transfer + /// and checked on every call. + pub fn release_tranche_via_arbitration(env: Env, proposal_id: u32, dispute_id: u64) { + let arbitration = Self::get_arbitration_contract(env.clone()) + .unwrap_or_else(|| panic_with_error!(&env, Error::ArbitrationNotSet)); + + let used_key = DataKey::ArbitrationDisputeUsed(dispute_id); + if env.storage().persistent().has(&used_key) { + panic_with_error!(&env, Error::DisputeAlreadyConsumed); + } + + let outcome = ArbitrationClient::new(&env, &arbitration).get_release_outcome(&dispute_id); + let favorable = matches!( + outcome, + Some((outcome_proposal_id, _milestone_id, release)) + if outcome_proposal_id == proposal_id && release + ); + if !favorable { + panic_with_error!(&env, Error::ArbitrationNotFavorable); + } + + env.storage().persistent().set(&used_key, &true); + + let key = DataKey::Escrow(proposal_id); + let mut record = Self::get_or_panic(&env, &key); + + if record.tranches_released >= record.total_tranches { + panic_with_error!(&env, Error::AllTranchesReleased); + } + + let amount = Self::next_tranche_amount(&env, &record); + record.released_amount = Self::checked_add_i128(&env, record.released_amount, amount); + record.tranches_released = Self::checked_add_u32(&env, record.tranches_released, 1); + record.last_activity = env.ledger().timestamp(); + env.storage().persistent().set(&key, &record); + + xlm::token_client(&env).transfer(&env.current_contract_address(), &record.scholar, &amount); + + TrancheReleasedViaArbitration { + scholar: record.scholar.clone(), + proposal_id, + dispute_id, + amount, + } + .publish(&env); + } + + // ----------------------------------------------------------------------- + // Arbitration wiring (timelocked, see module-level design notes in the PR) + // ----------------------------------------------------------------------- + + /// Bootstrap the arbitration contract address. Admin-gated, but only + /// callable once -- every change after this goes through + /// `queue_arbitration_change` / `execute_arbitration_change` instead, so + /// no single admin call can ever redirect an already-live escrow's + /// arbitration authority. + pub fn set_arbitration_contract(env: Env, arbitration: Address) { + let admin = Self::admin(&env); + admin.require_auth(); + + let mut arb_config = Self::get_arbitration_config(&env); + if arb_config.address.is_some() { + panic_with_error!(&env, Error::ArbitrationAlreadySet); + } + arb_config.address = Some(arbitration.clone()); + env.storage().instance().set(&ARBITRATION_KEY, &arb_config); + + ArbitrationContractSet { arbitration }.publish(&env); + } + + /// Queue a replacement arbitration-contract address. Executable no + /// sooner than `timelock_duration` (48h by default) after this call. + pub fn queue_arbitration_change(env: Env, new_arbitration: Address) { + let admin = Self::admin(&env); + admin.require_auth(); + + let arb_config = Self::get_arbitration_config(&env); + if arb_config.address.is_none() { + panic_with_error!(&env, Error::ArbitrationNotSet); + } + if env.storage().instance().has(&PENDING_ARBITRATION_KEY) { + panic_with_error!(&env, Error::ArbitrationChangeAlreadyQueued); + } + + let ready_at = env + .ledger() + .timestamp() + .checked_add(arb_config.timelock_duration) + .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)); + let pending = PendingArbitrationChange { + new_arbitration: new_arbitration.clone(), + ready_at, + }; + env.storage() + .instance() + .set(&PENDING_ARBITRATION_KEY, &pending); + + ArbitrationChangeQueued { + new_arbitration, + ready_at, + } + .publish(&env); + } + + /// Apply a queued arbitration-contract change once its timelock has + /// elapsed. + pub fn execute_arbitration_change(env: Env) { + let admin = Self::admin(&env); + admin.require_auth(); + + let mut arb_config = Self::get_arbitration_config(&env); + let pending: PendingArbitrationChange = env + .storage() + .instance() + .get(&PENDING_ARBITRATION_KEY) + .unwrap_or_else(|| panic_with_error!(&env, Error::ArbitrationChangeNotFound)); + let PendingArbitrationChange { + new_arbitration, + ready_at, + } = pending; + + if env.ledger().timestamp() < ready_at { + panic_with_error!(&env, Error::ArbitrationTimelockNotExpired); + } + + let old_arbitration = arb_config.address.clone(); + arb_config.address = Some(new_arbitration.clone()); + env.storage().instance().set(&ARBITRATION_KEY, &arb_config); + env.storage().instance().remove(&PENDING_ARBITRATION_KEY); + + ArbitrationChangeExecuted { + old_arbitration, + new_arbitration, + } + .publish(&env); + } + + /// Cancel a queued arbitration-contract change before it executes. + pub fn cancel_arbitration_change(env: Env) { + let admin = Self::admin(&env); + admin.require_auth(); + + let pending: PendingArbitrationChange = env + .storage() + .instance() + .get(&PENDING_ARBITRATION_KEY) + .unwrap_or_else(|| panic_with_error!(&env, Error::ArbitrationChangeNotFound)); + + env.storage().instance().remove(&PENDING_ARBITRATION_KEY); + + ArbitrationChangeCancelled { + cancelled_arbitration: pending.new_arbitration, + } + .publish(&env); + } + + pub fn get_arbitration_contract(env: Env) -> Option
{ + Self::get_arbitration_config(&env).address + } + + pub fn get_pending_arbitration_change(env: Env) -> Option { + env.storage().instance().get(&PENDING_ARBITRATION_KEY) + } + pub fn reclaim_inactive(env: Env, proposal_id: u32) { let key = DataKey::Escrow(proposal_id); let mut record = Self::get_or_panic(&env, &key); @@ -243,6 +503,16 @@ impl MilestoneEscrow { Self::get_config(env).admin } + fn get_arbitration_config(env: &Env) -> ArbitrationConfig { + env.storage() + .instance() + .get(&ARBITRATION_KEY) + .unwrap_or(ArbitrationConfig { + address: None, + timelock_duration: DEFAULT_ARBITRATION_TIMELOCK, + }) + } + fn get_config(env: &Env) -> Config { env.storage() .instance() diff --git a/contracts/milestone_escrow/src/test.rs b/contracts/milestone_escrow/src/test.rs index 36aa6b4e..f9265b04 100644 --- a/contracts/milestone_escrow/src/test.rs +++ b/contracts/milestone_escrow/src/test.rs @@ -500,6 +500,200 @@ fn last_tranche_rounding_releases_33_33_34_for_100_over_3_tranches() { assert_eq!(token_client(&env, &token).balance(&contract_id), 0); } +// --------------------------------------------------------------------------- +// Arbitration wiring +// --------------------------------------------------------------------------- + +/// A minimal stand-in for `milestone_arbitration::MilestoneArbitration`'s +/// read surface. `milestone_escrow` only ever calls `get_release_outcome` +/// through the `ArbitrationClient` trait client, so this is all a test needs +/// to exercise `release_tranche_via_arbitration` without a crate dependency +/// on the real arbitration contract. +mod mock_arbitration { + use soroban_sdk::{Env, Map, Symbol, contract, contractimpl, symbol_short}; + + const OUTCOMES_KEY: Symbol = symbol_short!("OUTCOMES"); + + #[contract] + pub struct MockArbitration; + + #[contractimpl] + impl MockArbitration { + pub fn set_outcome( + env: Env, + dispute_id: u64, + proposal_id: u32, + milestone_id: u32, + release: bool, + ) { + let mut outcomes: Map = env + .storage() + .instance() + .get(&OUTCOMES_KEY) + .unwrap_or_else(|| Map::new(&env)); + outcomes.set(dispute_id, (proposal_id, milestone_id, release)); + env.storage().instance().set(&OUTCOMES_KEY, &outcomes); + } + + pub fn get_release_outcome(env: Env, dispute_id: u64) -> Option<(u32, u32, bool)> { + let outcomes: Map = env + .storage() + .instance() + .get(&OUTCOMES_KEY) + .unwrap_or_else(|| Map::new(&env)); + outcomes.get(dispute_id) + } + } +} + +use mock_arbitration::{MockArbitration, MockArbitrationClient}; + +fn deploy_mock_arbitration(env: &Env) -> MockArbitrationClient<'_> { + let contract_id = env.register(MockArbitration, ()); + MockArbitrationClient::new(env, &contract_id) +} + +#[test] +fn set_arbitration_contract_bootstraps_once() { + let (env, contract_id, _token, admin, _treasury, _scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + let arbitration = Address::generate(&env); + + set_caller( + &client, + "set_arbitration_contract", + &admin, + (arbitration.clone(),), + ); + client.set_arbitration_contract(&arbitration); + assert_eq!(client.get_arbitration_contract(), Some(arbitration.clone())); + + let other = Address::generate(&env); + set_caller( + &client, + "set_arbitration_contract", + &admin, + (other.clone(),), + ); + let result = client.try_set_arbitration_contract(&other); + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::ArbitrationAlreadySet as u32 + ))) + ); +} + +#[test] +fn arbitration_change_respects_timelock() { + let (env, contract_id, _token, _admin, _treasury, _scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + let first = Address::generate(&env); + let second = Address::generate(&env); + + client.env.mock_all_auths(); + client.set_arbitration_contract(&first); + client.queue_arbitration_change(&second); + + let pending = client.get_pending_arbitration_change().unwrap(); + assert_eq!(pending.new_arbitration, second); + + // Too early. + let result = client.try_execute_arbitration_change(); + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::ArbitrationTimelockNotExpired as u32 + ))) + ); + assert_eq!(client.get_arbitration_contract(), Some(first.clone())); + + set_timestamp(&env, pending.ready_at); + client.execute_arbitration_change(); + assert_eq!(client.get_arbitration_contract(), Some(second)); + assert!(client.get_pending_arbitration_change().is_none()); +} + +#[test] +fn cancel_arbitration_change_removes_pending() { + let (env, contract_id, _token, admin, _treasury, _scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + let first = Address::generate(&env); + let second = Address::generate(&env); + let _ = admin; + + client.env.mock_all_auths(); + client.set_arbitration_contract(&first); + client.queue_arbitration_change(&second); + client.cancel_arbitration_change(); + + assert!(client.get_pending_arbitration_change().is_none()); + assert_eq!(client.get_arbitration_contract(), Some(first)); +} + +#[test] +fn release_tranche_via_arbitration_releases_on_favorable_outcome() { + let (env, contract_id, token, _admin, _treasury, scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + create_escrow(&client, 1, &scholar, 100, 2); + + let arbitration = deploy_mock_arbitration(&env); + client.env.mock_all_auths(); + client.set_arbitration_contract(&arbitration.address); + + arbitration.set_outcome(&7, &1, &1, &true); + client.release_tranche_via_arbitration(&1, &7); + + assert_eq!(token_client(&env, &token).balance(&scholar), 50); + let escrow = client.get_escrow(&1).unwrap(); + assert_eq!(escrow.tranches_released, 1); + + // The same dispute id can never authorize a second release. + let result = client.try_release_tranche_via_arbitration(&1, &7); + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::DisputeAlreadyConsumed as u32 + ))) + ); +} + +#[test] +fn release_tranche_via_arbitration_rejects_unfavorable_outcome() { + let (env, contract_id, token, _admin, _treasury, scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + create_escrow(&client, 1, &scholar, 100, 2); + + let arbitration = deploy_mock_arbitration(&env); + client.env.mock_all_auths(); + client.set_arbitration_contract(&arbitration.address); + + arbitration.set_outcome(&7, &1, &1, &false); + let result = client.try_release_tranche_via_arbitration(&1, &7); + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::ArbitrationNotFavorable as u32 + ))) + ); + assert_eq!(token_client(&env, &token).balance(&scholar), 0); +} + +#[test] +fn release_tranche_via_arbitration_without_arbitration_set_fails() { + let (env, contract_id, _token, _admin, _treasury, scholar) = setup(); + let client = MilestoneEscrowClient::new(&env, &contract_id); + create_escrow(&client, 1, &scholar, 100, 2); + + let result = client.try_release_tranche_via_arbitration(&1, &7); + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_contract_error( + Error::ArbitrationNotSet as u32 + ))) + ); +} + #[cfg(test)] mod fuzz_tests { use super::*; diff --git a/server/src/controllers/dispute.controller.ts b/server/src/controllers/dispute.controller.ts new file mode 100644 index 00000000..d0fc4431 --- /dev/null +++ b/server/src/controllers/dispute.controller.ts @@ -0,0 +1,184 @@ +import { type Request, type Response } from "express" +import { disputeStore, type DisputePhase } from "../db/dispute-store" +import { logger } from "../lib/logger" +import { type AuthRequest } from "../middleware/auth.middleware" + +const log = logger.child({ module: "dispute" }) + +const VALID_PHASES: DisputePhase[] = ["active", "resolved", "quorum_failed"] + +/** + * GET /api/disputes + * List arbitration disputes, optionally filtered by phase. + */ +export async function listDisputes(req: Request, res: Response): Promise { + try { + const phase = + typeof req.query.phase === "string" + ? (req.query.phase as DisputePhase) + : undefined + const page = Number.parseInt(String(req.query.page ?? ""), 10) || 1 + const pageSize = Number.parseInt(String(req.query.pageSize ?? ""), 10) || 20 + + if (phase && !VALID_PHASES.includes(phase)) { + res.status(400).json({ error: "Invalid phase filter" }) + return + } + + const result = await disputeStore.listDisputes({ phase, page, pageSize }) + + res.status(200).json({ + data: result.data, + pagination: { + page, + pageSize, + total: result.total, + totalPages: Math.ceil(result.total / pageSize), + }, + }) + } catch (err) { + log.error({ err }, "Failed to list disputes") + res.status(500).json({ error: "Failed to list disputes" }) + } +} + +/** + * GET /api/disputes/:id + * Fetch one dispute, including its panel and any revealed votes. + */ +export async function getDisputeById( + req: Request, + res: Response, +): Promise { + try { + const disputeId = req.params.id + if (!disputeId || !/^\d+$/.test(disputeId)) { + res.status(400).json({ error: "Invalid dispute id" }) + return + } + + const dispute = await disputeStore.getDisputeById(disputeId) + if (!dispute) { + res.status(404).json({ error: "Dispute not found" }) + return + } + + const [jurors, votes] = await Promise.all([ + disputeStore.getJurorsForDispute(disputeId), + disputeStore.getVotesForDispute(disputeId), + ]) + + res.status(200).json({ data: { ...dispute, jurors, votes } }) + } catch (err) { + log.error({ err }, "Failed to fetch dispute") + res.status(500).json({ error: "Failed to fetch dispute" }) + } +} + +/** + * GET /api/disputes/juror/:address + * Every dispute a given wallet address has been drawn onto a panel for. + */ +export async function getJurorAssignments( + req: Request, + res: Response, +): Promise { + try { + const address = req.params.address + if (!address) { + res.status(400).json({ error: "Invalid juror address" }) + return + } + + const assignments = await disputeStore.getAssignmentsForJuror(address) + res.status(200).json({ data: assignments }) + } catch (err) { + log.error({ err }, "Failed to fetch juror assignments") + res.status(500).json({ error: "Failed to fetch juror assignments" }) + } +} + +/** + * POST /api/disputes/pending-evidence + * Register an IPFS evidence CID for a not-yet-opened dispute, keyed by + * (proposalId, milestoneId). Call this *before* signing `open_dispute` + * on-chain -- the chain only ever receives the evidence hash, and the + * indexer attaches this CID to the dispute row once it observes the + * DisputeOpened event for the same (proposalId, milestoneId) pair. + */ +export async function registerPendingEvidence( + req: AuthRequest, + res: Response, +): Promise { + try { + const scholarAddress = req.user?.address + if (!scholarAddress) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const { proposalId, milestoneId, evidenceIpfsCid } = req.body as { + proposalId?: unknown + milestoneId?: unknown + evidenceIpfsCid?: unknown + } + const parsedProposalId = Number(proposalId) + const parsedMilestoneId = Number(milestoneId) + if ( + !Number.isFinite(parsedProposalId) || + !Number.isFinite(parsedMilestoneId) + ) { + res.status(400).json({ error: "proposalId and milestoneId are required" }) + return + } + if (typeof evidenceIpfsCid !== "string" || !evidenceIpfsCid.trim()) { + res.status(400).json({ error: "evidenceIpfsCid is required" }) + return + } + + await disputeStore.setPendingEvidence({ + proposalId: parsedProposalId, + milestoneId: parsedMilestoneId, + scholarAddress, + evidenceIpfsCid: evidenceIpfsCid.trim(), + }) + + res + .status(200) + .json({ + data: { proposalId: parsedProposalId, milestoneId: parsedMilestoneId }, + }) + } catch (err) { + log.error({ err }, "Failed to register pending dispute evidence") + res + .status(500) + .json({ error: "Failed to register pending dispute evidence" }) + } +} + +/** + * GET /api/disputes/milestone/:proposalId/:milestoneId + * Look up the dispute (if any) already opened for a specific milestone. + */ +export async function getDisputeByMilestone( + req: Request, + res: Response, +): Promise { + try { + const proposalId = Number.parseInt(req.params.proposalId ?? "", 10) + const milestoneId = Number.parseInt(req.params.milestoneId ?? "", 10) + if (!Number.isFinite(proposalId) || !Number.isFinite(milestoneId)) { + res.status(400).json({ error: "Invalid proposal or milestone id" }) + return + } + + const dispute = await disputeStore.getDisputeByMilestone( + proposalId, + milestoneId, + ) + res.status(200).json({ data: dispute }) + } catch (err) { + log.error({ err }, "Failed to look up dispute for milestone") + res.status(500).json({ error: "Failed to look up dispute for milestone" }) + } +} diff --git a/server/src/db/dispute-store.ts b/server/src/db/dispute-store.ts new file mode 100644 index 00000000..13074eee --- /dev/null +++ b/server/src/db/dispute-store.ts @@ -0,0 +1,301 @@ +import { pool } from "./index" + +export type DisputePhase = "active" | "resolved" | "quorum_failed" + +export interface Dispute { + dispute_id: string + proposal_id: number + milestone_id: number + scholar_address: string + evidence_ipfs_cid: string | null + evidence_hash: string + scholar_stake: string + opened_at: string + commit_deadline: string + reveal_deadline: string + phase: DisputePhase + votes_for: number + votes_against: number + revealed_count: number + outcome: boolean | null + resolved_at: string | null + resolve_tx_hash: string | null + open_tx_hash: string | null + created_at: string + updated_at: string +} + +export interface DisputeJuror { + dispute_id: string + juror_address: string + has_committed: boolean + has_revealed: boolean +} + +export interface DisputeVote { + dispute_id: string + juror_address: string + vote: boolean + revealed_at: string +} + +export interface DisputeFilters { + phase?: DisputePhase + page?: number + pageSize?: number +} + +export interface PaginatedDisputes { + data: Dispute[] + total: number +} + +export const disputeStore = { + async upsertDisputeOpened(data: { + disputeId: string + proposalId: number + milestoneId: number + scholarAddress: string + evidenceHash: string + scholarStake: string + openedAt: Date + commitDeadline: Date + revealDeadline: Date + panel: string[] + openTxHash: string | null + }): Promise { + await pool.query( + `INSERT INTO disputes ( + dispute_id, proposal_id, milestone_id, scholar_address, evidence_hash, + scholar_stake, opened_at, commit_deadline, reveal_deadline, open_tx_hash + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (dispute_id) DO NOTHING`, + [ + data.disputeId, + data.proposalId, + data.milestoneId, + data.scholarAddress, + data.evidenceHash, + data.scholarStake, + data.openedAt, + data.commitDeadline, + data.revealDeadline, + data.openTxHash, + ], + ) + + for (const jurorAddress of data.panel) { + await pool.query( + `INSERT INTO dispute_jurors (dispute_id, juror_address) + VALUES ($1, $2) + ON CONFLICT (dispute_id, juror_address) DO NOTHING`, + [data.disputeId, jurorAddress], + ) + } + }, + + async markCommitted(disputeId: string, jurorAddress: string): Promise { + await pool.query( + `UPDATE dispute_jurors SET has_committed = TRUE + WHERE dispute_id = $1 AND juror_address = $2`, + [disputeId, jurorAddress], + ) + }, + + async recordReveal(data: { + disputeId: string + jurorAddress: string + vote: boolean + revealedAt: Date + txHash: string | null + }): Promise { + await pool.query( + `INSERT INTO dispute_votes (dispute_id, juror_address, vote, revealed_at, tx_hash) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (dispute_id, juror_address) DO NOTHING`, + [ + data.disputeId, + data.jurorAddress, + data.vote, + data.revealedAt, + data.txHash, + ], + ) + await pool.query( + `UPDATE dispute_jurors SET has_revealed = TRUE + WHERE dispute_id = $1 AND juror_address = $2`, + [data.disputeId, data.jurorAddress], + ) + await pool.query( + `UPDATE disputes SET + votes_for = votes_for + CASE WHEN $2 THEN 1 ELSE 0 END, + votes_against = votes_against + CASE WHEN $2 THEN 0 ELSE 1 END, + revealed_count = revealed_count + 1, + updated_at = NOW() + WHERE dispute_id = $1`, + [data.disputeId, data.vote], + ) + }, + + async markResolved(data: { + disputeId: string + outcome: boolean | null + votesFor: number + votesAgainst: number + revealedCount: number + quorumMet: boolean + resolvedAt: Date + resolveTxHash: string | null + }): Promise { + await pool.query( + `UPDATE disputes SET + phase = $2, + outcome = $3, + votes_for = $4, + votes_against = $5, + revealed_count = $6, + resolved_at = $7, + resolve_tx_hash = $8, + updated_at = NOW() + WHERE dispute_id = $1`, + [ + data.disputeId, + data.quorumMet ? "resolved" : "quorum_failed", + data.outcome, + data.votesFor, + data.votesAgainst, + data.revealedCount, + data.resolvedAt, + data.resolveTxHash, + ], + ) + }, + + async setEvidenceCid( + disputeId: string, + evidenceIpfsCid: string, + ): Promise { + await pool.query( + `UPDATE disputes SET evidence_ipfs_cid = $2, updated_at = NOW() WHERE dispute_id = $1`, + [disputeId, evidenceIpfsCid], + ) + }, + + /** Register a CID before the scholar signs `open_dispute` on-chain. */ + async setPendingEvidence(data: { + proposalId: number + milestoneId: number + scholarAddress: string + evidenceIpfsCid: string + }): Promise { + await pool.query( + `INSERT INTO pending_dispute_evidence (proposal_id, milestone_id, scholar_address, evidence_ipfs_cid) + VALUES ($1, $2, $3, $4) + ON CONFLICT (proposal_id, milestone_id) + DO UPDATE SET evidence_ipfs_cid = EXCLUDED.evidence_ipfs_cid, scholar_address = EXCLUDED.scholar_address`, + [ + data.proposalId, + data.milestoneId, + data.scholarAddress, + data.evidenceIpfsCid, + ], + ) + }, + + /** Consumed once by the indexer when it observes DisputeOpened. */ + async takePendingEvidence( + proposalId: number, + milestoneId: number, + ): Promise { + const result = await pool.query<{ evidence_ipfs_cid: string }>( + `DELETE FROM pending_dispute_evidence WHERE proposal_id = $1 AND milestone_id = $2 + RETURNING evidence_ipfs_cid`, + [proposalId, milestoneId], + ) + return result.rows[0]?.evidence_ipfs_cid ?? null + }, + + async listDisputes(filters: DisputeFilters = {}): Promise { + const page = filters.page ?? 1 + const pageSize = Math.min(filters.pageSize ?? 20, 100) + const values: Array = [] + const conditions: string[] = [] + + if (filters.phase) { + values.push(filters.phase) + conditions.push(`phase = $${values.length}`) + } + + const where = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + const countResult = await pool.query<{ count: string }>( + `SELECT COUNT(*) FROM disputes ${where}`, + values, + ) + const total = Number.parseInt(countResult.rows[0]?.count ?? "0", 10) + + const offset = (page - 1) * pageSize + values.push(pageSize, offset) + const result = await pool.query( + `SELECT * FROM disputes ${where} + ORDER BY opened_at DESC + LIMIT $${values.length - 1} OFFSET $${values.length}`, + values, + ) + + return { data: result.rows, total } + }, + + async getDisputeById(disputeId: string): Promise { + const result = await pool.query( + `SELECT * FROM disputes WHERE dispute_id = $1`, + [disputeId], + ) + return result.rows[0] ?? null + }, + + async getJurorsForDispute(disputeId: string): Promise { + const result = await pool.query( + `SELECT dispute_id, juror_address, has_committed, has_revealed + FROM dispute_jurors WHERE dispute_id = $1 + ORDER BY id ASC`, + [disputeId], + ) + return result.rows + }, + + async getVotesForDispute(disputeId: string): Promise { + const result = await pool.query( + `SELECT dispute_id, juror_address, vote, revealed_at + FROM dispute_votes WHERE dispute_id = $1 + ORDER BY revealed_at ASC`, + [disputeId], + ) + return result.rows + }, + + /** Every dispute where `jurorAddress` was drawn onto the panel. */ + async getAssignmentsForJuror(jurorAddress: string): Promise { + const result = await pool.query( + `SELECT d.* FROM disputes d + JOIN dispute_jurors dj ON dj.dispute_id = d.dispute_id + WHERE dj.juror_address = $1 + ORDER BY d.opened_at DESC`, + [jurorAddress], + ) + return result.rows + }, + + async getDisputeByMilestone( + proposalId: number, + milestoneId: number, + ): Promise { + const result = await pool.query( + `SELECT * FROM disputes WHERE proposal_id = $1 AND milestone_id = $2`, + [proposalId, milestoneId], + ) + return result.rows[0] ?? null + }, +} diff --git a/server/src/db/migrations/032_milestone_arbitration.sql b/server/src/db/migrations/032_milestone_arbitration.sql new file mode 100644 index 00000000..2fbf91ec --- /dev/null +++ b/server/src/db/migrations/032_milestone_arbitration.sql @@ -0,0 +1,91 @@ +-- ============================================================ +-- Migration 032: On-chain milestone arbitration (issue #1082) +-- ============================================================ +-- Off-chain read model for the milestone_arbitration Soroban contract. The +-- chain is the source of truth for every state transition; these tables +-- exist so the UI (open disputes, a juror's assignments, a dispute detail +-- page) can be served from Postgres instead of replaying events on every +-- request. Rows are written by the event indexer as it observes +-- DisputeOpened / VoteCommitted / VoteRevealed / DisputeResolved events, not +-- by any direct user-facing write path. + +CREATE TABLE IF NOT EXISTS disputes ( + dispute_id BIGINT PRIMARY KEY, + proposal_id INTEGER NOT NULL, + milestone_id INTEGER NOT NULL, + scholar_address TEXT NOT NULL, + evidence_ipfs_cid TEXT, + evidence_hash TEXT NOT NULL, + scholar_stake NUMERIC NOT NULL, + opened_at TIMESTAMP WITH TIME ZONE NOT NULL, + commit_deadline TIMESTAMP WITH TIME ZONE NOT NULL, + reveal_deadline TIMESTAMP WITH TIME ZONE NOT NULL, + phase TEXT NOT NULL DEFAULT 'active' + CHECK (phase IN ('active', 'resolved', 'quorum_failed')), + votes_for INTEGER NOT NULL DEFAULT 0, + votes_against INTEGER NOT NULL DEFAULT 0, + revealed_count INTEGER NOT NULL DEFAULT 0, + outcome BOOLEAN, + resolved_at TIMESTAMP WITH TIME ZONE, + resolve_tx_hash TEXT, + open_tx_hash TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (proposal_id, milestone_id) +); + +CREATE INDEX IF NOT EXISTS idx_disputes_phase ON disputes (phase); +CREATE INDEX IF NOT EXISTS idx_disputes_scholar_address ON disputes (scholar_address); +CREATE INDEX IF NOT EXISTS idx_disputes_reveal_deadline ON disputes (reveal_deadline); + +-- One row per juror drawn onto a dispute's panel. Populated from the panel +-- list carried on the DisputeOpened event. +CREATE TABLE IF NOT EXISTS dispute_jurors ( + id SERIAL PRIMARY KEY, + dispute_id BIGINT NOT NULL REFERENCES disputes (dispute_id), + juror_address TEXT NOT NULL, + has_committed BOOLEAN NOT NULL DEFAULT FALSE, + has_revealed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (dispute_id, juror_address) +); + +CREATE INDEX IF NOT EXISTS idx_dispute_jurors_juror_address ON dispute_jurors (juror_address); +CREATE INDEX IF NOT EXISTS idx_dispute_jurors_dispute_id ON dispute_jurors (dispute_id); + +-- Vote reveals only -- commitments are opaque hashes with no informational +-- value off-chain, so only VoteRevealed events produce a row here. The +-- dispute_jurors.has_committed flag is the off-chain signal for "committed +-- but reveal is still pending." +CREATE TABLE IF NOT EXISTS dispute_votes ( + id SERIAL PRIMARY KEY, + dispute_id BIGINT NOT NULL REFERENCES disputes (dispute_id), + juror_address TEXT NOT NULL, + vote BOOLEAN NOT NULL, + revealed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + tx_hash TEXT, + UNIQUE (dispute_id, juror_address) +); + +CREATE INDEX IF NOT EXISTS idx_dispute_votes_dispute_id ON dispute_votes (dispute_id); + +-- A scholar uploads evidence to IPFS and registers its CID here *before* +-- signing the on-chain `open_dispute` call (which only ever receives the +-- hash). The indexer attaches this CID to the `disputes` row once it +-- observes the matching DisputeOpened event, keyed by (proposal_id, +-- milestone_id) since the dispute id isn't known client-side beforehand. +CREATE TABLE IF NOT EXISTS pending_dispute_evidence ( + proposal_id INTEGER NOT NULL, + milestone_id INTEGER NOT NULL, + scholar_address TEXT NOT NULL, + evidence_ipfs_cid TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (proposal_id, milestone_id) +); + +-- New notification types for the dispute lifecycle (see notifications-store.ts). +ALTER TABLE notification_preferences + ADD COLUMN IF NOT EXISTS dispute_opened BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS dispute_juror_selected BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS dispute_reveal_reminder BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS dispute_resolved BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/server/src/db/migrations/032_milestone_arbitration.undo.sql b/server/src/db/migrations/032_milestone_arbitration.undo.sql new file mode 100644 index 00000000..23098ad7 --- /dev/null +++ b/server/src/db/migrations/032_milestone_arbitration.undo.sql @@ -0,0 +1,22 @@ +-- Rollback for 032_milestone_arbitration.sql +-- WARNING: destroys all indexed dispute/juror/vote history. + +DROP TABLE IF EXISTS pending_dispute_evidence; + +ALTER TABLE notification_preferences + DROP COLUMN IF EXISTS dispute_opened, + DROP COLUMN IF EXISTS dispute_juror_selected, + DROP COLUMN IF EXISTS dispute_reveal_reminder, + DROP COLUMN IF EXISTS dispute_resolved; + +DROP INDEX IF EXISTS idx_dispute_votes_dispute_id; +DROP TABLE IF EXISTS dispute_votes; + +DROP INDEX IF EXISTS idx_dispute_jurors_dispute_id; +DROP INDEX IF EXISTS idx_dispute_jurors_juror_address; +DROP TABLE IF EXISTS dispute_jurors; + +DROP INDEX IF EXISTS idx_disputes_reveal_deadline; +DROP INDEX IF EXISTS idx_disputes_scholar_address; +DROP INDEX IF EXISTS idx_disputes_phase; +DROP TABLE IF EXISTS disputes; diff --git a/server/src/db/notifications-store.ts b/server/src/db/notifications-store.ts index 6ecd11ae..3ab3def6 100644 --- a/server/src/db/notifications-store.ts +++ b/server/src/db/notifications-store.ts @@ -10,6 +10,10 @@ export type NotificationType = | "disbursement" | "proposal_passed" | "voting_deadline_reminder" + | "dispute_opened" + | "dispute_juror_selected" + | "dispute_reveal_reminder" + | "dispute_resolved" export interface Notification { id: number @@ -54,6 +58,10 @@ export interface NotificationPreferences { disbursement: boolean proposal_passed: boolean voting_deadline_reminder: boolean + dispute_opened: boolean + dispute_juror_selected: boolean + dispute_reveal_reminder: boolean + dispute_resolved: boolean email_milestone_approved: boolean email_milestone_rejected: boolean email_vote_result: boolean @@ -258,6 +266,10 @@ export async function getNotificationPreferences( vote_result, disbursement, voting_deadline_reminder, + dispute_opened, + dispute_juror_selected, + dispute_reveal_reminder, + dispute_resolved, email_milestone_approved, email_milestone_rejected, email_vote_result, @@ -282,6 +294,10 @@ export async function getNotificationPreferences( vote_result, disbursement, voting_deadline_reminder, + dispute_opened, + dispute_juror_selected, + dispute_reveal_reminder, + dispute_resolved, email_milestone_approved, email_milestone_rejected, email_vote_result, @@ -318,6 +334,10 @@ export async function updateNotificationPreferences( vote_result, disbursement, voting_deadline_reminder, + dispute_opened, + dispute_juror_selected, + dispute_reveal_reminder, + dispute_resolved, email_milestone_approved, email_milestone_rejected, email_vote_result, diff --git a/server/src/index.ts b/server/src/index.ts index 9682cc4d..f43cf2af 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -42,6 +42,7 @@ import { createCommentsRouter } from "./routes/comments.routes" import { communityRouter } from "./routes/community.routes" import { createCoursesRouter } from "./routes/courses.routes" import { createCredentialsRouter } from "./routes/credentials.routes" +import { createDisputeRouter } from "./routes/dispute.routes" import { createEnrollmentsRouter } from "./routes/enrollments.routes" import { eventsRouter } from "./routes/events.routes" import { createForumRouter } from "./routes/forum.routes" @@ -333,6 +334,7 @@ app.use("/api", createStreaksRouter(jwtService)) app.use("/api", onboardingRouter) app.use("/api", createRelayRouter(jwtService)) app.use("/api", createAnchorsRouter(jwtService)) +app.use("/api", createDisputeRouter(jwtService)) if (process.env.NODE_ENV !== "test") { void import("./workers/escrow-timeout-worker").then( diff --git a/server/src/routes/dispute.routes.ts b/server/src/routes/dispute.routes.ts new file mode 100644 index 00000000..cee25057 --- /dev/null +++ b/server/src/routes/dispute.routes.ts @@ -0,0 +1,148 @@ +import { Router } from "express" +import { + getDisputeById, + getDisputeByMilestone, + getJurorAssignments, + listDisputes, + registerPendingEvidence, +} from "../controllers/dispute.controller" +import { + type AuthRequest, + createRequireAuth, +} from "../middleware/auth.middleware" +import { type JwtService } from "../services/jwt.service" + +export function createDisputeRouter(jwtService: JwtService): Router { + const router = Router() + const requireAuth = createRequireAuth(jwtService) + + /** + * @openapi + * /api/disputes: + * get: + * tags: [Disputes] + * summary: List milestone arbitration disputes + * description: > + * Reads the off-chain index of on-chain milestone_arbitration disputes. + * Filter by phase to find open disputes needing jurors. + * parameters: + * - in: query + * name: phase + * schema: + * type: string + * enum: [active, resolved, quorum_failed] + * - in: query + * name: page + * schema: { type: integer } + * - in: query + * name: pageSize + * schema: { type: integer } + * responses: + * 200: + * description: Paginated list of disputes + */ + router.get("/disputes", (req, res) => { + void listDisputes(req, res) + }) + + /** + * @openapi + * /api/disputes/juror/{address}: + * get: + * tags: [Disputes] + * summary: List a juror's dispute panel assignments + * parameters: + * - in: path + * name: address + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Disputes this address has been drawn onto a panel for + */ + router.get("/disputes/juror/:address", (req, res) => { + void getJurorAssignments(req, res) + }) + + /** + * @openapi + * /api/disputes/milestone/{proposalId}/{milestoneId}: + * get: + * tags: [Disputes] + * summary: Look up the dispute (if any) opened for a specific milestone + * parameters: + * - in: path + * name: proposalId + * required: true + * schema: { type: integer } + * - in: path + * name: milestoneId + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: The dispute for this milestone, or null + */ + router.get("/disputes/milestone/:proposalId/:milestoneId", (req, res) => { + void getDisputeByMilestone(req, res) + }) + + /** + * @openapi + * /api/disputes/pending-evidence: + * post: + * tags: [Disputes] + * summary: Register an IPFS evidence CID before opening a dispute on-chain + * description: > + * Call after uploading evidence to IPFS (via POST /api/upload) and + * before signing `open_dispute`. Only the evidence hash is ever + * written on-chain; the indexer attaches this CID to the dispute + * once it observes the matching DisputeOpened event. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [proposalId, milestoneId, evidenceIpfsCid] + * properties: + * proposalId: { type: integer } + * milestoneId: { type: integer } + * evidenceIpfsCid: { type: string } + * responses: + * 200: + * description: Evidence CID registered + * 400: + * $ref: '#/components/responses/BadRequestError' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + */ + router.post("/disputes/pending-evidence", requireAuth, (req, res) => { + void registerPendingEvidence(req as AuthRequest, res) + }) + + /** + * @openapi + * /api/disputes/{id}: + * get: + * tags: [Disputes] + * summary: Fetch one dispute, its panel, and any revealed votes + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Dispute detail + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + router.get("/disputes/:id", (req, res) => { + void getDisputeById(req, res) + }) + + return router +} diff --git a/server/src/services/event-indexer.service.ts b/server/src/services/event-indexer.service.ts index daab2aff..2d428a4e 100644 --- a/server/src/services/event-indexer.service.ts +++ b/server/src/services/event-indexer.service.ts @@ -355,12 +355,204 @@ async function persistIndexedEvent( } } + if ( + topic === "MilestoneArbitration::dispute_opened" || + topic === "MilestoneArbitration::vote_committed" || + topic === "MilestoneArbitration::vote_revealed" || + topic === "MilestoneArbitration::dispute_resolved" + ) { + await handleArbitrationEvent(topic, data, txHash) + } + return true } return false } +// Matches milestone_arbitration's COMMIT_WINDOW_SECONDS -- used only to +// back-derive an approximate `opened_at` for display, since the on-chain +// DisputeOpened event carries the commit/reveal deadlines but not the open +// timestamp itself. +const COMMIT_WINDOW_SECONDS = 3 * 24 * 60 * 60 + +/** + * Reactive side effects for milestone_arbitration dispute-lifecycle events: + * mirror the event into the `disputes` / `dispute_jurors` / `dispute_votes` + * read model, and notify the parties who need to act before a deadline + * passes. Best-effort throughout -- a notification failure never blocks + * indexing the event itself. + */ +async function handleArbitrationEvent( + topic: string, + data: { value?: unknown }, + txHash: string | undefined, +): Promise { + try { + const { disputeStore } = await import("../db/dispute-store") + const value = ( + data.value && typeof data.value === "object" ? data.value : {} + ) as Record + + const disputeId = String(value.dispute_id ?? value.disputeId ?? "") + if (!disputeId) return + + if (topic === "MilestoneArbitration::dispute_opened") { + const proposalId = Number(value.proposal_id ?? value.proposalId ?? 0) + const milestoneId = Number(value.milestone_id ?? value.milestoneId ?? 0) + const scholar = String(value.scholar ?? "") + const commitDeadlineSec = Number( + value.commit_deadline ?? value.commitDeadline ?? 0, + ) + const revealDeadlineSec = Number( + value.reveal_deadline ?? value.revealDeadline ?? 0, + ) + const panel = Array.isArray(value.panel) ? value.panel.map(String) : [] + + await disputeStore.upsertDisputeOpened({ + disputeId, + proposalId, + milestoneId, + scholarAddress: scholar, + evidenceHash: String(value.evidence_hash ?? value.evidenceHash ?? ""), + scholarStake: String(value.scholar_stake ?? value.scholarStake ?? "0"), + openedAt: new Date((commitDeadlineSec - COMMIT_WINDOW_SECONDS) * 1000), + commitDeadline: new Date(commitDeadlineSec * 1000), + revealDeadline: new Date(revealDeadlineSec * 1000), + panel, + openTxHash: txHash ?? null, + }) + + const evidenceCid = await disputeStore.takePendingEvidence( + proposalId, + milestoneId, + ) + if (evidenceCid) { + await disputeStore.setEvidenceCid(disputeId, evidenceCid) + } + + if (scholar) { + void createNotification({ + recipient_address: scholar, + type: "dispute_opened", + message: `Your dispute #${disputeId} over milestone #${milestoneId} has been opened. A panel of ${panel.length} jurors has been drawn.`, + href: `/disputes/${disputeId}`, + data: { disputeId, proposalId, milestoneId }, + }) + void deliverNotificationChannels({ + recipientAddress: scholar, + type: "dispute_opened", + title: "Arbitration Dispute Opened", + message: `Dispute #${disputeId} is now with a panel of jurors.`, + href: `/disputes/${disputeId}`, + }) + } + + for (const juror of panel) { + void createNotification({ + recipient_address: juror, + type: "dispute_juror_selected", + message: `You have been drawn onto the panel for dispute #${disputeId}. Commit your vote before the deadline.`, + href: `/disputes/juror/${disputeId}`, + data: { disputeId, proposalId, milestoneId, commitDeadlineSec }, + }) + void deliverNotificationChannels({ + recipientAddress: juror, + type: "dispute_juror_selected", + title: "You've Been Selected as a Juror", + message: `Dispute #${disputeId} needs your committed vote before the commit window closes.`, + href: `/disputes/juror/${disputeId}`, + }) + } + } + + if (topic === "MilestoneArbitration::vote_committed") { + const juror = String(value.juror ?? "") + if (juror) { + await disputeStore.markCommitted(disputeId, juror) + } + } + + if (topic === "MilestoneArbitration::vote_revealed") { + const juror = String(value.juror ?? "") + const vote = value.vote === true || value.vote === "true" + if (juror) { + await disputeStore.recordReveal({ + disputeId, + jurorAddress: juror, + vote, + revealedAt: new Date(), + txHash: txHash ?? null, + }) + } + } + + if (topic === "MilestoneArbitration::dispute_resolved") { + const proposalId = Number(value.proposal_id ?? value.proposalId ?? 0) + const milestoneId = Number(value.milestone_id ?? value.milestoneId ?? 0) + const quorumMet = value.quorum_met === true || value.quorum_met === "true" + const outcome = + value.outcome === null || value.outcome === undefined + ? null + : value.outcome === true || value.outcome === "true" + const votesFor = Number(value.votes_for ?? value.votesFor ?? 0) + const votesAgainst = Number( + value.votes_against ?? value.votesAgainst ?? 0, + ) + const revealedCount = Number( + value.revealed_count ?? value.revealedCount ?? 0, + ) + + await disputeStore.markResolved({ + disputeId, + outcome, + votesFor, + votesAgainst, + revealedCount, + quorumMet, + resolvedAt: new Date(), + resolveTxHash: txHash ?? null, + }) + + const dispute = await disputeStore.getDisputeById(disputeId) + if (dispute?.scholar_address) { + const outcomeText = !quorumMet + ? "the panel did not reach quorum, so the rejection stands and your stake was refunded" + : outcome + ? "the panel ruled in your favor -- the milestone tranche has been released" + : "the panel upheld the rejection" + void createNotification({ + recipient_address: dispute.scholar_address, + type: "dispute_resolved", + message: `Dispute #${disputeId} has been resolved: ${outcomeText}.`, + href: `/disputes/${disputeId}`, + data: { disputeId, proposalId, milestoneId, outcome, quorumMet }, + }) + void deliverNotificationChannels({ + recipientAddress: dispute.scholar_address, + type: "dispute_resolved", + title: "Dispute Resolved", + message: `Dispute #${disputeId} has been resolved.`, + href: `/disputes/${disputeId}`, + }) + } + + const jurors = await disputeStore.getJurorsForDispute(disputeId) + for (const juror of jurors) { + void createNotification({ + recipient_address: juror.juror_address, + type: "dispute_resolved", + message: `Dispute #${disputeId} you served on has been resolved.`, + href: `/disputes/${disputeId}`, + data: { disputeId, proposalId, milestoneId, outcome, quorumMet }, + }) + } + } + } catch (err) { + log.error({ err, topic }, "arbitration event handler failed") + } +} + /** * Process events pushed via the Horizon webhook relay. */ diff --git a/server/src/types/events.ts b/server/src/types/events.ts index 87c500e3..8c6f3b39 100644 --- a/server/src/types/events.ts +++ b/server/src/types/events.ts @@ -7,6 +7,7 @@ export const CONTRACT_IDS = { scholarshipTreasury: process.env.SCHOLARSHIP_TREASURY_CONTRACT_ID!, milestoneEscrow: process.env.MILESTONE_ESCROW_CONTRACT_ID!, scholarNft: process.env.SCHOLAR_NFT_CONTRACT_ID!, + milestoneArbitration: process.env.MILESTONE_ARBITRATION_CONTRACT_ID!, } as const export type ContractName = keyof typeof CONTRACT_IDS @@ -17,11 +18,21 @@ export const EVENT_TOPICS = { CourseMilestone_MilestoneComplete: "CourseMilestone::MilestoneComplete", ScholarshipTreasury_Deposit: "ScholarshipTreasury::Deposit", ScholarshipTreasury_ProposalCreated: "ScholarshipTreasury::ProposalCreated", - ScholarshipTreasury_ProposalExecuted: "ScholarshipTreasury::proposal_executed", + ScholarshipTreasury_ProposalExecuted: + "ScholarshipTreasury::proposal_executed", ScholarshipTreasury_VoteCastEvent: "ScholarshipTreasury::VoteCastEvent", MilestoneEscrow_FundsDisbursed: "MilestoneEscrow::FundsDisbursed", + MilestoneEscrow_TrancheReleasedViaArbitration: + "MilestoneEscrow::arb_released", ScholarNft_Minted: "ScholarNFT::minted", ScholarNft_Revoked: "ScholarNFT::revoked", + MilestoneArbitration_DisputeOpened: "MilestoneArbitration::dispute_opened", + MilestoneArbitration_VoteCommitted: "MilestoneArbitration::vote_committed", + MilestoneArbitration_VoteRevealed: "MilestoneArbitration::vote_revealed", + MilestoneArbitration_DisputeResolved: + "MilestoneArbitration::dispute_resolved", + MilestoneArbitration_JurorJoined: "MilestoneArbitration::juror_joined", + MilestoneArbitration_JurorLeft: "MilestoneArbitration::juror_left", } as const export type EventTopic = keyof typeof EVENT_TOPICS @@ -37,8 +48,19 @@ export const EVENTS_TO_INDEX: Record = { "ScholarshipTreasury_ProposalExecuted", "ScholarshipTreasury_VoteCastEvent", ], - milestoneEscrow: ["MilestoneEscrow_FundsDisbursed"], + milestoneEscrow: [ + "MilestoneEscrow_FundsDisbursed", + "MilestoneEscrow_TrancheReleasedViaArbitration", + ], scholarNft: ["ScholarNft_Minted", "ScholarNft_Revoked"], + milestoneArbitration: [ + "MilestoneArbitration_DisputeOpened", + "MilestoneArbitration_VoteCommitted", + "MilestoneArbitration_VoteRevealed", + "MilestoneArbitration_DisputeResolved", + "MilestoneArbitration_JurorJoined", + "MilestoneArbitration_JurorLeft", + ], } as const // Zod schemas for event data parsing (extend as needed) @@ -61,6 +83,35 @@ export const EVENT_DATA_SCHEMAS: Partial> = token_id: z.string().regex(/^\d+$/), reason: z.string(), }), + "MilestoneArbitration::dispute_opened": z.object({ + dispute_id: z.string().regex(/^\d+$/), + scholar: z.string(), + proposal_id: z.string().regex(/^\d+$/), + milestone_id: z.string().regex(/^\d+$/), + evidence_hash: z.string(), + panel: z.array(z.string()), + commit_deadline: z.string().regex(/^\d+$/), + reveal_deadline: z.string().regex(/^\d+$/), + }), + "MilestoneArbitration::vote_committed": z.object({ + dispute_id: z.string().regex(/^\d+$/), + juror: z.string(), + }), + "MilestoneArbitration::vote_revealed": z.object({ + dispute_id: z.string().regex(/^\d+$/), + juror: z.string(), + vote: z.boolean(), + }), + "MilestoneArbitration::dispute_resolved": z.object({ + dispute_id: z.string().regex(/^\d+$/), + proposal_id: z.string().regex(/^\d+$/), + milestone_id: z.string().regex(/^\d+$/), + outcome: z.boolean().nullable(), + votes_for: z.number(), + votes_against: z.number(), + revealed_count: z.number(), + quorum_met: z.boolean(), + }), // Add others... } diff --git a/src/App.tsx b/src/App.tsx index c7d3b5c9..5835f07c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,8 @@ const DaoProposals = lazy(() => import("./pages/DaoProposals")) const DaoPropose = lazy(() => import("./pages/DaoPropose")) const Dashboard = lazy(() => import("./pages/Dashboard")) const Debug = lazy(() => import("./pages/Debug")) +const Disputes = lazy(() => import("./pages/Disputes")) +const DisputeDetail = lazy(() => import("./pages/DisputeDetail")) const Donor = lazy(() => import("./pages/Donor")) const Home = lazy(() => import("./pages/Home")) const History = lazy(() => import("./pages/History")) @@ -145,6 +147,11 @@ function App() { element={renderRoute()} /> )} /> + )} /> + )} + /> )} /> diff --git a/src/components/EscalateDisputeButton.tsx b/src/components/EscalateDisputeButton.tsx new file mode 100644 index 00000000..472fbd45 --- /dev/null +++ b/src/components/EscalateDisputeButton.tsx @@ -0,0 +1,177 @@ +import { Button } from "@stellar/design-system" +import { useState } from "react" +import { useContractIds } from "../hooks/useContractIds" +import { useWallet } from "../hooks/useWallet" +import { apiFetchJson } from "../lib/api" +import { + bytesToHex, + hashEvidenceCid, + submitOpenDispute, +} from "../lib/milestoneArbitrationContract" +import { useToast } from "./Toast/ToastProvider" + +const SCHOLAR_DISPUTE_STAKE_LRN = 500 + +interface EscalateDisputeButtonProps { + /** The rejected milestone's sequence number (arbitration's `milestoneId`). */ + milestoneId: number + /** When the rejection happened -- bounds the dispute window on-chain. */ + rejectedAt: string | null +} + +export default function EscalateDisputeButton({ + milestoneId, + rejectedAt, +}: EscalateDisputeButtonProps) { + const { address, signTransaction } = useWallet() + const { milestoneArbitration } = useContractIds() + const { showSuccess, showError, showInfo } = useToast() + const [isOpen, setIsOpen] = useState(false) + const [proposalId, setProposalId] = useState("") + const [file, setFile] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [lastTxHash, setLastTxHash] = useState(null) + + if (!milestoneArbitration) { + return null + } + + const handleEscalate = async () => { + if (!address || !signTransaction) { + showError("Connect your wallet first.") + return + } + const parsedProposalId = Number(proposalId) + if (!Number.isFinite(parsedProposalId) || parsedProposalId <= 0) { + showError("Enter the proposal ID this milestone belongs to.") + return + } + if (!file) { + showError("Attach evidence supporting your dispute before escalating.") + return + } + + setIsSubmitting(true) + try { + showInfo("Uploading evidence to IPFS...") + const formData = new FormData() + formData.append("file", file) + const uploadResult = await apiFetchJson<{ cid: string }>("/api/upload", { + method: "POST", + auth: true, + body: formData, + }) + + await apiFetchJson("/api/disputes/pending-evidence", { + method: "POST", + auth: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + proposalId: parsedProposalId, + milestoneId, + evidenceIpfsCid: uploadResult.cid, + }), + }) + + const evidenceHash = await hashEvidenceCid(uploadResult.cid) + const rejectedAtSeconds = BigInt( + Math.floor(new Date(rejectedAt ?? Date.now()).getTime() / 1000), + ) + + showInfo( + `Waiting for wallet approval to stake ${SCHOLAR_DISPUTE_STAKE_LRN} LRN...`, + ) + const txHash = await submitOpenDispute({ + contractId: milestoneArbitration, + scholarAddress: address, + proposalId: parsedProposalId, + milestoneId, + evidenceHash, + rejectedAtSeconds, + signTransaction, + }) + + setLastTxHash(txHash) + showSuccess( + `Dispute opened. A panel of jurors will be drawn to review evidence hash ${bytesToHex(evidenceHash).slice(0, 10)}...`, + ) + } catch (err) { + showError( + err instanceof Error + ? err.message + : "Failed to escalate to arbitration.", + ) + } finally { + setIsSubmitting(false) + } + } + + if (!isOpen) { + return ( + + ) + } + + return ( +
+

+ Escalating requires staking{" "} + {SCHOLAR_DISPUTE_STAKE_LRN} LRN, which is forfeited if + a juror panel upholds this rejection. A panel of staked jurors will + review your evidence and vote; the outcome directly authorizes (or + denies) release of the escrow tranche. +

+ + +
+ + +
+ {lastTxHash && ( +

+ Dispute tx: {lastTxHash} +

+ )} +
+ ) +} diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 4b2eda5f..333bb668 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -49,6 +49,7 @@ export default function NavBar() { { to: "/donor", label: "Donor" }, { to: "/sponsor", label: "Sponsor" }, { to: "/treasury", label: t("nav.treasury") }, + { to: "/disputes", label: "Disputes" }, ] const closeMenu = () => setMenuOpen(false) diff --git a/src/constants/contracts.ts b/src/constants/contracts.ts index f51ad385..af901789 100644 --- a/src/constants/contracts.ts +++ b/src/constants/contracts.ts @@ -43,4 +43,9 @@ export const CONTRACT_IDS = { "PUBLIC_MILESTONE_ESCROW_CONTRACT", "PUBLIC_MILESTONE_ESCROW_CONTRACT_ID", ), + milestoneArbitration: readContractId( + "VITE_MILESTONE_ARBITRATION_CONTRACT_ID", + "PUBLIC_MILESTONE_ARBITRATION_CONTRACT", + "PUBLIC_MILESTONE_ARBITRATION_CONTRACT_ID", + ), } as const diff --git a/src/hooks/useContractIds.ts b/src/hooks/useContractIds.ts index 6191c1c7..082e4cfc 100644 --- a/src/hooks/useContractIds.ts +++ b/src/hooks/useContractIds.ts @@ -14,6 +14,9 @@ export function useContractIds() { CONTRACT_IDS.scholarshipTreasury, ) const milestoneEscrow = normalizeContractId(CONTRACT_IDS.milestoneEscrow) + const milestoneArbitration = normalizeContractId( + CONTRACT_IDS.milestoneArbitration, + ) const usdc = normalizeContractId(import.meta.env.PUBLIC_USDC_CONTRACT_ID as string) ?? normalizeContractId(import.meta.env.VITE_USDC_CONTRACT_ID as string) @@ -25,6 +28,7 @@ export function useContractIds() { courseMilestone, scholarshipTreasury, milestoneEscrow, + milestoneArbitration, usdc, isDeployed: (id: string | undefined): id is string => Boolean(id), } diff --git a/src/hooks/useDisputes.ts b/src/hooks/useDisputes.ts new file mode 100644 index 00000000..a2f14886 --- /dev/null +++ b/src/hooks/useDisputes.ts @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query" +import { apiFetchJson } from "../lib/api" +import { + type DisputeDetailResponse, + type DisputeListResponse, + type DisputePhase, + type JurorAssignmentsResponse, +} from "../types/dispute" + +const DISPUTES_BASE = "/api/disputes" + +export function useDisputes(filters?: { + phase?: DisputePhase + page?: number + pageSize?: number +}) { + const params = new URLSearchParams() + if (filters?.phase) params.set("phase", filters.phase) + if (filters?.page) params.set("page", String(filters.page)) + if (filters?.pageSize) params.set("pageSize", String(filters.pageSize)) + const qs = params.toString() + const url = qs ? `${DISPUTES_BASE}?${qs}` : DISPUTES_BASE + + return useQuery({ + queryKey: ["disputes", filters], + queryFn: () => apiFetchJson(url), + staleTime: 15_000, + }) +} + +export function useDispute(disputeId: string | null) { + return useQuery({ + queryKey: ["dispute", disputeId], + queryFn: () => + apiFetchJson(`${DISPUTES_BASE}/${disputeId}`), + enabled: Boolean(disputeId), + staleTime: 10_000, + }) +} + +export function useJurorAssignments(address: string | null) { + return useQuery({ + queryKey: ["dispute-assignments", address], + queryFn: () => + apiFetchJson( + `${DISPUTES_BASE}/juror/${address}`, + ), + enabled: Boolean(address), + staleTime: 15_000, + }) +} diff --git a/src/lib/disputeVoteStorage.ts b/src/lib/disputeVoteStorage.ts new file mode 100644 index 00000000..dfff9fdc --- /dev/null +++ b/src/lib/disputeVoteStorage.ts @@ -0,0 +1,48 @@ +// A commit-reveal vote is only as good as the juror's ability to reveal it +// later with the exact same (vote, salt) pair. There is no way to recover a +// randomly generated salt after the fact, so it has to be persisted +// somewhere the browser controls between the commit and reveal steps. +// localStorage, scoped per wallet address, is good enough for that -- this +// is convenience state, not a security boundary (the commitment on-chain is +// what actually binds the vote). + +interface StoredVote { + vote: boolean + saltHex: string +} + +function storageKey(walletAddress: string): string { + return `learnvault:dispute-votes:${walletAddress}` +} + +function readAll(walletAddress: string): Record { + try { + const raw = localStorage.getItem(storageKey(walletAddress)) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +export function saveVote( + walletAddress: string, + disputeId: string, + vote: boolean, + saltHex: string, +): void { + const all = readAll(walletAddress) + all[disputeId] = { vote, saltHex } + try { + localStorage.setItem(storageKey(walletAddress), JSON.stringify(all)) + } catch { + // Storage full or unavailable -- the juror will need to note their + // salt manually. Nothing more we can do client-side. + } +} + +export function getStoredVote( + walletAddress: string, + disputeId: string, +): StoredVote | null { + return readAll(walletAddress)[disputeId] ?? null +} diff --git a/src/lib/milestoneArbitrationContract.ts b/src/lib/milestoneArbitrationContract.ts new file mode 100644 index 00000000..82febd27 --- /dev/null +++ b/src/lib/milestoneArbitrationContract.ts @@ -0,0 +1,190 @@ +import { Address, nativeToScVal, type xdr } from "@stellar/stellar-sdk" + +type WalletSignTransaction = ( + xdr: string, + opts?: { networkPassphrase?: string }, +) => Promise + +/** + * sha256 via the browser's Web Crypto API. Used for both the evidence hash + * (`sha256(cid)`) and the commit-reveal commitment -- the arbitration + * contract never receives evidence text or a raw vote before reveal, only + * hashes. + */ +async function sha256(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + bytes.slice().buffer as ArrayBuffer, + ) + return new Uint8Array(digest) +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +function u64BeBytes(value: bigint): Uint8Array { + const bytes = new Uint8Array(8) + const view = new DataView(bytes.buffer) + view.setBigUint64(0, value, false) + return bytes +} + +/** A fresh, cryptographically random 32-byte salt for one commit-reveal vote. */ +export function generateSalt(): Uint8Array { + const salt = new Uint8Array(32) + crypto.getRandomValues(salt) + return salt +} + +/** `sha256(utf8(ipfsCid))` -- the only evidence reference ever written on-chain. */ +export async function hashEvidenceCid(ipfsCid: string): Promise { + return sha256(new TextEncoder().encode(ipfsCid)) +} + +/** + * `sha256(dispute_id (8 bytes, big-endian) ++ vote_byte (0/1) ++ salt (32 bytes))`, + * matching `milestone_arbitration::compute_commitment` exactly. Store the + * salt locally (or let the juror re-derive it) -- it is required again at + * reveal time, and a lost salt means a lost vote. + */ +export async function computeVoteCommitment( + disputeId: bigint, + vote: boolean, + salt: Uint8Array, +): Promise { + const voteByte = new Uint8Array([vote ? 1 : 0]) + return sha256(concatBytes(u64BeBytes(disputeId), voteByte, salt)) +} + +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +export function hexToBytes(hex: string): Uint8Array { + const clean = hex.startsWith("0x") ? hex.slice(2) : hex + const bytes = new Uint8Array(clean.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +async function submit(options: { + contractId: string + methodName: string + sourceAddress: string + signTransaction: WalletSignTransaction + args: xdr.ScVal[] +}): Promise { + const { invokeContractMethod } = await import("../util/sorobanAdmin") + return invokeContractMethod({ + contractId: options.contractId, + methodName: options.methodName, + sourceAddress: options.sourceAddress, + signTransaction: options.signTransaction, + args: options.args, + }) +} + +/** Stake LRN to join the eligible-juror pool (`join_panel`). */ +export async function submitJoinPanel(options: { + contractId: string + jurorAddress: string + stakeAmount: string + signTransaction: WalletSignTransaction +}): Promise { + return submit({ + contractId: options.contractId, + methodName: "join_panel", + sourceAddress: options.jurorAddress, + signTransaction: options.signTransaction, + args: [ + new Address(options.jurorAddress).toScVal(), + nativeToScVal(options.stakeAmount, { type: "i128" }), + ], + }) +} + +/** + * Escalate a rejected milestone (`open_dispute`). `evidenceHash` and + * `rejectedAtSeconds` come from the caller so the UI stays in full control + * of what evidence CID the hash corresponds to and which rejection is being + * disputed. + */ +export async function submitOpenDispute(options: { + contractId: string + scholarAddress: string + proposalId: number + milestoneId: number + evidenceHash: Uint8Array + rejectedAtSeconds: bigint + signTransaction: WalletSignTransaction +}): Promise { + return submit({ + contractId: options.contractId, + methodName: "open_dispute", + sourceAddress: options.scholarAddress, + signTransaction: options.signTransaction, + args: [ + new Address(options.scholarAddress).toScVal(), + nativeToScVal(options.proposalId, { type: "u32" }), + nativeToScVal(options.milestoneId, { type: "u32" }), + nativeToScVal(Buffer.from(options.evidenceHash), { type: "bytes" }), + nativeToScVal(options.rejectedAtSeconds, { type: "u64" }), + ], + }) +} + +/** Submit a blind commitment for a dispute this address was drawn to judge. */ +export async function submitCommitVote(options: { + contractId: string + disputeId: bigint + jurorAddress: string + commitment: Uint8Array + signTransaction: WalletSignTransaction +}): Promise { + return submit({ + contractId: options.contractId, + methodName: "commit_vote", + sourceAddress: options.jurorAddress, + signTransaction: options.signTransaction, + args: [ + nativeToScVal(options.disputeId, { type: "u64" }), + new Address(options.jurorAddress).toScVal(), + nativeToScVal(Buffer.from(options.commitment), { type: "bytes" }), + ], + }) +} + +/** Reveal a previously committed vote. Must match the commitment exactly. */ +export async function submitRevealVote(options: { + contractId: string + disputeId: bigint + jurorAddress: string + vote: boolean + salt: Uint8Array + signTransaction: WalletSignTransaction +}): Promise { + return submit({ + contractId: options.contractId, + methodName: "reveal_vote", + sourceAddress: options.jurorAddress, + signTransaction: options.signTransaction, + args: [ + nativeToScVal(options.disputeId, { type: "u64" }), + new Address(options.jurorAddress).toScVal(), + nativeToScVal(options.vote, { type: "bool" }), + nativeToScVal(Buffer.from(options.salt), { type: "bytes" }), + ], + }) +} diff --git a/src/pages/DisputeDetail.tsx b/src/pages/DisputeDetail.tsx new file mode 100644 index 00000000..32c6cbb0 --- /dev/null +++ b/src/pages/DisputeDetail.tsx @@ -0,0 +1,200 @@ +import { Card } from "@stellar/design-system" +import { Link, useParams } from "react-router-dom" +import { useDispute } from "../hooks/useDisputes" +import { getIpfsUrl } from "../lib/ipfs" +import { + explorerTransactionUrl, + shortenAddress, +} from "../util/scholarshipApplications" + +function formatDeadline(iso: string | null): string { + if (!iso) return "--" + return new Date(iso).toLocaleString() +} + +const PHASE_LABELS: Record = { + active: "Voting in progress", + resolved: "Resolved", + quorum_failed: "Quorum not met", +} + +export default function DisputeDetail() { + const { id } = useParams<{ id: string }>() + const { data, isLoading, error } = useDispute(id ?? null) + const dispute = data?.data + + return ( +
+
+
+

+ Milestone arbitration +

+

+ Dispute #{id} +

+ + Back to juror console + +
+ + {isLoading ? ( +

Loading...

+ ) : error || !dispute ? ( +

Dispute not found.

+ ) : ( + <> +
+ +
+

+ {PHASE_LABELS[dispute.phase] ?? dispute.phase} +

+ + {dispute.phase} + +
+ +
+

+ Scholar:{" "} + {shortenAddress(dispute.scholar_address)} +

+

+ Proposal:{" "} + #{dispute.proposal_id} +

+

+ Milestone:{" "} + #{dispute.milestone_id} +

+

+ + Scholar stake: + {" "} + {Number(dispute.scholar_stake) / 10_000_000} LRN +

+

+ + Commit deadline: + {" "} + {formatDeadline(dispute.commit_deadline)} +

+

+ + Reveal deadline: + {" "} + {formatDeadline(dispute.reveal_deadline)} +

+
+ + {dispute.evidence_ipfs_cid && ( + + View evidence on IPFS + + )} +
+
+ +
+ +

+ Vote tally{" "} + {dispute.phase === "active" ? "(hidden until reveal)" : ""} +

+
+
+

+ {dispute.votes_for} +

+

Release

+
+
+

+ {dispute.votes_against} +

+

Uphold

+
+
+

+ {dispute.revealed_count} +

+

Revealed

+
+
+ + {dispute.phase !== "active" && ( +
+

+ {dispute.phase === "quorum_failed" + ? "Quorum was not reached -- the rejection stands and the scholar's stake was refunded." + : dispute.outcome + ? "The panel ruled in favor of the scholar -- the milestone tranche was released." + : "The panel upheld the rejection."} +

+ {dispute.resolve_tx_hash && ( + + View resolution transaction + + )} +
+ )} +
+
+ +
+ +

+ Panel +

+
+ {dispute.jurors.map((juror) => ( +
+ {shortenAddress(juror.juror_address)} + + {juror.has_committed && ( + + committed + + )} + {juror.has_revealed && ( + + revealed + + )} + +
+ ))} +
+
+
+ + )} +
+
+ ) +} diff --git a/src/pages/Disputes.tsx b/src/pages/Disputes.tsx new file mode 100644 index 00000000..8cc98f30 --- /dev/null +++ b/src/pages/Disputes.tsx @@ -0,0 +1,348 @@ +import { Button, Card } from "@stellar/design-system" +import { useState } from "react" +import { Link } from "react-router-dom" +import { useToast } from "../components/Toast/ToastProvider" +import { useContractIds } from "../hooks/useContractIds" +import { useDisputes, useJurorAssignments } from "../hooks/useDisputes" +import { useWallet } from "../hooks/useWallet" +import { getStoredVote, saveVote } from "../lib/disputeVoteStorage" +import { + bytesToHex, + computeVoteCommitment, + generateSalt, + hexToBytes, + submitCommitVote, + submitJoinPanel, + submitRevealVote, +} from "../lib/milestoneArbitrationContract" +import { type Dispute } from "../types/dispute" +import { shortenAddress } from "../util/scholarshipApplications" + +const MIN_JUROR_STAKE_LRN = "1000" + +function formatDeadline(iso: string): string { + return new Date(iso).toLocaleString() +} + +function DisputeCard({ dispute }: { dispute: Dispute }) { + const { address, signTransaction } = useWallet() + const { milestoneArbitration } = useContractIds() + const { showSuccess, showError, showInfo } = useToast() + const [isVoting, setIsVoting] = useState(false) + + const now = Date.now() + const commitOpen = now <= new Date(dispute.commit_deadline).getTime() + const revealOpen = + now > new Date(dispute.commit_deadline).getTime() && + now <= new Date(dispute.reveal_deadline).getTime() + + const handleCommit = async (vote: boolean) => { + if (!address || !signTransaction || !milestoneArbitration) { + showError("Connect your wallet first.") + return + } + setIsVoting(true) + try { + showInfo("Waiting for wallet approval...") + const salt = generateSalt() + const commitment = await computeVoteCommitment( + BigInt(dispute.dispute_id), + vote, + salt, + ) + await submitCommitVote({ + contractId: milestoneArbitration, + disputeId: BigInt(dispute.dispute_id), + jurorAddress: address, + commitment, + signTransaction, + }) + saveVote(address, dispute.dispute_id, vote, bytesToHex(salt)) + showSuccess( + "Vote committed. Keep this browser session -- your salt is stored locally and is required to reveal.", + ) + } catch (err) { + showError(err instanceof Error ? err.message : "Failed to commit vote.") + } finally { + setIsVoting(false) + } + } + + const handleReveal = async () => { + if (!address || !signTransaction || !milestoneArbitration) { + showError("Connect your wallet first.") + return + } + const stored = getStoredVote(address, dispute.dispute_id) + if (!stored) { + showError( + "No locally stored vote found for this dispute. Reveal must happen from the browser you committed from.", + ) + return + } + setIsVoting(true) + try { + showInfo("Waiting for wallet approval...") + await submitRevealVote({ + contractId: milestoneArbitration, + disputeId: BigInt(dispute.dispute_id), + jurorAddress: address, + vote: stored.vote, + salt: hexToBytes(stored.saltHex), + signTransaction, + }) + showSuccess("Vote revealed.") + } catch (err) { + showError(err instanceof Error ? err.message : "Failed to reveal vote.") + } finally { + setIsVoting(false) + } + } + + const storedVote = address ? getStoredVote(address, dispute.dispute_id) : null + + return ( +
+
+ + Dispute #{dispute.dispute_id} + + + {dispute.phase} + +
+

+ Proposal #{dispute.proposal_id} · Milestone #{dispute.milestone_id} · + Scholar {shortenAddress(dispute.scholar_address)} +

+

+ Commit by {formatDeadline(dispute.commit_deadline)} · Reveal by{" "} + {formatDeadline(dispute.reveal_deadline)} +

+ + {dispute.phase === "active" && commitOpen && !storedVote && ( +
+ + +
+ )} + {dispute.phase === "active" && commitOpen && storedVote && ( +

+ Vote committed locally ({storedVote.vote ? "release" : "uphold"}). + Reveal opens after the commit deadline. +

+ )} + {dispute.phase === "active" && revealOpen && ( +
+ + {!storedVote && ( +

+ No locally stored vote -- reveal from the browser you committed + in, or your vote cannot be revealed and your stake will be slashed + for non-participation. +

+ )} +
+ )} +
+ ) +} + +export default function Disputes() { + const { address, signTransaction } = useWallet() + const { milestoneArbitration } = useContractIds() + const { showSuccess, showError, showInfo } = useToast() + const [stakeAmount, setStakeAmount] = useState(MIN_JUROR_STAKE_LRN) + const [isJoining, setIsJoining] = useState(false) + + const { data: openDisputes, isLoading: isLoadingOpen } = useDisputes({ + phase: "active", + }) + const { data: assignments, isLoading: isLoadingAssignments } = + useJurorAssignments(address ?? null) + + const handleJoinPanel = async () => { + if (!address || !signTransaction) { + showError("Connect your wallet first.") + return + } + if (!milestoneArbitration) { + showError("Arbitration contract is not configured.") + return + } + const atomicAmount = String(Math.round(Number(stakeAmount) * 10_000_000)) + setIsJoining(true) + try { + showInfo("Waiting for wallet approval...") + await submitJoinPanel({ + contractId: milestoneArbitration, + jurorAddress: address, + stakeAmount: atomicAmount, + signTransaction, + }) + showSuccess(`Staked ${stakeAmount} LRN and joined the juror pool.`) + } catch (err) { + showError( + err instanceof Error ? err.message : "Failed to join the juror pool.", + ) + } finally { + setIsJoining(false) + } + } + + return ( +
+
+
+

+ Milestone arbitration +

+

+ Juror console +

+

+ Stake LRN to join the eligible-juror pool. When a dispute draws you + onto its panel, you have a commit window to submit a blind vote and + a reveal window to disclose it -- miss either and your stake is + slashed. +

+
+ +
+
+ +

+ Join the juror pool +

+

+ Minimum stake is {MIN_JUROR_STAKE_LRN} LRN. Your stake is + slashed if you're drawn onto a panel and fail to reveal, or if + you vote against the majority. +

+ +
+ +
+
+
+ +
+ +

+ Your assignments +

+ {!address ? ( +

+ Connect your wallet to see disputes you've been drawn to + judge. +

+ ) : isLoadingAssignments ? ( +

Loading...

+ ) : !assignments?.data.length ? ( +

+ No panel assignments yet. +

+ ) : ( +
+ {assignments.data.map((dispute) => ( + + ))} +
+ )} +
+
+
+ +
+ +

+ Open disputes +

+ {isLoadingOpen ? ( +

Loading...

+ ) : !openDisputes?.data.length ? ( +

+ No open disputes right now. +

+ ) : ( +
+ {openDisputes.data.map((dispute) => ( + +

+ Dispute #{dispute.dispute_id} +

+

+ Proposal #{dispute.proposal_id} · Milestone # + {dispute.milestone_id} +

+

+ Reveal deadline {formatDeadline(dispute.reveal_deadline)} +

+ + ))} +
+ )} +
+
+
+
+ ) +} diff --git a/src/pages/ScholarMilestones.tsx b/src/pages/ScholarMilestones.tsx index 87fba2c5..72837f95 100644 --- a/src/pages/ScholarMilestones.tsx +++ b/src/pages/ScholarMilestones.tsx @@ -2,6 +2,7 @@ import { Card } from "@stellar/design-system" import { useMemo, useState } from "react" import { Link } from "react-router-dom" import ConnectWalletGuard from "../components/ConnectWalletGuard" +import EscalateDisputeButton from "../components/EscalateDisputeButton" import MilestoneReportForm from "../components/MilestoneReportForm" import { useScholarMilestones, @@ -314,15 +315,26 @@ export default function ScholarMilestones() { - {milestone.status === "rejected" && ( - - )} +
+ {milestone.status === "rejected" && ( + + )} + {(milestone.status === "rejected" || + milestone.status === "final_rejected") && ( + + )} +
))} diff --git a/src/types/dispute.ts b/src/types/dispute.ts new file mode 100644 index 00000000..9c719de0 --- /dev/null +++ b/src/types/dispute.ts @@ -0,0 +1,59 @@ +export type DisputePhase = "active" | "resolved" | "quorum_failed" + +export interface Dispute { + dispute_id: string + proposal_id: number + milestone_id: number + scholar_address: string + evidence_ipfs_cid: string | null + evidence_hash: string + scholar_stake: string + opened_at: string + commit_deadline: string + reveal_deadline: string + phase: DisputePhase + votes_for: number + votes_against: number + revealed_count: number + outcome: boolean | null + resolved_at: string | null + resolve_tx_hash: string | null + open_tx_hash: string | null +} + +export interface DisputeJuror { + dispute_id: string + juror_address: string + has_committed: boolean + has_revealed: boolean +} + +export interface DisputeVote { + dispute_id: string + juror_address: string + vote: boolean + revealed_at: string +} + +export interface DisputeDetail extends Dispute { + jurors: DisputeJuror[] + votes: DisputeVote[] +} + +export interface DisputeListResponse { + data: Dispute[] + pagination: { + page: number + pageSize: number + total: number + totalPages: number + } +} + +export interface DisputeDetailResponse { + data: DisputeDetail +} + +export interface JurorAssignmentsResponse { + data: Dispute[] +}