Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
350 changes: 348 additions & 2 deletions contracts/src/disputes.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//! Expert cooldown after dispute loss — Issue #240.
//! Expert-initiated session cancellation with partial refund (#238).

use soroban_sdk::{symbol_short, token, Address, Env, String};
use soroban_sdk::{contracttype, symbol_short, token, Address, Env, String, Vec};

use crate::{
events, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW,
events, DataKey, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW,
};

/// Stellar closes a ledger roughly every 5 seconds; seven days ≈ 120_960 ledgers.
Expand Down Expand Up @@ -153,3 +153,349 @@ pub fn cancel_session_by_expert(
crate::security::ReentrancyGuard::clear(env);
Ok((expert_payout, seeker_refund))
}

// ---------------------------------------------------------------------------
// Issue #284 — Jury Selection
// ---------------------------------------------------------------------------

/// Minimum reputation an expert must have to serve as a juror.
pub const MIN_JURY_CANDIDATE_REPUTATION: u32 = 300;
/// Default jury size when no custom size has been configured by admin.
pub const DEFAULT_JURY_SIZE: u32 = 3;

/// On-chain record tracking the jury panel and votes for a disputed session.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JuryVoteRecord {
pub jurors: Vec<Address>,
pub votes_for_seeker: u32,
pub votes_for_expert: u32,
pub voted: Vec<Address>,
pub finalized: bool,
}

/// Select a jury panel from the provided `candidates` list for `dispute_id`.
///
/// Candidates are filtered by minimum reputation, then `jury_size` jurors
/// are chosen via on-chain PRNG. Pass `jury_size = 0` to use the
/// admin-configured default (see `DataKey::JurySize`).
pub fn select_jury(
env: &Env,
dispute_id: u64,
candidates: Vec<Address>,
jury_size: u32,
) -> Result<(), Error> {
if env.storage().persistent().has(&DataKey::JurySession(dispute_id)) {
return Err(Error::JuryAlreadyVoted);
}

let size = if jury_size == 0 {
env.storage()
.instance()
.get(&DataKey::JurySize)
.unwrap_or(DEFAULT_JURY_SIZE)
} else {
jury_size
};

// Filter candidates by minimum reputation threshold.
let mut eligible: Vec<Address> = Vec::new(env);
let cand_len = candidates.len();
for i in 0..cand_len {
let candidate = candidates.get(i).unwrap();
let profile = SkillSphereContract::expert_profile(env, candidate.clone());
if profile.reputation >= MIN_JURY_CANDIDATE_REPUTATION {
eligible.push_back(candidate);
}
Comment on lines +201 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Filter for active eligible experts, not just raw reputation.

The selection path ignores availability_status and registration state, so inactive/unregistered experts can be selected as jurors and stall dispute voting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 201 - 209, The juror candidate
filtering in the dispute selection flow only checks reputation, so inactive or
unregistered experts can still be selected. Update the eligibility logic in the
candidate loop in the dispute handling code to also require an active
registration state and an available availability_status from
SkillSphereContract::expert_profile before pushing candidates into eligible, so
only active eligible experts are considered for juror selection.

}

if eligible.len() < size {
return Err(Error::InsufficientCandidates);
}

// Shuffle eligible candidates in-place using on-chain PRNG, then take the first `size`.
env.prng().shuffle(&mut eligible);
let mut jurors: Vec<Address> = Vec::new(env);
for i in 0..size {
jurors.push_back(eligible.get(i).unwrap());
Comment on lines +192 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate jury size and de-duplicate candidates before selection.

The resolved size can be 0, 1, 2, or any large value, and duplicate addresses in candidates can be selected multiple times. That breaks the required 3-or-5 juror panel and can make voting impossible when one address occupies multiple seats.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 192 - 220, The jury selection logic
in the dispute resolver must enforce a valid panel size and remove duplicate
addresses before drawing jurors. Update the size resolution in the jury
selection flow so the selected value is constrained to the supported 3-or-5
juror counts, and deduplicate the `candidates` input before filtering by
reputation and shuffling. Make the fix in the selection routine that builds
`eligible` and `jurors` so repeated addresses cannot occupy multiple seats and
an invalid `size` cannot produce a zero/too-small panel.

}

let record = JuryVoteRecord {
jurors: jurors.clone(),
votes_for_seeker: 0,
votes_for_expert: 0,
voted: Vec::new(env),
finalized: false,
};

env.storage()
.persistent()
.set(&DataKey::JurySession(dispute_id), &record);

events::publish_event(
env,
events::event_type::jury_selected(),
dispute_id,
(dispute_id, jurors),
);

Ok(())
}

/// Record a juror's vote for the given `dispute_id`.
///
/// Each juror may only vote once. Voting closes once `finalize_jury_verdict`
/// is called or a majority is reached.
pub fn cast_jury_vote(
env: &Env,
juror: Address,
dispute_id: u64,
vote_for_seeker: bool,
) -> Result<(), Error> {
juror.require_auth();

let mut record: JuryVoteRecord = env
.storage()
.persistent()
.get(&DataKey::JurySession(dispute_id))
.ok_or(Error::JuryNotSelected)?;

if record.finalized {
return Err(Error::JuryVotingClosed);
}

// Verify juror is on the panel.
let juror_len = record.jurors.len();
let mut is_juror = false;
for i in 0..juror_len {
if record.jurors.get(i).unwrap() == juror {
is_juror = true;
break;
}
}
if !is_juror {
return Err(Error::Unauthorized);
}

// Reject duplicate votes.
let voted_len = record.voted.len();
for i in 0..voted_len {
if record.voted.get(i).unwrap() == juror {
return Err(Error::JuryAlreadyVoted);
}
}

if vote_for_seeker {
record.votes_for_seeker = record.votes_for_seeker.saturating_add(1);
} else {
record.votes_for_expert = record.votes_for_expert.saturating_add(1);
}
record.voted.push_back(juror.clone());

env.storage()
.persistent()
.set(&DataKey::JurySession(dispute_id), &record);

events::publish_event(
env,
events::event_type::jury_vote_cast(),
dispute_id,
(dispute_id, juror, vote_for_seeker),
);

Ok(())
}

/// Finalize the jury verdict for `dispute_id`.
///
/// Requires either all jurors have voted, or a majority has been reached.
/// Returns `(seeker_award_bps, expert_award_bps)`. A winning side receives
/// 9000 bps; ties produce a 5000/5000 split.
pub fn finalize_jury_verdict(env: &Env, dispute_id: u64) -> Result<(u32, u32), Error> {
let mut record: JuryVoteRecord = env
.storage()
.persistent()
.get(&DataKey::JurySession(dispute_id))
.ok_or(Error::JuryNotSelected)?;

if record.finalized {
return Err(Error::JuryVotingClosed);
}

let jury_size = record.jurors.len();
let majority = jury_size / 2 + 1;
let all_voted = record.voted.len() >= jury_size;
let seeker_majority = record.votes_for_seeker >= majority;
let expert_majority = record.votes_for_expert >= majority;

if !all_voted && !seeker_majority && !expert_majority {
return Err(Error::JuryVotingClosed);
}

let (seeker_bps, expert_bps) = if record.votes_for_seeker == record.votes_for_expert {
(5_000u32, 5_000u32)
} else if record.votes_for_seeker > record.votes_for_expert {
(9_000u32, 1_000u32)
} else {
(1_000u32, 9_000u32)
};

record.finalized = true;
env.storage()
.persistent()
.set(&DataKey::JurySession(dispute_id), &record);

events::publish_event(
env,
events::event_type::jury_verdict(),
dispute_id,
(dispute_id, seeker_bps, expert_bps),
);

Ok((seeker_bps, expert_bps))
Comment on lines +343 to +355

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Apply the jury verdict to the dispute escrow and juror rewards.

Finalization only stores the jury record and returns (seeker_bps, expert_bps); it does not resolve the underlying dispute, release escrow, or pay jurors their required share.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 343 - 355, The jury verdict
finalization currently only persists the jury session and emits the verdict
event, but it does not actually settle the dispute outcome or distribute
rewards. Update the finalization flow that writes DataKey::JurySession and calls
events::publish_event so it also applies the verdict to the underlying dispute
escrow, releases the correct funds based on seeker_bps and expert_bps, and pays
out the jurors’ required share before returning the result.

}

// ---------------------------------------------------------------------------
// Issue #285 — Appeal Mechanism
// ---------------------------------------------------------------------------

/// Default appeal bond amount: 0 (no bond required until configured).
pub const DEFAULT_APPEAL_BOND_AMOUNT: i128 = 0;

/// On-chain record for an appeal filed against a dispute ruling.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppealRecord {
pub dispute_id: u64,
pub appellant: Address,
pub bond_amount: i128,
pub bond_token: Address,
pub filed_at: u64,
pub resolved: bool,
pub ruling_bps_seeker: u32,
pub ruling_bps_expert: u32,
}

/// Returns the currently-configured appeal bond amount.
pub fn appeal_bond_amount(env: &Env) -> i128 {
env.storage()
.instance()
.get(&DataKey::AppealBondAmount)
.unwrap_or(DEFAULT_APPEAL_BOND_AMOUNT)
}

/// Admin-only setter for the appeal bond amount.
pub fn set_appeal_bond_amount(env: &Env, amount: i128) {
env.storage()
.instance()
.set(&DataKey::AppealBondAmount, &amount);
}

/// File an appeal against the ruling for `dispute_id`.
///
/// Can only be called while the dispute is still open (not yet resolved by
/// admin). If a bond is configured, it is collected from `appellant` and
/// held in the contract until `resolve_appeal` is called.
pub fn appeal_dispute(
env: &Env,
appellant: Address,
dispute_id: u64,
bond_token: Address,
) -> Result<(), Error> {
appellant.require_auth();

if env
.storage()
.persistent()
.has(&DataKey::Appeal(dispute_id))
{
return Err(Error::AppealAlreadyFiled);
}

let bond = appeal_bond_amount(env);
if bond > 0 {
let token_client = token::Client::new(env, &bond_token);
if token_client.balance(&appellant) < bond {
return Err(Error::AppealBondRequired);
}
token_client.transfer(&appellant, &env.current_contract_address(), &bond);
}
Comment on lines +399 to +422

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authorize appeals against real disputes within the appeal window.

appeal_dispute lets any authenticated address file the first appeal for any dispute_id, without verifying the dispute exists, the appellant is a party, or the 48-hour appeal window is open. This can block the legitimate party with AppealAlreadyFiled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 399 - 422, The appeal flow in
appeal_dispute is missing validation for dispute ownership and timing, so add
checks before creating the appeal record: verify the dispute exists, confirm
appellant is one of the dispute parties, and ensure the appeal is still within
the 48-hour window. Use the existing appeal_dispute path and DataKey::Appeal
handling to locate the spot, then reject unauthorized or late appeals before the
first has been filed so a random authenticated address cannot lock out the real
party.


let record = AppealRecord {
dispute_id,
appellant: appellant.clone(),
bond_amount: bond,
bond_token,
filed_at: env.ledger().timestamp(),
resolved: false,
ruling_bps_seeker: 0,
ruling_bps_expert: 0,
};

env.storage()
.persistent()
.set(&DataKey::Appeal(dispute_id), &record);

events::publish_event(
env,
events::event_type::appeal_filed(),
dispute_id,
(dispute_id, appellant, bond),
);

Ok(())
}

/// Resolve an appeal with a new ruling (admin-only, called from lib.rs).
///
/// Calls `resolve_dispute_internal` to apply the new split on the underlying
/// session. The appeal bond is returned to the appellant upon resolution.
pub fn resolve_appeal(
env: &Env,
dispute_id: u64,
seeker_award_bps: u32,
expert_award_bps: u32,
) -> Result<(), Error> {
if seeker_award_bps.saturating_add(expert_award_bps) != 10_000 {
return Err(Error::InvalidSplitBps);
}

let mut record: AppealRecord = env
.storage()
.persistent()
.get(&DataKey::Appeal(dispute_id))
.ok_or(Error::AppealNotFound)?;

if record.resolved {
return Err(Error::DisputeResolved);
}

record.resolved = true;
record.ruling_bps_seeker = seeker_award_bps;
record.ruling_bps_expert = expert_award_bps;
env.storage()
.persistent()
.set(&DataKey::Appeal(dispute_id), &record);

// Apply the new ruling to the underlying dispute.
SkillSphereContract::resolve_dispute_internal(env, dispute_id, seeker_award_bps)?;

// Return bond to appellant now that the appeal has been resolved.
if record.bond_amount > 0 {
let token_client = token::Client::new(env, &record.bond_token);
token_client.transfer(
&env.current_contract_address(),
&record.appellant,
&record.bond_amount,
);
Comment on lines +480 to +490

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Rework appeal resolution for resolved disputes and failed bonds.

resolve_dispute_internal rejects already resolved disputes, so it cannot apply a post-ruling appeal. The bond is also always returned, with no way to redistribute it when the appeal fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 480 - 490, The appeal resolution flow
currently calls resolve_dispute_internal, which rejects already resolved
disputes, so the post-ruling appeal path needs to be reworked in the dispute
resolution logic. Update the appeal-handling code in the dispute resolution
function to apply the ruling without relying on the already-finalized dispute
state, and add explicit handling for failed appeals so the bond is not always
returned. Use the existing dispute/bond fields and the token::Client transfer
path to route the bond appropriately depending on the appeal outcome.

}

events::publish_event(
env,
events::event_type::appeal_resolved(),
dispute_id,
(dispute_id, seeker_award_bps, expert_award_bps),
);

Ok(())
}
18 changes: 18 additions & 0 deletions contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,22 @@ pub enum Error {
InsufficientAntiSpamDeposit = 67,
CircuitBreakerActive = 68,
SessionNotExpired = 69,

// #284 - Jury Selection
JuryNotSelected = 70,
JuryAlreadyVoted = 71,
JuryVotingClosed = 72,
InsufficientCandidates = 73,

// #285 - Appeal Mechanism
AppealBondRequired = 74,
AppealAlreadyFiled = 75,
AppealNotFound = 76,

// #286 - Fee Auto-Conversion
SkillTokenNotSet = 78,
BuybackDisabled = 79,

// #287 - Surge Pricing
SurgeNotAccepted = 82,
}
Loading