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 ? (
+
+ {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."}
+
+ 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.
+
+ 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.
+