From 600a175b59e4f2890452f483d65fc2756f7eaabe Mon Sep 17 00:00:00 2001 From: jahrulezfrancis Date: Sun, 31 May 2026 00:26:35 +0100 Subject: [PATCH] feat(contracts): add rate limits, token whitelist, top-up, and expert cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #236–#239: per-address ledger cooldowns, admin-managed payment token registry, seeker session top-ups, and expert-initiated cancellation with partial refund and stored reason CID. --- contracts/src/admin.rs | 124 +++++++++++++++ contracts/src/disputes.rs | 98 ++++++++++++ contracts/src/errors.rs | 6 + contracts/src/events.rs | 32 ++++ contracts/src/lib.rs | 327 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 contracts/src/admin.rs create mode 100644 contracts/src/disputes.rs create mode 100644 contracts/src/events.rs diff --git a/contracts/src/admin.rs b/contracts/src/admin.rs new file mode 100644 index 0000000..7ff36d2 --- /dev/null +++ b/contracts/src/admin.rs @@ -0,0 +1,124 @@ +//! Admin-managed configuration: rate-limit cooldowns (#236) and the +//! approved-token registry (#239). + +use soroban_sdk::{Address, Env, Vec}; + +use crate::{DataKey, Error}; + +/// Default rate-limit cooldown: 0 disables per-address throttling. +const DEFAULT_RATE_LIMIT_MIN_LEDGERS: u32 = 0; + +/// 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) + .unwrap_or(DEFAULT_RATE_LIMIT_MIN_LEDGERS) +} + +/// Persists the minimum ledger gap between rate-limited calls. +pub fn set_rate_limit_min_ledgers(env: &Env, min_ledgers: u32) { + env.storage() + .instance() + .set(&DataKey::RateLimitMinLedgers, &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 +/// 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 current = env.ledger().sequence(); + + if let Some(last) = env + .storage() + .temporary() + .get::(&key) + { + if current.saturating_sub(last) < min_ledgers { + return Err(Error::RateLimitExceeded); + } + } + + env.storage().temporary().set(&key, ¤t); + + // Keep the tombstone alive through the cooldown window. + let extend_to = min_ledgers.saturating_add(10); + env.storage() + .temporary() + .extend_ttl(&key, min_ledgers, extend_to); + + Ok(()) +} + +/// Returns the admin-maintained list of approved payment tokens. +pub fn approved_tokens(env: &Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::ApprovedTokens) + .unwrap_or_else(|| Vec::new(env)) +} + +fn save_approved_tokens(env: &Env, tokens: &Vec
) { + env.storage() + .persistent() + .set(&DataKey::ApprovedTokens, tokens); +} + +/// Returns `true` when `token` is present in the approved-token registry. +pub fn is_token_whitelisted(env: &Env, token: &Address) -> bool { + let tokens = approved_tokens(env); + for i in 0..tokens.len() { + if tokens.get(i).unwrap() == *token { + return true; + } + } + false +} + +/// Rejects `token` unless it appears in the approved-token registry. +pub fn require_token_whitelisted(env: &Env, token: &Address) -> Result<(), Error> { + if is_token_whitelisted(env, token) { + Ok(()) + } else { + Err(Error::TokenNotWhitelisted) + } +} + +/// Appends `token` to the approved-token registry. +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); + } + } + tokens.push_back(token); + save_approved_tokens(env, &tokens); + Ok(()) +} + +/// Removes `token` from the approved-token registry. +pub fn remove_approved_token(env: &Env, token: Address) -> Result<(), Error> { + let tokens = approved_tokens(env); + let mut next = Vec::new(env); + let mut found = false; + for i in 0..tokens.len() { + let entry = tokens.get(i).unwrap(); + if entry == token { + found = true; + } else { + next.push_back(entry); + } + } + if !found { + return Err(Error::TokenNotInWhitelist); + } + save_approved_tokens(env, &next); + Ok(()) +} diff --git a/contracts/src/disputes.rs b/contracts/src/disputes.rs new file mode 100644 index 0000000..05326ea --- /dev/null +++ b/contracts/src/disputes.rs @@ -0,0 +1,98 @@ +//! Expert-initiated session cancellation with partial refund (#238). + +use soroban_sdk::{token, Address, Env, String}; + +use crate::{ + events, DataKey, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW, +}; + +/// 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); + + env.storage() + .persistent() + .set(&DataKey::SessionCancelReason(session_id), &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_expert_cancel( + env, + 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 abe187c..1083476 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -62,4 +62,10 @@ pub enum Error { SessionFrozen = 48, SwapFailed = 49, + // #236 / #239 / #237 / #238 + RateLimitExceeded = 50, + TokenNotWhitelisted = 51, + TokenAlreadyWhitelisted = 52, + TokenNotInWhitelist = 53, + } diff --git a/contracts/src/events.rs b/contracts/src/events.rs new file mode 100644 index 0000000..8cd5346 --- /dev/null +++ b/contracts/src/events.rs @@ -0,0 +1,32 @@ +//! Centralised event publishing helpers for session lifecycle events. + +use soroban_sdk::{symbol_short, Env}; + +/// Emitted when a seeker tops up an active session escrow (#237). +pub fn publish_top_up(env: &Env, session_id: u64, amount: i128, new_balance: i128) { + env.events().publish( + (symbol_short!("session"), symbol_short!("topup")), + (session_id, amount, new_balance), + ); +} + +/// Emitted when an expert cancels their session (#238). +pub fn publish_expert_cancel( + env: &Env, + session_id: u64, + expert: soroban_sdk::Address, + expert_payout: i128, + seeker_refund: i128, + reason_cid: soroban_sdk::String, +) { + env.events().publish( + (symbol_short!("session"), symbol_short!("expcncl")), + ( + session_id, + expert, + expert_payout, + seeker_refund, + reason_cid, + ), + ); +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index e42d95b..fbf5808 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,8 +1,11 @@ #![no_std] pub mod bridge; +mod admin; mod dex; +mod disputes; mod errors; +mod events; mod governance; mod reputation; pub use bridge::BridgeError; @@ -42,7 +45,7 @@ const TIMELOCK_DURATION: u64 = 48 * 60 * 60; const DISPUTE_EXPIRY_WINDOW: u64 = 30 * 24 * 60 * 60; const SESSION_ESCROW_TTL: u64 = 300; // 5 minutes for pause grace period const SESSION_NO_SHOW_REFUND_WINDOW: u64 = 600; // 10 minutes -const MIN_SESSION_ESCROW: i128 = 10; // Dust cleanup threshold +pub(crate) const MIN_SESSION_ESCROW: i128 = 10; // Dust cleanup threshold const DEFAULT_FEE_FIRST_TIER_LIMIT: i128 = 1_000; const DEFAULT_FEE_FIRST_TIER_BPS: u32 = 500; const DEFAULT_FEE_SECOND_TIER_BPS: u32 = 300; @@ -187,6 +190,14 @@ pub enum DataKey { // call sites that opt into a "if oracle unavailable, fall back to // a static rate" policy. ExpertPriceFeed(Address), + // #236: per-address rate-limit tombstone (temporary storage). + LastAction(Address), + // #236: admin-configured minimum ledger gap between rate-limited calls. + RateLimitMinLedgers, + // #239: admin-managed whitelist of approved payment tokens. + ApprovedTokens, + // #238: expert cancellation reason stored separately for transparency. + SessionCancelReason(u64), } #[contracttype] @@ -197,6 +208,7 @@ pub enum SessionStatus { Completed, Disputed, Resolved, + CancelledByExpert, } /// Per-commitment metadata for the privacy-preserving session-handshake @@ -853,6 +865,7 @@ impl SkillSphereContract { /// working until they opt in. pub fn heartbeat(env: Env, expert: Address) -> Result<(), Error> { expert.require_auth(); + admin::rate_limit(&env, &expert, admin::rate_limit_min_ledgers(&env))?; let profile = Self::expert_profile(&env, expert.clone()); if profile.rate_per_second == 0 { return Err(Error::ExpertNotRegistered); @@ -1394,6 +1407,57 @@ impl SkillSphereContract { Self::min_session_deposit(&env) } + // ==================================================================== + // #236 — Per-Address Rate-Limit Guard + // ==================================================================== + + /// Sets the minimum ledger gap enforced between rate-limited calls + /// (`start_session`, `heartbeat`). Zero disables throttling. + pub fn set_rate_limit_min_ledgers(env: Env, min_ledgers: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + admin::set_rate_limit_min_ledgers(&env, min_ledgers); + env.events() + .publish((symbol_short!("rateLim"),), min_ledgers); + Ok(()) + } + + /// Returns the configured rate-limit cooldown in ledgers. + pub fn get_rate_limit_min_ledgers(env: Env) -> u32 { + admin::rate_limit_min_ledgers(&env) + } + + // ==================================================================== + // #239 — Whitelisted Token Registry + // ==================================================================== + + /// Adds a token contract to the approved payment-token registry. + pub fn add_approved_token(env: Env, token: Address) -> Result<(), Error> { + Self::require_admin(&env)?; + admin::add_approved_token(&env, token.clone())?; + env.events() + .publish((symbol_short!("addToken"),), token); + Ok(()) + } + + /// Removes a token contract from the approved payment-token registry. + pub fn remove_approved_token(env: Env, token: Address) -> Result<(), Error> { + Self::require_admin(&env)?; + admin::remove_approved_token(&env, token.clone())?; + env.events() + .publish((symbol_short!("rmToken"),), token); + Ok(()) + } + + /// Returns whether `token` is on the approved payment-token registry. + pub fn is_token_approved(env: Env, token: Address) -> bool { + admin::is_token_whitelisted(&env, &token) + } + + /// Returns the full list of approved payment tokens. + pub fn get_approved_tokens(env: Env) -> Vec
{ + admin::approved_tokens(&env) + } + /// Sets the staking contract address. /// /// # Arguments @@ -1812,6 +1876,12 @@ impl SkillSphereContract { if Self::protocol_paused(&env) { panic_with_error!(&env, Error::ProtocolPaused); } + if let Err(e) = admin::rate_limit(&env, &seeker, admin::rate_limit_min_ledgers(&env)) { + panic_with_error!(&env, e); + } + if let Err(e) = admin::require_token_whitelisted(&env, &token) { + panic_with_error!(&env, e); + } if !Self::is_valid_ipfs_cid(&metadata_cid) { panic_with_error!(&env, Error::InvalidCid); } @@ -2098,6 +2168,73 @@ impl SkillSphereContract { Ok(()) } + // ==================================================================== + // #238 — Expert-Initiated Session Cancellation + // ==================================================================== + + /// Allows an expert to cancel their own active or paused session. + /// + /// Accrued earnings are paid to the expert; the remaining escrow + /// balance is refunded to the seeker. `reason_cid` is stored for + /// transparency and the session status becomes `CancelledByExpert`. + pub fn cancel_session( + env: Env, + expert: Address, + session_id: u64, + reason_cid: String, + ) -> Result<(i128, i128), Error> { + disputes::cancel_session_by_expert(&env, expert, session_id, reason_cid) + } + + /// Returns the expert-provided cancellation reason CID, if any. + pub fn get_session_cancel_reason(env: Env, session_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::SessionCancelReason(session_id)) + } + + // ==================================================================== + // #237 — Session Renewal / Top-Up + // ==================================================================== + + /// Adds `amount` tokens to an active session's escrow balance. + /// + /// Only the seeker may top up their own session while it is active. + pub fn top_up_session( + env: Env, + seeker: Address, + session_id: u64, + amount: i128, + ) -> Result { + seeker.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let mut session = Self::get_session_or_error(&env, session_id)?; + + if seeker != session.seeker { + return Err(Error::Unauthorized); + } + if session.status != SessionStatus::Active { + return Err(Error::SessionNotActive); + } + + let token_client = token::Client::new(&env, &session.token); + if token_client.balance(&seeker) < amount { + return Err(Error::InsufficientBalance); + } + token_client.transfer(&seeker, &env.current_contract_address(), &amount); + + session.balance = session.balance.saturating_add(amount); + let new_balance = session.balance; + Self::save_session(&env, &session); + + events::publish_top_up(&env, session_id, amount, new_balance); + Ok(new_balance) + } + /// Retrieves the details of a session. /// /// # Errors @@ -2363,13 +2500,13 @@ impl SkillSphereContract { .unwrap_or(false) } - fn set_reentrancy_lock(env: &Env, locked: bool) { + pub(crate) fn set_reentrancy_lock(env: &Env, locked: bool) { env.storage() .instance() .set(&DataKey::ReentrancyLock, &locked); } - fn assert_not_locked(env: &Env) -> Result<(), Error> { + pub(crate) fn assert_not_locked(env: &Env) -> Result<(), Error> { if Self::reentrancy_locked(env) { return Err(Error::Reentrancy); } @@ -2384,14 +2521,14 @@ impl SkillSphereContract { Ok(()) } - fn get_session_or_error(env: &Env, session_id: u64) -> Result { + pub(crate) fn get_session_or_error(env: &Env, session_id: u64) -> Result { env.storage() .persistent() .get(&DataKey::Session(session_id)) .ok_or(Error::SessionNotFound) } - fn save_session(env: &Env, session: &Session) { + pub(crate) fn save_session(env: &Env, session: &Session) { env.storage() .persistent() .set(&DataKey::Session(session.id), session); @@ -2593,7 +2730,10 @@ impl SkillSphereContract { // === CHECKS === if matches!( session.status, - SessionStatus::Completed | SessionStatus::Disputed | SessionStatus::Resolved + SessionStatus::Completed + | SessionStatus::Disputed + | SessionStatus::Resolved + | SessionStatus::CancelledByExpert ) { Self::set_reentrancy_lock(env, false); return Err(Error::InvalidSessionState); @@ -2756,7 +2896,10 @@ impl SkillSphereContract { // === CHECKS === if matches!( session.status, - SessionStatus::Completed | SessionStatus::Disputed | SessionStatus::Resolved + SessionStatus::Completed + | SessionStatus::Disputed + | SessionStatus::Resolved + | SessionStatus::CancelledByExpert ) { Self::set_reentrancy_lock(env, false); return Err(Error::InvalidSessionState); @@ -2814,7 +2957,7 @@ impl SkillSphereContract { Ok((final_claimable, final_remaining)) } - fn claimable_amount_for_session(session: &Session, current_time: u64) -> i128 { + pub(crate) fn claimable_amount_for_session(session: &Session, current_time: u64) -> i128 { let streamed = if session.status == SessionStatus::Active { Self::streamed_amount_since(session, current_time) } else { @@ -2849,7 +2992,7 @@ impl SkillSphereContract { (session.last_settlement_timestamp as u64).saturating_add(funded_seconds) } - fn bounded_time(session: &Session, current_time: u64) -> u64 { + pub(crate) fn bounded_time(session: &Session, current_time: u64) -> u64 { let expiry = Self::expiry_timestamp_for_session(session); if current_time > expiry { expiry @@ -3004,7 +3147,7 @@ impl SkillSphereContract { (dispute.created_at as u64).saturating_add(DISPUTE_EXPIRY_WINDOW) } - fn is_valid_ipfs_cid(cid: &String) -> bool { + pub(crate) fn is_valid_ipfs_cid(cid: &String) -> bool { let len = cid.len() as usize; if !(2..=64).contains(&len) { return false; @@ -3888,6 +4031,13 @@ impl SkillSphereContract { seeker.require_auth(); Self::ensure_protocol_active(&env)?; + if let Err(e) = admin::rate_limit(&env, &seeker, admin::rate_limit_min_ledgers(&env)) { + return Err(e); + } + if let Err(e) = admin::require_token_whitelisted(&env, &ask_token) { + return Err(e); + } + if offer_amount <= 0 { return Err(Error::InvalidAmount); } @@ -4272,6 +4422,16 @@ mod test { String::from_str(env, "QmYwAPJzv5CZsnAzt8auVZRnGzrYxkM4Tveoxu48UUfGz8") } + fn whitelist_token( + client: &SkillSphereContractClient, + admin: &Address, + token: &Address, + ) { + if !client.is_token_approved(token) { + client.add_approved_token(admin, token); + } + } + fn setup() -> ( Env, SkillSphereContractClient<'static>, @@ -4298,6 +4458,8 @@ mod test { client.initialize(&admin); + client.add_approved_token(&admin, &token_address); + let asset_admin = token::StellarAssetClient::new(&env, &token_address); asset_admin.mint(&seeker, &100_000); @@ -5489,12 +5651,13 @@ mod test { #[test] fn test_multiple_sessions_with_different_tokens() { - let (env, client, _, _, seeker, expert, token1, token_admin) = setup(); + let (env, client, _, admin, seeker, expert, token1, token_admin) = setup(); register_and_avail(&env, &client, &expert, 10); // Create second token (USDC) let token2 = env.register_stellar_asset_contract_v2(token_admin.clone()); let token2_address = token2.address(); + whitelist_token(&client, &admin, &token2_address); let asset_admin2 = token::StellarAssetClient::new(&env, &token2_address); asset_admin2.mint(&seeker, &10_000); @@ -5522,12 +5685,13 @@ mod test { #[test] fn test_settle_session_uses_correct_token_contract() { - let (env, client, _, _, seeker, expert, token1, token_admin) = setup(); + let (env, client, _, admin, seeker, expert, token1, token_admin) = setup(); register_and_avail(&env, &client, &expert, 10); // Create USDC token let usdc_token = env.register_stellar_asset_contract_v2(token_admin.clone()); let usdc_address = usdc_token.address(); + whitelist_token(&client, &admin, &usdc_address); let usdc_admin = token::StellarAssetClient::new(&env, &usdc_address); usdc_admin.mint(&seeker, &10_000); @@ -5549,7 +5713,7 @@ mod test { #[test] fn test_expert_can_accept_multiple_token_types() { - let (env, client, _, _, seeker, expert, xlm_token, token_admin) = setup(); + let (env, client, _, admin, seeker, expert, xlm_token, token_admin) = setup(); register_and_avail(&env, &client, &expert, 10); // Create USDC and DAI tokens @@ -5557,6 +5721,8 @@ mod test { let usdc_address = usdc.address(); let dai = env.register_stellar_asset_contract_v2(token_admin.clone()); let dai_address = dai.address(); + whitelist_token(&client, &admin, &usdc_address); + whitelist_token(&client, &admin, &dai_address); let usdc_admin = token::StellarAssetClient::new(&env, &usdc_address); let dai_admin = token::StellarAssetClient::new(&env, &dai_address); @@ -5579,11 +5745,12 @@ mod test { #[test] fn test_treasury_tracks_fees_per_token() { - let (env, client, _, _, seeker, expert, token1, token_admin) = setup(); + let (env, client, _, admin, seeker, expert, token1, token_admin) = setup(); register_and_avail(&env, &client, &expert, 10); let token2 = env.register_stellar_asset_contract_v2(token_admin.clone()); let token2_address = token2.address(); + whitelist_token(&client, &admin, &token2_address); let asset_admin2 = token::StellarAssetClient::new(&env, &token2_address); asset_admin2.mint(&seeker, &10_000); @@ -6122,4 +6289,136 @@ mod test { // Volume counter tracks gross claimable, not net-of-fee payout. assert!(v1 >= claimable); } + + // #236 / #237 / #238 / #239 tests + // ==================================================================== + + #[test] + #[should_panic(expected = "Error(Contract, #50)")] + fn test_rate_limit_blocks_rapid_start_session_calls() { + let (env, client, _, admin, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + client.set_rate_limit_min_ledgers(&admin, &2); + + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + + env.ledger().set_sequence_number(env.ledger().sequence() + 1); + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + } + + #[test] + fn test_rate_limit_allows_start_session_after_cooldown() { + let (env, client, _, admin, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + client.set_rate_limit_min_ledgers(&admin, &2); + + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + env.ledger().set_sequence_number(env.ledger().sequence() + 2); + let session_id = + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + assert!(session_id >= 2); + } + + #[test] + fn test_rate_limit_blocks_rapid_heartbeat_calls() { + let (env, client, _, admin, _, expert, _, _) = setup(); + client.register_expert(&expert, &10, &test_cid(&env)); + client.set_rate_limit_min_ledgers(&admin, &2); + + client.heartbeat(&expert); + env.ledger().set_sequence_number(env.ledger().sequence() + 1); + assert_eq!(client.heartbeat(&expert), Err(Error::RateLimitExceeded)); + + env.ledger().set_sequence_number(env.ledger().sequence() + 2); + assert_eq!(client.heartbeat(&expert), Ok(())); + } + + #[test] + #[should_panic(expected = "Error(Contract, #51)")] + 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.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + } + + #[test] + fn test_admin_token_whitelist_add_and_remove() { + let (env, client, _, admin, _, _, token, _) = setup(); + let extra = Address::generate(&env); + + assert!(client.is_token_approved(&token)); + assert!(!client.is_token_approved(&extra)); + + client.add_approved_token(&admin, &extra); + assert!(client.is_token_approved(&extra)); + + client.remove_approved_token(&admin, &extra); + assert!(!client.is_token_approved(&extra)); + } + + #[test] + fn test_top_up_session_increases_balance_and_settles() { + let (env, client, contract_id, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + + let top_up = 2_000i128; + let asset_admin = token::StellarAssetClient::new(&env, &token); + asset_admin.mint(&seeker, &top_up); + + let new_balance = client.top_up_session(&seeker, &session_id, &top_up); + assert_eq!(new_balance, 5_000); + + let session = client.get_session(&session_id); + assert_eq!(session.balance, 5_000); + + let token_client = token::Client::new(&env, &token); + assert_eq!(token_client.balance(&contract_id), 5_000); + + env.ledger().set_timestamp(1_100); + let settled = client.settle_session(&session_id); + assert!(settled > 0); + + let session_after = client.get_session(&session_id); + assert!(session_after.balance < 5_000); + } + + #[test] + fn test_expert_cancel_session_partial_refund() { + let (env, client, contract_id, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + + env.ledger().set_timestamp(1_100); + let reason = test_cid(&env); + let (expert_payout, seeker_refund) = + client.cancel_session(&expert, &session_id, &reason); + + assert!(expert_payout > 0); + assert!(seeker_refund > 0); + assert_eq!(expert_payout + seeker_refund, 3_000); + + let session = client.get_session(&session_id); + assert_eq!(session.status, SessionStatus::CancelledByExpert); + assert_eq!(session.balance, 0); + assert_eq!(client.get_session_cancel_reason(&session_id), Some(reason)); + + let token_client = token::Client::new(&env, &token); + assert_eq!(token_client.balance(&contract_id), 0); + assert_eq!(token_client.balance(&expert), expert_payout); + assert_eq!(token_client.balance(&seeker), 97_000 + seeker_refund); + } + + #[test] + #[should_panic(expected = "Error(Contract, #2)")] + fn test_cancel_session_rejects_non_expert() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + client.cancel_session(&seeker, &session_id, &test_cid(&env)); + } }