From 8eed5af36e026f4a9e8bc3bcf5db25f423b90343 Mon Sep 17 00:00:00 2001 From: gregemax Date: Sun, 31 May 2026 09:33:01 +0100 Subject: [PATCH 1/4] feat: contract versioning and schema migration tooling (#246) - Add DataKey::ContractVersion to instance storage - Add migrate(new_version) public function (admin-guarded, once-per-version) - Add migrations.rs with SessionV1 struct and migrate_v1_to_v2 logic - Add admin.rs with admin helper re-exports - Add unit tests: test_migrate_v1_to_v2 and test_migrate_same_version_is_rejected --- contracts/src/admin.rs | 21 +++++ contracts/src/lib.rs | 37 ++++++++ contracts/src/migrations.rs | 181 ++++++++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 contracts/src/admin.rs create mode 100644 contracts/src/migrations.rs diff --git a/contracts/src/admin.rs b/contracts/src/admin.rs new file mode 100644 index 0000000..a5ed46c --- /dev/null +++ b/contracts/src/admin.rs @@ -0,0 +1,21 @@ +//! Admin helpers – thin re-exports so other modules can import from a single place. + +pub use crate::DataKey; +pub use crate::Error; + +use soroban_sdk::{Address, Env}; + +/// 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) +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 26dc474..d938ef1 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,5 +1,8 @@ #![no_std] +mod migrations; +mod admin; + use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, panic_with_error, String, Vec, @@ -74,6 +77,7 @@ pub enum DataKey { TreasuryAddress, TreasuryBalance(Address), ArbitrationCommittee, + ContractVersion, } #[contracttype] @@ -966,6 +970,39 @@ impl SkillSphereContract { .ok_or(Error::UpgradeNotInitiated) } + pub fn get_contract_version(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::ContractVersion) + .unwrap_or(1u32) + } + + /// 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() 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); + } +} From c0c9849f9b14750efa2b950735f61149de147b77 Mon Sep 17 00:00:00 2001 From: gregemax Date: Sun, 31 May 2026 10:01:17 +0100 Subject: [PATCH 2/4] feat: health_check() endpoint and active session tracking (#247) - Add ContractStatus struct { version, admin, is_paused, total_sessions, active_sessions } - Add health_check() read-only function (no auth required) - Add DataKey::ActiveSessionCount to instance storage - Increment on start_session, decrement on all terminal transitions - Add test_health_check_returns_correct_status unit test --- contracts/src/lib.rs | 106 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index d938ef1..5dc3116 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -78,6 +78,7 @@ pub enum DataKey { TreasuryBalance(Address), ArbitrationCommittee, ContractVersion, + ActiveSessionCount, } #[contracttype] @@ -148,6 +149,16 @@ pub struct Session { pub paused_at: Option, } +#[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, +} + #[contract] pub struct SkillSphereContract; @@ -609,6 +620,8 @@ impl SkillSphereContract { .persistent() .set(&DataKey::Session(session_id), &session); + Self::increment_active_sessions(&env); + env.events().publish( (symbol_short!("session"), symbol_short!("started")), ( @@ -686,6 +699,7 @@ impl SkillSphereContract { // Auto-settle the session as completed session.status = SessionStatus::Completed; Self::save_session(&env, &session); + Self::decrement_active_sessions(&env); return Err(Error::SessionExpired); } @@ -784,6 +798,7 @@ impl SkillSphereContract { session.status = SessionStatus::Completed; session.last_settlement_timestamp = now as u32; Self::save_session(&env, &session); + Self::decrement_active_sessions(&env); env.events().publish( (symbol_short!("session"), symbol_short!("noShowRf")), @@ -977,6 +992,35 @@ impl SkillSphereContract { .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, + } + } + /// 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)?; @@ -1069,6 +1113,28 @@ impl SkillSphereContract { .set(&DataKey::Session(session.id), session); } + 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); @@ -1102,6 +1168,7 @@ impl SkillSphereContract { 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); } @@ -1133,6 +1200,9 @@ impl SkillSphereContract { let token = session.token.clone(); Self::save_session(env, &session); + if session.status == SessionStatus::Completed { + Self::decrement_active_sessions(env); + } // === INTERACTIONS === let token_client = token::Client::new(env, &token); @@ -1197,6 +1267,7 @@ impl SkillSphereContract { session.status = SessionStatus::Completed; Self::save_session(env, session); + Self::decrement_active_sessions(env); // === INTERACTIONS === let token_client = token::Client::new(env, &session.token); @@ -1373,6 +1444,7 @@ impl SkillSphereContract { session.status = SessionStatus::Resolved; Self::save_session(env, session); + Self::decrement_active_sessions(env); env.storage() .persistent() .set(&DataKey::Dispute(session.id), dispute); @@ -2676,4 +2748,38 @@ mod test { assert_eq!(results.get(0).unwrap(), 95); assert_eq!(results.get(1).unwrap(), 0); } + + // --- #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); + } } From 01be44bad49462b68d54414b9829c121faa95f65 Mon Sep 17 00:00:00 2001 From: gregemax Date: Sun, 31 May 2026 10:26:40 +0100 Subject: [PATCH 3/4] feat: session archival to temporary storage (#248) - Add contracts/src/storage.rs with write_archive/read_archive helpers and ARCHIVE_TTL_LEDGERS (~90 days at 5s/ledger) - Add ARCHIVE_DELAY_SECS (90 days) constant to lib.rs - Add archive_session(session_id): admin-only, enforces 90-day delay, moves session from persistent to temporary storage, removes persistent entry - Add get_archived_session(session_id): reads from temporary storage - Emit (session, archived) event with session_id and archived_at timestamp - Add 3 unit tests covering happy path, early-call rejection, active-session rejection --- contracts/src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++ contracts/src/storage.rs | 27 +++++++++++ 2 files changed, 127 insertions(+) create mode 100644 contracts/src/storage.rs diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 5dc3116..94d1588 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -2,6 +2,7 @@ mod migrations; mod admin; +mod storage; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, @@ -25,6 +26,8 @@ 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; #[contracterror] #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -1021,6 +1024,45 @@ impl SkillSphereContract { } } + /// 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) + } + /// 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)?; @@ -2782,4 +2824,62 @@ mod test { 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); + } } 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)) +} From b8a785b2cefe2df65470547048cdfd8e7146cc94 Mon Sep 17 00:00:00 2001 From: gregemax Date: Sun, 31 May 2026 10:29:49 +0100 Subject: [PATCH 4/4] feat: batch_archive_sessions() bulk admin operation (#249) - Add MAX_ARCHIVE_BATCH_SIZE = 50 constant - Add ArchiveSummary { archived, skipped } contracttype struct - Add batch_archive_sessions(session_ids): admin-only, enforces batch cap, skips non-existent / active / too-recent sessions without panicking, emits (session, archived) per archived session - Add 3 unit tests: happy path, skip ineligible, oversized batch rejection --- contracts/src/lib.rs | 134 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 94d1588..b1ee258 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -28,6 +28,8 @@ 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)] @@ -152,6 +154,13 @@ 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 ContractStatus { @@ -1063,6 +1072,58 @@ impl SkillSphereContract { 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)?; @@ -2882,4 +2943,77 @@ mod test { 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); + } }