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))
+}