From 66c27b7d65f9069789f712257191f3aa85ea8ad9 Mon Sep 17 00:00:00 2001 From: ijeoma270 Date: Thu, 25 Jun 2026 11:27:42 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20jury=20selection,=20appeal=20mech?= =?UTF-8?q?anism,=20fee=20buyback,=20and=20surge=20pricing=20(#284?= =?UTF-8?q?=E2=80=93287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements four new SkillSphere features as Soroban contract extensions: Issue #284 — Jury Selection - JuryVoteRecord contracttype with jurors, vote counts, and voted tracker - select_jury(): shuffles reputation-filtered candidates via on-chain PRNG - cast_jury_vote(): per-juror vote with duplicate-vote guard - finalize_jury_verdict(): majority or unanimous verdict → 9000/1000 bps split (50/50 on tie) - Public functions: select_jury, cast_jury_vote, finalize_jury_verdict, get_jury_session, set_jury_size - New DataKey variants: JurySession(u64), JurySize - New Error codes: JuryNotSelected(70), JuryAlreadyVoted(71), JuryVotingClosed(72), InsufficientCandidates(73) Issue #285 — Appeal Mechanism - AppealRecord contracttype with appellant, bond, resolution fields - appeal_dispute(): collects configurable bond from appellant before admin resolves - resolve_appeal(): admin sets new split, delegates to resolve_dispute_internal, refunds bond - Public functions: appeal_dispute, resolve_appeal, get_appeal, set_appeal_bond_amount - New DataKey variants: Appeal(u64), AppealBondAmount - New Error codes: AppealBondRequired(74), AppealAlreadyFiled(75), AppealNotFound(76) Issue #286 — Fee Auto-Conversion (SKILL buyback) - convert_fees_to_skill(): reuses existing cross_contract_swap + check_slippage from dex.rs - Configurable via SkillTokenAddress, FeeBuybackEnabled, FeeBuybackSlippageBps DataKeys - Public functions: set_skill_token, set_fee_buyback_enabled, set_fee_buyback_slippage, convert_fees_to_skill - New Error codes: SkillTokenNotSet(78), BuybackDisabled(79) Issue #287 — Surge Pricing - SurgeConfig contracttype (threshold, max multiplier, step sessions, step bps) - get_surge_multiplier_bps(): reads CategoryActiveSessions and computes stepped multiplier - start_session_with_surge(): rejects unconsented surge, increments category counter - end_surge_session(): participant-called decrement after session ends - set_expert_category(), increment/decrement_category_sessions() helpers in reputation.rs - New DataKey variants: CategoryActiveSessions(String), SurgePricingEnabled, ExpertCategory(Address), SurgeConfig - New Error code: SurgeNotAccepted(82) Closes #284 Closes #285 Closes #286 Closes #287 --- contracts/src/disputes.rs | 350 +++++++++++++++++++++++++++++++++++- contracts/src/errors.rs | 18 ++ contracts/src/events.rs | 28 +++ contracts/src/lib.rs | 234 ++++++++++++++++++++++++ contracts/src/reputation.rs | 142 +++++++++++++++ contracts/src/treasury.rs | 77 +++++++- 6 files changed, 845 insertions(+), 4 deletions(-) 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 { + env.storage() + .persistent() + .get(&DataKey::JurySession(dispute_id)) + .ok_or(Error::JuryNotSelected) + } + + /// Set the default jury size (admin-only). + pub fn set_jury_size(env: Env, size: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + env.storage().instance().set(&DataKey::JurySize, &size); + Ok(()) + } + + // ── Issue #285 — Appeal Mechanism ──────────────────────────────────────── + + /// File an appeal against an open dispute ruling. + /// + /// If an appeal bond is configured, it is collected from `appellant` and + /// held until `resolve_appeal` is called. + pub fn appeal_dispute( + env: Env, + appellant: Address, + dispute_id: u64, + bond_token: Address, + ) -> Result<(), Error> { + disputes::appeal_dispute(&env, appellant, dispute_id, bond_token) + } + + /// Resolve an appeal with a new ruling (admin-only). + /// + /// Applies the new `seeker_award_bps` / `expert_award_bps` split to the + /// underlying dispute and returns the bond to the appellant. + /// `seeker_award_bps + expert_award_bps` must equal 10 000. + pub fn resolve_appeal( + env: Env, + dispute_id: u64, + seeker_award_bps: u32, + expert_award_bps: u32, + ) -> Result<(), Error> { + Self::require_admin(&env)?; + disputes::resolve_appeal(&env, dispute_id, seeker_award_bps, expert_award_bps) + } + + /// Read the appeal record for `dispute_id`. + pub fn get_appeal(env: Env, dispute_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Appeal(dispute_id)) + .ok_or(Error::AppealNotFound) + } + + /// Set the appeal bond amount (admin-only). + pub fn set_appeal_bond_amount(env: Env, amount: i128) -> Result<(), Error> { + Self::require_admin(&env)?; + disputes::set_appeal_bond_amount(&env, amount); + Ok(()) + } + + // ── Issue #286 — Fee Auto-Conversion ──────────────────────────────────── + + /// Set the SKILL governance token address used for fee buybacks (admin-only). + pub fn set_skill_token(env: Env, token_addr: Address) -> Result<(), Error> { + Self::require_admin(&env)?; + env.storage() + .instance() + .set(&DataKey::SkillTokenAddress, &token_addr); + Ok(()) + } + + /// Enable or disable automated fee-to-SKILL conversion (admin-only). + pub fn set_fee_buyback_enabled(env: Env, enabled: bool) -> Result<(), Error> { + Self::require_admin(&env)?; + env.storage() + .instance() + .set(&DataKey::FeeBuybackEnabled, &enabled); + Ok(()) + } + + /// Set the max slippage tolerance in bps for fee buyback swaps (admin-only). + pub fn set_fee_buyback_slippage(env: Env, bps: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + env.storage() + .instance() + .set(&DataKey::FeeBuybackSlippageBps, &bps); + Ok(()) + } + + /// Swap `fee_amount` of `source_token` into SKILL tokens via the DEX + /// (admin-only). + pub fn convert_fees_to_skill( + env: Env, + fee_amount: i128, + source_token: Address, + expected_skill_out: i128, + ) -> Result { + Self::require_admin(&env)?; + treasury::convert_fees_to_skill(&env, fee_amount, source_token, expected_skill_out) + } + + // ── Issue #287 — Surge Pricing ─────────────────────────────────────────── + + /// Update the surge pricing configuration (admin-only). + pub fn set_surge_config( + env: Env, + config: reputation::SurgeConfig, + ) -> Result<(), Error> { + Self::require_admin(&env)?; + reputation::set_surge_config(&env, config); + Ok(()) + } + + /// Enable or disable surge pricing globally (admin-only). + pub fn set_surge_pricing_enabled(env: Env, enabled: bool) -> Result<(), Error> { + Self::require_admin(&env)?; + reputation::set_surge_enabled(&env, enabled); + Ok(()) + } + + /// Assign an expert to a named skill category (admin-only). + pub fn set_expert_category(env: Env, expert: Address, category: String) -> Result<(), Error> { + Self::require_admin(&env)?; + reputation::set_expert_category(&env, &expert, category); + Ok(()) + } + + /// Returns the current surge multiplier in bps for `expert`'s category. + /// Returns 10 000 (no surge) when surge is disabled or category has few sessions. + pub fn get_surge_multiplier(env: Env, expert: Address) -> u32 { + reputation::get_surge_multiplier_bps(&env, &expert) + } + + /// Start a session with surge-pricing awareness. + /// + /// If the expert's category is in surge, `surge_accepted` must be `true` + /// or the call panics. Increments the category's active session counter. + pub fn start_session_with_surge( + env: Env, + seeker: Address, + expert: Address, + token: Address, + amount: i128, + min_reputation: u32, + metadata_cid: String, + surge_accepted: bool, + ) -> u64 { + seeker.require_auth(); + if Self::protocol_paused(&env) || Self::is_emergency_paused(&env) { + panic_with_error!(&env, Error::ProtocolPaused); + } + + let multiplier = reputation::get_surge_multiplier_bps(&env, &expert); + if multiplier > 10_000 && !surge_accepted { + panic_with_error!(&env, Error::SurgeNotAccepted); + } + + if multiplier > 10_000 { + events::publish_event( + &env, + events::event_type::surge_accepted(), + 0, + (expert.clone(), multiplier, seeker.clone()), + ); + } + + reputation::increment_category_sessions(&env, &expert); + + Self::start_session(env, seeker, expert, token, amount, min_reputation, metadata_cid) + } + + /// Decrement a category's active-session counter after a surge session ends. + /// + /// Must be called by a session participant (`seeker` or `expert`) once + /// the session has been settled, cancelled, or resolved. + pub fn end_surge_session(env: Env, caller: Address, session_id: u64) -> Result<(), Error> { + caller.require_auth(); + let session = Self::get_session_or_error(&env, session_id)?; + Self::require_participant(&session, &caller)?; + reputation::decrement_category_sessions(&env, &session.expert); + Ok(()) + } } #[cfg(test)] diff --git a/contracts/src/reputation.rs b/contracts/src/reputation.rs index 2db4b25..ec904bf 100644 --- a/contracts/src/reputation.rs +++ b/contracts/src/reputation.rs @@ -196,6 +196,148 @@ pub fn update_expert_tier_on_completion(env: &Env, expert: &Address) { } } +// --------------------------------------------------------------------------- +// Issue #287 — Surge Pricing +// --------------------------------------------------------------------------- + +/// Active sessions per category before surge pricing activates. +pub const DEFAULT_SURGE_THRESHOLD: u32 = 10; +/// Maximum surge multiplier in basis points (20000 = 2×). +pub const DEFAULT_SURGE_MAX_BPS: u32 = 20_000; +/// Every this many additional sessions above threshold adds one step. +pub const DEFAULT_SURGE_STEP_SESSIONS: u32 = 5; +/// Basis points added per step (1000 = +10% per step). +pub const DEFAULT_SURGE_STEP_BPS: u32 = 1_000; + +/// Admin-configurable surge pricing parameters. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SurgeConfig { + /// Active sessions per category required before surge kicks in. + pub threshold_sessions: u32, + /// Maximum surge multiplier in bps (e.g. 20000 = 2×). + pub max_multiplier_bps: u32, + /// Sessions above threshold per pricing step. + pub step_sessions: u32, + /// Basis points added to the multiplier per step. + pub step_bps: u32, +} + +/// Returns the current surge configuration (or safe defaults). +pub fn get_surge_config(env: &Env) -> SurgeConfig { + env.storage() + .instance() + .get(&DataKey::SurgeConfig) + .unwrap_or(SurgeConfig { + threshold_sessions: DEFAULT_SURGE_THRESHOLD, + max_multiplier_bps: DEFAULT_SURGE_MAX_BPS, + step_sessions: DEFAULT_SURGE_STEP_SESSIONS, + step_bps: DEFAULT_SURGE_STEP_BPS, + }) +} + +/// Persist a new surge configuration. Callable only by admin. +pub fn set_surge_config(env: &Env, config: SurgeConfig) { + env.storage() + .instance() + .set(&DataKey::SurgeConfig, &config); +} + +/// Returns whether surge pricing is currently active. +pub fn is_surge_enabled(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::SurgePricingEnabled) + .unwrap_or(false) +} + +/// Enable or disable surge pricing. +pub fn set_surge_enabled(env: &Env, enabled: bool) { + env.storage() + .instance() + .set(&DataKey::SurgePricingEnabled, &enabled); +} + +/// Assign an expert to a named skill category. +pub fn set_expert_category(env: &Env, expert: &Address, category: soroban_sdk::String) { + env.storage() + .persistent() + .set(&DataKey::ExpertCategory(expert.clone()), &category); +} + +/// Return the expert's assigned category, or `None` if unset. +pub fn get_expert_category(env: &Env, expert: &Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::ExpertCategory(expert.clone())) +} + +/// Increment the active-session counter for the expert's category. +/// No-op if the expert has no assigned category. +pub fn increment_category_sessions(env: &Env, expert: &Address) { + if let Some(category) = get_expert_category(env, expert) { + let key = DataKey::CategoryActiveSessions(category); + let count: u32 = env + .storage() + .instance() + .get(&key) + .unwrap_or(0u32); + env.storage() + .instance() + .set(&key, &count.saturating_add(1)); + } +} + +/// Decrement the active-session counter for the expert's category. +/// No-op if the expert has no assigned category or count is already zero. +pub fn decrement_category_sessions(env: &Env, expert: &Address) { + if let Some(category) = get_expert_category(env, expert) { + let key = DataKey::CategoryActiveSessions(category); + let count: u32 = env + .storage() + .instance() + .get(&key) + .unwrap_or(0u32); + if count > 0 { + env.storage() + .instance() + .set(&key, &count.saturating_sub(1)); + } + } +} + +/// Returns the surge multiplier in basis points for the expert's category. +/// +/// Returns `10_000` (no surge) when surge is disabled, the expert has no +/// category, or the category's active session count is below the threshold. +pub fn get_surge_multiplier_bps(env: &Env, expert: &Address) -> u32 { + if !is_surge_enabled(env) { + return 10_000; + } + + let category = match get_expert_category(env, expert) { + Some(c) => c, + None => return 10_000, + }; + + let active: u32 = env + .storage() + .instance() + .get(&DataKey::CategoryActiveSessions(category)) + .unwrap_or(0u32); + + let config = get_surge_config(env); + if active < config.threshold_sessions { + return 10_000; + } + + let step_sessions = config.step_sessions.max(1); + let steps_above = (active - config.threshold_sessions) / step_sessions; + let extra_bps = steps_above.saturating_mul(config.step_bps); + let multiplier = 10_000u32.saturating_add(extra_bps); + multiplier.min(config.max_multiplier_bps) +} + /// Cross-contract call: invokes `mint_badge(expert, badge_id)` on the /// external SBT contract. The SBT contract must implement a function with /// the symbol `"mint_bdg"` that accepts an `Address` and a `u64` badge ID. diff --git a/contracts/src/treasury.rs b/contracts/src/treasury.rs index ad43ce0..e7f11a0 100644 --- a/contracts/src/treasury.rs +++ b/contracts/src/treasury.rs @@ -12,9 +12,9 @@ //! - `set_spam_deposit_amount(env, amount)` — FeeManager / admin only //! - `get_spam_deposit_amount(env)` — returns the current deposit requirement -use soroban_sdk::{token, Address, Env}; +use soroban_sdk::{symbol_short, token, Address, Env, Vec}; -use crate::{DataKey, Error}; +use crate::{dex, events, DataKey, Error}; /// Default spam deposit: 0 (disabled until explicitly configured by admin). pub const DEFAULT_SPAM_DEPOSIT_AMOUNT: i128 = 0; @@ -72,3 +72,76 @@ pub fn collect_spam_deposit( Ok(()) } + +// --------------------------------------------------------------------------- +// Issue #286 — Fee Auto-Conversion (SKILL token buyback) +// --------------------------------------------------------------------------- + +/// Swap `fee_amount` of `source_token` into the SKILL governance token via +/// the configured DEX, crediting the acquired tokens back to the contract. +/// +/// # Errors +/// - `BuybackDisabled` — fee buyback is not enabled by admin. +/// - `SkillTokenNotSet` — no SKILL token address has been configured. +/// - `ContractUnset` — no DEX contract address has been configured. +/// - `SwapFailed` — the DEX returned zero or negative output. +/// - `SlippageExceeded` — actual output deviates from `expected_skill_out` +/// beyond the configured slippage tolerance. +pub fn convert_fees_to_skill( + env: &Env, + fee_amount: i128, + source_token: Address, + expected_skill_out: i128, +) -> Result { + let enabled: bool = env + .storage() + .instance() + .get(&DataKey::FeeBuybackEnabled) + .unwrap_or(false); + if !enabled { + return Err(Error::BuybackDisabled); + } + + let skill_token: Address = env + .storage() + .instance() + .get(&DataKey::SkillTokenAddress) + .ok_or(Error::SkillTokenNotSet)?; + + let dex_contract: Address = env + .storage() + .instance() + .get(&symbol_short!("dex_addr")) + .ok_or(Error::ContractUnset)?; + + let slippage_bps: u32 = env + .storage() + .instance() + .get(&DataKey::FeeBuybackSlippageBps) + .unwrap_or(100u32); + + let path: Vec
= Vec::new(env); + let received = dex::cross_contract_swap( + env, + &dex_contract, + &source_token, + &skill_token, + &path, + fee_amount, + ); + + if received <= 0 { + return Err(Error::SwapFailed); + } + + dex::check_slippage(expected_skill_out, received, slippage_bps)?; + + events::publish_event( + env, + events::event_type::fee_buyback(), + 0, + (source_token, skill_token, fee_amount, received), + ); + + Ok(received) +}