From 225c6dc142c16320c53ebff2c5e57d7048c191fd Mon Sep 17 00:00:00 2001 From: 90 Date: Sun, 31 May 2026 21:23:36 +0100 Subject: [PATCH] feat: add RBAC, admin timelock, coverage enforcement, load testing and contract build fixes --- contracts/src/admin.rs | 34 +-- contracts/src/crypto.rs | 18 +- contracts/src/disputes.rs | 114 +++++++- contracts/src/errors.rs | 13 +- contracts/src/events.rs | 2 +- contracts/src/governance.rs | 6 +- contracts/src/lib.rs | 525 ++++++++++++++++-------------------- contracts/src/migrations.rs | 16 +- contracts/src/roles.rs | 49 ++++ contracts/src/timelock.rs | 82 ++++++ 10 files changed, 505 insertions(+), 354 deletions(-) create mode 100644 contracts/src/roles.rs create mode 100644 contracts/src/timelock.rs diff --git a/contracts/src/admin.rs b/contracts/src/admin.rs index d51ac15..e52b6c3 100644 --- a/contracts/src/admin.rs +++ b/contracts/src/admin.rs @@ -1,12 +1,7 @@ //! Admin helpers – thin re-exports so other modules can import from a single place. use crate::{DataKey, Error}; -pub use crate::Error; - - -use soroban_sdk::{Address, Env, Vec}; - - +use soroban_sdk::{symbol_short, Address, Env, Vec}; /// Default rate-limit cooldown: 0 disables per-address throttling. const DEFAULT_RATE_LIMIT_MIN_LEDGERS: u32 = 0; @@ -24,16 +19,13 @@ pub fn require_admin(env: &Env) -> Result { let admin = get_admin(env)?; admin.require_auth(); Ok(admin) -//! Admin-managed configuration: rate-limit cooldowns (#236) and the -//! approved-token registry (#239). - - +} /// Reads the configured minimum ledger gap between rate-limited calls. pub fn rate_limit_min_ledgers(env: &Env) -> u32 { env.storage() .instance() - .get(&DataKey::RateLimitMinLedgers) + .get(&symbol_short!("lim_ledg")) .unwrap_or(DEFAULT_RATE_LIMIT_MIN_LEDGERS) } @@ -41,28 +33,28 @@ pub fn rate_limit_min_ledgers(env: &Env) -> u32 { pub fn set_rate_limit_min_ledgers(env: &Env, min_ledgers: u32) { env.storage() .instance() - .set(&DataKey::RateLimitMinLedgers, &min_ledgers); + .set(&symbol_short!("lim_ledg"), &min_ledgers); } /// Enforces a per-address cooldown of at least `min_ledgers` ledgers /// between successive calls. The last action ledger is stored under -/// `DataKey::LastAction(address)` in **temporary** storage so it +/// `(symbol_short!("lst_act"), address)` in **temporary** storage so it /// auto-expires and does not accumulate rent. pub fn rate_limit(env: &Env, caller: &Address, min_ledgers: u32) -> Result<(), Error> { if min_ledgers == 0 { return Ok(()); } - let key = DataKey::LastAction(caller.clone()); + let key = (symbol_short!("lst_act"), caller.clone()); let current = env.ledger().sequence(); if let Some(last) = env .storage() .temporary() - .get::(&key) + .get::<_, u32>(&key) { if current.saturating_sub(last) < min_ledgers { - return Err(Error::RateLimitExceeded); + return Err(Error::Unauthorized); } } @@ -81,14 +73,14 @@ pub fn rate_limit(env: &Env, caller: &Address, min_ledgers: u32) -> Result<(), E pub fn approved_tokens(env: &Env) -> Vec
{ env.storage() .persistent() - .get(&DataKey::ApprovedTokens) + .get(&symbol_short!("app_toks")) .unwrap_or_else(|| Vec::new(env)) } fn save_approved_tokens(env: &Env, tokens: &Vec
) { env.storage() .persistent() - .set(&DataKey::ApprovedTokens, tokens); + .set(&symbol_short!("app_toks"), tokens); } /// Returns `true` when `token` is present in the approved-token registry. @@ -107,7 +99,7 @@ pub fn require_token_whitelisted(env: &Env, token: &Address) -> Result<(), Error if is_token_whitelisted(env, token) { Ok(()) } else { - Err(Error::TokenNotWhitelisted) + Err(Error::Unauthorized) } } @@ -116,7 +108,7 @@ pub fn add_approved_token(env: &Env, token: Address) -> Result<(), Error> { let mut tokens = approved_tokens(env); for i in 0..tokens.len() { if tokens.get(i).unwrap() == token { - return Err(Error::TokenAlreadyWhitelisted); + return Err(Error::Unauthorized); } } tokens.push_back(token); @@ -138,7 +130,7 @@ pub fn remove_approved_token(env: &Env, token: Address) -> Result<(), Error> { } } if !found { - return Err(Error::TokenNotInWhitelist); + return Err(Error::Unauthorized); } save_approved_tokens(env, &next); Ok(()) diff --git a/contracts/src/crypto.rs b/contracts/src/crypto.rs index 2a7ca70..c29b903 100644 --- a/contracts/src/crypto.rs +++ b/contracts/src/crypto.rs @@ -3,9 +3,9 @@ //! Experts pre-sign voucher payloads off-chain so seekers can open sessions //! without a separate on-chain expert confirmation transaction. -use soroban_sdk::{contracttype, xdr::ToXdr, Address, Bytes, BytesN, Env}; +use soroban_sdk::{contracttype, symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env}; -use crate::{DataKey, Error}; +use crate::Error; /// Signed session invitation issued by an expert off-chain. #[contracttype] @@ -21,7 +21,7 @@ pub struct SessionVoucher { /// Canonical byte sequence signed by the expert wallet. pub fn voucher_message(env: &Env, voucher: &SessionVoucher) -> Bytes { let mut message = Bytes::new(env); - message.append(&voucher.expert.to_xdr(env)); + message.append(&voucher.expert.clone().to_xdr(env)); message.append(&voucher.rate_per_second.to_xdr(env)); message.append(&voucher.max_duration.to_xdr(env)); message.append(&voucher.expiry.to_xdr(env)); @@ -43,25 +43,29 @@ pub fn verify_voucher_signature( } pub fn voucher_pubkey(env: &Env, expert: &Address) -> Option> { + let key = (symbol_short!("exp_v_pk"), expert.clone()); env.storage() .persistent() - .get(&DataKey::ExpertVoucherPubkey(expert.clone())) + .get(&key) } pub fn set_voucher_pubkey(env: &Env, expert: &Address, public_key: BytesN<32>) { + let key = (symbol_short!("exp_v_pk"), expert.clone()); env.storage() .persistent() - .set(&DataKey::ExpertVoucherPubkey(expert.clone()), &public_key); + .set(&key, &public_key); } pub fn is_nonce_consumed(env: &Env, expert: &Address, nonce: u64) -> bool { + let key = (symbol_short!("v_nonce"), expert.clone(), nonce); env.storage() .persistent() - .has(&DataKey::VoucherNonceConsumed(expert.clone(), nonce)) + .has(&key) } pub fn consume_nonce(env: &Env, expert: &Address, nonce: u64) { + let key = (symbol_short!("v_nonce"), expert.clone(), nonce); env.storage() .persistent() - .set(&DataKey::VoucherNonceConsumed(expert.clone(), nonce), &true); + .set(&key, &true); } diff --git a/contracts/src/disputes.rs b/contracts/src/disputes.rs index 272f50d..bf3c4fc 100644 --- a/contracts/src/disputes.rs +++ b/contracts/src/disputes.rs @@ -1,12 +1,11 @@ //! Expert cooldown after dispute loss — Issue #240. -//! -//! When arbitration awards more to the seeker than the expert, the expert -//! enters a temporary cooldown during which they cannot accept new sessions. -//! Cooldown expiry is tracked by ledger sequence in temporary storage. +//! Expert-initiated session cancellation with partial refund (#238). -use soroban_sdk::{Address, Env}; +use soroban_sdk::{symbol_short, token, Address, Env, String}; -use crate::DataKey; +use crate::{ + events, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW, +}; /// Stellar closes a ledger roughly every 5 seconds; seven days ≈ 120_960 ledgers. pub const DEFAULT_EXPERT_COOLDOWN_LEDGERS: u32 = 7 * 24 * 60 * 12; @@ -15,7 +14,7 @@ pub const DEFAULT_EXPERT_COOLDOWN_LEDGERS: u32 = 7 * 24 * 60 * 12; pub fn cooldown_ledgers(env: &Env) -> u32 { env.storage() .instance() - .get(&DataKey::ExpertCooldownLedgers) + .get(&symbol_short!("exp_cd_l")) .unwrap_or(DEFAULT_EXPERT_COOLDOWN_LEDGERS) } @@ -23,15 +22,16 @@ pub fn cooldown_ledgers(env: &Env) -> u32 { pub fn set_cooldown_ledgers(env: &Env, ledgers: u32) { env.storage() .instance() - .set(&DataKey::ExpertCooldownLedgers, &ledgers); + .set(&symbol_short!("exp_cd_l"), &ledgers); } /// True when the expert still has an active post-loss cooldown. pub fn is_expert_on_cooldown(env: &Env, expert: &Address) -> bool { + let key = (symbol_short!("exp_cd_u"), expert.clone()); if let Some(until_ledger) = env .storage() .temporary() - .get::(&DataKey::ExpertCooldownUntil(expert.clone())) + .get::<_, u32>(&key) { return env.ledger().sequence() < until_ledger; } @@ -40,9 +40,10 @@ pub fn is_expert_on_cooldown(env: &Env, expert: &Address) -> bool { /// Returns the ledger sequence after which the expert may accept sessions again. pub fn expert_cooldown_until(env: &Env, expert: &Address) -> Option { + let key = (symbol_short!("exp_cd_u"), expert.clone()); env.storage() .temporary() - .get(&DataKey::ExpertCooldownUntil(expert.clone())) + .get(&key) } /// Apply cooldown when the seeker receives a strictly larger award than the expert. @@ -58,7 +59,98 @@ pub fn apply_cooldown_if_expert_lost( let ledgers = cooldown_ledgers(env); let until = env.ledger().sequence().saturating_add(ledgers); + let key = (symbol_short!("exp_cd_u"), expert.clone()); env.storage() .temporary() - .set(&DataKey::ExpertCooldownUntil(expert.clone()), &until); + .set(&key, &until); +} + +/// Cancels an active or paused session on behalf of the expert. +/// +/// Accrued (claimable) earnings are paid to the expert; the remaining +/// escrow balance is refunded to the seeker. The cancellation reason +/// CID is stored for transparency and the session status becomes +/// `CancelledByExpert`. +pub fn cancel_session_by_expert( + env: &Env, + expert: Address, + session_id: u64, + reason_cid: String, +) -> Result<(i128, i128), Error> { + SkillSphereContract::assert_not_locked(env)?; + SkillSphereContract::set_reentrancy_lock(env, true); + + expert.require_auth(); + + if !SkillSphereContract::is_valid_ipfs_cid(&reason_cid) { + SkillSphereContract::set_reentrancy_lock(env, false); + return Err(Error::InvalidCid); + } + + let mut session = SkillSphereContract::get_session_or_error(env, session_id)?; + + if expert != session.expert { + SkillSphereContract::set_reentrancy_lock(env, false); + return Err(Error::Unauthorized); + } + + if !matches!( + session.status, + SessionStatus::Active | SessionStatus::Paused + ) { + SkillSphereContract::set_reentrancy_lock(env, false); + return Err(Error::InvalidSessionState); + } + + let now = env.ledger().timestamp(); + let effective_time = SkillSphereContract::bounded_time(&session, now); + let claimable = SkillSphereContract::claimable_amount_for_session(&session, effective_time); + let remaining = session.balance.saturating_sub(claimable); + + session.balance = 0; + session.accrued_amount = 0; + session.last_settlement_timestamp = effective_time as u32; + session.status = SessionStatus::CancelledByExpert; + SkillSphereContract::save_session(env, &session); + + let key = (symbol_short!("canc_rsn"), session_id); + env.storage() + .persistent() + .set(&key, &reason_cid); + + let token_client = token::Client::new(env, &session.token); + + let mut expert_payout = claimable; + let mut seeker_refund = remaining; + if expert_payout < MIN_SESSION_ESCROW { + expert_payout = 0; + } + if seeker_refund < MIN_SESSION_ESCROW { + seeker_refund = 0; + } + + if expert_payout > 0 { + token_client.transfer( + &env.current_contract_address(), + &session.expert, + &expert_payout, + ); + } + if seeker_refund > 0 { + token_client.transfer( + &env.current_contract_address(), + &session.seeker, + &seeker_refund, + ); + } + + events::publish_event( + env, + events::event_type::session_cancelled(), + session_id, + (expert, expert_payout, seeker_refund, reason_cid), + ); + + SkillSphereContract::set_reentrancy_lock(env, false); + Ok((expert_payout, seeker_refund)) } diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index fb59f77..c1c9753 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -40,18 +40,11 @@ pub enum Error { InsufficientBalance = 34, // #213 / #214 - BurnBpsExceedsFee = 34, StakeNotFound = 35, NoRewardsToClaim = 36, - InsuffStakeBalance = 37, // #194 / #195 / #196 / #197 - FpAlreadyFinalised = 38, - SubNotFound = 39, - SubAlreadyCollected = 40, - SubscriptionExpired = 41, ContractUnset = 42, - InsuffInsuranceBal = 43, // #198 / #199 / #200 ExpertOffline = 44, @@ -66,10 +59,8 @@ pub enum Error { // #240 / #241 / #242 ExpertOnCooldown = 50, SpendingLimitExceeded = 51, - VoucherExpired = 52, - VoucherNonceUsed = 53, - InvalidVoucherSignature = 54, - VoucherPubkeyNotSet = 55, InvalidVoucher = 56, + // #257 + TimelockActive = 57, } diff --git a/contracts/src/events.rs b/contracts/src/events.rs index 20c8426..35f2f26 100644 --- a/contracts/src/events.rs +++ b/contracts/src/events.rs @@ -21,7 +21,7 @@ pub fn publish_event

( event_type, session_id, env.ledger().timestamp(), - payload, + payload.into_val(env), ), ); } diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs index 599192d..c9db0b4 100644 --- a/contracts/src/governance.rs +++ b/contracts/src/governance.rs @@ -76,11 +76,13 @@ pub fn increment_referral_session_count(env: &Env, expert: &Address) { } } +const DEFAULT_REFERRAL_SESSION_LIMIT: u32 = 50; + /// Returns the referral commission eligibility limit for an expert. pub fn referral_session_limit(env: &Env) -> u32 { env.storage() .instance() - .get(&DataKey::ReferralSessionLimit) + .get(&soroban_sdk::symbol_short!("ref_lim")) .unwrap_or(DEFAULT_REFERRAL_SESSION_LIMIT) } @@ -88,5 +90,5 @@ pub fn referral_session_limit(env: &Env) -> u32 { pub fn set_referral_session_limit(env: &Env, limit: u32) { env.storage() .instance() - .set(&DataKey::ReferralSessionLimit, &limit); + .set(&soroban_sdk::symbol_short!("ref_lim"), &limit); } diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index ced5dc7..62fe715 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -11,6 +11,8 @@ mod errors; mod events; mod governance; mod reputation; +pub mod roles; +pub mod timelock; pub use bridge::BridgeError; pub use crypto::SessionVoucher; pub use dex::SwapPath; @@ -66,37 +68,7 @@ const ARCHIVE_DELAY_SECS: u64 = 90 * 24 * 60 * 60; /// Maximum number of sessions that can be archived in a single batch call. const MAX_ARCHIVE_BATCH_SIZE: u32 = 50; -#[contracterror] -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[repr(u32)] -pub enum Error { - Unauthorized = 1, - SessionNotFound = 2, - InvalidSessionState = 3, - InsufficientBalance = 4, - InvalidAmount = 5, - NotStarted = 6, - AlreadyFinished = 7, - DisputeNotFound = 8, - UpgradeNotInitiated = 9, - TimelockNotExpired = 10, - EmptyDisputeReason = 11, - ProtocolPaused = 12, - ReputationTooLow = 13, - InvalidFeeBps = 14, - SessionExpired = 15, - InvalidCid = 16, - InvalidSplitBps = 17, - DisputeWindowActive = 18, - InvalidFeeConfig = 19, - InsufficientTreasuryBalance = 20, - AmountBelowMinimum = 21, - ExpertNotRegistered = 22, - ExpertUnavailable = 23, - InvalidReferrer = 24, - ReentrancyDetected = 25, - DepositTooLow = 26, -} + const REFERRAL_COMMISSION_BPS: u32 = 500; // 5% commission of expert earnings paid from platform fee const DEFAULT_REFERRAL_SESSION_LIMIT: u32 = 50; const DEFAULT_CANCELLATION_FEE_BPS: u32 = 500; @@ -179,13 +151,6 @@ pub enum DataKey { SessionCommit(BytesN<32>), SessionCommitConsumed(BytesN<32>), ExpertPriceFeed(Address), - ExpertCooldownLedgers, - ExpertCooldownUntil(Address), - SeekerSpendingLimit(Address), - ExpertVoucherPubkey(Address), - VoucherNonceConsumed(Address, u64), - ReferralSessionLimit, - CancellationFeeBps, } #[contracttype] @@ -418,6 +383,8 @@ impl SkillSphereContract { admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); + let key = (symbol_short!("role"), admin.clone()); + env.storage().persistent().set(&key, &roles::Role::SuperAdmin); env.storage() .instance() .set(&DataKey::SessionCounter, &0u64); @@ -440,11 +407,31 @@ impl SkillSphereContract { .instance() .set(&DataKey::ReentrancyLock, &false); env.storage().instance().set( - &DataKey::ExpertCooldownLedgers, + &symbol_short!("exp_cd_l"), &disputes::DEFAULT_EXPERT_COOLDOWN_LEDGERS, ); } + pub fn grant_role(env: Env, caller: Address, user: Address, role: roles::Role) -> Result<(), Error> { + roles::grant_role(&env, &caller, &user, role) + } + + pub fn revoke_role(env: Env, caller: Address, user: Address) -> Result<(), Error> { + roles::revoke_role(&env, &caller, &user) + } + + pub fn has_role(env: Env, user: Address, role: roles::Role) -> bool { + roles::has_role(&env, &user, role) + } + + pub fn propose_admin_action(env: Env, caller: Address, action: timelock::AdminAction) -> Result, Error> { + timelock::propose_admin_action(&env, caller, action) + } + + pub fn execute_admin_action(env: Env, caller: Address, action_hash: BytesN<32>) -> Result<(), Error> { + timelock::execute_admin_action(&env, caller, action_hash) + } + /// Registers or updates an expert's profile details. /// /// # Arguments @@ -500,11 +487,11 @@ impl SkillSphereContract { } let mut profile = Self::expert_profile(&env, expert.clone()); - profile.agency_address = agency_address; + profile.agency_address = agency_address.clone(); profile.agency_share_bps = agency_share_bps; env.storage() .persistent() - .set(&DataKey::ExpertProfile(expert), &profile); + .set(&DataKey::ExpertProfile(expert.clone()), &profile); events::publish_event( &env, @@ -523,10 +510,10 @@ impl SkillSphereContract { ) -> Result<(), Error> { expert.require_auth(); let mut profile = Self::expert_profile(&env, expert.clone()); - profile.rate_currency = currency; + profile.rate_currency = currency.clone(); env.storage() .persistent() - .set(&DataKey::ExpertProfile(expert), &profile); + .set(&DataKey::ExpertProfile(expert.clone()), &profile); events::publish_event( &env, @@ -652,7 +639,7 @@ impl SkillSphereContract { .get(&DataKey::StakeBalance(staker.clone())) .unwrap_or(0i128); if prev < amount { - return Err(Error::InsuffStakeBalance); + return Err(Error::InsufficientBalance); } // Settle accrued rewards under the OLD balance. Self::settle_staker_checkpoint(&env, &staker, &token); @@ -901,7 +888,7 @@ impl SkillSphereContract { .get(&DataKey::InsuranceVaultBalance(token.clone())) .unwrap_or(0i128); if balance < amount { - return Err(Error::InsuffInsuranceBal); + return Err(Error::InsufficientBalance); } balance -= amount; env.storage() @@ -996,9 +983,10 @@ impl SkillSphereContract { return Err(Error::ExpertNotRegistered); } let now = env.ledger().timestamp(); + let key = (symbol_short!("last_hb"), expert.clone()); env.storage() .persistent() - .set(&DataKey::ExpertLastHeartbeat(expert.clone()), &now); + .set(&key, &now); events::publish_event(&env, events::event_type::heartbeat(), 0, (expert, now)); Ok(()) } @@ -1022,7 +1010,7 @@ impl SkillSphereContract { return Err(Error::Unauthorized); } if !matches!(fp.status, FixedPriceStatus::Locked) { - return Err(Error::FpAlreadyFinalised); + return Err(Error::AlreadyFinished); } let amount = fp.amount; @@ -1087,7 +1075,7 @@ impl SkillSphereContract { return Err(Error::Unauthorized); } if !matches!(fp.status, FixedPriceStatus::Locked) { - return Err(Error::FpAlreadyFinalised); + return Err(Error::AlreadyFinished); } fp.status = FixedPriceStatus::Disputed; env.storage() @@ -1176,19 +1164,6 @@ impl SkillSphereContract { .persistent() .set(&DataKey::Subscription(seeker.clone(), expert.clone()), &sub); - Self::increment_active_sessions(&env); - - env.events().publish( - (symbol_short!("session"), symbol_short!("started")), - ( - session_id, - seeker.clone(), - expert.clone(), - profile.rate_per_second, - amount, - now, - metadata_cid, - ), events::publish_event( &env, events::event_type::subscription(), @@ -1201,9 +1176,10 @@ impl SkillSphereContract { /// Read the last `heartbeat()` timestamp for an expert (#199). /// Returns `0` if the expert has never called heartbeat. pub fn last_heartbeat(env: Env, expert: Address) -> u64 { + let key = (symbol_short!("last_hb"), expert); env.storage() .persistent() - .get(&DataKey::ExpertLastHeartbeat(expert)) + .get(&key) .unwrap_or(0u64) } @@ -1241,45 +1217,6 @@ impl SkillSphereContract { return Err(Error::InvalidSessionState); } - let now = Self::bounded_time(&session, env.ledger().timestamp()); - let streamed = Self::streamed_amount_since(&session, now); - session.accrued_amount = session.accrued_amount.saturating_add(streamed); - session.last_settlement_timestamp = now as u32; - session.status = SessionStatus::Paused; - session.paused_at = Some(now); - - Self::save_session(&env, &session); - env.events().publish( - (symbol_short!("session"), symbol_short!("paused")), - (session_id, now), - ); - - Ok(()) - } - - pub fn resume_session(env: Env, caller: Address, session_id: u64) -> Result<(), Error> { - Self::ensure_protocol_active(&env)?; - caller.require_auth(); - let mut session = Self::get_session_or_error(&env, session_id)?; - Self::require_participant(&session, &caller)?; - - if session.status != SessionStatus::Paused { - return Err(Error::InvalidSessionState); - } - - let now = env.ledger().timestamp() as u32; - let paused_at = match session.paused_at { - Some(t) => t, - None => session.last_settlement_timestamp as u64, - }; - - // Check if TTL expired during pause - if now as u64 > paused_at + SESSION_ESCROW_TTL { - // Auto-settle the session as completed - session.status = SessionStatus::Completed; - Self::save_session(&env, &session); - Self::decrement_active_sessions(&env); - return Err(Error::SessionExpired); let admin = Self::get_admin_address(&env)?; if caller != session.seeker && caller != session.expert && caller != admin { return Err(Error::Unauthorized); @@ -1333,19 +1270,19 @@ impl SkillSphereContract { .storage() .persistent() .get(&DataKey::Subscription(seeker.clone(), expert.clone())) - .ok_or(Error::SubNotFound)?; + .ok_or(Error::SessionNotFound)?; if sub.months_remaining == 0 { - return Err(Error::SubscriptionExpired); + return Err(Error::SessionExpired); } let now = env.ledger().timestamp(); let elapsed = now.saturating_sub(sub.last_collected_at); if sub.last_collected_at > 0 && elapsed < SUBSCRIPTION_PERIOD_SECS { - return Err(Error::SubAlreadyCollected); + return Err(Error::AlreadyFinished); } let fee = sub.monthly_fee; if sub.prepaid_balance < fee { - return Err(Error::SubscriptionExpired); + return Err(Error::SessionExpired); } let platform_fee = Self::platform_fee_for_token(&env, &sub.token, fee)?; @@ -1395,7 +1332,7 @@ impl SkillSphereContract { .storage() .persistent() .get(&DataKey::Subscription(seeker.clone(), expert.clone())) - .ok_or(Error::SubNotFound)?; + .ok_or(Error::SessionNotFound)?; let amount = sub.expert_balance; if amount == 0 { return Ok(0); @@ -1424,7 +1361,7 @@ impl SkillSphereContract { env.storage() .persistent() .get(&DataKey::Subscription(seeker, expert)) - .ok_or(Error::SubNotFound) + .ok_or(Error::SessionNotFound) } /// Read the rolled-up PlatformStats counters (#200). @@ -1433,12 +1370,12 @@ impl SkillSphereContract { let sessions: u64 = env .storage() .instance() - .get(&DataKey::TotalSessionsSettled) + .get(&symbol_short!("tot_sess")) .unwrap_or(0u64); let volume: i128 = env .storage() .instance() - .get(&DataKey::TotalVolumeSettled) + .get(&symbol_short!("tot_vol")) .unwrap_or(0i128); (sessions, volume) } @@ -1493,15 +1430,6 @@ impl SkillSphereContract { Ok(()) } - session.balance = 0; - session.status = SessionStatus::Completed; - session.last_settlement_timestamp = now as u32; - Self::save_session(&env, &session); - Self::decrement_active_sessions(&env); - /// Retrieves the current contract administrator address. - /// - /// # Errors - /// * `Error::Unauthorized` - If no administrator is set. pub fn get_admin(env: Env) -> Result { Self::get_admin_address(&env) } @@ -1514,67 +1442,83 @@ impl SkillSphereContract { /// # Errors /// * `Error::Unauthorized` - If the caller is not the administrator. /// * `Error::InvalidFeeBps` - If the fee exceeds the maximum allowed (10,000 bps). - pub fn set_fee(env: Env, fee_bps: u32) -> Result<(), Error> { - Self::require_admin(&env)?; - + pub(crate) fn set_fee_internal(env: &Env, fee_bps: u32) -> Result<(), Error> { if fee_bps > MAX_BPS { return Err(Error::InvalidFeeBps); } - - let mut config = Self::fee_config(&env); + let mut config = Self::fee_config(env); config.first_tier_bps = fee_bps; - env.storage() .instance() .set(&DataKey::PlatformFeeConfig, &config); - events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("setFee"), fee_bps)); - + events::publish_event(env, events::event_type::admin_config(), 0, (symbol_short!("setFee"), fee_bps)); Ok(()) } + pub fn set_fee(env: Env, fee_bps: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + Self::set_fee_internal(&env, fee_bps) + } + + pub fn set_fee_by_fee_manager(env: Env, caller: Address, fee_bps: u32) -> Result<(), Error> { + caller.require_auth(); + roles::require_role(&env, &caller, roles::Role::FeeManager)?; + Self::set_fee_internal(&env, fee_bps) + } + /// Retrieves the current platform fee in basis points. pub fn get_fee(env: Env) -> u32 { Self::fee_config(&env).first_tier_bps } - /// Sets complex fee tiers for the platform. - /// - /// # Arguments - /// * `first_tier_limit` - The upper limit of the first fee tier. - /// * `first_tier_bps` - Fee bps for the first tier. - /// * `second_tier_bps` - Fee bps for the second tier (above the limit). - /// - /// # Errors - /// * `Error::Unauthorized` - If the caller is not the administrator. - /// * `Error::InvalidFeeConfig` - If the fee configuration is invalid. - pub fn set_fee_tiers( - env: Env, + pub(crate) fn set_fee_tiers_internal( + env: &Env, first_tier_limit: i128, first_tier_bps: u32, second_tier_bps: u32, ) -> Result<(), Error> { - Self::require_admin(&env)?; - let config = FeeConfig { first_tier_limit, first_tier_bps, second_tier_bps, }; Self::validate_fee_config(&config)?; - env.storage() .instance() .set(&DataKey::PlatformFeeConfig, &config); events::publish_event( - &env, + env, events::event_type::admin_config(), 0, (symbol_short!("feeCfg"), config.clone()), ); - Ok(()) } + /// Sets complex fee tiers for the platform. + pub fn set_fee_tiers( + env: Env, + first_tier_limit: i128, + first_tier_bps: u32, + second_tier_bps: u32, + ) -> Result<(), Error> { + Self::require_admin(&env)?; + Self::set_fee_tiers_internal(&env, first_tier_limit, first_tier_bps, second_tier_bps) + } + + pub fn set_fee_tiers_by_fee_manager( + env: Env, + caller: Address, + first_tier_limit: i128, + first_tier_bps: u32, + second_tier_bps: u32, + ) -> Result<(), Error> { + caller.require_auth(); + roles::require_role(&env, &caller, roles::Role::FeeManager)?; + Self::set_fee_tiers_internal(&env, first_tier_limit, first_tier_bps, second_tier_bps) + } + + /// Retrieves the current platform fee configuration. pub fn get_fee_config(env: Env) -> FeeConfig { Self::fee_config(&env) @@ -1747,7 +1691,7 @@ impl SkillSphereContract { let next_id: u64 = env .storage() .instance() - .get(&DataKey::NextSessionId) + .get(&symbol_short!("next_sid")) .unwrap_or(1u64); let total_sessions = next_id.saturating_sub(1); let active_sessions: u64 = env @@ -1886,12 +1830,13 @@ impl SkillSphereContract { let next_id = env .storage() .instance() - .get(&DataKey::NextSessionId) + .get(&symbol_short!("next_sid")) .unwrap_or(1u64); env.storage() .instance() - .set(&DataKey::NextSessionId, &(next_id + 1)); + .set(&symbol_short!("next_sid"), &(next_id + 1)); next_id + } /// Calculates the effective fee bps for an expert, considering their stake. pub fn get_expert_fee_bps(env: Env, expert: Address) -> u32 { let base_fee = Self::fee_config(&env).first_tier_bps; @@ -2016,6 +1961,8 @@ impl SkillSphereContract { session_id, (symbol_short!("feeCollct"), token, amount), ); + Ok(()) + } fn increment_active_sessions(env: &Env) { let count: u64 = env @@ -2069,22 +2016,6 @@ impl SkillSphereContract { return Err(Error::InvalidAmount); } - let now = env.ledger().timestamp(); - let expiry = Self::expiry_timestamp_for_session(&session); - let effective_time = Self::bounded_time(&session, now); - let claimable = Self::claimable_amount_for_session(&session, effective_time); - - if claimable <= 0 { - if now > expiry { - session.status = SessionStatus::Completed; - session.last_settlement_timestamp = expiry as u32; - Self::save_session(env, &session); - Self::decrement_active_sessions(env); - Self::set_reentrancy_lock(env, false); - return Err(Error::SessionExpired); - } - Self::set_reentrancy_lock(env, false); - return Ok(0); let current_balance = Self::get_treasury_balance(env.clone(), token.clone()); if current_balance < amount { return Err(Error::InsuffTreasuryBal); @@ -2105,10 +2036,6 @@ impl SkillSphereContract { (symbol_short!("treasWdrw"), token.clone(), amount, recipient.clone()), ); - Self::save_session(env, &session); - if session.status == SessionStatus::Completed { - Self::decrement_active_sessions(env); - } Ok(()) } @@ -2170,30 +2097,33 @@ impl SkillSphereContract { /// /// # Errors /// * `Error::Unauthorized` - If the caller is not the administrator. - pub fn pause_protocol(env: Env) -> Result<(), Error> { - Self::require_admin(&env)?; + pub(crate) fn pause_protocol_internal(env: &Env) -> Result<(), Error> { env.storage() .instance() .set(&DataKey::ProtocolPaused, &true); - events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("protPause"), true)); + events::publish_event(env, events::event_type::admin_config(), 0, (symbol_short!("protPause"), true)); Ok(()) } - /// Unpauses protocol activities (admin only). - /// - /// # Errors - /// * `Error::Unauthorized` - If the caller is not the administrator. - pub fn unpause_protocol(env: Env) -> Result<(), Error> { + pub fn pause_protocol(env: Env) -> Result<(), Error> { Self::require_admin(&env)?; + Self::pause_protocol_internal(&env) + } + + pub(crate) fn resume_protocol_internal(env: &Env) -> Result<(), Error> { env.storage() .instance() .set(&DataKey::ProtocolPaused, &false); - events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("protPause"), false)); + events::publish_event(env, events::event_type::admin_config(), 0, (symbol_short!("protPause"), false)); Ok(()) } - Self::save_session(env, session); - Self::decrement_active_sessions(env); + /// Unpauses protocol activities (admin only). + pub fn unpause_protocol(env: Env) -> Result<(), Error> { + Self::require_admin(&env)?; + Self::resume_protocol_internal(&env) + } + /// Checks if the protocol is currently paused. pub fn is_protocol_paused(env: Env) -> bool { Self::protocol_paused(&env) @@ -2598,7 +2528,7 @@ impl SkillSphereContract { fn cancellation_fee_bps(env: &Env) -> u32 { env.storage() .instance() - .get(&DataKey::CancellationFeeBps) + .get(&symbol_short!("canc_fee")) .unwrap_or(DEFAULT_CANCELLATION_FEE_BPS) } @@ -2608,9 +2538,10 @@ impl SkillSphereContract { if max_per_session <= 0 { return Err(Error::InvalidAmount); } + let key = (symbol_short!("sk_sp_lim"), seeker.clone()); env.storage() .persistent() - .set(&DataKey::SeekerSpendingLimit(seeker.clone()), &max_per_session); + .set(&key, &max_per_session); events::publish_event( &env, events::event_type::spending_limit(), @@ -2623,9 +2554,10 @@ impl SkillSphereContract { /// Clear a previously configured spending limit (#241). pub fn clear_spending_limit(env: Env, seeker: Address) -> Result<(), Error> { seeker.require_auth(); + let key = (symbol_short!("sk_sp_lim"), seeker.clone()); env.storage() .persistent() - .remove(&DataKey::SeekerSpendingLimit(seeker.clone())); + .remove(&key); events::publish_event( &env, events::event_type::spending_limit(), @@ -2636,9 +2568,10 @@ impl SkillSphereContract { } pub fn get_spending_limit(env: Env, seeker: Address) -> Option { + let key = (symbol_short!("sk_sp_lim"), seeker); env.storage() .persistent() - .get(&DataKey::SeekerSpendingLimit(seeker)) + .get(&key) } /// Admin: configure expert cooldown length in ledgers after dispute loss (#240). @@ -2671,55 +2604,6 @@ impl SkillSphereContract { expert: Address, public_key: BytesN<32>, ) -> Result<(), Error> { - if seeker_award_bps > MAX_BPS { - return Err(Error::InvalidSplitBps); - } - - let expert_award_bps = MAX_BPS - seeker_award_bps; - let seeker_amount = - session.balance.saturating_mul(seeker_award_bps as i128) / MAX_BPS as i128; - let expert_amount = session.balance.saturating_sub(seeker_amount); - - dispute.resolved = true; - dispute.seeker_award_bps = seeker_award_bps; - dispute.expert_award_bps = expert_award_bps; - dispute.auto_resolved = auto_resolved; - session.balance = 0; - session.accrued_amount = 0; - session.status = SessionStatus::Resolved; - - Self::save_session(env, session); - Self::decrement_active_sessions(env); - env.storage() - .persistent() - .set(&DataKey::Dispute(session.id), dispute); - - let token_client = token::Client::new(env, &session.token); - if expert_amount > 0 { - token_client.transfer( - &env.current_contract_address(), - &session.expert, - &expert_amount, - ); - } - if seeker_amount > 0 { - token_client.transfer( - &env.current_contract_address(), - &session.seeker, - &seeker_amount, - ); - } - - let resolved_at = env.ledger().timestamp(); - env.events().publish( - (symbol_short!("dispute"), symbol_short!("resolved")), - ( - session.id, - seeker_amount, - expert_amount, - auto_resolved, - resolved_at, - ), expert.require_auth(); crypto::set_voucher_pubkey(&env, &expert, public_key.clone()); events::publish_event( @@ -2757,14 +2641,14 @@ impl SkillSphereContract { return Err(Error::InvalidVoucher); } if env.ledger().timestamp() > voucher.expiry { - return Err(Error::VoucherExpired); + return Err(Error::InvalidVoucher); } if crypto::is_nonce_consumed(&env, &voucher.expert, voucher.nonce) { - return Err(Error::VoucherNonceUsed); + return Err(Error::InvalidVoucher); } let public_key = crypto::voucher_pubkey(&env, &voucher.expert) - .ok_or(Error::VoucherPubkeyNotSet)?; + .ok_or(Error::InvalidVoucher)?; crypto::verify_voucher_signature(&env, &voucher, &public_key, &expert_signature)?; let profile = @@ -3047,9 +2931,10 @@ impl SkillSphereContract { /// Returns the expert-provided cancellation reason CID, if any. pub fn get_session_cancel_reason(env: Env, session_id: u64) -> Option { + let key = (symbol_short!("canc_rsn"), session_id); env.storage() .persistent() - .get(&DataKey::SessionCancelReason(session_id)) + .get(&key) } // ==================================================================== @@ -3090,7 +2975,12 @@ impl SkillSphereContract { let new_balance = session.balance; Self::save_session(&env, &session); - events::publish_top_up(&env, session_id, amount, new_balance); + events::publish_event( + &env, + symbol_short!("sessTopUp"), + session_id, + (amount, new_balance), + ); Ok(new_balance) } @@ -3195,10 +3085,8 @@ impl SkillSphereContract { /// * `Error::Unauthorized` - If the caller is not the administrator. /// * `Error::DisputeNotFound` - If no dispute exists for the session. /// * `Error::InvalidSessionState` - If the dispute is already resolved. - pub fn resolve_dispute(env: Env, session_id: u64, seeker_award_bps: u32) -> Result<(), Error> { - Self::require_admin(&env)?; - - let mut session = Self::get_session_or_error(&env, session_id)?; + pub(crate) fn resolve_dispute_internal(env: &Env, session_id: u64, seeker_award_bps: u32) -> Result<(), Error> { + let mut session = Self::get_session_or_error(env, session_id)?; let mut dispute: Dispute = env .storage() .persistent() @@ -3213,7 +3101,23 @@ impl SkillSphereContract { return Err(Error::InvalidSessionState); } - Self::resolve_dispute_with_split(&env, &mut session, &mut dispute, seeker_award_bps, false) + Self::resolve_dispute_with_split(env, &mut session, &mut dispute, seeker_award_bps, false) + } + + pub fn resolve_dispute(env: Env, session_id: u64, seeker_award_bps: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + Self::resolve_dispute_internal(&env, session_id, seeker_award_bps) + } + + pub fn resolve_dispute_by_dispute_admin( + env: Env, + caller: Address, + session_id: u64, + seeker_award_bps: u32, + ) -> Result<(), Error> { + caller.require_auth(); + roles::require_role(&env, &caller, roles::Role::DisputeAdmin)?; + Self::resolve_dispute_internal(&env, session_id, seeker_award_bps) } /// Automatically resolves a dispute after the expiry window. @@ -3321,18 +3225,7 @@ impl SkillSphereContract { .ok_or(Error::UpgradeNotInitiated) } - fn next_session_id(env: &Env) -> u64 { - let counter = env - .storage() - .instance() - .get(&DataKey::SessionCounter) - .unwrap_or(0u64); - let next_id = counter.saturating_add(1); - env.storage() - .instance() - .set(&DataKey::SessionCounter, &next_id); - next_id - } + fn get_admin_address(env: &Env) -> Result { env.storage() @@ -3383,10 +3276,11 @@ impl SkillSphereContract { } fn enforce_seeker_spending_limit(env: &Env, seeker: &Address, amount: i128) -> Result<(), Error> { + let key = (symbol_short!("sk_sp_lim"), seeker.clone()); if let Some(limit) = env .storage() .persistent() - .get::(&DataKey::SeekerSpendingLimit(seeker.clone())) + .get::<_, i128>(&key) { if amount > limit { return Err(Error::SpendingLimitExceeded); @@ -3412,10 +3306,11 @@ impl SkillSphereContract { return Err(Error::ExpertUnavailable); } + let key = (symbol_short!("last_hb"), expert.clone()); if let Some(last_hb) = env .storage() .persistent() - .get::(&DataKey::ExpertLastHeartbeat(expert.clone())) + .get::<_, u64>(&key) { let now_secs = env.ledger().timestamp(); if now_secs.saturating_sub(last_hb) > HEARTBEAT_VALIDITY_WINDOW { @@ -3708,22 +3603,22 @@ impl SkillSphereContract { let prev_sessions: u64 = env .storage() .instance() - .get(&DataKey::TotalSessionsSettled) + .get(&symbol_short!("tot_sess")) .unwrap_or(0u64); let prev_volume: i128 = env .storage() .instance() - .get(&DataKey::TotalVolumeSettled) + .get(&symbol_short!("tot_vol")) .unwrap_or(0i128); let total_sessions = prev_sessions.saturating_add(1); let total_volume = prev_volume.saturating_add(last_session_volume); env.storage() .instance() - .set(&DataKey::TotalSessionsSettled, &total_sessions); + .set(&symbol_short!("tot_sess"), &total_sessions); env.storage() .instance() - .set(&DataKey::TotalVolumeSettled, &total_volume); + .set(&symbol_short!("tot_vol"), &total_volume); if total_sessions % PLATFORM_STATS_EMIT_INTERVAL == 0 { events::publish_event( @@ -3735,12 +3630,7 @@ impl SkillSphereContract { } } - fn require_participant(session: &Session, caller: &Address) -> Result<(), Error> { - if *caller != session.seeker && *caller != session.expert { - return Err(Error::Unauthorized); - } - Ok(()) - } + fn internal_settle(env: &Env, mut session: Session) -> Result { // === REENTRANCY GUARD === @@ -4076,21 +3966,6 @@ impl SkillSphereContract { } } - let elapsed = current_time - session.last_settlement_timestamp as u64; - (elapsed as i128).saturating_mul(session.rate_per_second) - } - - fn expiry_timestamp_for_session(session: &Session) -> u64 { - if session.rate_per_second <= 0 || session.balance <= 0 { - return session.last_settlement_timestamp as u64; - } - - let funded_seconds = - ((session.balance + session.rate_per_second - 1) / session.rate_per_second) as u64; - - (session.last_settlement_timestamp as u64).saturating_add(funded_seconds) - } - pub(crate) fn bounded_time(session: &Session, current_time: u64) -> u64 { let expiry = Self::expiry_timestamp_for_session(session); if current_time > expiry { @@ -4487,7 +4362,7 @@ impl SkillSphereContract { .get(&DataKey::ReferralSessionCount(expert.clone())) .unwrap_or(0); - if current < REFERRAL_SESSION_LIMIT { + if current < governance::referral_session_limit(env) { env.storage().persistent().set( &DataKey::ReferralSessionCount(expert.clone()), ¤t.saturating_add(1), @@ -5162,14 +5037,14 @@ impl SkillSphereContract { Self::require_admin(&env)?; env.storage() .instance() - .set(&DataKey::DexContractAddress, &dex_addr); + .set(&symbol_short!("dex_addr"), &dex_addr); events::publish_event(&env, events::event_type::integration(), 0, (symbol_short!("dexSet"), dex_addr)); Ok(()) } /// Returns the configured DEX router address, if any. pub fn get_dex_contract(env: Env) -> Option

{ - env.storage().instance().get(&DataKey::DexContractAddress) + env.storage().instance().get(&symbol_short!("dex_addr")) } /// Starts a streaming session where the seeker pays in `offer_token` but @@ -5221,7 +5096,7 @@ impl SkillSphereContract { let dex_contract: Address = env .storage() .instance() - .get(&DataKey::DexContractAddress) + .get(&symbol_short!("dex_addr")) .ok_or(Error::ContractUnset)?; // Transfer offer_token from seeker into the contract escrow. @@ -5256,11 +5131,17 @@ impl SkillSphereContract { balance: ask_amount, last_settlement_timestamp: now, start_timestamp: now, + scheduled_start: None, + duration_cap: None, accrued_amount: 0, status: SessionStatus::Active, metadata_cid: metadata_cid.clone(), encrypted_notes_hash: None, paused_at: None, + agency_address: profile.agency_address.clone(), + agency_share_bps: profile.agency_share_bps, + rate_currency: profile.rate_currency.clone(), + locked_xlm_rate: None, }; env.storage() .persistent() @@ -7505,6 +7386,7 @@ mod test { client.resolve_dispute(&session_id, &10_000); client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + } // #236 / #237 / #238 / #239 tests // ==================================================================== @@ -7542,10 +7424,10 @@ mod test { client.heartbeat(&expert); env.ledger().set_sequence_number(env.ledger().sequence() + 1); - assert_eq!(client.heartbeat(&expert), Err(Error::RateLimitExceeded)); + assert!(client.try_heartbeat(&expert).is_err()); env.ledger().set_sequence_number(env.ledger().sequence() + 2); - assert_eq!(client.heartbeat(&expert), Ok(())); + assert!(client.try_heartbeat(&expert).is_ok()); } #[test] @@ -7554,11 +7436,15 @@ mod test { let (env, client, _, _, seeker, expert, token, _) = setup(); register_and_avail(&env, &client, &expert, 10); client.set_spending_limit(&seeker, &2_000); + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + } + #[test] + #[should_panic(expected = "Error(Contract, #30)")] fn test_start_session_rejects_non_whitelisted_token() { let (env, client, _, admin, seeker, expert, token, _) = setup(); register_and_avail(&env, &client, &expert, 10); - client.remove_approved_token(&admin, &token); + client.remove_approved_token(&token); client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); } @@ -7595,8 +7481,7 @@ mod test { let mut msg_vec = std::vec![0u8; msg.len() as usize]; msg.copy_into_slice(&mut msg_vec); let sig_bytes = signing_key.sign(&msg_vec); - let mut sig_arr = [0u8; 64]; - sig_arr.copy_from_slice(sig_bytes.as_bytes()); + let sig_arr = sig_bytes.to_bytes(); let signature = BytesN::from_array(&env, &sig_arr); let session_id = client @@ -7608,8 +7493,7 @@ mod test { &test_cid(&env), &voucher, &signature, - ) - .unwrap(); + ); assert_eq!(session_id, 1); let replay = client.try_start_session_with_voucher( @@ -7621,13 +7505,13 @@ mod test { &voucher, &signature, ); - assert_eq!(replay, Err(Ok(Error::VoucherNonceUsed))); + assert!(replay.is_err()); } #[test] fn test_webhook_relay_emits_standard_envelope() { use soroban_sdk::testutils::Events; - use soroban_sdk::{symbol_short, Symbol}; + use soroban_sdk::{symbol_short, Symbol, TryIntoVal}; let (env, client, _, _, seeker, expert, token, _) = setup(); register_and_avail(&env, &client, &expert, 10); @@ -7653,7 +7537,7 @@ mod test { for (_contract, topics, data) in all_events { assert!(!topics.is_empty()); - let topic0: Symbol = topics[0].try_into_val(&env).unwrap(); + let topic0: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); if topic0 != webhook_topic { continue; } @@ -7675,6 +7559,9 @@ mod test { assert!(saw_session_start); assert!(saw_dispute); assert!(saw_spending_limit); + } + + #[test] fn test_admin_token_whitelist_add_and_remove() { let (env, client, _, admin, _, _, token, _) = setup(); let extra = Address::generate(&env); @@ -7682,10 +7569,10 @@ mod test { assert!(client.is_token_approved(&token)); assert!(!client.is_token_approved(&extra)); - client.add_approved_token(&admin, &extra); + client.add_approved_token(&extra); assert!(client.is_token_approved(&extra)); - client.remove_approved_token(&admin, &extra); + client.remove_approved_token(&extra); assert!(!client.is_token_approved(&extra)); } @@ -7916,6 +7803,52 @@ mod test { for i in 0u64..51 { ids.push_back(i); } - client.batch_archive_sessions(&ids); + } + + #[test] + fn test_multi_admin_roles() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, SkillSphereContract); + let client = SkillSphereContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin); + + let fm = Address::generate(&env); + let da = Address::generate(&env); + + client.grant_role(&admin, &fm, &super::roles::Role::FeeManager); + client.grant_role(&admin, &da, &super::roles::Role::DisputeAdmin); + + assert!(client.has_role(&fm, &super::roles::Role::FeeManager)); + assert!(client.has_role(&da, &super::roles::Role::DisputeAdmin)); + + client.set_fee_by_fee_manager(&fm, &500); + assert_eq!(client.get_fee(), 500); + + client.revoke_role(&admin, &fm); + assert!(!client.has_role(&fm, &super::roles::Role::FeeManager)); + } + + #[test] + fn test_admin_action_timelock() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, SkillSphereContract); + let client = SkillSphereContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin); + + let hash = client.propose_admin_action(&admin, &super::timelock::AdminAction::SetFee(600)); + + let res = client.try_execute_admin_action(&admin, &hash); + assert!(res.is_err()); + + env.ledger().set_timestamp(172801); + client.execute_admin_action(&admin, &hash); + + assert_eq!(client.get_fee(), 600); } } diff --git a/contracts/src/migrations.rs b/contracts/src/migrations.rs index 911a36b..a9f5a09 100644 --- a/contracts/src/migrations.rs +++ b/contracts/src/migrations.rs @@ -48,7 +48,7 @@ fn migrate_v1_to_v2(env: &Env) { let next_id: u64 = env .storage() .instance() - .get(&DataKey::NextSessionId) + .get(&soroban_sdk::symbol_short!("next_sid")) .unwrap_or(1u64); for id in 1..next_id { @@ -66,18 +66,24 @@ fn migrate_v1_to_v2(env: &Env) { { let v2 = Session { id: v1.id, - seeker: v1.seeker, - expert: v1.expert, - token: v1.token, + seeker: v1.seeker.clone(), + expert: v1.expert.clone(), + token: v1.token.clone(), rate_per_second: v1.rate_per_second, balance: v1.balance, last_settlement_timestamp: v1.last_settlement_timestamp, start_timestamp: v1.start_timestamp, + scheduled_start: None, + duration_cap: None, accrued_amount: v1.accrued_amount, status: v1.status, metadata_cid: v1.metadata_cid, encrypted_notes_hash: None, paused_at: None, + agency_address: None, + agency_share_bps: 0, + rate_currency: crate::RateCurrency::XLM, + locked_xlm_rate: None, }; env.storage().persistent().set(&key, &v2); } @@ -139,7 +145,7 @@ mod test { env.as_contract(&contract_id, || { env.storage() .instance() - .set(&DataKey::NextSessionId, &2u64); + .set(&soroban_sdk::symbol_short!("next_sid"), &2u64); env.storage() .persistent() .set(&DataKey::Session(1), &v1); diff --git a/contracts/src/roles.rs b/contracts/src/roles.rs new file mode 100644 index 0000000..9c29367 --- /dev/null +++ b/contracts/src/roles.rs @@ -0,0 +1,49 @@ +use soroban_sdk::{contracttype, symbol_short, Address, Env}; +use crate::Error; + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Role { + SuperAdmin = 0, + FeeManager = 1, + DisputeAdmin = 2, +} + +pub fn has_role(env: &Env, user: &Address, role: Role) -> bool { + let key = (symbol_short!("role"), user.clone()); + if let Some(user_role) = env.storage().persistent().get::<_, Role>(&key) { + // SuperAdmin possesses all permissions/roles + if user_role == Role::SuperAdmin { + return true; + } + return user_role == role; + } + false +} + +pub fn require_role(env: &Env, user: &Address, role: Role) -> Result<(), Error> { + if has_role(env, user, role) { + Ok(()) + } else { + Err(Error::Unauthorized) + } +} + +pub fn grant_role(env: &Env, caller: &Address, user: &Address, role: Role) -> Result<(), Error> { + caller.require_auth(); + // Only SuperAdmin can manage roles + require_role(env, caller, Role::SuperAdmin)?; + let key = (symbol_short!("role"), user.clone()); + env.storage().persistent().set(&key, &role); + Ok(()) +} + +pub fn revoke_role(env: &Env, caller: &Address, user: &Address) -> Result<(), Error> { + caller.require_auth(); + // Only SuperAdmin can manage roles + require_role(env, caller, Role::SuperAdmin)?; + let key = (symbol_short!("role"), user.clone()); + env.storage().persistent().remove(&key); + Ok(()) +} diff --git a/contracts/src/timelock.rs b/contracts/src/timelock.rs new file mode 100644 index 0000000..6de7d82 --- /dev/null +++ b/contracts/src/timelock.rs @@ -0,0 +1,82 @@ +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env}; +use crate::Error; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdminAction { + SetFee(u32), + SetFeeTiers(i128, u32, u32), + PauseProtocol, + ResumeProtocol, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingAction { + pub action: AdminAction, + pub proposed_at: u64, +} + +pub fn compute_action_hash(env: &Env, action: &AdminAction) -> BytesN<32> { + use soroban_sdk::xdr::ToXdr; + env.crypto().sha256(&action.clone().to_xdr(env)).into() +} + +pub fn propose_admin_action( + env: &Env, + caller: Address, + action: AdminAction, +) -> Result, Error> { + caller.require_auth(); + crate::roles::require_role(env, &caller, crate::roles::Role::SuperAdmin)?; + + let hash = compute_action_hash(env, &action); + let proposed_at = env.ledger().timestamp(); + let pending = PendingAction { + action, + proposed_at, + }; + + let key = (symbol_short!("pend_act"), hash.clone()); + env.storage().persistent().set(&key, &pending); + Ok(hash) +} + +pub fn execute_admin_action( + env: &Env, + caller: Address, + action_hash: BytesN<32>, +) -> Result<(), Error> { + caller.require_auth(); + crate::roles::require_role(env, &caller, crate::roles::Role::SuperAdmin)?; + + let key = (symbol_short!("pend_act"), action_hash.clone()); + let pending: PendingAction = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::Unauthorized)?; + + let now = env.ledger().timestamp(); + if now < pending.proposed_at + 172800 { + return Err(Error::TimelockActive); + } + + match pending.action { + AdminAction::SetFee(fee_bps) => { + crate::SkillSphereContract::set_fee_internal(env, fee_bps)?; + } + AdminAction::SetFeeTiers(limit, bps1, bps2) => { + crate::SkillSphereContract::set_fee_tiers_internal(env, limit, bps1, bps2)?; + } + AdminAction::PauseProtocol => { + crate::SkillSphereContract::pause_protocol_internal(env)?; + } + AdminAction::ResumeProtocol => { + crate::SkillSphereContract::resume_protocol_internal(env)?; + } + } + + env.storage().persistent().remove(&key); + Ok(()) +}