diff --git a/contracts/src/admin.rs b/contracts/src/admin.rs index 7ff36d2..d51ac15 100644 --- a/contracts/src/admin.rs +++ b/contracts/src/admin.rs @@ -1,13 +1,34 @@ -//! Admin-managed configuration: rate-limit cooldowns (#236) and the -//! approved-token registry (#239). +//! 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 crate::{DataKey, Error}; + /// Default rate-limit cooldown: 0 disables per-address throttling. const DEFAULT_RATE_LIMIT_MIN_LEDGERS: u32 = 0; +/// Returns the stored admin address, or `Error::Unauthorized` if not set. +pub fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::Unauthorized) +} + +/// Requires the stored admin to have signed the current transaction. +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() diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index e166b69..f493cef 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,5 +1,8 @@ #![no_std] +mod migrations; +mod admin; +mod storage; pub mod bridge; mod crypto; mod dex; @@ -58,6 +61,42 @@ const STAKE_TIER_3: i128 = 10_000; const FEE_REDUCTION_TIER_1_BPS: u32 = 100; const FEE_REDUCTION_TIER_2_BPS: u32 = 200; const FEE_REDUCTION_TIER_3_BPS: u32 = 300; +/// 90 days in seconds — minimum age of a completed session before archival. +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% of platform fee for referrer const REFERRAL_SESSION_LIMIT: u32 = 10; // Referral commission applies to first X sessions const RATING_SCALE_MIN: u32 = 1; @@ -107,6 +146,8 @@ pub enum DataKey { TreasuryAddress, TreasuryBalance(Address), ArbitrationCommittee, + ContractVersion, + ActiveSessionCount, SessionRating(u64), ExpertAverageRating(Address), ExpertRatingCount(Address), @@ -315,6 +356,12 @@ pub struct Session { pub paused_at: Option, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArchiveSummary { + pub archived: u32, + pub skipped: u32, +} #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct SessionRating { @@ -324,6 +371,15 @@ pub struct SessionRating { pub created_at: u32, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractStatus { + pub version: u32, + pub admin: Option
, + pub is_paused: bool, + pub total_sessions: u64, + pub active_sessions: u64, +} #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ExpertVerification { @@ -1086,6 +1142,19 @@ 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(), @@ -1138,6 +1207,45 @@ 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); @@ -1351,6 +1459,11 @@ 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 @@ -1582,6 +1695,169 @@ impl SkillSphereContract { .unwrap_or(0i128) } + pub fn get_contract_version(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::ContractVersion) + .unwrap_or(1u32) + } + + pub fn health_check(env: Env) -> ContractStatus { + let version: u32 = env + .storage() + .instance() + .get(&DataKey::ContractVersion) + .unwrap_or(1u32); + let admin: Option
= env.storage().instance().get(&DataKey::Admin); + let is_paused = Self::protocol_paused(&env); + let next_id: u64 = env + .storage() + .instance() + .get(&DataKey::NextSessionId) + .unwrap_or(1u64); + let total_sessions = next_id.saturating_sub(1); + let active_sessions: u64 = env + .storage() + .instance() + .get(&DataKey::ActiveSessionCount) + .unwrap_or(0u64); + + ContractStatus { + version, + admin, + is_paused, + total_sessions, + active_sessions, + } + } + + /// Move a completed session from Persistent to Temporary storage (90-day TTL). + /// Admin-only. Callable only after the session has been completed for ≥90 days. + pub fn archive_session(env: Env, session_id: u64) -> Result<(), Error> { + Self::require_admin(&env)?; + + let session = Self::get_session_or_error(&env, session_id)?; + + if !matches!( + session.status, + SessionStatus::Completed | SessionStatus::Resolved + ) { + return Err(Error::InvalidSessionState); + } + + let now = env.ledger().timestamp(); + let completed_at = session.last_settlement_timestamp as u64; + if now < completed_at.saturating_add(ARCHIVE_DELAY_SECS) { + return Err(Error::TimelockNotExpired); + } + + // Write to temporary storage with 90-day TTL, then remove from persistent. + storage::write_archive(&env, &session); + env.storage() + .persistent() + .remove(&DataKey::Session(session_id)); + + env.events().publish( + (symbol_short!("session"), symbol_short!("archived")), + (session_id, now), + ); + + Ok(()) + } + + /// Read a session from temporary (archived) storage. + pub fn get_archived_session(env: Env, session_id: u64) -> Option { + storage::read_archive(&env, session_id) + } + + /// Batch-archive up to `MAX_ARCHIVE_BATCH_SIZE` completed sessions. + /// Admin-only. Skips sessions that are not eligible without panicking. + pub fn batch_archive_sessions( + env: Env, + session_ids: Vec, + ) -> Result { + Self::require_admin(&env)?; + + if session_ids.len() > MAX_ARCHIVE_BATCH_SIZE { + return Err(Error::InvalidAmount); + } + + let now = env.ledger().timestamp(); + let mut archived: u32 = 0; + let mut skipped: u32 = 0; + + for session_id in session_ids.iter() { + let session = match env + .storage() + .persistent() + .get::(&DataKey::Session(session_id)) + { + Some(s) => s, + None => { skipped += 1; continue; } + }; + + let eligible = matches!( + session.status, + SessionStatus::Completed | SessionStatus::Resolved + ) && now >= (session.last_settlement_timestamp as u64).saturating_add(ARCHIVE_DELAY_SECS); + + if !eligible { + skipped += 1; + continue; + } + + storage::write_archive(&env, &session); + env.storage() + .persistent() + .remove(&DataKey::Session(session_id)); + + env.events().publish( + (symbol_short!("session"), symbol_short!("archived")), + (session_id, now), + ); + + archived += 1; + } + + Ok(ArchiveSummary { archived, skipped }) + } + + /// Migrate storage schema to `new_version`. Admin-only, runs once per version bump. + pub fn migrate(env: Env, new_version: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + + let current: u32 = env + .storage() + .instance() + .get(&DataKey::ContractVersion) + .unwrap_or(1u32); + + if new_version <= current { + return Err(Error::InvalidSessionState); + } + + migrations::run(&env, current, new_version); + + env.storage() + .instance() + .set(&DataKey::ContractVersion, &new_version); + + env.events() + .publish((symbol_short!("migrated"),), (current, new_version)); + + Ok(()) + } + + fn next_session_id(env: &Env) -> u64 { + let next_id = env + .storage() + .instance() + .get(&DataKey::NextSessionId) + .unwrap_or(1u64); + env.storage() + .instance() + .set(&DataKey::NextSessionId, &(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; @@ -1707,6 +1983,32 @@ impl SkillSphereContract { (symbol_short!("feeCollct"), token, amount), ); + fn increment_active_sessions(env: &Env) { + let count: u64 = env + .storage() + .instance() + .get(&DataKey::ActiveSessionCount) + .unwrap_or(0u64); + env.storage() + .instance() + .set(&DataKey::ActiveSessionCount, &count.saturating_add(1)); + } + + fn decrement_active_sessions(env: &Env) { + let count: u64 = env + .storage() + .instance() + .get(&DataKey::ActiveSessionCount) + .unwrap_or(0u64); + env.storage() + .instance() + .set(&DataKey::ActiveSessionCount, &count.saturating_sub(1)); + } + + fn require_participant(session: &Session, caller: &Address) -> Result<(), Error> { + if *caller != session.seeker && *caller != session.expert { + return Err(Error::Unauthorized); + } Ok(()) } @@ -1733,6 +2035,22 @@ 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); @@ -1753,6 +2071,10 @@ 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(()) } @@ -1836,6 +2158,8 @@ impl SkillSphereContract { Ok(()) } + Self::save_session(env, session); + Self::decrement_active_sessions(env); /// Checks if the protocol is currently paused. pub fn is_protocol_paused(env: Env) -> bool { Self::protocol_paused(&env) @@ -2081,6 +2405,55 @@ 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( @@ -6970,4 +7343,169 @@ mod test { client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); client.cancel_session(&seeker, &session_id, &test_cid(&env)); } + + // --- #247: health_check --- + + #[test] + fn test_health_check_returns_correct_status() { + let (env, client, _, admin, seeker, expert, token, _) = setup(); + + // Before any sessions: total=0, active=0, not paused, version=1 + let status = client.health_check(); + assert_eq!(status.version, 1); + assert_eq!(status.admin, Some(admin)); + assert!(!status.is_paused); + assert_eq!(status.total_sessions, 0); + assert_eq!(status.active_sessions, 0); + + // Start a session: total=1, active=1 + register_and_avail(&env, &client, &expert, 10); + client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + let status = client.health_check(); + assert_eq!(status.total_sessions, 1); + assert_eq!(status.active_sessions, 1); + + // Settle the session fully: active drops to 0 + env.ledger().set_timestamp(1_300); + client.settle_session(&1); + let status = client.health_check(); + assert_eq!(status.total_sessions, 1); + assert_eq!(status.active_sessions, 0); + + // Pause the protocol + client.pause_protocol(); + let status = client.health_check(); + assert!(status.is_paused); + } + + // --- #248: archive_session --- + + #[test] + fn test_archive_session_moves_to_temporary_storage() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + + // Fully settle the session. + env.ledger().set_timestamp(1_300); + client.settle_session(&session_id); + + // Advance 90 days past completion. + let ninety_days: u64 = 90 * 24 * 60 * 60; + env.ledger().set_timestamp(1_300 + ninety_days + 1); + + client.archive_session(&session_id); + + // Session no longer in persistent storage (get_session should fail). + let result = client.try_get_session(&session_id); + assert!(result.is_err()); + + // Session readable from temporary storage. + let archived = client.get_archived_session(&session_id); + assert!(archived.is_some()); + assert_eq!(archived.unwrap().id, session_id); + } + + #[test] + #[should_panic] + fn test_archive_session_fails_before_90_days() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + + env.ledger().set_timestamp(1_300); + client.settle_session(&session_id); + + // Only 1 day after completion — should fail. + env.ledger().set_timestamp(1_300 + 24 * 60 * 60); + client.archive_session(&session_id); + } + + #[test] + #[should_panic] + fn test_archive_session_fails_for_active_session() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + + let ninety_days: u64 = 90 * 24 * 60 * 60; + env.ledger().set_timestamp(1_000 + ninety_days + 1); + client.archive_session(&session_id); + } + + // --- #249: batch_archive_sessions --- + + #[test] + fn test_batch_archive_archives_eligible_sessions() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let asset_admin = token::StellarAssetClient::new(&env, &token); + asset_admin.mint(&seeker, &6_000); + + let s1 = client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + let s2 = client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + + // Settle both sessions. + env.ledger().set_timestamp(1_300); + client.settle_session(&s1); + client.settle_session(&s2); + + // Advance 90 days. + let ninety_days: u64 = 90 * 24 * 60 * 60; + env.ledger().set_timestamp(1_300 + ninety_days + 1); + + let mut ids = Vec::new(&env); + ids.push_back(s1); + ids.push_back(s2); + + let summary = client.batch_archive_sessions(&ids); + assert_eq!(summary.archived, 2); + assert_eq!(summary.skipped, 0); + + assert!(client.get_archived_session(&s1).is_some()); + assert!(client.get_archived_session(&s2).is_some()); + } + + #[test] + fn test_batch_archive_skips_ineligible_sessions() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + + // s1: completed and old enough + let s1 = client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + env.ledger().set_timestamp(1_300); + client.settle_session(&s1); + + let ninety_days: u64 = 90 * 24 * 60 * 60; + env.ledger().set_timestamp(1_300 + ninety_days + 1); + + // s2: still active (not settled) + let asset_admin = token::StellarAssetClient::new(&env, &token); + asset_admin.mint(&seeker, &3_000); + let s2 = client.start_session(&seeker, &expert, &token, &3000, &0, &test_cid(&env)); + + // 999: non-existent + let mut ids = Vec::new(&env); + ids.push_back(s1); + ids.push_back(s2); + ids.push_back(999u64); + + let summary = client.batch_archive_sessions(&ids); + assert_eq!(summary.archived, 1); + assert_eq!(summary.skipped, 2); + } + + #[test] + #[should_panic] + fn test_batch_archive_rejects_oversized_batch() { + let (env, client, _, _, _, _, _, _) = setup(); + let mut ids = Vec::new(&env); + for i in 0u64..51 { + ids.push_back(i); + } + client.batch_archive_sessions(&ids); + } } diff --git a/contracts/src/migrations.rs b/contracts/src/migrations.rs new file mode 100644 index 0000000..911a36b --- /dev/null +++ b/contracts/src/migrations.rs @@ -0,0 +1,181 @@ +//! Schema migration helpers for SkillSphere contract upgrades. +//! +//! Each version bump gets a dedicated `migrate_vN_to_vM` function. +//! `run()` is the single dispatch entry called by `SkillSphereContract::migrate`. + +use soroban_sdk::{contracttype, Address, Env, String}; + +use crate::{DataKey, Session, SessionStatus}; + +// --------------------------------------------------------------------------- +// V1 schema – the original Session layout (no `encrypted_notes_hash` / `paused_at`) +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionV1 { + pub id: u64, + pub seeker: Address, + pub expert: Address, + pub token: Address, + pub rate_per_second: i128, + pub balance: i128, + pub last_settlement_timestamp: u32, + pub start_timestamp: u32, + pub accrued_amount: i128, + pub status: SessionStatus, + pub metadata_cid: String, +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/// Called by `SkillSphereContract::migrate` with the current and target versions. +/// Add new arms here as the schema evolves. +pub fn run(env: &Env, from: u32, to: u32) { + if from == 1 && to == 2 { + migrate_v1_to_v2(env); + } + // future: if from == 2 && to == 3 { migrate_v2_to_v3(env); } +} + +// --------------------------------------------------------------------------- +// v1 → v2: add `encrypted_notes_hash` (None) and `paused_at` (None) fields +// --------------------------------------------------------------------------- + +fn migrate_v1_to_v2(env: &Env) { + let next_id: u64 = env + .storage() + .instance() + .get(&DataKey::NextSessionId) + .unwrap_or(1u64); + + for id in 1..next_id { + let key = DataKey::Session(id); + + // Try to read as the new format first; if it already has the new + // fields we skip it. If that fails, attempt the old V1 layout. + if env.storage().persistent().has(&key) { + // Attempt V1 deserialization. If the stored value is already V2 + // this will fail silently and we leave it untouched. + if let Some(v1) = env + .storage() + .persistent() + .get::(&key) + { + let v2 = Session { + id: v1.id, + seeker: v1.seeker, + expert: v1.expert, + token: v1.token, + rate_per_second: v1.rate_per_second, + balance: v1.balance, + last_settlement_timestamp: v1.last_settlement_timestamp, + start_timestamp: v1.start_timestamp, + accrued_amount: v1.accrued_amount, + status: v1.status, + metadata_cid: v1.metadata_cid, + encrypted_notes_hash: None, + paused_at: None, + }; + env.storage().persistent().set(&key, &v2); + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Env, String, + }; + + use crate::{DataKey, SkillSphereContract, SkillSphereContractClient}; + + fn test_cid(env: &Env) -> String { + String::from_str(env, "QmYwAPJzv5CZsnAzt8auVZRnGzrYxkM4Tveoxu48UUfGz8") + } + + /// Simulate a V1 session stored under the old schema, then run the + /// migration and verify the V2 fields are populated correctly. + #[test] + fn test_migrate_v1_to_v2() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let contract_id = env.register_contract(None, SkillSphereContract); + let client = SkillSphereContractClient::new(&env, &contract_id); + + let admin = soroban_sdk::Address::generate(&env); + client.initialize(&admin); + + // Write a raw V1 session directly into persistent storage. + let seeker = soroban_sdk::Address::generate(&env); + let expert = soroban_sdk::Address::generate(&env); + let token = soroban_sdk::Address::generate(&env); + + let v1 = SessionV1 { + id: 1, + seeker: seeker.clone(), + expert: expert.clone(), + token: token.clone(), + rate_per_second: 10, + balance: 3_000, + last_settlement_timestamp: 1_000, + start_timestamp: 1_000, + accrued_amount: 0, + status: SessionStatus::Active, + metadata_cid: test_cid(&env), + }; + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::NextSessionId, &2u64); + env.storage() + .persistent() + .set(&DataKey::Session(1), &v1); + }); + + // Run the migration v1 → v2. + client.migrate(&2); + + // Verify the session now deserialises as the V2 Session struct. + let session = client.get_session(&1); + assert_eq!(session.id, 1); + assert_eq!(session.seeker, seeker); + assert_eq!(session.expert, expert); + assert_eq!(session.balance, 3_000); + assert_eq!(session.encrypted_notes_hash, None); + assert_eq!(session.paused_at, None); + + // Version should now be 2. + assert_eq!(client.get_contract_version(), 2); + } + + /// Calling migrate with the same or lower version must be rejected. + #[test] + #[should_panic] + fn test_migrate_same_version_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let contract_id = env.register_contract(None, SkillSphereContract); + let client = SkillSphereContractClient::new(&env, &contract_id); + + let admin = soroban_sdk::Address::generate(&env); + client.initialize(&admin); + + // Version starts at 1; trying to migrate to 1 must fail. + client.migrate(&1); + } +} diff --git a/contracts/src/storage.rs b/contracts/src/storage.rs new file mode 100644 index 0000000..88c8ad4 --- /dev/null +++ b/contracts/src/storage.rs @@ -0,0 +1,27 @@ +//! Storage helpers for session archival. + +use soroban_sdk::Env; + +use crate::{DataKey, Session}; + +/// ~90 days in ledgers (5-second ledger time). +pub const ARCHIVE_TTL_LEDGERS: u32 = 90 * 24 * 60 * 60 / 5; // 1_555_200 + +/// Minimum ledgers remaining before we bother extending (same as TTL). +pub const ARCHIVE_TTL_THRESHOLD: u32 = ARCHIVE_TTL_LEDGERS; + +/// Write `session` to temporary storage and set its TTL to ~90 days. +pub fn write_archive(env: &Env, session: &Session) { + let key = DataKey::Session(session.id); + env.storage().temporary().set(&key, session); + env.storage() + .temporary() + .extend_ttl(&key, ARCHIVE_TTL_THRESHOLD, ARCHIVE_TTL_LEDGERS); +} + +/// Read a session from temporary (archived) storage. +pub fn read_archive(env: &Env, session_id: u64) -> Option { + env.storage() + .temporary() + .get(&DataKey::Session(session_id)) +}