diff --git a/contracts/src/disputes.rs b/contracts/src/disputes.rs index 596d551..820c6ef 100644 --- a/contracts/src/disputes.rs +++ b/contracts/src/disputes.rs @@ -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. @@ -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
, + pub votes_for_seeker: u32, + pub votes_for_expert: u32, + pub voted: Vec, + 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, + 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 = 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); + } + } + + 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 = Vec::new(env); + for i in 0..size { + jurors.push_back(eligible.get(i).unwrap()); + } + + 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)) +} + +// --------------------------------------------------------------------------- +// 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); + } + + 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, + ); + } + + events::publish_event( + env, + events::event_type::appeal_resolved(), + dispute_id, + (dispute_id, seeker_award_bps, expert_award_bps), + ); + + Ok(()) +} diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index 65d21f8..d61bfcd 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -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, } diff --git a/contracts/src/events.rs b/contracts/src/events.rs index 11e941f..39c2871 100644 --- a/contracts/src/events.rs +++ b/contracts/src/events.rs @@ -272,4 +272,32 @@ pub mod event_type { pub fn session_rated() -> Symbol { symbol_short!("sessRated") } + /// Issue #284 — jury selected for a disputed session. + pub fn jury_selected() -> Symbol { + symbol_short!("jurySlct") + } + /// Issue #284 — a juror cast their vote. + pub fn jury_vote_cast() -> Symbol { + symbol_short!("juryVote") + } + /// Issue #284 — jury voting finalized, verdict reached. + pub fn jury_verdict() -> Symbol { + symbol_short!("juryVrdt") + } + /// Issue #285 — appeal filed against a dispute ruling. + pub fn appeal_filed() -> Symbol { + symbol_short!("appFiled") + } + /// Issue #285 — appeal resolved by admin with new ruling. + pub fn appeal_resolved() -> Symbol { + symbol_short!("appResl") + } + /// Issue #286 — platform fees converted to SKILL token. + pub fn fee_buyback() -> Symbol { + symbol_short!("feeBybak") + } + /// Issue #287 — seeker accepted surge-priced session. + pub fn surge_accepted() -> Symbol { + symbol_short!("surgeAcc") + } } diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 6d8ed55..e60b189 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -213,6 +213,21 @@ pub enum DataKey { ExpertAvgRatingComm(Address), ExpertAvgRatingExpertise(Address), ExpertAvgRatingPunct(Address), + // Issue #284 - Jury Selection + JurySession(u64), + JurySize, + // Issue #285 - Appeal Mechanism + Appeal(u64), + AppealBondAmount, + // Issue #286 - Fee Auto-Conversion + SkillTokenAddress, + FeeBuybackEnabled, + FeeBuybackSlippageBps, + // Issue #287 - Surge Pricing + CategoryActiveSessions(String), + SurgePricingEnabled, + ExpertCategory(Address), + SurgeConfig, } #[contracttype] @@ -6231,6 +6246,225 @@ impl SkillSphereContract { pub fn is_paused(env: Env) -> bool { Self::protocol_paused(&env) } + + // ── Issue #284 — Jury Selection ────────────────────────────────────────── + + /// Select a jury panel for `dispute_id` from the provided `candidates`. + /// + /// Only the admin may call this. `jury_size = 0` uses the configured + /// default (`DataKey::JurySize`, default 3). + pub fn select_jury( + env: Env, + dispute_id: u64, + candidates: Vec, + jury_size: u32, + ) -> Result<(), Error> { + Self::require_admin(&env)?; + disputes::select_jury(&env, dispute_id, candidates, jury_size) + } + + /// Cast a vote as a juror for `dispute_id`. + pub fn cast_jury_vote( + env: Env, + juror: Address, + dispute_id: u64, + vote_for_seeker: bool, + ) -> Result<(), Error> { + disputes::cast_jury_vote(&env, juror, dispute_id, vote_for_seeker) + } + + /// Finalize the jury verdict and return `(seeker_bps, expert_bps)`. + /// + /// Requires all jurors to have voted or a majority to have been reached. + pub fn finalize_jury_verdict(env: Env, dispute_id: u64) -> Result<(u32, u32), Error> { + disputes::finalize_jury_verdict(&env, dispute_id) + } + + /// Read the current jury vote record for `dispute_id`. + pub fn get_jury_session( + env: Env, + dispute_id: u64, + ) -> Result