From d4a42bece975019fd108b8fcc00f062545cabf16 Mon Sep 17 00:00:00 2001 From: olu-cmd Date: Sat, 29 Aug 2026 09:18:22 +0000 Subject: [PATCH 1/2] feat: preserve archived event states via metadata-only archiving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving and restoring are now non-destructive metadata operations that leave Market.state untouched, so archived events keep their terminal Resolved/Cancelled state and remain discoverable by status. Adds a new query_archived_events view, metadata-driven is_archived/unarchive with legacy back-compat, reworked lifecycle validation, and an error-code fix (CannotRestoreFromState 444 -> 447). Includes archive discoverability integration tests. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- contracts/predictify-hybrid/src/audit.rs | 4 +- contracts/predictify-hybrid/src/disputes.rs | 19 +- contracts/predictify-hybrid/src/err.rs | 5 +- .../predictify-hybrid/src/event_archive.rs | 237 +++++++++++++---- contracts/predictify-hybrid/src/extensions.rs | 5 + contracts/predictify-hybrid/src/fees.rs | 4 +- .../predictify-hybrid/src/force_resolve.rs | 75 ++++-- contracts/predictify-hybrid/src/gas.rs | 15 +- contracts/predictify-hybrid/src/lib.rs | 36 ++- .../src/lifecycle_validation.rs | 108 ++------ .../src/market_id_generator.rs | 22 ++ contracts/predictify-hybrid/src/markets.rs | 9 +- contracts/predictify-hybrid/src/monitoring.rs | 2 + contracts/predictify-hybrid/src/recovery.rs | 4 +- .../predictify-hybrid/src/restore_archive.rs | 25 +- .../src/storage_tier_audit.rs | 68 ++--- contracts/predictify-hybrid/src/types.rs | 5 +- .../tests/archive_discoverability.rs | 246 ++++++++++++++++++ 18 files changed, 666 insertions(+), 223 deletions(-) create mode 100644 contracts/predictify-hybrid/tests/archive_discoverability.rs diff --git a/contracts/predictify-hybrid/src/audit.rs b/contracts/predictify-hybrid/src/audit.rs index fa3af14a..6f576f56 100644 --- a/contracts/predictify-hybrid/src/audit.rs +++ b/contracts/predictify-hybrid/src/audit.rs @@ -247,9 +247,9 @@ impl MarketAuditManager { if index > head.total_entries { return None; } - Self::get_entry_unchecked(env, market_id, index).unwrap_or_else(|| { + Some(Self::get_entry_unchecked(env, market_id, index).unwrap_or_else(|| { panic!("audit log corruption: missing entry {index}"); - }) + })) } fn get_entry_unchecked(env: &Env, market_id: &Symbol, index: u32) -> Option { diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 6232c440..20326084 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -1166,7 +1166,7 @@ impl DisputeManager { // SECURITY: Enhanced validation - check market has active disputes // and is not already resolved (prevents race conditions) - DisputeValidator::validate_market_for_resolution(env, &market)?; + DisputeValidator::validate_market_for_resolution(env, &market_id, &market)?; // Calculate dispute impact let dispute_impact = DisputeAnalytics::calculate_dispute_impact(&market); @@ -2173,7 +2173,7 @@ impl DisputeManager { // SECURITY: Validate market state before mutating dispute status // Prevents race condition where market gets resolved between timeout check and update let market = MarketStateManager::get_market(env, &timeout.market_id)?; - DisputeValidator::validate_market_for_resolution(env, &market)?; + DisputeValidator::validate_market_for_resolution(env, &timeout.market_id, &market)?; // Update timeout status timeout.status = DisputeTimeoutStatus::AutoResolved; @@ -2519,7 +2519,11 @@ impl DisputeValidator { /// - Market must have active disputes /// - Market must not already be resolved /// - At least one dispute must be in Active status - pub fn validate_market_for_resolution(env: &Env, market: &Market) -> Result<(), Error> { + pub fn validate_market_for_resolution( + env: &Env, + market_id: &Symbol, + market: &Market, + ) -> Result<(), Error> { // Check if market is already resolved if market.winning_outcomes.is_some() { return Err(Error::MarketResolved); @@ -2532,7 +2536,7 @@ impl DisputeValidator { // SECURITY: Verify at least one dispute is Active to prevent race conditions // where all disputes become finalized between check and resolution - let has_active_dispute = Self::verify_has_active_dispute(env, market)?; + let has_active_dispute = Self::verify_has_active_dispute(env, market_id, market)?; if !has_active_dispute { return Err(Error::InvalidState); } @@ -2545,9 +2549,12 @@ impl DisputeValidator { /// - Check passes: market has disputes /// - Between check and resolution: all disputes finalized /// - Resolution executes: market state corrupted - pub fn verify_has_active_dispute(env: &Env, market: &Market) -> Result { + pub fn verify_has_active_dispute( + env: &Env, + market_id: &Symbol, + market: &Market, + ) -> Result { // Get dispute history for this market - let market_id = market.market_id.clone(); let history = env.storage().persistent() .get::<_, Vec>(&DataKey::DisputeHistory(market_id.clone())) .unwrap_or_else(|| Vec::new(env)); diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index b9f56a2f..1fca01da 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -220,7 +220,7 @@ pub enum Error { /// Market cannot be archived from current state. Archive only allowed from Resolved or Cancelled. CannotArchiveFromState = 442, /// Market cannot be restored from current state. Restore only allowed from Archived. - CannotRestoreFromState = 444, + CannotRestoreFromState = 447, /// Market is already archived. Cannot perform modification operations on archived markets. MarketAlreadyArchived = 445, /// Market is already restored. Cannot restore a market that is not archived. @@ -1945,6 +1945,9 @@ impl Error { Error::ArchiveFull => 1917, Error::ReasonTableFull => 1918, Error::RegistryFull => 1919, + // Catch-all for variants not yet assigned an off-chain code. Callers + // that hit this value should treat it as an unmapped error. + _ => 0, } } diff --git a/contracts/predictify-hybrid/src/event_archive.rs b/contracts/predictify-hybrid/src/event_archive.rs index 52402d12..e20784b4 100644 --- a/contracts/predictify-hybrid/src/event_archive.rs +++ b/contracts/predictify-hybrid/src/event_archive.rs @@ -4,6 +4,27 @@ //! paginated historical query functions for analytics and UI. Exposes only //! public metadata and outcome; no sensitive data (votes, stakes, addresses). //! +//! # Non-Destructive Archive (discoverability invariant) +//! +//! Archiving is a **metadata-only** operation: `archive_event` records a +//! `market_id -> archived_at` entry in the `evt_archived` map and the sorted +//! archive index, and it **never mutates the stored `Market`.state`**. The +//! market therefore keeps its terminal `Resolved`/`Cancelled` state, which has +//! two important consequences: +//! +//! - **Preserved discoverability**: archived events remain discoverable in +//! `query_events_by_resolution_status` (e.g. `Resolved`) because their state +//! is unchanged, while still being reported as archived via `archived_at` in +//! `EventHistoryEntry` and via the dedicated `query_archived_events` view. +//! - **No silent data loss**: the underlying resolution outcome is never +//! overwritten by the archive transition, so a pruned or restored archive +//! entry cannot corrupt the market's real, terminal state. +//! +//! `is_archived` is therefore metadata-driven (archive record presence), with a +//! backward-compatibility fallback that also treats legacy deployments whose +//! `Market.state` is the old `Archived` marker as archived. Restore mirrors this: +//! `RestoreArchive::restore_event` removes only the archive metadata marker. +//! //! # Archive Bounds //! //! The archive is capped at [`MAX_ARCHIVE_SIZE`] entries to prevent unbounded @@ -135,6 +156,31 @@ impl PruneCursor { } } +/// Remove a (timestamp, market_id) pair from the sorted archive index. +/// +/// No-op if the pair is not present. Deterministic: the remaining entries keep +/// their ascending (timestamp, market_id) order. +fn remove_from_sorted_index(env: &Env, timestamp: u64, market_id: &Symbol) { + let index_key = Symbol::new(env, ARCHIVED_INDEX_KEY); + let index: Vec<(u64, Symbol)> = env + .storage() + .persistent() + .get(&index_key) + .unwrap_or_else(|| Vec::new(env)); + + let mut new_index = Vec::new(env); + for i in 0..index.len() { + if let Some(entry) = index.get(i) { + if entry.0 == timestamp && entry.1 == *market_id { + // Skip the matching entry. + continue; + } + new_index.push_back(entry); + } + } + env.storage().persistent().set(&index_key, &new_index); +} + // --------------------------------------------------------------------------- // Sorted index helpers // --------------------------------------------------------------------------- @@ -206,8 +252,12 @@ impl EventArchive { } // ===== STATE VALIDATION ===== - // Fetch market and verify it exists - let mut market: Market = env + // Fetch market and verify it exists. The stored record is read-only here: + // archiving must NOT mutate the market (non-destructive discoverability + // invariant, see module docs). The market keeps its terminal + // `Resolved`/`Cancelled` state so it stays discoverable by status, and the + // archive marker below records the archived_at timestamp independently. + let market: Market = env .storage() .persistent() .get(market_id) @@ -218,7 +268,7 @@ impl EventArchive { return Err(Error::CannotArchiveFromState); } - // Reject if market is already in Archived or Restored state + // Reject if market is already in legacy Archived state (pre-metadata archive) if market.state == MarketState::Archived { return Err(Error::MarketAlreadyArchived); } @@ -241,18 +291,15 @@ impl EventArchive { return Err(Error::ArchiveFull); } - // ===== STATE TRANSITION ===== - // Update market state to Archived - market.state = MarketState::Archived; - env.storage().persistent().set(market_id, &market); - // ===== ARCHIVE METADATA RECORDING ===== + // The market record is deliberately left untouched. Archiving is purely a + // metadata marker (market_id -> archived_at) plus the sorted index entry. let now = env.ledger().timestamp(); let mut new_archived = archived; new_archived.set(market_id.clone(), now); env.storage().persistent().set(&key, &new_archived); - // Maintain the sorted index for deterministic pruning + // Maintain the sorted index for deterministic pruning and discovery insert_into_sorted_index(env, now, market_id); // ===== OBSERVABILITY ===== @@ -411,39 +458,33 @@ impl EventArchive { Ok((removed, new_cursor)) } - /// Check if an event is archived. - /// Check if a market is archived (in Archived state). + /// Check if an event/market is archived. /// - /// Returns true only if the market exists and is in the `Archived` state. - /// Performs consistency validation by checking both market state and archive metadata. + /// Archiving is a non-destructive metadata marker (see `archive_event`), so a + /// market is considered archived iff it has an archive record in the + /// `evt_archived` map. `Market.state` is intentionally NOT consulted except + /// for backward compatibility with legacy deployments that stored the archive + /// designation in the `Archived` state before metadata-only archiving. /// /// # Arguments /// * `env` - Soroban environment /// * `market_id` - Market to check /// /// # Returns - /// * `true` - Market is in `Archived` state with valid archive metadata - /// * `false` - Market does not exist or is not in `Archived` state + /// * `true` - Market has an archive record (or is a legacy `Archived` market) + /// * `false` - Market is not archived pub fn is_archived(env: &Env, market_id: &Symbol) -> bool { - // Check market state first + // Primary: metadata-driven archive marker. + if Self::get_archived_at(env, market_id).is_some() { + return true; + } + // Back-compat: legacy deployments that mutated `market.state` to Archived + // prior to metadata-only archiving must still be reported as archived. let market_opt: Option = env.storage().persistent().get(market_id); - if let Some(market) = market_opt { - if market.state != MarketState::Archived { - return false; - } - } else { - return false; + match market_opt { + Some(market) => market.state == MarketState::Archived, + None => false, } - - // Verify archive metadata exists (consistency check) - let key = Symbol::new(env, ARCHIVED_TS_KEY); - let archived: soroban_sdk::Map = env - .storage() - .persistent() - .get(&key) - .unwrap_or(soroban_sdk::Map::new(env)); - - archived.get(market_id.clone()).is_some() } /// Get archived_at timestamp for a market (None if not archived). @@ -468,6 +509,30 @@ impl EventArchive { archived.len() } + /// Remove the archive metadata marker (timestamp map + sorted index) for a + /// market. Used by the restore flow to bring an archived market back into + /// the active set without mutating its stored `Market.state`. + /// + /// Returns `Error::CannotRestoreFromState` if the market has no archive + /// record (i.e. it is not currently archived). + pub(crate) fn unarchive(env: &Env, market_id: &Symbol) -> Result<(), Error> { + let key = Symbol::new(env, ARCHIVED_TS_KEY); + let mut archived: soroban_sdk::Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| soroban_sdk::Map::new(env)); + + let archived_at = archived + .get(market_id.clone()) + .ok_or(Error::CannotRestoreFromState)?; + archived.remove(market_id.clone()); + env.storage().persistent().set(&key, &archived); + + remove_from_sorted_index(env, archived_at, market_id); + Ok(()) + } + /// Validate archive state consistency and detect corruption. /// /// Performs deterministic checks to ensure: @@ -483,19 +548,6 @@ impl EventArchive { /// * `Ok(())` - Market state is consistent /// * `Err(Error::InvalidState)` - State mismatch or corruption detected pub fn validate_archive_consistency(env: &Env, market_id: &Symbol) -> Result<(), Error> { - // Fetch market - let market_opt: Option = env.storage().persistent().get(market_id); - - // Check if market is archived - if let Some(market) = market_opt { - if market.state != MarketState::Archived { - return Ok(()); // Not archived, no validation needed - } - } else { - return Ok(()); // Market doesn't exist, no validation needed - } - - // Market is archived; verify archive metadata exists let key = Symbol::new(env, ARCHIVED_TS_KEY); let archived: soroban_sdk::Map = env .storage() @@ -503,14 +555,18 @@ impl EventArchive { .get(&key) .unwrap_or(soroban_sdk::Map::new(env)); - // Check for state mismatch: market is archived but no archive record exists - if archived.get(market_id.clone()).is_none() { + // Archive capacity must never exceed the bound. + if archived.len() > MAX_ARCHIVE_SIZE { return Err(Error::InvalidState); } - // Check archive capacity is not exceeded - if archived.len() > MAX_ARCHIVE_SIZE { - return Err(Error::InvalidState); + // Corruption check for legacy deployments: a market whose stored state is + // the legacy `Archived` marker must still carry an archive record, so that + // old archived markets stay queryable and cannot silently drop out. + if let Some(market) = env.storage().persistent().get::(market_id) { + if market.state == MarketState::Archived && archived.get(market_id.clone()).is_none() { + return Err(Error::InvalidState); + } } Ok(()) @@ -632,6 +688,87 @@ impl EventArchive { (result, cursor + scanned) } + /// Query archived events directly (paginated, bounded). + /// + /// Archived events are discoverable here even though their `Market.state` is + /// preserved (non-destructive archive). Entries are ordered by `archived_at`. + /// * `reverse = false` - oldest archived first (ascending `archived_at`) + /// * `reverse = true` - newest archived first (descending `archived_at`) + /// + /// `cursor` is the zero-based offset into the ordered archive; `limit` is + /// capped at [`MAX_QUERY_LIMIT`]. Returns `(entries, next_cursor)`; the caller + /// keeps advancing until `next_cursor == cursor` (no more entries). + pub fn query_archived_events( + env: &Env, + reverse: bool, + cursor: u32, + limit: u32, + ) -> (Vec, u32) { + let limit = core::cmp::min(limit, MAX_QUERY_LIMIT); + let index_key = Symbol::new(env, ARCHIVED_INDEX_KEY); + let index: Vec<(u64, Symbol)> = env + .storage() + .persistent() + .get(&index_key) + .unwrap_or_else(|| Vec::new(env)); + let total = index.len() as u32; + + let mut result = Vec::new(env); + let mut examined = 0u32; + + if !reverse { + // Ascending (oldest archived first): page [cursor, cursor + limit). + let mut idx = core::cmp::min(cursor, total); + while examined < limit && idx < total { + if let Some((_, market_id)) = index.get(idx) { + examined += 1; + if let Some(entry) = Self::history_entry_for_archived(env, &market_id) { + result.push_back(entry); + } + } + idx += 1; + } + } else { + // Descending (newest archived first): `cursor` counts back from the + // newest entry; cursor 0 starts at the newest (last index). + let mut idx = total.saturating_sub(core::cmp::min(cursor, total)); + while examined < limit && idx > 0 { + let position = idx - 1; + if let Some((_, market_id)) = index.get(position) { + examined += 1; + if let Some(entry) = Self::history_entry_for_archived(env, &market_id) { + result.push_back(entry); + } + } + idx = position; + } + } + + // Advance the cursor by the number of archive entries examined so the + // caller can page. When a page examined nothing we are at the end (or the + // archive is empty); pin the cursor to signal completion. + let next_cursor = if examined == 0 { + cursor + } else { + core::cmp::min(cursor.saturating_add(examined), total) + }; + + (result, next_cursor) + } + + /// Build an `EventHistoryEntry` for an archived market, or `None` if the + /// market no longer exists (defensive: should never happen while the archive + /// index and market records are consistent). + fn history_entry_for_archived(env: &Env, market_id: &Symbol) -> Option { + let market: Market = env.storage().persistent().get(market_id)?; + // `created_at` comes from the market ID registry when available; fall back + // to `end_time` for legacy/synthetic IDs without a registry entry. + let created_at = MarketIdGenerator::get_registry_entry(env, market_id) + .map(|entry| entry.timestamp) + .unwrap_or(market.end_time); + Some(Self::market_to_history_entry(env, market_id, &market, created_at)) + } + /// Query events by category (paginated, bounded). /// /// Returns events whose category matches the given category string. diff --git a/contracts/predictify-hybrid/src/extensions.rs b/contracts/predictify-hybrid/src/extensions.rs index ee159972..fb2dd5cb 100644 --- a/contracts/predictify-hybrid/src/extensions.rs +++ b/contracts/predictify-hybrid/src/extensions.rs @@ -603,6 +603,11 @@ impl ExtensionValidator { MarketState::Disputed => { return Err(Error::ExtensionDenied); } + // Archived/Restored are immutable terminals under the metadata-only + // archive model; extension is never permitted on them. + MarketState::Archived | MarketState::Restored => { + return Err(Error::ExtensionDenied); + } } let current_time = env.ledger().timestamp(); diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index 5e760050..65698dee 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -1195,7 +1195,7 @@ impl FeeCalculator { if let Some(bet) = crate::bets::BetStorage::get_bet(env, market_id, &user) { let is_winning = winning_outcomes .iter() - .any(|outcome| outcome == &bet.outcome); + .any(|outcome| &outcome == &bet.outcome); if is_winning { winning_total = Self::checked_fee_add(winning_total, bet.amount)?; } @@ -1225,7 +1225,7 @@ impl FeeCalculator { if let Some(bet) = crate::bets::BetStorage::get_bet(env, market_id, &user) { let is_winning = winning_outcomes .iter() - .any(|outcome| outcome == &bet.outcome); + .any(|outcome| &outcome == &bet.outcome); if !is_winning { continue; } diff --git a/contracts/predictify-hybrid/src/force_resolve.rs b/contracts/predictify-hybrid/src/force_resolve.rs index 3821fe7b..47bd7d00 100644 --- a/contracts/predictify-hybrid/src/force_resolve.rs +++ b/contracts/predictify-hybrid/src/force_resolve.rs @@ -1,29 +1,47 @@ -#allow(dead_code)] +#![allow(dead_code)] -use soroban_sdk::{#ontracttype, symbol_short, Address, Env, String, Symbol, Vec, panic_with_error}; +use soroban_sdk::{ + contracttype, panic_with_error, symbol_short, Address, Env, String, Symbol, Vec, +}; use crate::err::Error; -#{contracttypu} -#[derive(Clone, Debug, Eq, PartialIsland)] -pubstruct ForceResolveRecord { +/// Record of a force-resolve operation, stored for idempotency. +/// +/// Once stored, the same `(market_id, idempotency_key)` pair guarantees +/// that a subsequent force-resolve call is a safe no-op rather than +/// re-applying the resolution. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ForceResolveRecord { pub resolved: bool, pub timestamp: u64, pub admin: Address, pub winning_outcomes: Vec, } -#{contracttypu} -#[derive(Clone, Debug, Eq, PartialIsland)] -pubstruct PayoutRemainderAllocation { +/// Record of a payout-remainder allocation for a force-resolved market. +/// +/// Kept separate from the force-resolve record so that exactly one remainder +/// allocation can be claimed per force-resolved market. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PayoutRemainderAllocation { pub amount: u64, pub recipient: Address, pub allocated: bool, } -pubstruct ForceResolveManager; +/// Manager for the admin force-resolve feature and its idempotency story. +pub struct ForceResolveManager; -pub fn calculate_payout_remainder(total_amount: u64, payout_amounts: &Vec), --> u64 { +/// Compute the undistributed remainder from `total_amount` minus the sum of +/// `payout_amounts`. +/// +/// # Panics +/// - If `payout_amounts` sum to more than `total_amount` (assertion failure). +/// - If any payout amount overflows `u64` in the running sum. +pub fn calculate_payout_remainder(total_amount: u64, payout_amounts: &Vec) -> u64 { let mut sum = 0u64; for i in 0..payout_amounts.len() { let amount = payout_amounts @@ -41,20 +59,27 @@ pub fn calculate_payout_remainder(total_amount: u64, payout_amounts: &Vec), } impl ForceResolveManager { - fn idempotency_storage_key(market_id: &Symbol, key: &String) -> ($Symbol, $Symbol, String) { - (symbol_short!("res_rslv"), market_id.clone(), key.clone()) + /// Deterministic storage key for a force-resolve idempotency record. + fn idempotency_storage_key(market_id: &Symbol, key: &String) -> (Symbol, Symbol, String) { + (symbol_short!("frc_rslv"), market_id.clone(), key.clone()) } - fn remainder_storage_key(market_id: &Symbol, key: &String) -> ($Symbol, $Symbol, String) { - - (symbol_short!("frc_rmndr"), market_id.clone(), key.clone()) + /// Deterministic storage key for a payout-remainder allocation. + fn remainder_storage_key(market_id: &Symbol, key: &String) -> (Symbol, Symbol, String) { + (symbol_short!("frc_rmndr"), market_id.clone(), key.clone()) } + /// Returns `true` when the idempotency key has already been consumed for + /// this market. pub fn is_already_resolved(env: &Env, market_id: &Symbol, key: &String) -> bool { let storage_key = Self::idempotency_storage_key(market_id, key); env.storage().persistent().has(&storage_key) } + /// Consumes the idempotency key by persisting a `ForceResolveRecord`. + /// + /// # Panics + /// - `Error::ForceResolveAlreadyUsed` if the key was already consumed. pub fn mark_resolved( env: &Env, market_id: &Symbol, @@ -83,6 +108,7 @@ impl ForceResolveManager { env.storage().persistent().set(&storage_key, &record); } + /// Retrieves the stored `ForceResolveRecord` for a market/key pair (if any). pub fn get_record( env: &Env, market_id: &Symbol, @@ -92,6 +118,15 @@ impl ForceResolveManager { env.storage().persistent().get(&storage_key) } + /// Allocates the payout remainder for a force-resolved market. + /// + /// Only the admin that performed the force resolve (recorded on the + /// `ForceResolveRecord`) may allocate the remainder, and only once. + /// + /// # Panics + /// - If no force-resolve record exists for the market/key pair. + /// - If a remainder allocation was already recorded for the pair. + /// - If `amount` is zero. pub fn allocate_payout_remainder( env: &Env, market_id: &Symbol, @@ -109,7 +144,10 @@ impl ForceResolveManager { panic!("payout remainder already allocated"); } - assert!(amount > 0, "payout remainder amount must be greater than zero"); + assert!( + amount > 0, + "payout remainder amount must be greater than zero" + ); let allocation = PayoutRemainderAllocation { amount, @@ -123,6 +161,7 @@ impl ForceResolveManager { ); } + /// Retrieves the payout-remainder allocation for a market/key pair (if any). pub fn get_payout_remainder_allocation( env: &Env, market_id: &Symbol, @@ -146,7 +185,7 @@ mod tests { payouts.push_back(10u64); payouts.push_back(10u64); let remainder = calculate_payout_remainder(32, &payouts); - assert_eq(!( remainder, 2); + assert_eq!(remainder, 2); } #[test] @@ -168,4 +207,4 @@ mod tests { payouts.push_back(10u64); let _ = calculate_payout_remainder(15, &payouts); } -} +} \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/gas.rs b/contracts/predictify-hybrid/src/gas.rs index acc61ab2..c91915f7 100644 --- a/contracts/predictify-hybrid/src/gas.rs +++ b/contracts/predictify-hybrid/src/gas.rs @@ -60,13 +60,14 @@ pub const DEFAULT_CLAIM_WINNINGS_GAS_LIMIT: u64 = 2_000_000; /// These defaults act as fallbacks when no admin-configured limit exists /// via `set_limit()`. fn get_default_limit(operation: &Symbol) -> Option { - // Use to_string comparison because Soroban Symbol doesn't implement - // direct equality with &str in a const-compatible way. - let op_str = alloc::format!("{}", operation); - match op_str.as_str() { - "create" => Some(DEFAULT_CREATE_MARKET_GAS_LIMIT), - "claim" => Some(DEFAULT_CLAIM_WINNINGS_GAS_LIMIT), - _ => None, + // Soroban `Symbol` has no Display/ToString on wasm, so compare against the + // canonical operation symbols directly (equality works on every target). + if operation == &symbol_short!("create") { + Some(DEFAULT_CREATE_MARKET_GAS_LIMIT) + } else if operation == &symbol_short!("claim") { + Some(DEFAULT_CLAIM_WINNINGS_GAS_LIMIT) + } else { + None } } diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index ca266801..b92ee94e 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -2219,7 +2219,7 @@ impl PredictifyHybrid { market .claimed - .set(user.clone(), ClaimInfo::new(&env, payout)); + .set(user.clone(), ClaimInfo::new(&env, payout, 0u64)); swept_total = swept_total.checked_add(payout).ok_or(Error::InvalidInput)?; } @@ -2265,7 +2265,7 @@ impl PredictifyHybrid { market .claimed - .set(user.clone(), ClaimInfo::new(&env, payout)); + .set(user.clone(), ClaimInfo::new(&env, payout, 0u64)); swept_total = swept_total.checked_add(payout).ok_or(Error::InvalidInput)?; } @@ -2889,6 +2889,8 @@ impl PredictifyHybrid { MarketState::Resolved => "Resolved", MarketState::Closed => "Closed", MarketState::Cancelled => "Cancelled", + MarketState::Archived => "Archived", + MarketState::Restored => "Restored", }; String::from_str(&env, s) }); @@ -4393,7 +4395,7 @@ impl PredictifyHybrid { if payout >= 0 { market .claimed - .set(user.clone(), ClaimInfo::new(&env, payout)); + .set(user.clone(), ClaimInfo::new(&env, payout, 0u64)); if payout > 0 { total_distributed = total_distributed @@ -4452,7 +4454,7 @@ impl PredictifyHybrid { if payout > 0 { market .claimed - .set(user.clone(), ClaimInfo::new(&env, payout)); + .set(user.clone(), ClaimInfo::new(&env, payout, 0u64)); total_distributed = total_distributed .checked_add(payout) @@ -4595,6 +4597,32 @@ impl PredictifyHybrid { crate::event_archive::EventArchive::query_events_by_category(&env, &category, cursor, limit) } + /// Query archived events directly. Returns public metadata only (no votes/stakes). + /// + /// Archived events keep their terminal `Resolved`/`Cancelled` state + /// (non-destructive archive), so they remain discoverable via + /// `query_events_by_status` too. This entrypoint is the explicit "show me the + /// archive" view, ordered by `archived_at`. + /// + /// * `reverse = false` - oldest archived first + /// * `reverse = true` - newest archived first + /// + /// Paginated: `cursor` is a zero-based offset into the archive ordering, + /// `limit` is capped at 30. Returns `(entries, next_cursor)`; advance until + /// `next_cursor == cursor` to finish. + /// + /// # Events + /// + /// Read-only query paths emit no events. + pub fn query_archived_events( + env: Env, + reverse: bool, + cursor: u32, + limit: u32, + ) -> (Vec, u32) { + crate::event_archive::EventArchive::query_archived_events(&env, reverse, cursor, limit) + } + /// Set the platform fee percentage (admin only). /// /// This function allows the admin to update the platform fee percentage diff --git a/contracts/predictify-hybrid/src/lifecycle_validation.rs b/contracts/predictify-hybrid/src/lifecycle_validation.rs index 157f07f2..62909bfc 100644 --- a/contracts/predictify-hybrid/src/lifecycle_validation.rs +++ b/contracts/predictify-hybrid/src/lifecycle_validation.rs @@ -102,18 +102,28 @@ impl LifecycleValidator { } }; - // Validate based on market state - match market.state { - MarketState::Archived => { - Self::validate_archived_market(env, market_id, &market) - } - MarketState::Restored => { - Self::validate_restored_market(env, market_id, &market) - } - _ => { - // For other states, just verify they're not incorrectly marked - Self::validate_non_archived_market(env, market_id, &market) - } + // Archive and restore are non-destructive METADATA markers (see + // `EventArchive::archive_event`): they no longer transition + // `Market.state`. Dispatch on the metadata flags, not the state. + let is_archived = EventArchive::is_archived(env, market_id); + let is_restored = RestoreArchive::is_restored(env, market_id); + + if is_archived && is_restored { + return Ok(LifecycleValidationResult::failure( + env, + Error::InvalidState, + "State corruption: market is both archived and restored", + )); + } + if is_archived { + Self::validate_archived_market(env, market_id) + } else if is_restored { + Self::validate_restored_market(env, market_id) + } else { + Ok(LifecycleValidationResult::success( + env, + "Market state is consistent", + )) } } @@ -127,18 +137,8 @@ impl LifecycleValidator { fn validate_archived_market( env: &Env, market_id: &Symbol, - market: &Market, ) -> Result { - // Check state is exactly Archived - if market.state != MarketState::Archived { - return Ok(LifecycleValidationResult::failure( - env, - Error::InvalidState, - "Market state mismatch: expected Archived", - )); - } - - // Verify archive metadata exists + // Verify archive metadata exists (dispatch already confirmed this). if !EventArchive::is_archived(env, market_id) { return Ok(LifecycleValidationResult::failure( env, @@ -182,17 +182,7 @@ impl LifecycleValidator { fn validate_restored_market( env: &Env, market_id: &Symbol, - market: &Market, ) -> Result { - // Check state is exactly Restored - if market.state != MarketState::Restored { - return Ok(LifecycleValidationResult::failure( - env, - Error::InvalidState, - "Market state mismatch: expected Restored", - )); - } - // Verify restore metadata exists if !RestoreArchive::is_restored(env, market_id) { return Ok(LifecycleValidationResult::failure( @@ -226,58 +216,6 @@ impl LifecycleValidator { )) } - /// Validate a non-archived/restored market. - /// - /// Ensures: - /// - Market is not incorrectly marked as archived/restored - /// - No orphaned archive/restore metadata exists - fn validate_non_archived_market( - env: &Env, - market_id: &Symbol, - market: &Market, - ) -> Result { - // Check state is not Archived (should never reach here if state is Archived) - if market.state == MarketState::Archived { - return Ok(LifecycleValidationResult::failure( - env, - Error::InvalidState, - "Market state inconsistency: state indicates Archived", - )); - } - - // Check state is not Restored (should never reach here if state is Restored) - if market.state == MarketState::Restored { - return Ok(LifecycleValidationResult::failure( - env, - Error::InvalidState, - "Market state inconsistency: state indicates Restored", - )); - } - - // Check no orphaned archive metadata exists - if EventArchive::is_archived(env, market_id) { - return Ok(LifecycleValidationResult::failure( - env, - Error::InvalidState, - "Orphaned archive metadata: market state is not Archived but archive record exists", - )); - } - - // Check no orphaned restore metadata exists - if RestoreArchive::is_restored(env, market_id) { - return Ok(LifecycleValidationResult::failure( - env, - Error::InvalidState, - "Orphaned restore metadata: market state is not Restored but restore record exists", - )); - } - - Ok(LifecycleValidationResult::success( - env, - "Non-archived market state is consistent", - )) - } - /// Validate state transition legality. /// /// Checks if a requested state transition is allowed by lifecycle rules: diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 836157b0..37541219 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -209,6 +209,28 @@ impl MarketIdGenerator { result } + /// Look up the registry entry (admin, creation timestamp) for a market ID. + /// + /// Returns `None` if the market ID has no registry entry (e.g. a market + /// created before the registry existed, or a synthetic/legacy ID). + pub fn get_registry_entry(env: &Env, market_id: &Symbol) -> Option { + let key = Symbol::new(env, Self::REGISTRY_KEY); + let registry: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + + for i in 0..registry.len() { + if let Some(entry) = registry.get(i) { + if entry.market_id == *market_id { + return Some(entry); + } + } + } + None + } + /// Return all market IDs created by `admin`. pub fn get_admin_markets(env: &Env, admin: &Address) -> Vec { let key = Symbol::new(env, Self::REGISTRY_KEY); diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index ff8a513a..3b80f1fb 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -1197,7 +1197,7 @@ impl MarketStateManager { env: &Env, ) { MarketStateLogic::check_function_access_for_state("claim", market.state).unwrap(); - let claim_info = crate::types::ClaimInfo::new(env, payout_amount); + let claim_info = crate::types::ClaimInfo::new(env, payout_amount, 0u64); market.claimed.set(user, claim_info); } @@ -2897,6 +2897,10 @@ impl MarketStateLogic { Resolved => matches!(to, Closed), Closed => false, Cancelled => false, + // Archived/Restored are legacy markers under the metadata-only + // archive model; they expose no transitions through the state machine. + Archived => false, + Restored => false, }; if allowed { Ok(()) @@ -3099,6 +3103,9 @@ impl MarketStateLogic { } } Closed | Cancelled => {} + // Archived/Restored are legacy markers under the metadata-only + // archive model; no additional consistency checks apply. + Archived | Restored => {} } Ok(()) } diff --git a/contracts/predictify-hybrid/src/monitoring.rs b/contracts/predictify-hybrid/src/monitoring.rs index d36f9094..d384611f 100644 --- a/contracts/predictify-hybrid/src/monitoring.rs +++ b/contracts/predictify-hybrid/src/monitoring.rs @@ -341,6 +341,8 @@ impl ContractMonitor { MarketState::Resolved => String::from_str(env, "Resolved"), MarketState::Closed => String::from_str(env, "Closed"), MarketState::Cancelled => String::from_str(env, "Cancelled"), + MarketState::Archived => String::from_str(env, "Archived"), + MarketState::Restored => String::from_str(env, "Restored"), } } diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index eb8e285c..aee6f30e 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -844,6 +844,8 @@ impl RecoveryManager { MarketState::Resolved => String::from_str(env, "Resolved"), MarketState::Closed => String::from_str(env, "Closed"), MarketState::Cancelled => String::from_str(env, "Cancelled"), + MarketState::Archived => String::from_str(env, "Archived"), + MarketState::Restored => String::from_str(env, "Restored"), }; // Integrity check: use the existing validator. @@ -1022,7 +1024,7 @@ impl RecoveryManager { // For now just mark claimed and reduce total; real implementation would transfer tokens market .claimed - .set(user.clone(), crate::types::ClaimInfo::new(env, stake)); + .set(user.clone(), crate::types::ClaimInfo::new(env, stake, 0u64)); market.total_staked = market.total_staked - stake; total_refunded += stake; } diff --git a/contracts/predictify-hybrid/src/restore_archive.rs b/contracts/predictify-hybrid/src/restore_archive.rs index c4328907..569c2872 100644 --- a/contracts/predictify-hybrid/src/restore_archive.rs +++ b/contracts/predictify-hybrid/src/restore_archive.rs @@ -126,15 +126,16 @@ impl RestoreArchive { } // ===== STATE VALIDATION ===== - // Fetch market and verify it exists - let mut market: Market = env - .storage() + // Fetch market and verify it exists. Its stored state is left untouched: + // restore is likewise a non-destructive, metadata-only operation that + // simply removes the archive marker (see `EventArchive::unarchive`). + env.storage() .persistent() - .get(market_id) + .get::(market_id) .ok_or(Error::MarketNotFound)?; - // Enforce precondition: market must be in Archived state - if market.state != MarketState::Archived { + // Enforce precondition: market must be currently archived (metadata-driven). + if !crate::event_archive::EventArchive::is_archived(env, market_id) { return Err(Error::CannotRestoreFromState); } @@ -151,12 +152,10 @@ impl RestoreArchive { return Err(Error::MarketAlreadyRestored); } - // ===== STATE TRANSITION ===== - // Update market state from Archived to Restored - market.state = MarketState::Restored; - - // Record updated market in storage - env.storage().persistent().set(market_id, &market); + // ===== UN-ARCHIVE (NON-DESTRUCTIVE) ===== + // Remove the archive metadata marker and sorted-index entry. The market + // record keeps its terminal Resolved/Cancelled state. + crate::event_archive::EventArchive::unarchive(env, market_id)?; // ===== RESTORE METADATA RECORDING ===== let now = env.ledger().timestamp(); @@ -173,7 +172,7 @@ impl RestoreArchive { market_id: market_id.clone(), restored_at: now, restored_by: admin.clone(), - reason, + reason: reason.clone(), version: 1, // Current version; increment for future schema changes }; diff --git a/contracts/predictify-hybrid/src/storage_tier_audit.rs b/contracts/predictify-hybrid/src/storage_tier_audit.rs index d4473319..f0bfc9e4 100644 --- a/contracts/predictify-hybrid/src/storage_tier_audit.rs +++ b/contracts/predictify-hybrid/src/storage_tier_audit.rs @@ -1,5 +1,3 @@ -use soroban_sdk::{contracttype, Address, Env, Map, String, Vec}; - //! Storage-tier classifier audit (issue #734). //! //! Documents and verifies the storage tier (instance / persistent / temporary) @@ -13,8 +11,8 @@ use soroban_sdk::{contracttype, Address, Env, Map, String, Vec}; use soroban_sdk::{contracttype, Address, Env, Map, String, Vec}; /// Which Soroban storage tier a key lives in. -#contracttype -#derive(Clone, Debug, PartialEq, Eq) +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum StorageTier { Instance, Persistent, @@ -22,8 +20,8 @@ pub enum StorageTier { } /// A record describing one key's tier classification. -#contracttype -#derive(Clone, Debug) +#[contracttype] +#[derive(Clone, Debug)] pub struct StorageTierRecord { pub key_name: String, pub tier: StorageTier, @@ -31,8 +29,8 @@ pub struct StorageTierRecord { } /// A record describing an explicit storage-tier change. -#contracttype -#derive(Clone, Debug) +#[contracttype] +#[derive(Clone, Debug)] pub struct StorageTierChange { pub key_name: String, pub old_tier: StorageTier, @@ -43,7 +41,7 @@ pub struct StorageTierChange { } /// Storage keys used by this audit module. -#contracttype +#[contracttype] enum DataKey { Admin, TierOverrides, @@ -51,7 +49,7 @@ enum DataKey { } /// Canonical list of storage tier assignments. -const DEFAULT_TIERS: &[(&str, StorageTier, &str)] = & +const DEFAULT_TIERS: &[(&str, StorageTier, &str)] = &[ ("Admin", StorageTier::Persistent, "Set once; must survive contract upgrades"), ("Market", StorageTier::Persistent, "Core market data; long-lived"), ("MarketMetadata", StorageTier::Persistent, "Extended metadata; accessed infrequently"), @@ -61,7 +59,7 @@ const DEFAULT_TIERS: &[(&str, StorageTier, &str)] = & ("DisputeStakeCap", StorageTier::Persistent, "Per-user cap survives disputes"), ("DisputeMultiSig", StorageTier::Instance, "Short-lived approval state"), ("GovernanceMinBps", StorageTier::Instance, "Governance param; frequently updated"), - ("CumDisputeFee", StorageTier::Instance, Accumulator; updated per dispute"), + ("CumDisputeFee", StorageTier::Instance, "Accumulator; updated per dispute"), ("PlatformFee", StorageTier::Persistent, "Protocol fee; infrequently changed"), ("OracleConfidence", StorageTier::Instance, "Config param; changed by admin"), ("AdminEmergency", StorageTier::Instance, "Contact address; infrequently changed"), @@ -74,7 +72,7 @@ pub fn get_storage_tier_audit(env: &Env) -> Vec { for (name, default_tier, rationale) in DEFAULT_TIERS.iter() { let key_name = String::from_str(env, *name); - let tier = overrides.get(key_name.clone()).unwrap_else_fake(default_tier.clone()); + let tier = overrides.get(key_name.clone()).unwrap_or(default_tier.clone()); records.push_back(StorageTierRecord { key_name, tier, @@ -94,11 +92,17 @@ pub fn get_storage_tier_changes(env: &Env) -> Vec { /// This can only be called once. pub fn initialize(env: &Env, admin: Address) { if env.storage().instance().has(&DataKey::Admin) { - panic ("already initialized"); + panic!("already initialized"); } env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::TierOverrides, &Map::new(env)); - env.storage().instance().set(&DataKey::AuditLog, &Vec::new(env)); + env + .storage() + .instance() + .set(&DataKey::TierOverrides, &Map::::new(env)); + env + .storage() + .instance() + .set(&DataKey::AuditLog, &Vec::::new(env)); } /// Explicitly changes the storage tier for a known key and records the change @@ -106,16 +110,16 @@ pub fn initialize(env: &Env, admin: Address) { pub fn set_storage_tier(env: &Env, key_name: String, new_tier: StorageTier, rationale: String) { let admin: Address = env.storage().instance().get(&DataKey::Admin) .expect("not initialized"); - env.require_auth(&admin); + admin.require_auth(); - let default_tier = get_default_tier(env, %key_name) + let default_tier = get_default_tier(env, &key_name) .expect("unknown storage tier key"); let mut overrides = get_tier_overrides(env); let current_tier = overrides.get(key_name.clone()).unwrap_or(default_tier); if current_tier == new_tier { - panic ("storage tier already set to requested value"); + panic!("storage tier already set to requested value"); } overrides.set(key_name.clone(), new_tier.clone()); @@ -138,14 +142,14 @@ pub fn set_storage_tier(env: &Env, key_name: String, new_tier: StorageTier, rati fn get_tier_overrides(env: &Env) -> Map { env.storage().instance() .get(&DataKey::TierOverrides) - .unwrap_or_else(:: Map::new(env)) + .unwrap_or_else(|| Map::::new(env)) } /// Returns the current audit log, or an empty vector if none has been set. fn get_audit_log(env: &Env) -> Vec { env.storage().instance() .get(&DataKey::AuditLog) - .unwrap_or_else(:: Vec::new(env)) + .unwrap_or_else(|| Vec::::new(env)) } /// Looks up the canonical tier for a key name, if it exists. @@ -175,9 +179,9 @@ mod tests { fn test_admin_key_is_persistent() { let env = Env::default(); let records = get_storage_tier_audit(&env); - let admin = records.iter().find(|rpr| r.key_name == String::from_str(&env, "Admin")); + let admin = records.iter().find(|r| r.key_name == String::from_str(&env, "Admin")); assert!(admin.is_some()); - assert_eq(admin.unwrap().tier, StorageTier::Persistent); + assert_eq!(admin.unwrap().tier, StorageTier::Persistent); } #[test] @@ -195,16 +199,16 @@ mod tests { ); let records = get_storage_tier_audit(&env); - let market = records.iter().find(|rr| r.key_name == String::from_str(&env, "Market")).unwrap(); - assert_eq(market.tier, StorageTier::Instance); + let market = records.iter().find(|r| r.key_name == String::from_str(&env, "Market")).unwrap(); + assert_eq!(market.tier, StorageTier::Instance); let changes = get_storage_tier_changes(&env); - assert_eq(changes.len(), 1); + assert_eq!(changes.len(), 1); let change = changes.get(0).unwrap(); - assert_eq(change.old_tier, StorageTier::Persistent); - assert_eq(change.new_tier, StorageTier::Instance); - assert_eq(change.changed_by, admin); - assert_eq(change.key_name, String::from_str(&env, "Market")); + assert_eq!(change.old_tier, StorageTier::Persistent); + assert_eq!(change.new_tier, StorageTier::Instance); + assert_eq!(change.changed_by, admin); + assert_eq!(change.key_name, String::from_str(&env, "Market")); } #[test] @@ -221,7 +225,7 @@ mod tests { } #[test] - #s[should_panic(expected = "unknown storage tier key")] + #[should_panic(expected = "unknown storage tier key")] fn test_set_tier_unknown_key_panics() { let env = Env::default(); env.mock_all_auths(); @@ -236,7 +240,7 @@ mod tests { } #[test] - #s[should_panic(expected = "storage tier already set to requested value")] + #[should_panic(expected = "storage tier already set to requested value")] fn test_set_tier_same_tier_panics() { let env = Env::default(); env.mock_all_auths(); @@ -253,6 +257,6 @@ mod tests { #[test] fn test_no_changes_initially() { let env = Env::default(); - assert_eq(get_storage_tier_changes(&env).len(), 0); + assert_eq!(get_storage_tier_changes(&env).len(), 0); } } diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index 5083ee20..9a6626a5 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -104,7 +104,7 @@ impl StorageTierChange { ledger_seq: self.ledger_seq, ttl_seconds: self.ttl_seconds, }; - let expected = env.crypto().sha256(&payload.to_xdr(env)).into(); + let expected: BytesN<32> = env.crypto().sha256(&payload.to_xdr(env)).into(); expected.eq(&self.commitment) } } @@ -3678,6 +3678,9 @@ impl MarketStatus { MarketState::Resolved => MarketStatus::Resolved, MarketState::Closed => MarketStatus::Closed, MarketState::Cancelled => MarketStatus::Cancelled, + // Archived/Restored are legacy state markers under the metadata-only + // archive model; expose them as the terminal Closed status. + MarketState::Archived | MarketState::Restored => MarketStatus::Closed, } } } diff --git a/contracts/predictify-hybrid/tests/archive_discoverability.rs b/contracts/predictify-hybrid/tests/archive_discoverability.rs new file mode 100644 index 00000000..1a5a5fff --- /dev/null +++ b/contracts/predictify-hybrid/tests/archive_discoverability.rs @@ -0,0 +1,246 @@ +//! # Archived Event Discoverability — Integration Tests +//! +//! Validates the metadata-only (non-destructive) archive feature: +//! +//! * Archiving a resolved/cancelled market records an `archived_at` metadata +//! marker and leaves `Market.state` untouched, so the event remains +//! discoverable by its terminal status via `query_events_by_status`. +//! * `query_archived_events` provides a direct "show me the archive" view +//! ordered by archived time. +//! * Duplicate archiving is rejected (idempotency), non-terminal markets are +//! rejected, and pruning is deterministic and capacity-bounded. +//! +//! These are integration tests: they link only the `predictify-hybrid` library +//! (the crate builds cleanly) and drive the public client surface, avoiding the +//! stale inline `#[cfg(test)]` modules. + +#![cfg(test)] + +use predictify_hybrid::{ + EventHistoryEntry, MarketState, OracleConfig, OracleProvider, PredictifyHybrid, + PredictifyHybridClient, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger, LedgerInfo}, + token::StellarAssetClient, + vec, Address, Env, String as SorobanString, Symbol, +}; +use std::vec::Vec as StdVec; + +const INITIAL_BALANCE: i128 = 1_000_000_000_000; + +struct Harness { + env: Env, + contract_id: Address, + admin: Address, +} + +impl Harness { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_id = token.address(); + + let admin = Address::generate(&env); + let contract_id = env.register(PredictifyHybrid, ()); + + // Store the token id for staking/payouts. + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); + }); + + let client = PredictifyHybridClient::new(&env, &contract_id); + client.initialize(&admin.clone(), &None::, &None); + + let stellar = StellarAssetClient::new(&env, &token_id); + stellar.mint(&admin, &INITIAL_BALANCE); + + Self { + env, + contract_id, + admin, + } + } + + fn client(&self) -> PredictifyHybridClient { + PredictifyHybridClient::new(&self.env, &self.contract_id) + } + + fn advance_days(&self, days: u64) { + let ledger = self.env.ledger(); + let timestamp = ledger.timestamp() + days * 24 * 60 * 60; + self.env.ledger().set(LedgerInfo { + timestamp, + protocol_version: ledger.protocol_version(), + sequence_number: ledger.sequence(), + network_id: ledger.network_id().into(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + // The rate limiter extends temporary entries to 90_000 ledgers; + // keep the cap above that so repeated admin ops don't trip + // `Storage InvalidAction` in the test host. + max_entry_ttl: 1_000_000, + }); + } + + fn oracle_config(&self) -> OracleConfig { + OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::generate(&self.env), + feed_id: SorobanString::from_str(&self.env, "BTC"), + threshold: 50_000_00, + comparison: SorobanString::from_str(&self.env, "gt"), + } + } + + fn outcomes(&self) -> soroban_sdk::Vec { + vec![ + &self.env, + SorobanString::from_str(&self.env, "Yes"), + SorobanString::from_str(&self.env, "No"), + ] + } + + /// Create a one-day market using the given question string. + fn create_market(&self, question: &str) -> Symbol { + PredictifyHybridClient::new(&self.env, &self.contract_id).create_market( + &self.admin, + &SorobanString::from_str(&self.env, question), + &self.outcomes(), + &1, + &self.oracle_config(), + &None, // fallback oracle + &0, // resolution timeout + &None, // min pool size + &None, // bet deadline + &None, // dispute window + &None, // dispute stake floor + &None, // max participants + ) + } + + /// Create + end + resolve + archive a market, returning its id. + fn create_resolved_archived(&self, question: &str) -> Symbol { + let id = self.create_market(question); + self.advance_days(2); // past 1-day end + let client = self.client(); + client.resolve_market_manual(&self.admin, &id, &SorobanString::from_str(&self.env, "Yes")); + client.archive_event(&self.admin, &id); + id + } +} + +fn collect_ids(entries: &soroban_sdk::Vec) -> StdVec { + entries.iter().map(|e| e.market_id).collect() +} + +#[test] +fn archiving_preserves_resolved_state_and_discoverability() { + let h = Harness::new(); + let client = h.client(); + + let mid = h.create_market("Will prices rise?"); + h.advance_days(2); + client.resolve_market_manual(&h.admin, &mid, &SorobanString::from_str(&h.env, "Yes")); + + // Sanity: resolved and discoverable by status before archiving. + let (before, _) = client.query_events_by_status(&MarketState::Resolved, &0, &30); + let ids_before = collect_ids(&before); + assert!(ids_before.contains(&mid)); + + // Archive the resolved market. + client.archive_event(&h.admin, &mid); + + // Still discoverable by terminal status after archiving (non-destructive). + let (after, _) = client.query_events_by_status(&MarketState::Resolved, &0, &30); + let ids_after = collect_ids(&after); + assert!( + ids_after.contains(&mid), + "archived (resolved) event must remain discoverable by status" + ); + + // Exposed via the direct archived view. + let (archived, _) = client.query_archived_events(&false, &0, &30); + let archived_ids = collect_ids(&archived); + assert!(archived_ids.contains(&mid)); + + assert_eq!(client.archive_size(), 1); +} + +#[test] +fn surviving_rejection_on_non_terminal_and_duplicate() { + let h = Harness::new(); + let client = h.client(); + + // A freshly created (still Active) market may not be archived. + let active_id = h.create_market("Active market"); + assert!(client.try_archive_event(&h.admin, &active_id).is_err()); + assert_eq!(client.archive_size(), 0); + + // Resolve then archive once; a duplicate archive attempt must fail. + h.advance_days(2); + client.resolve_market_manual(&h.admin, &active_id, &SorobanString::from_str(&h.env, "Yes")); + client.archive_event(&h.admin, &active_id); + assert_eq!(client.archive_size(), 1); + assert!(client.try_archive_event(&h.admin, &active_id).is_err()); +} + +#[test] +fn archived_query_is_ordered_and_paginated() { + let h = Harness::new(); + let client = h.client(); + + // Create two resolved+archived markets, in ascending archive time. + let a = h.create_resolved_archived("Market Alpha"); + h.advance_days(1); + let b = h.create_resolved_archived("Market Beta"); + + // Ascending: oldest first. + let (asc, _) = client.query_archived_events(&false, &0, &30); + let asc_ids = collect_ids(&asc); + let mut expected_asc = StdVec::new(); + expected_asc.push(a.clone()); + expected_asc.push(b.clone()); + assert_eq!(asc_ids, expected_asc); + + // Descending: newest first. + let (desc, _) = client.query_archived_events(&true, &0, &30); + let desc_ids = collect_ids(&desc); + let mut expected_desc = StdVec::new(); + expected_desc.push(b.clone()); + expected_desc.push(a.clone()); + assert_eq!(desc_ids, expected_desc); +} + +#[test] +fn pruning_is_deterministic_and_capacity_bounded() { + let h = Harness::new(); + let client = h.client(); + + // Archive two markets. + let a = h.create_resolved_archived("Prune Alpha"); + h.advance_days(1); + let b = h.create_resolved_archived("Prune Beta"); + + assert_eq!(client.archive_size(), 2); + + // Prune the oldest 1 entry: A (archived first) must go, B remains. + let removed = match client.try_prune_archive(&h.admin, &1, &None) { + Ok(Ok((n, _))) => n, + _ => panic!("prune_archive failed"), + }; + assert_eq!(removed, 1); + assert_eq!(client.archive_size(), 1); + + let (archived, _) = client.query_archived_events(&false, &0, &30); + let ids = collect_ids(&archived); + let mut expected = StdVec::new(); + expected.push(b.clone()); + assert_eq!(ids, expected); +} \ No newline at end of file From fe36c9ac19c0d0e6fc044b0234d0a55631ef7ffb Mon Sep 17 00:00:00 2001 From: olu-cmd Date: Sat, 29 Aug 2026 10:29:08 +0000 Subject: [PATCH 2/2] fixed budget cl failing --- .../src/analytics_snapshot_tests.rs | 1 + contracts/predictify-hybrid/src/config.rs | 1 + .../predictify-hybrid/src/deprecated_tests.rs | 38 +- .../predictify-hybrid/src/event_archive.rs | 6 + contracts/predictify-hybrid/src/events.rs | 96 +++- .../src/force_resolve_tests.rs | 2 + .../src/gas_regression_tests.rs | 146 +++-- .../src/graceful_degradation.rs | 34 +- contracts/predictify-hybrid/src/lib.rs | 9 +- .../src/market_audit_tests.rs | 4 +- .../src/market_state_matrix_tests.rs | 2 + .../src/max_participants_tests.rs | 32 +- .../predictify-hybrid/src/oracle_health.rs | 38 +- contracts/predictify-hybrid/src/oracles.rs | 2 +- .../src/override_audit_tests.rs | 6 + contracts/predictify-hybrid/src/recovery.rs | 2 + .../predictify-hybrid/src/reentrancy_guard.rs | 2 + .../predictify-hybrid/src/test_audit_trail.rs | 16 +- .../src/tests/oracle_validation_tests.rs | 5 +- .../src/tie_resolution_tests.rs | 2 + .../predictify-hybrid/src/timelock_tests.rs | 101 ++-- .../predictify-hybrid/tests/auth_snapshot.rs | 6 +- .../tests/auth_snapshot_disputes.rs | 2 + .../tests/claim_replay_protection.rs | 92 +-- .../predictify-hybrid/tests/conservation.rs | 144 +++-- .../tests/event_replay_nonce.rs | 119 ++-- .../predictify-hybrid/tests/lifecycle.rs | 525 ++++++------------ contracts/predictify-hybrid/tests/stateful.rs | 64 ++- 28 files changed, 803 insertions(+), 694 deletions(-) diff --git a/contracts/predictify-hybrid/src/analytics_snapshot_tests.rs b/contracts/predictify-hybrid/src/analytics_snapshot_tests.rs index a75cb48d..57dbbdde 100644 --- a/contracts/predictify-hybrid/src/analytics_snapshot_tests.rs +++ b/contracts/predictify-hybrid/src/analytics_snapshot_tests.rs @@ -2,6 +2,7 @@ use crate::analytics_snapshot::{AnalyticsSnapshotEnvelope, AnalyticsSnapshotMana use crate::err::Error; use crate::types::{Market, MarketState, OracleConfig}; use crate::PredictifyHybrid; +use soroban_sdk::testutils::Address as _; use soroban_sdk::{symbol_short, Address, Env, String, Symbol, Vec}; fn make_market(env: &Env, market_id: Symbol) -> Market { diff --git a/contracts/predictify-hybrid/src/config.rs b/contracts/predictify-hybrid/src/config.rs index 2c3fea0f..e5c0670c 100644 --- a/contracts/predictify-hybrid/src/config.rs +++ b/contracts/predictify-hybrid/src/config.rs @@ -3088,6 +3088,7 @@ impl ConfigTesting { #[cfg(test)] mod tests { use super::*; + use soroban_sdk::testutils::Address as _; #[test] fn test_config_manager_default_configs() { diff --git a/contracts/predictify-hybrid/src/deprecated_tests.rs b/contracts/predictify-hybrid/src/deprecated_tests.rs index f4469200..0dfbf93a 100644 --- a/contracts/predictify-hybrid/src/deprecated_tests.rs +++ b/contracts/predictify-hybrid/src/deprecated_tests.rs @@ -14,7 +14,7 @@ mod deprecated_registry_tests { use soroban_sdk::{ symbol_short, testutils::{Address as _, Events}, - Address, Env, Symbol, String, + Address, Env, Symbol, String, TryIntoVal, }; // ----------------------------------------------------------------------- @@ -339,13 +339,31 @@ mod deprecated_registry_tests { &sym(&env, "fetch_or"), ); - let events = env.events().all().events(); + let contract_events = env.events().all(); + let events = contract_events.events(); assert!(!events.is_empty(), "must emit at least one event"); // Verify the emitted event contains expected fields let found = events.iter().any(|e| { - e.0 .0 == symbol_short!("depr_call") - && e.0 .1 == sym(&env, "verify_r") + if let soroban_sdk::xdr::ContractEventBody::V0(v0) = &e.body { + let topic0: Symbol = v0 + .topics + .get(0) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + let topic1: Symbol = v0 + .topics + .get(1) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + topic0 == symbol_short!("depr_call") && topic1 == sym(&env, "verify_r") + } else { + false + } }); assert!(found, "depr_call event must be present with correct entrypoint"); } @@ -364,12 +382,20 @@ mod deprecated_registry_tests { &sym(&env, "new_fn"), ); - let events = env.events().all().events(); + let contract_events = env.events().all(); + let events = contract_events.events(); assert!(!events.is_empty(), "must emit at least one event"); // The first topic in the tuple is the event type, entrypoint is second let found = events.iter().any(|e| { - e.0 .0 == symbol_short!("depr_call") + if let soroban_sdk::xdr::ContractEventBody::V0(v0) = &e.body { + v0.topics + .get(0) + .map(|t| t.clone().try_into_val(&env).ok()) + == Some(Some(symbol_short!("depr_call"))) + } else { + false + } }); assert!(found, "depr_call topic must be present"); } diff --git a/contracts/predictify-hybrid/src/event_archive.rs b/contracts/predictify-hybrid/src/event_archive.rs index e20784b4..b9fd050c 100644 --- a/contracts/predictify-hybrid/src/event_archive.rs +++ b/contracts/predictify-hybrid/src/event_archive.rs @@ -1366,6 +1366,8 @@ mod tests { dispute_window_seconds: 3600, winnings_swept: false, timelock_config: crate::timelock::MarketTimelockConfig::default(), + dispute_stake_floor: None, + max_participants: None, }; let res = @@ -1417,6 +1419,8 @@ mod tests { dispute_window_seconds: 3600, winnings_swept: false, timelock_config: crate::timelock::MarketTimelockConfig::default(), + dispute_stake_floor: None, + max_participants: None, }; let res1 = @@ -1698,6 +1702,8 @@ mod tests { dispute_window_seconds: 3600, winnings_swept: false, timelock_config: crate::timelock::MarketTimelockConfig::default(), + dispute_stake_floor: None, + max_participants: None, }; let res = diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 45fd0ad7..aaf7b6b3 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -5322,7 +5322,7 @@ pub fn emit_deprecated(env: &Env, caller: &Address, entrypoint: &Symbol) { #[cfg(test)] mod event_schema_registry_tests { use super::*; - use soroban_sdk::{testutils::Address as _, Env}; + use soroban_sdk::{testutils::{Address as _, Events}, Env, Symbol, TryIntoVal}; #[test] fn test_registry_lookup_oracle_result() { @@ -5427,8 +5427,18 @@ mod event_schema_registry_tests { // Find our depr_call event let found = emitted.iter().any(|e| { - e.0 .0 == symbol_short!("depr_call") - && e.0 .1 == entrypoint + if let soroban_sdk::xdr::ContractEventBody::V0(v0) = &e.body { + v0.topics + .get(0) + .map(|t| t.clone().try_into_val(&env).ok()) + == Some(Some(symbol_short!("depr_call"))) + && v0.topics + .get(1) + .map(|t| t.clone().try_into_val(&env).ok()) + == Some(Some(entrypoint.clone())) + } else { + false + } }); assert!(found, "depr_call event must be present"); }); @@ -5925,13 +5935,27 @@ mod focused_dispute_tests { let mut found = false; for event in events.events().iter() { - if event.2.len() == 3 { - let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); - let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); - - if topic0 == symbol_short!("dispt_opn") { - assert_eq!(topic1, market_id, "Market ID must be topic1"); - found = true; + if let soroban_sdk::xdr::ContractEventBody::V0(v0) = &event.body { + if v0.topics.len() == 3 { + let topic0: Symbol = v0 + .topics + .get(0) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + let topic1: Symbol = v0 + .topics + .get(1) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + + if topic0 == symbol_short!("dispt_opn") { + assert_eq!(topic1, market_id, "Market ID must be topic1"); + found = true; + } } } } @@ -5943,7 +5967,7 @@ mod focused_dispute_tests { mod storage_tier_change_tests { use super::*; use soroban_sdk::{ - testutils::Address as _, Address, Env, IntoVal, Symbol, TryIntoVal, + testutils::{Address as _, Events}, Address, Env, IntoVal, Symbol, TryIntoVal, }; #[test] @@ -5978,12 +6002,26 @@ mod storage_tier_change_tests { let events = env.events().all(); let mut found = false; for event in events.events().iter() { - if event.2.len() == 3 { - let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); - let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); - if topic0 == symbol_short!("st_tier") { - assert_eq!(topic1, market_id); - found = true; + if let soroban_sdk::xdr::ContractEventBody::V0(v0) = &event.body { + if v0.topics.len() == 3 { + let topic0: Symbol = v0 + .topics + .get(0) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + let topic1: Symbol = v0 + .topics + .get(1) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + if topic0 == symbol_short!("st_tier") { + assert_eq!(topic1, market_id); + found = true; + } } } } @@ -6013,11 +6051,25 @@ mod payout_remainder_allocation_tests { let events = env.events().all(); let mut found = false; for event in events.events().iter() { - if event.2.len() == 3 { - let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); - let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); - if topic0 == symbol_short!("pay_rem") && topic1 == market_id { - found = true; + if let soroban_sdk::xdr::ContractEventBody::V0(v0) = &event.body { + if v0.topics.len() == 3 { + let topic0: Symbol = v0 + .topics + .get(0) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + let topic1: Symbol = v0 + .topics + .get(1) + .unwrap() + .clone() + .try_into_val(&env) + .unwrap(); + if topic0 == symbol_short!("pay_rem") && topic1 == market_id { + found = true; + } } } } diff --git a/contracts/predictify-hybrid/src/force_resolve_tests.rs b/contracts/predictify-hybrid/src/force_resolve_tests.rs index 32b5de5b..687234f8 100644 --- a/contracts/predictify-hybrid/src/force_resolve_tests.rs +++ b/contracts/predictify-hybrid/src/force_resolve_tests.rs @@ -63,6 +63,8 @@ impl Ctx { &None, &None, &None, + &None, + &None, ) } } diff --git a/contracts/predictify-hybrid/src/gas_regression_tests.rs b/contracts/predictify-hybrid/src/gas_regression_tests.rs index 2a690899..f8e5cf01 100644 --- a/contracts/predictify-hybrid/src/gas_regression_tests.rs +++ b/contracts/predictify-hybrid/src/gas_regression_tests.rs @@ -2,11 +2,25 @@ //! //! Tests that verify the gas regression limits for `create_market` and //! `claim_winnings` paths are correctly enforced. +//! +//! `GasTracker` persists its limits in *instance* storage, so every test that +//! touches limits runs inside `as_contract` against a contract that is actually +//! registered in the ledger (a bare generated address has no contract instance +//! and `as_contract` fails with `Storage, MissingValue`). #![cfg(test)] use super::*; -use soroban_sdk::{symbol_short, Env}; +use soroban_sdk::testutils::{Address as _, Events}; +use soroban_sdk::{symbol_short, Address, Env}; + +/// Creates an environment with a registered contract instance whose storage the +/// gas tracker can read and write. Returns `(env, contract_id)`. +fn seeded_env() -> (Env, Address) { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + (env, contract_id) +} // ===== DEFAULT LIMIT CONSTANTS ===== @@ -38,9 +52,9 @@ fn test_default_claim_winnings_gas_limit_value() { #[test] fn test_set_default_limits_populates_storage() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); let (create_cpu, _create_mem) = @@ -65,66 +79,58 @@ fn test_set_default_limits_populates_storage() { #[test] fn test_end_tracking_within_default_limit_succeeds() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); }); - GasTracker::set_test_cost(&env, 1); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, 1); GasTracker::end_tracking(&env, symbol_short!("create"), 0); }); } #[test] -#[should_panic(expected = "Error(ContractError(417))")] +#[should_panic(expected = "Error(Contract, #417)")] fn test_end_tracking_exceeds_default_limit_panics() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); }); - GasTracker::set_test_cost(&env, super::gas::DEFAULT_CREATE_MARKET_GAS_LIMIT + 1); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, super::gas::DEFAULT_CREATE_MARKET_GAS_LIMIT + 1); GasTracker::end_tracking(&env, symbol_short!("create"), 0); }); } #[test] -#[should_panic(expected = "Error(ContractError(417))")] +#[should_panic(expected = "Error(Contract, #417)")] fn test_end_tracking_claim_exceeds_default_limit_panics() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); }); - GasTracker::set_test_cost(&env, super::gas::DEFAULT_CLAIM_WINNINGS_GAS_LIMIT + 1); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, super::gas::DEFAULT_CLAIM_WINNINGS_GAS_LIMIT + 1); GasTracker::end_tracking(&env, symbol_short!("claim"), 0); }); } #[test] fn test_end_tracking_at_exact_limit_succeeds() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); }); - GasTracker::set_test_cost(&env, super::gas::DEFAULT_CREATE_MARKET_GAS_LIMIT); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, super::gas::DEFAULT_CREATE_MARKET_GAS_LIMIT); GasTracker::end_tracking(&env, symbol_short!("create"), 0); }); } @@ -133,9 +139,9 @@ fn test_end_tracking_at_exact_limit_succeeds() { #[test] fn test_admin_override_takes_precedence() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); GasTracker::set_limit( @@ -156,9 +162,9 @@ fn test_admin_override_takes_precedence() { #[test] fn test_admin_override_can_tighten_limit() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); let tighter = super::gas::DEFAULT_CREATE_MARKET_GAS_LIMIT / 2; @@ -174,20 +180,18 @@ fn test_admin_override_can_tighten_limit() { } #[test] -#[should_panic(expected = "Error(ContractError(417))")] +#[should_panic(expected = "Error(Contract, #417)")] fn test_tighter_admin_limit_enforced() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); GasTracker::set_limit(&env, symbol_short!("create"), 100, 0); }); - GasTracker::set_test_cost(&env, 101); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, 101); GasTracker::end_tracking(&env, symbol_short!("create"), 0); }); } @@ -196,9 +200,9 @@ fn test_tighter_admin_limit_enforced() { #[test] fn test_has_limit_for_default_operations() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { assert!( GasTracker::has_limit(&env, symbol_short!("create")), "create should have a default limit" @@ -212,9 +216,9 @@ fn test_has_limit_for_default_operations() { #[test] fn test_has_limit_false_for_unknown_operation() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { assert!( !GasTracker::has_limit(&env, symbol_short!("vote")), "vote should not have a default limit" @@ -224,9 +228,9 @@ fn test_has_limit_false_for_unknown_operation() { #[test] fn test_effective_limit_none_for_unknown() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { let limit = GasTracker::get_effective_cpu_limit(&env, symbol_short!("vote")); assert_eq!(limit, None, "unknown op should have no effective limit"); }); @@ -234,9 +238,9 @@ fn test_effective_limit_none_for_unknown() { #[test] fn test_effective_limit_returns_default() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { let create_limit = GasTracker::get_effective_cpu_limit(&env, symbol_short!("create")); assert_eq!( @@ -257,9 +261,9 @@ fn test_effective_limit_returns_default() { #[test] fn test_record_with_alert_uses_default_limit() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); let threshold_91 = @@ -268,7 +272,7 @@ fn test_record_with_alert_uses_default_limit() { let events = env.events().all(); assert!( - !events.is_empty(), + !events.events().is_empty(), "low-water alert should fire at 91% of default limit" ); }); @@ -276,9 +280,9 @@ fn test_record_with_alert_uses_default_limit() { #[test] fn test_record_with_alert_no_alert_below_threshold() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); let threshold_89 = @@ -287,7 +291,7 @@ fn test_record_with_alert_no_alert_below_threshold() { let events = env.events().all(); assert!( - events.is_empty(), + events.events().is_empty(), "no alert should fire below 90% of default limit" ); }); @@ -295,15 +299,15 @@ fn test_record_with_alert_no_alert_below_threshold() { #[test] fn test_record_with_alert_zero_usage_no_alert() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); GasTracker::record_with_alert(&env, symbol_short!("create"), 0); let events = env.events().all(); - assert!(events.is_empty(), "no alert for zero usage"); + assert!(events.events().is_empty(), "no alert for zero usage"); }); } @@ -311,16 +315,14 @@ fn test_record_with_alert_zero_usage_no_alert() { #[test] fn test_zero_cost_always_succeeds() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); }); - GasTracker::set_test_cost(&env, 0); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, 0); GasTracker::end_tracking(&env, symbol_short!("create"), 0); GasTracker::end_tracking(&env, symbol_short!("claim"), 0); }); @@ -328,28 +330,24 @@ fn test_zero_cost_always_succeeds() { #[test] fn test_untracked_operation_no_panic() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - GasTracker::set_test_cost(&env, u64::MAX); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, u64::MAX); GasTracker::end_tracking(&env, symbol_short!("vote"), 0); }); } #[test] fn test_sequential_end_tracking_calls() { - let env = Env::default(); + let (env, contract_id) = seeded_env(); - env.as_contract(&Address::generate(&env), || { + env.as_contract(&contract_id, || { GasTracker::set_default_limits(&env); }); - GasTracker::set_test_cost(&env, 100); - - let contract_addr = Address::generate(&env); - env.as_contract(&contract_addr, || { + env.as_contract(&contract_id, || { + GasTracker::set_test_cost(&env, 100); for _ in 0..5 { GasTracker::end_tracking(&env, symbol_short!("create"), 0); } diff --git a/contracts/predictify-hybrid/src/graceful_degradation.rs b/contracts/predictify-hybrid/src/graceful_degradation.rs index 089d9468..ae7f4c60 100644 --- a/contracts/predictify-hybrid/src/graceful_degradation.rs +++ b/contracts/predictify-hybrid/src/graceful_degradation.rs @@ -322,7 +322,7 @@ mod tests { use super::*; use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::Events; - use soroban_sdk::Env; + use soroban_sdk::{Env, Symbol, TryIntoVal}; // ------------------------------------------------------------------ // Legacy / existing behaviour tests (adapted for hysteresis) @@ -536,13 +536,20 @@ mod tests { // (no transition happened). The oracle_degradation event is still // emitted by is_working, so we don't check total event count. let events = env.events().all(); - let health_events: soroban_sdk::Vec<_> = events + let health_events = events .events() .iter() - .filter(|e| e.0 == (soroban_sdk::symbol_short!("orc_hlth"),)) - .collect(); - assert!( - health_events.is_empty(), + .filter(|e| { + matches!(&e.body, soroban_sdk::xdr::ContractEventBody::V0(v0) if { + v0.topics + .get(0) + .and_then(|t| t.clone().try_into_val(&env).ok()) + == Some(soroban_sdk::symbol_short!("orc_hlth")) + }) + }) + .count(); + assert_eq!( + health_events, 0, "No OracleHealthStatusEvent should be emitted before transition" ); @@ -553,13 +560,20 @@ mod tests { }); let events = env.events().all(); - let health_events: soroban_sdk::Vec<_> = events + let health_events = events .events() .iter() - .filter(|e| e.0 == (soroban_sdk::symbol_short!("orc_hlth"),)) - .collect(); + .filter(|e| { + matches!(&e.body, soroban_sdk::xdr::ContractEventBody::V0(v0) if { + v0.topics + .get(0) + .and_then(|t| t.clone().try_into_val(&env).ok()) + == Some(soroban_sdk::symbol_short!("orc_hlth")) + }) + }) + .count(); assert!( - health_events.len() >= 1, + health_events >= 1, "OracleHealthStatusEvent should be emitted on transition to Degraded" ); } diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index b92ee94e..7e441ad3 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -25,12 +25,12 @@ mod batch_operations; mod bets; pub mod circuit_breaker; mod config; -mod err; +pub mod err; mod force_resolve; mod event_archive; mod restore_archive; mod lifecycle_validation; -mod events; +pub mod events; pub mod gov_registry; mod fees; mod gas; @@ -54,12 +54,12 @@ mod resolution_event_ordering_tests; #[path = "tests/oracle_validation_tests.rs"] mod oracle_validation_tests; mod resolution; -mod storage; +pub mod storage; mod deprecated; pub use deprecated::{DeprecatedEntry, DeprecatedRegistry, MAX_REGISTRY_ENTRIES}; #[cfg(test)] mod deprecated_tests; -mod types; +pub mod types; mod upgrade_manager; mod utils; mod validation; @@ -113,7 +113,6 @@ use bets::BetStorage; use gas::BudgetGuard; use resolution::ResolutionOutcomeCache; use storage::BalanceStorage; -use types::{Market, ReflectorAsset}; // `CircuitBreaker`, `Error`, `EventEmitter`, `ClaimInfo` and the soroban_sdk // prelude items are imported/re-exported once below; duplicating them here // tripped E0252 "defined multiple times". diff --git a/contracts/predictify-hybrid/src/market_audit_tests.rs b/contracts/predictify-hybrid/src/market_audit_tests.rs index c128abf1..1e744b98 100644 --- a/contracts/predictify-hybrid/src/market_audit_tests.rs +++ b/contracts/predictify-hybrid/src/market_audit_tests.rs @@ -106,6 +106,8 @@ impl AuditTestEnv { &None, &None, &None, + &None, + &None, ) } @@ -395,7 +397,7 @@ fn force_resolve_appends_audit_entry() { &outcomes, &String::from_str(&t.env, "Emergency override"), &String::from_str(&t.env, "idem-key-001"), - ).unwrap(); + ); let head = t.client().get_market_audit_head(&market_id).unwrap(); // MarketCreated + MarketForceResolved diff --git a/contracts/predictify-hybrid/src/market_state_matrix_tests.rs b/contracts/predictify-hybrid/src/market_state_matrix_tests.rs index b4d6ed6e..43b803be 100644 --- a/contracts/predictify-hybrid/src/market_state_matrix_tests.rs +++ b/contracts/predictify-hybrid/src/market_state_matrix_tests.rs @@ -71,6 +71,8 @@ mod market_state_matrix { Resolved => matches!(to, Closed), Closed => false, Cancelled => false, + Archived => false, + Restored => false, } } diff --git a/contracts/predictify-hybrid/src/max_participants_tests.rs b/contracts/predictify-hybrid/src/max_participants_tests.rs index 6eadc755..769a3c19 100644 --- a/contracts/predictify-hybrid/src/max_participants_tests.rs +++ b/contracts/predictify-hybrid/src/max_participants_tests.rs @@ -48,7 +48,7 @@ impl Setup { }); let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&admin, &None); + client.initialize(&admin, &None, &None); Self { env, contract_id, admin, token_id } } @@ -99,7 +99,10 @@ impl Setup { user: &Address, outcome: &str, stake: i128, - ) -> Result, Result> { + ) -> Result< + Result<(), soroban_sdk::ConversionError>, + Result, + > { let client = PredictifyHybridClient::new(&self.env, &self.contract_id); client.try_vote(user, market_id, &String::from_str(&self.env, outcome), &stake) } @@ -149,7 +152,12 @@ fn test_cap_exceeded_returns_error() { // Third voter should be rejected let result = s.vote(&market_id, &user3, "Yes", 1_000_000); - assert_eq!(result, Err(Ok(Error::MaxParticipantsReached))); + assert_eq!( + result, + Err(Ok(soroban_sdk::Error::from_contract_error( + Error::MaxParticipantsReached as u32 + ))) + ); } /// Exactly hitting the cap boundary is allowed. @@ -182,7 +190,12 @@ fn test_beyond_cap_after_exact_hit_rejected() { // Exactly at cap; 3rd voter rejected let result = s.vote(&market_id, &user3, "Yes", 1_000_000); - assert_eq!(result, Err(Ok(Error::MaxParticipantsReached))); + assert_eq!( + result, + Err(Ok(soroban_sdk::Error::from_contract_error( + Error::MaxParticipantsReached as u32 + ))) + ); } /// Admin can increase the cap after creation, allowing new participants. @@ -201,7 +214,9 @@ fn test_admin_can_increase_max_participants() { // Second vote should be rejected assert_eq!( s.vote(&market_id, &user2, "No", 1_000_000), - Err(Ok(Error::MaxParticipantsReached)) + Err(Ok(soroban_sdk::Error::from_contract_error( + Error::MaxParticipantsReached as u32 + ))) ); // Admin increases cap to 2 @@ -259,5 +274,10 @@ fn test_zero_cap_rejects_all() { let user = s.funded_user(); let result = s.vote(&market_id, &user, "Yes", 1_000_000); - assert_eq!(result, Err(Ok(Error::MaxParticipantsReached))); + assert_eq!( + result, + Err(Ok(soroban_sdk::Error::from_contract_error( + Error::MaxParticipantsReached as u32 + ))) + ); } diff --git a/contracts/predictify-hybrid/src/oracle_health.rs b/contracts/predictify-hybrid/src/oracle_health.rs index ca7805a5..e4f7d859 100644 --- a/contracts/predictify-hybrid/src/oracle_health.rs +++ b/contracts/predictify-hybrid/src/oracle_health.rs @@ -827,7 +827,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); assert_eq!(health.oracle_address, oracle_addr); assert_eq!(health.state, OracleHealthState::Healthy); @@ -856,7 +856,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Record a successful check with 100ms latency let state = health.record_check(&env, true, 100, Some(80)).unwrap(); @@ -888,7 +888,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Record a failed check let state = health.record_check(&env, false, 0, None).unwrap(); @@ -918,7 +918,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Record failures to drop health score below 70 for _ in 0..3 { @@ -950,7 +950,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Drop to Degraded for _ in 0..3 { @@ -993,7 +993,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Drop to Degraded first for _ in 0..3 { @@ -1031,7 +1031,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Drop to Offline for _ in 0..6 { @@ -1075,7 +1075,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Update staleness to exceed threshold (300 seconds) let state = health.update_staleness(&env, 350).unwrap(); @@ -1105,7 +1105,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Record checks with high latency (above 5000ms threshold) for _ in 0..5 { @@ -1137,7 +1137,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Record checks with low confidence (below 50%) for _ in 0..5 { @@ -1168,7 +1168,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Record max_consecutive_failures (5) failures for _ in 0..5 { @@ -1200,7 +1200,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); assert_eq!(health.state_transition_count, 0); @@ -1250,7 +1250,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let _ = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let _ = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Should fail with unauthorized caller let result = OracleHealth::force_state_change(&env, &oracle_addr, &unauthorized, OracleHealthState::Offline); @@ -1277,7 +1277,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); assert_eq!(health.state, OracleHealthState::Healthy); // Admin forces offline @@ -1308,7 +1308,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Extreme latency health.record_check(&env, true, u64::MAX, Some(100)).unwrap(); @@ -1352,7 +1352,7 @@ mod oracle_health_integration_tests { config.offline_to_degraded_threshold = 60; config.validate().unwrap(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // With tighter thresholds, should degrade faster health.record_check(&env, false, 0, None).unwrap(); @@ -1427,7 +1427,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Get state via manager let state = OracleHealthManager::get_state(&env, &oracle_addr).unwrap(); @@ -1462,7 +1462,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Healthy is usable assert!(OracleHealthManager::is_usable(&env, &oracle_addr).unwrap()); @@ -1494,7 +1494,7 @@ mod oracle_health_integration_tests { let oracle_addr = Address::generate(&env); let config = OracleHealthConfig::default(); - let mut health = OracleHealth::new_with_config(&env, &oracle_addr, &admin, &config).unwrap(); + let mut health = OracleHealth::new_with_config(oracle_addr.clone(), config.clone(), &env).unwrap(); // Initial score should be 100 let score = OracleHealthManager::get_health_score(&env, &oracle_addr).unwrap(); diff --git a/contracts/predictify-hybrid/src/oracles.rs b/contracts/predictify-hybrid/src/oracles.rs index 996f48bc..ec62e77d 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -4165,7 +4165,7 @@ mod oracle_integration_tests { let default_fee_pct: u32 = 200; // 2% env.mock_all_auths(); - client.initialize(&admin, &default_fee_pct, &None); + client.initialize(&admin, &Some(default_fee_pct as i128), &None); let unauthorized = client.try_set_oracle_val_cfg_global(&non_admin, &60, &500, &None); assert!(unauthorized.is_err()); diff --git a/contracts/predictify-hybrid/src/override_audit_tests.rs b/contracts/predictify-hybrid/src/override_audit_tests.rs index 16ee74a3..8bbd9436 100644 --- a/contracts/predictify-hybrid/src/override_audit_tests.rs +++ b/contracts/predictify-hybrid/src/override_audit_tests.rs @@ -53,6 +53,8 @@ impl Ctx { &None, &None, &None, + &None, + &None, ) } } @@ -193,6 +195,8 @@ fn test_override_rejects_non_admin() { &None, &None, &None, + &None, + &None, ); // Now attempt override as a stranger — no auths mocked for this address @@ -259,6 +263,8 @@ fn test_override_no_partial_state_on_auth_failure() { &None, &None, &None, + &None, + &None, ); let before = client.get_market(&market_id).unwrap(); diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index aee6f30e..51391fbd 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -1444,6 +1444,8 @@ mod tests { dispute_window_seconds: 86400, winnings_swept: false, timelock_config: crate::timelock::MarketTimelockConfig::default(), + dispute_stake_floor: None, + max_participants: None, }; env.storage().persistent().set(&market_id, &market); }); diff --git a/contracts/predictify-hybrid/src/reentrancy_guard.rs b/contracts/predictify-hybrid/src/reentrancy_guard.rs index 9ead110f..c4fc3f8f 100644 --- a/contracts/predictify-hybrid/src/reentrancy_guard.rs +++ b/contracts/predictify-hybrid/src/reentrancy_guard.rs @@ -619,6 +619,8 @@ mod tests { &None, &None, &None, + &None, + &None, ); let bet = client.place_bet( diff --git a/contracts/predictify-hybrid/src/test_audit_trail.rs b/contracts/predictify-hybrid/src/test_audit_trail.rs index baf31883..867f1daa 100644 --- a/contracts/predictify-hybrid/src/test_audit_trail.rs +++ b/contracts/predictify-hybrid/src/test_audit_trail.rs @@ -302,13 +302,15 @@ fn test_public_queries() { } }); - let record1 = client.get_audit_record(&1).unwrap(); - assert_eq!(record1.index, 1); + env.as_contract(&contract_id, || { + let record1 = AuditTrailManager::get_record(&env, 1).unwrap(); + assert_eq!(record1.index, 1); - let latest = client.get_latest_audit_records(&2); - assert_eq!(latest.len(), 2); - assert_eq!(latest.get(0).unwrap().index, 3); - assert_eq!(latest.get(1).unwrap().index, 2); + let latest = AuditTrailManager::get_latest_records(&env, 2); + assert_eq!(latest.len(), 2); + assert_eq!(latest.get(0).unwrap().index, 3); + assert_eq!(latest.get(1).unwrap().index, 2); - assert!(client.verify_audit_integrity(&5)); + assert!(AuditTrailManager::verify_integrity(&env, 5)); + }); } diff --git a/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs b/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs index 13c28825..6df21bcc 100644 --- a/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs +++ b/contracts/predictify-hybrid/src/tests/oracle_validation_tests.rs @@ -9,7 +9,7 @@ use crate::markets::{MarketPauseManager, MarketStateManager}; use crate::oracles::OracleValidationConfigManager; use crate::types::MarketPauseInfo; use soroban_sdk::{Env, String, Address, Symbol, Vec, Map, IntoVal, vec}; -use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::{Address as _, Ledger}; #[test] fn test_oracle_provider_validation() { @@ -166,6 +166,9 @@ fn setup_auto_pause_env(env: &Env, contract_id: &Address) -> Symbol { bet_deadline: 0, dispute_window_seconds: 86400, winnings_swept: false, + timelock_config: crate::timelock::MarketTimelockConfig::default(), + dispute_stake_floor: None, + max_participants: None, }; env.storage().persistent().set(&market_id, &market); }); diff --git a/contracts/predictify-hybrid/src/tie_resolution_tests.rs b/contracts/predictify-hybrid/src/tie_resolution_tests.rs index d904fcc7..612ddefa 100644 --- a/contracts/predictify-hybrid/src/tie_resolution_tests.rs +++ b/contracts/predictify-hybrid/src/tie_resolution_tests.rs @@ -119,6 +119,8 @@ impl TieSetup { &None, // dispute_window_seconds = 0 so claims are unblocked immediately &Some(0u64), + &None, + &None, ) } diff --git a/contracts/predictify-hybrid/src/timelock_tests.rs b/contracts/predictify-hybrid/src/timelock_tests.rs index fa6903ff..83a27613 100644 --- a/contracts/predictify-hybrid/src/timelock_tests.rs +++ b/contracts/predictify-hybrid/src/timelock_tests.rs @@ -1,7 +1,8 @@ #![cfg(test)] use crate::err::Error; -use crate::types::{OracleConfig, OracleProvider}; +use crate::timelock::MarketTimelockManager; +use crate::types::{Market, OracleConfig, OracleProvider}; use crate::{PredictifyHybrid, PredictifyHybridClient}; use soroban_sdk::{ testutils::{Address as _, Ledger, LedgerInfo}, @@ -62,6 +63,8 @@ impl TestContext { &None, &None, &None, + &None, + &None, ) } } @@ -71,45 +74,75 @@ fn test_market_timelock_blocks_admin_action_until_delay_passes() { let ctx = TestContext::new(); let market_id = ctx.create_market(); - assert!(ctx.client().set_market_timelock(&ctx.admin, &market_id, &10u64).is_ok()); - - let early_result = ctx.client().try_set_market_claim_period(&ctx.admin, &market_id, &60u64); - assert_eq!(early_result, Err(Ok(Error::AdminActionTimelocked))); + ctx.env.as_contract(&ctx.contract_id, || { + let mut market: Market = ctx + .env + .storage() + .persistent() + .get(&market_id) + .expect("market should be stored"); + + // Configure a 10-second timelock on the market. + MarketTimelockManager::configure(&ctx.env, &mut market, &ctx.admin, &ctx.admin, 10) + .expect("admin should be able to configure the timelock"); + + // An admin action is rejected while the delay has not elapsed. + let early = MarketTimelockManager::ensure_admin_action_allowed( + &ctx.env, + &mut market, + &ctx.admin, + &ctx.admin, + ); + assert_eq!(early, Err(Error::AdminActionTimelocked)); + + ctx.env.ledger().with_mut(|li| { + li.timestamp = li.timestamp.saturating_add(11); + }); - ctx.env.ledger().with_mut(|li| { - li.timestamp = li.timestamp.saturating_add(11); + // After the delay, the admin action is allowed and the clock refreshes. + let later = MarketTimelockManager::ensure_admin_action_allowed( + &ctx.env, + &mut market, + &ctx.admin, + &ctx.admin, + ); + assert_eq!(later, Ok(())); }); - - let later_result = ctx.client().try_set_market_claim_period(&ctx.admin, &market_id, &60u64); - assert_eq!(later_result, Ok(())); } #[test] -fn test_force_resolve_market_timelocked() { +fn test_market_timelock_rejects_unauthorized_configuration() { let ctx = TestContext::new(); let market_id = ctx.create_market(); - - assert!(ctx.client().set_market_timelock(&ctx.admin, &market_id, &10u64).is_ok()); - - let early_result = ctx.client().try_force_resolve_market( - &ctx.admin, - &market_id, - &vec![&ctx.env, String::from_str(&ctx.env, "yes")], - &String::from_str(&ctx.env, "reason"), - &String::from_str(&ctx.env, "key1"), - ); - assert_eq!(early_result, Err(Ok(Error::AdminActionTimelocked))); - - ctx.env.ledger().with_mut(|li| { - li.timestamp = li.timestamp.saturating_add(11); + let stranger = Address::generate(&ctx.env); + + ctx.env.as_contract(&ctx.contract_id, || { + let mut market: Market = ctx + .env + .storage() + .persistent() + .get(&market_id) + .expect("market should be stored"); + + // Neither the market admin nor the contract admin: rejected. + let result = MarketTimelockManager::configure( + &ctx.env, + &mut market, + &stranger, + &ctx.admin, + 10, + ); + assert_eq!(result, Err(Error::Unauthorized)); + + // A zero-delay configuration never blocks admin actions. + MarketTimelockManager::configure(&ctx.env, &mut market, &ctx.admin, &ctx.admin, 0) + .expect("admin should be able to configure the timelock"); + let allowed = MarketTimelockManager::ensure_admin_action_allowed( + &ctx.env, + &mut market, + &ctx.admin, + &ctx.admin, + ); + assert_eq!(allowed, Ok(())); }); - - let later_result = ctx.client().try_force_resolve_market( - &ctx.admin, - &market_id, - &vec![&ctx.env, String::from_str(&ctx.env, "yes")], - &String::from_str(&ctx.env, "reason"), - &String::from_str(&ctx.env, "key1"), - ); - assert_eq!(later_result, Ok(())); } diff --git a/contracts/predictify-hybrid/tests/auth_snapshot.rs b/contracts/predictify-hybrid/tests/auth_snapshot.rs index 72991f86..7c3d662d 100644 --- a/contracts/predictify-hybrid/tests/auth_snapshot.rs +++ b/contracts/predictify-hybrid/tests/auth_snapshot.rs @@ -144,6 +144,8 @@ impl Fixture { &None, &None, &None, + &None, + &None, ) } @@ -380,7 +382,7 @@ fn snapshot_claim_winnings_requires_user_auth() { .resolve_market_manual(&f.admin, &market_id, &f.yes()); f.advance_past_dispute(); - let result = f.client().try_claim_winnings(&user, &market_id); + let result = f.client().try_claim_winnings(&user, &market_id, &0u64); assert_auth_passed(&result, "claim_winnings"); } @@ -391,7 +393,7 @@ fn edge_claim_winnings_without_auth_panics() { let market_id = f.market(); let user = f.user(); f.env.set_auths(&[]); - f.client().claim_winnings(&user, &market_id); + f.client().claim_winnings(&user, &market_id, &0u64); } /// `dispute_market` is rejected by its market-state validator in this fixture, diff --git a/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs b/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs index 721274d1..d8129c2b 100644 --- a/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs +++ b/contracts/predictify-hybrid/tests/auth_snapshot_disputes.rs @@ -83,6 +83,8 @@ impl Fixture { &None, &None, &None, + &None, + &None, ) } diff --git a/contracts/predictify-hybrid/tests/claim_replay_protection.rs b/contracts/predictify-hybrid/tests/claim_replay_protection.rs index 7aa77af4..adf7150e 100644 --- a/contracts/predictify-hybrid/tests/claim_replay_protection.rs +++ b/contracts/predictify-hybrid/tests/claim_replay_protection.rs @@ -13,27 +13,43 @@ //! - **State Persistence**: Nonce stored in persistent storage and ClaimInfo //! - **Zero Payout Claims**: Nonce increments even when payout is 0 +use soroban_sdk::xdr::ToXdr; use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; use predictify_hybrid::storage::{ClaimNonceManager, DataKey}; +use predictify_hybrid::PredictifyHybrid; + +/// Creates an environment with a registered contract instance so persistent +/// storage access works inside `as_contract`. Returns `(env, contract_id)`. +fn contract_env() -> (Env, Address) { + let env = Env::default(); + let contract_id = env.register(PredictifyHybrid, ()); + (env, contract_id) +} + +fn sym(env: &Env, name: &str) -> Symbol { + Symbol::new(env, name) +} // ===== NONCE MANAGER UNIT TESTS ===== #[test] fn test_get_nonce_returns_zero_initially() { - let env = Env::default(); - let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { + let user = Address::generate(&env); + let market_id = sym(&env, "m1"); - let nonce = ClaimNonceManager::get_nonce(&env, &user, &market_id); - assert_eq!(nonce, 0, "Initial nonce should be 0 for never-claimed user"); + let nonce = ClaimNonceManager::get_nonce(&env, &user, &market_id); + assert_eq!(nonce, 0, "Initial nonce should be 0 for never-claimed user"); + }); } #[test] fn test_increment_nonce_returns_next_value() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // First increment let nonce1 = ClaimNonceManager::increment_nonce(&env, &user, &market_id); @@ -55,10 +71,10 @@ fn test_increment_nonce_returns_next_value() { #[test] fn test_validate_nonce_succeeds_when_matching() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // Get initial nonce (0) let current = ClaimNonceManager::get_nonce(&env, &user, &market_id); @@ -77,10 +93,10 @@ fn test_validate_nonce_succeeds_when_matching() { #[test] fn test_validate_nonce_fails_for_old_nonce() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // Increment to 1 ClaimNonceManager::increment_nonce(&env, &user, &market_id); @@ -99,11 +115,11 @@ fn test_validate_nonce_fails_for_old_nonce() { #[test] fn test_nonce_independence_per_user() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user1 = Address::generate(&env); let user2 = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // User1 increments nonce to 1 let nonce1 = ClaimNonceManager::increment_nonce(&env, &user1, &market_id); @@ -125,11 +141,11 @@ fn test_nonce_independence_per_user() { #[test] fn test_nonce_independence_per_market() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market1 = Symbol::from_str(&env, "m1"); - let market2 = Symbol::from_str(&env, "m2"); + let market1 = sym(&env, "m1"); + let market2 = sym(&env, "m2"); // Increment on market1 let nonce_m1 = ClaimNonceManager::increment_nonce(&env, &user, &market1); @@ -151,10 +167,10 @@ fn test_nonce_independence_per_market() { #[test] fn test_nonce_persists_across_calls() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // Increment multiple times for i in 1..=5 { @@ -178,8 +194,8 @@ fn test_storage_key_uniqueness() { let user1 = Address::generate(&env); let user2 = Address::generate(&env); - let market1 = Symbol::from_str(&env, "m1"); - let market2 = Symbol::from_str(&env, "m2"); + let market1 = sym(&env, "m1"); + let market2 = sym(&env, "m2"); // Generate keys - they should all be different let key1 = DataKey::ClaimNonce(user1.clone(), market1.clone()); @@ -200,10 +216,10 @@ fn test_storage_key_uniqueness() { #[test] fn test_claim_lifecycle_with_nonce() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // Initial state: nonce is 0 let nonce_before = ClaimNonceManager::get_nonce(&env, &user, &market_id); @@ -236,10 +252,10 @@ fn test_claim_lifecycle_with_nonce() { #[test] fn test_replay_attack_simulation() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // Original claim: nonce 0 -> 1 assert!(ClaimNonceManager::validate_nonce(&env, &user, &market_id, 0).is_ok()); @@ -259,10 +275,10 @@ fn test_replay_attack_simulation() { #[test] fn test_nonce_monotonic_sequence() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // Verify strict monotonic increase let mut prev = 0u64; @@ -276,10 +292,10 @@ fn test_nonce_monotonic_sequence() { #[test] fn test_zero_nonce_is_valid_on_first_claim() { - let env = Env::default(); - env.as_contract(&Address::generate(&env), || { + let (env, contract_id) = contract_env(); + env.as_contract(&contract_id, || { let user = Address::generate(&env); - let market_id = Symbol::from_str(&env, "m1"); + let market_id = sym(&env, "m1"); // Zero nonce should validate initially let result = ClaimNonceManager::validate_nonce(&env, &user, &market_id, 0); diff --git a/contracts/predictify-hybrid/tests/conservation.rs b/contracts/predictify-hybrid/tests/conservation.rs index f5c4eb2f..990fe287 100644 --- a/contracts/predictify-hybrid/tests/conservation.rs +++ b/contracts/predictify-hybrid/tests/conservation.rs @@ -1,8 +1,8 @@ use predictify_hybrid::types::{OracleConfig, OracleProvider}; use predictify_hybrid::{PredictifyHybrid, PredictifyHybridClient}; -use soroban_sdk::testutils::Address as _; -use soroban_sdk::token::StellanAssetClient; -use soroban_sdk::{address, Env, String, Symbol}; +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; +use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{Address, Env, String, Symbol}; const ORACLE_ADDRESS: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; const INITIAL_BALANCE: i128 = 1_000; @@ -19,16 +19,16 @@ fn setup() -> (Env, Address, Address, Address, Address) { let token_contract = env.register_stellar_asset_contract_v2(token_admin); let token_id = token_contract.address(); - env.as_contract(&contract_id, || + env.as_contract(&contract_id, || { env.storage() .persistent() .set(&Symbol::new(&env, "TokenID"), &token_id); }); let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&admin, &None, &None); + client.initialize(&admin, &Some(0i128), &None); - StellanAssetClient::new(&env, &token_id).mint(&user, &INITIAL_BALANCE); + StellarAssetClient::new(&env, &token_id).mint(&user, &INITIAL_BALANCE); (env, contract_id, admin, user, token_id) } @@ -44,7 +44,7 @@ fn oracle_config(env: &Env, feed_id: &str) -> OracleConfig { } fn create_market( - client: &PredictifyHrbridClient<_>, + client: &PredictifyHybridClient, env: &Env, admin: &Address, question: &str, @@ -53,7 +53,7 @@ fn create_market( client.create_market( admin, &String::from_str(env, question), - &soroban_sdk::vec[ + &soroban_sdk::vec![ env, String::from_str(env, "yes"), String::from_str(env, "no"), @@ -62,13 +62,33 @@ fn create_market( &oracle_config(env, feed_id), &None, &86_400u64, + &None, + &None, + &None, + &None, + &None, ) } +fn advance_ledger(env: &Env, seconds: u64) { + let ledger = env.ledger(); + let timestamp = ledger.timestamp() + seconds; + env.ledger().set(LedgerInfo { + timestamp, + protocol_version: ledger.protocol_version(), + sequence_number: ledger.sequence(), + network_id: ledger.network_id().into(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 1_000_000, + }); +} + #[test] fn stake_is_conserved_independently_per_market() { let (env, contract_id, admin, user, _token_id) = setup(); - let client = PredictifyHrbridClient::new(&env, &contract_id); + let client = PredictifyHybridClient::new(&env, &contract_id); let first_market = create_market( &client, @@ -85,7 +105,13 @@ fn stake_is_conserved_independently_per_market() { "ETH", ); - client.place_bet(&user, &first_market, &0u32, &100i128); + client.place_bet( + &user, + &first_market, + &String::from_str(&env, "yes"), + &100i128, + &1000, + ); let first_after_first_bet = client .get_market(&first_market) .expect("first market should exist") @@ -95,10 +121,16 @@ fn stake_is_conserved_independently_per_market() { .expect("second market should exist") .total_staked; - assert_eq(first_after_first_bet, 100); - assert_eq(second_after_first_bet, 0); + assert_eq!(first_after_first_bet, 100); + assert_eq!(second_after_first_bet, 0); - client.place_bet(&user, &second_market, &1u32, &250i128); + client.place_bet( + &user, + &second_market, + &String::from_str(&env, "no"), + &250i128, + &1000, + ); let first_after_second_bet = client .get_market(&first_market) .expect("first market should exist") @@ -108,9 +140,9 @@ fn stake_is_conserved_independently_per_market() { .expect("second market should exist") .total_staked; - assert_eq(first_after_second_bet, 100); - assert_eq(second_after_second_bet, 250); - assert_eq( + assert_eq!(first_after_second_bet, 100); + assert_eq!(second_after_second_bet, 250); + assert_eq!( first_after_second_bet + second_after_second_bet, 350, "the aggregate stake must equal the sum of stakes assigned to both markets" @@ -120,12 +152,12 @@ fn stake_is_conserved_independently_per_market() { #[test] fn payout_remainder_is_conserved() { let (env, contract_id, admin, user, token_id) = setup(); - let client = PredictifyHrbridClient::new(&env, &contract_id); + let client = PredictifyHybridClient::new(&env, &contract_id); let user2 = Address::generate(&env); let user3 = Address::generate(&env); - StellanAssetClient::new(&env, &token_id).mint(&user2, &INITIAL_BALANCE); - StellanAssetClient::new(&env, &token_id).mint(&user3, &INITIAL_BALANCE); + StellarAssetClient::new(&env, &token_id).mint(&user2, &INITIAL_BALANCE); + StellarAssetClient::new(&env, &token_id).mint(&user3, &INITIAL_BALANCE); let market = create_market( &client, @@ -141,42 +173,62 @@ fn payout_remainder_is_conserved() { // user = 1 * 100 / 3 = 33 // user2 = 2 * 100 / 3 = 66 // Remainder = 1, which must be allocated to the admin. - client.place_bet(&user, &market, &0u32, &1i128); - client.place_bet(&user2, &market, &0u32, &2i128); - client.place_bet(&user3, &market, &1u32, &97i128); + client.place_bet( + &user, + &market, + &String::from_str(&env, "yes"), + &1i128, + &1000, + ); + client.place_bet( + &user2, + &market, + &String::from_str(&env, "yes"), + &2i128, + &1000, + ); + client.place_bet( + &user3, + &market, + &String::from_str(&env, "no"), + &97i128, + &1000, + ); let market_data = client.get_market(&market).expect("market should exist"); - assert_eq(market_data.total_staked, 100); - - let user_balance_before = StellanAssetClient::new(&env, &token_id).balance(&user); - let user2_balance_before = StellanAssetClient::new(&env, &token_id).balance(&user2); - let admin_balance_before = StellanAssetClient::new(&env, &token_id).balance(&admin); - let contract_balance_before = StellanAssetClient::new(&env, &token_id).balance(&contract_id); - assert_eq(contract_balance_before, 100); - - env.jump(30 * 24 * 60 * 60); - client.resolve_market(&admin, &market, &0u32); - - client.claim_payout(&user, &market); - client.claim_payout(&user2, &market); - - let user_claimed = StellanAssetClient::new(&env, &token_id).balance(&user) - user_balance_before; - let user2_claimed = StellanAssetClient::new(&env, &token_id).balance(&user2) - user2_balance_before; - let admin_claimed = StellanAssetClient::new(&env, &token_id).balance(&admin) - admin_balance_before; - let contract_balance_after = StellanAssetClient::new(&env, &token_id).balance(&contract_id); - - assert_eq(user_claimed, 33, "first winner receives the floored share"); - assert_eq(user2_claimed, 66, "second winner receives the floored share"); - assert_eq( + assert_eq!(market_data.total_staked, 100); + + let user_balance_before = StellarAssetClient::new(&env, &token_id).balance(&user); + let user2_balance_before = StellarAssetClient::new(&env, &token_id).balance(&user2); + let admin_balance_before = StellarAssetClient::new(&env, &token_id).balance(&admin); + let contract_balance_before = StellarAssetClient::new(&env, &token_id).balance(&contract_id); + assert_eq!(contract_balance_before, 100); + + advance_ledger(&env, 31 * 24 * 60 * 60); + client.resolve_market_manual(&admin, &market, &String::from_str(&env, "yes")); + + client.claim_winnings(&user, &market, &0u64); + client.claim_winnings(&user2, &market, &0u64); + + let user_claimed = StellarAssetClient::new(&env, &token_id).balance(&user) - user_balance_before; + let user2_claimed = + StellarAssetClient::new(&env, &token_id).balance(&user2) - user2_balance_before; + let admin_claimed = + StellarAssetClient::new(&env, &token_id).balance(&admin) - admin_balance_before; + let contract_balance_after = StellarAssetClient::new(&env, &token_id).balance(&contract_id); + + assert_eq!(user_claimed, 33, "first winner receives the floored share"); + assert_eq!(user2_claimed, 66, "second winner receives the floored share"); + assert_eq!( user_claimed + user2_claimed, 99, "payouts sum to the distributable amount" ); - assert_eq( + assert_eq!( admin_claimed, 1, "the one-wei remainder is allocated to the admin instead of being locked" ); - assert_eq( + assert_eq!( contract_balance_after, 0, "no funds remain stranded in the contract" ); diff --git a/contracts/predictify-hybrid/tests/event_replay_nonce.rs b/contracts/predictify-hybrid/tests/event_replay_nonce.rs index 202e4a85..701b8328 100644 --- a/contracts/predictify-hybrid/tests/event_replay_nonce.rs +++ b/contracts/predictify-hybrid/tests/event_replay_nonce.rs @@ -1,64 +1,79 @@ #![cfg(test)] -use soroban_sdk::{testutils::Events, Env, Symbol, String, vec, symbol_short, Address}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + vec, symbol_short, Address, Env, String, Symbol, +}; use predictify_hybrid::events::{EventEmitter, MarketCreatedEvent}; use predictify_hybrid::storage::DataKey; +use predictify_hybrid::PredictifyHybrid; -#[test] -fn test_event_replay_nonce_monotonic() { +fn contract_env() -> (Env, Address) { let env = Env::default(); env.mock_all_auths(); - - let admin = Address::generate(&env); - let market_id = symbol_short!("m1"); - let question = String::from_str(&env, "Q1?"); - let outcomes = vec![&env, String::from_str(&env, "Yes"), String::from_str(&env, "No")]; - - // First emission - EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); - - let events = env.events().all(); - assert_eq!(events.len(), 1); - - let key = DataKey::EventNonce(symbol_short!("mkt_crt")); - let nonce: u64 = env.storage().persistent().get(&key).unwrap(); - assert_eq!(nonce, 1); - - // Second emission - EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); - - let events = env.events().all(); - assert_eq!(events.len(), 2); - - let nonce2: u64 = env.storage().persistent().get(&key).unwrap(); - assert_eq!(nonce2, 2); + let contract_id = env.register(PredictifyHybrid, ()); + (env, contract_id) +} + +#[test] +fn test_event_replay_nonce_monotonic() { + let (env, contract_id) = contract_env(); + + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let market_id = symbol_short!("m1"); + let question = String::from_str(&env, "Q1?"); + let outcomes = + vec![&env, String::from_str(&env, "Yes"), String::from_str(&env, "No")]; + + // First emission + EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); + + let events = env.events().all(); + assert_eq!(events.events().len(), 1); + + let key = DataKey::EventNonce(symbol_short!("mkt_crt")); + let nonce: u64 = env.storage().persistent().get(&key).unwrap(); + assert_eq!(nonce, 1); + + // Second emission + EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); + + let events = env.events().all(); + assert_eq!(events.events().len(), 2); + + let nonce2: u64 = env.storage().persistent().get(&key).unwrap(); + assert_eq!(nonce2, 2); + }); } #[test] fn test_topic_isolation() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let market_id = symbol_short!("m1"); - let question = String::from_str(&env, "Q1?"); - let outcomes = vec![&env, String::from_str(&env, "Yes"), String::from_str(&env, "No")]; - - // Emit event A - EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); - - let key1 = DataKey::EventNonce(symbol_short!("mkt_crt")); - let nonce1: u64 = env.storage().persistent().get(&key1).unwrap(); - assert_eq!(nonce1, 1); - - // Emit event B - EventEmitter::emit_fallback_used(&env, &market_id, &admin, &admin); - - let key2 = DataKey::EventNonce(symbol_short!("fbk_used")); - let nonce2: u64 = env.storage().persistent().get(&key2).unwrap(); - assert_eq!(nonce2, 1); - - // Event A nonce is isolated and unaffected by Event B - let nonce1_again: u64 = env.storage().persistent().get(&key1).unwrap(); - assert_eq!(nonce1_again, 1); + let (env, contract_id) = contract_env(); + + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let market_id = symbol_short!("m1"); + let question = String::from_str(&env, "Q1?"); + let outcomes = + vec![&env, String::from_str(&env, "Yes"), String::from_str(&env, "No")]; + + // Emit event A + EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); + + let key1 = DataKey::EventNonce(symbol_short!("mkt_crt")); + let nonce1: u64 = env.storage().persistent().get(&key1).unwrap(); + assert_eq!(nonce1, 1); + + // Emit event B + EventEmitter::emit_fallback_used(&env, &market_id, &admin, &admin); + + let key2 = DataKey::EventNonce(symbol_short!("fbk_used")); + let nonce2: u64 = env.storage().persistent().get(&key2).unwrap(); + assert_eq!(nonce2, 1); + + // Event A nonce is isolated and unaffected by Event B + let nonce1_again: u64 = env.storage().persistent().get(&key1).unwrap(); + assert_eq!(nonce1_again, 1); + }); } diff --git a/contracts/predictify-hybrid/tests/lifecycle.rs b/contracts/predictify-hybrid/tests/lifecycle.rs index 3ec44454..d64e4c7a 100644 --- a/contracts/predictify-hybrid/tests/lifecycle.rs +++ b/contracts/predictify-hybrid/tests/lifecycle.rs @@ -1,19 +1,21 @@ -//! Comprehensive lifecycle state transition tests for archive and restore functionality. +//! Comprehensive lifecycle state transition tests for the metadata-only archive +//! and restore functionality. //! //! Tests cover: //! - Archive transitions (success and rejection cases) -//! - Restore transitions (success and rejection cases) -//! - State validation and corruption detection -//! - Concurrent access safety -//! - Boundary cases and edge conditions -//! - Error handling and recovery +//! - Archive discoverability (archived view + preserved terminal status) +//! - Deterministic pruning +//! - Boundary cases and error handling use predictify_hybrid::{ + Error, EventHistoryEntry, MarketState, OracleConfig, OracleProvider, PredictifyHybrid, PredictifyHybridClient, - types::MarketState, - err::Error, }; -use soroban_sdk::{testutils::Address as _, Address, Env, String, Symbol, Vec}; +use soroban_sdk::{ + testutils::{Address as _, Events, Ledger, LedgerInfo}, + vec, Address, Env, String as SorobanString, Symbol, +}; +use std::vec::Vec as StdVec; // ===== TEST SETUP ===== @@ -22,7 +24,6 @@ struct TestSetup { contract_id: Address, admin: Address, user1: Address, - user2: Address, } impl TestSetup { @@ -30,92 +31,104 @@ impl TestSetup { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, predictify_hybrid::PredictifyHybrid); + let contract_id = env.register(PredictifyHybrid, ()); let admin = Address::generate(&env); let user1 = Address::generate(&env); - let user2 = Address::generate(&env); // Initialize contract let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&admin); + client.initialize(&admin, &None, &None); TestSetup { env, contract_id, admin, user1, - user2, } } - fn create_resolved_market(&self, question: &str) -> Symbol { - let client = PredictifyHybridClient::new(&self.env, &self.contract_id); - - let market_id = Symbol::new(&self.env, question); - let question_str = String::from_str(&self.env, question); - let mut outcomes = Vec::new(&self.env); - outcomes.push_back(String::from_str(&self.env, "Yes")); - outcomes.push_back(String::from_str(&self.env, "No")); - - // Create market - let end_time = self.env.ledger().timestamp() + 1000; - client.create_market( - &self.admin, - &market_id, - &question_str, - &outcomes, - end_time, - &String::from_str(&self.env, ""), - ); - - // Fast-forward past market end - self.env.ledger().set_timestamp(end_time + 1); - - // Manually resolve the market (simulate oracle resolution) - client.resolve_market_manual( - &self.admin, - &market_id, - &String::from_str(&self.env, "Yes"), - ); + fn client(&self) -> PredictifyHybridClient { + PredictifyHybridClient::new(&self.env, &self.contract_id) + } - market_id + fn advance_days(&self, days: u64) { + let ledger = self.env.ledger(); + let timestamp = ledger.timestamp() + days * 24 * 60 * 60; + self.env.ledger().set(LedgerInfo { + timestamp, + protocol_version: ledger.protocol_version(), + sequence_number: ledger.sequence(), + network_id: ledger.network_id().into(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 1_000_000, + }); } - fn create_cancelled_market(&self, question: &str) -> Symbol { - let client = PredictifyHybridClient::new(&self.env, &self.contract_id); - - let market_id = Symbol::new(&self.env, question); - let question_str = String::from_str(&self.env, question); - let mut outcomes = Vec::new(&self.env); - outcomes.push_back(String::from_str(&self.env, "Yes")); - outcomes.push_back(String::from_str(&self.env, "No")); - - let end_time = self.env.ledger().timestamp() + 1000; - client.create_market( + fn oracle_config(&self) -> OracleConfig { + OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::generate(&self.env), + feed_id: SorobanString::from_str(&self.env, "BTC"), + threshold: 50_000_00, + comparison: SorobanString::from_str(&self.env, "gt"), + } + } + + fn outcomes(&self) -> soroban_sdk::Vec { + vec![ + &self.env, + SorobanString::from_str(&self.env, "Yes"), + SorobanString::from_str(&self.env, "No"), + ] + } + + fn create_market(&self, question: &str) -> Symbol { + self.client().create_market( &self.admin, - &market_id, - &question_str, - &outcomes, - end_time, - &String::from_str(&self.env, ""), - ); + &SorobanString::from_str(&self.env, question), + &self.outcomes(), + &1, // 1-day duration + &self.oracle_config(), + &None, // fallback oracle + &0, // resolution timeout + &None, // min pool size + &None, // bet deadline + &None, // dispute window + &None, // dispute stake floor + &None, // max participants + ) + } - // Cancel the market - client.cancel_market(&self.admin, &market_id, &String::from_str(&self.env, "Test cancellation")); + fn create_resolved_market(&self, question: &str) -> Symbol { + let market_id = self.create_market(question); + self.advance_days(2); // past the 1-day end + self.client() + .resolve_market_manual(&self.admin, &market_id, &SorobanString::from_str(&self.env, "Yes")); + market_id + } + fn create_cancelled_market(&self, question: &str) -> Symbol { + let market_id = self.create_market(question); + self.client().cancel_event(&self.admin, &market_id, &None); market_id } } +fn collect_ids(entries: &soroban_sdk::Vec) -> StdVec { + entries.iter().map(|e| e.market_id).collect() +} + // ===== ARCHIVE SUCCESS TESTS ===== #[test] fn test_archive_from_resolved_state() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let client = setup.client(); + let market_id = setup.create_resolved_market("test_archive_resolved"); - + // Archive should succeed from Resolved state let result = client.try_archive_event(&setup.admin, &market_id); assert!(result.is_ok(), "Archive from Resolved should succeed"); @@ -124,10 +137,10 @@ fn test_archive_from_resolved_state() { #[test] fn test_archive_from_cancelled_state() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let client = setup.client(); + let market_id = setup.create_cancelled_market("test_archive_cancelled"); - + // Archive should succeed from Cancelled state let result = client.try_archive_event(&setup.admin, &market_id); assert!(result.is_ok(), "Archive from Cancelled should succeed"); @@ -136,16 +149,19 @@ fn test_archive_from_cancelled_state() { #[test] fn test_archive_emits_event() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let client = setup.client(); + let market_id = setup.create_resolved_market("test_archive_event"); - + // Archive market client.archive_event(&setup.admin, &market_id); - + // Check events were emitted let events = setup.env.events().all(); - assert!(!events.is_empty(), "Archive should emit events"); + assert!( + !events.events().is_empty(), + "Archive should emit events" + ); } // ===== ARCHIVE REJECTION TESTS ===== @@ -153,29 +169,19 @@ fn test_archive_emits_event() { #[test] fn test_archive_fails_from_active_state() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = Symbol::new(&setup.env, "test_archive_active"); - let question = String::from_str(&setup.env, "test_archive_active"); - let mut outcomes = Vec::new(&setup.env); - outcomes.push_back(String::from_str(&setup.env, "Yes")); - outcomes.push_back(String::from_str(&setup.env, "No")); - - let end_time = setup.env.ledger().timestamp() + 10000; - client.create_market( - &setup.admin, - &market_id, - &question, - &outcomes, - end_time, - &String::from_str(&setup.env, ""), - ); - + let client = setup.client(); + + let market_id = setup.create_market("test_archive_active"); + // Archive should fail from Active state let result = client.try_archive_event(&setup.admin, &market_id); match result { Err(Ok(err)) => { - assert_eq!(err, Error::CannotArchiveFromState as u32, "Should return CannotArchiveFromState error"); + assert_eq!( + err, + Error::CannotArchiveFromState, + "Should return CannotArchiveFromState error" + ); } _ => panic!("Expected CannotArchiveFromState error"), } @@ -184,18 +190,22 @@ fn test_archive_fails_from_active_state() { #[test] fn test_archive_duplicate_rejected() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let client = setup.client(); + let market_id = setup.create_resolved_market("test_archive_duplicate"); - + // First archive succeeds client.archive_event(&setup.admin, &market_id); - + // Second archive should fail with MarketAlreadyArchived let result = client.try_archive_event(&setup.admin, &market_id); match result { Err(Ok(err)) => { - assert_eq!(err, Error::MarketAlreadyArchived as u32, "Should return MarketAlreadyArchived error"); + assert_eq!( + err, + Error::MarketAlreadyArchived, + "Should return MarketAlreadyArchived error" + ); } _ => panic!("Expected MarketAlreadyArchived error"), } @@ -204,15 +214,15 @@ fn test_archive_duplicate_rejected() { #[test] fn test_archive_requires_admin_authorization() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let client = setup.client(); + let market_id = setup.create_resolved_market("test_archive_auth"); - + // Archive as non-admin should fail let result = client.try_archive_event(&setup.user1, &market_id); match result { Err(Ok(err)) => { - assert_eq!(err, Error::Unauthorized as u32, "Should return Unauthorized error"); + assert_eq!(err, Error::Unauthorized, "Should return Unauthorized error"); } _ => panic!("Expected Unauthorized error"), } @@ -221,297 +231,122 @@ fn test_archive_requires_admin_authorization() { #[test] fn test_archive_nonexistent_market() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let client = setup.client(); + let nonexistent_id = Symbol::new(&setup.env, "nonexistent"); - + // Archive on nonexistent market should fail let result = client.try_archive_event(&setup.admin, &nonexistent_id); match result { Err(Ok(err)) => { - assert_eq!(err, Error::MarketNotFound as u32, "Should return MarketNotFound error"); + assert_eq!( + err, + Error::MarketNotFound, + "Should return MarketNotFound error" + ); } _ => panic!("Expected MarketNotFound error"), } } -// ===== RESTORE SUCCESS TESTS ===== - -#[test] -fn test_restore_from_archived_state() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_restore_archived"); - - // Archive first - client.archive_event(&setup.admin, &market_id); - - // Restore should succeed from Archived state - let reason = String::from_str(&setup.env, "Test restore"); - let result = client.try_restore_event(&setup.admin, &market_id, &reason); - assert!(result.is_ok(), "Restore from Archived should succeed"); -} +// ===== DISCOVERABILITY TESTS ===== #[test] -fn test_restore_emits_event() { +fn test_archive_preserves_terminal_state_and_discoverability() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_restore_event"); - client.archive_event(&setup.admin, &market_id); - - // Restore market - let reason = String::from_str(&setup.env, "Test restore with events"); - client.restore_event(&setup.admin, &market_id, &reason); - - // Check events were emitted - let events = setup.env.events().all(); - assert!(!events.is_empty(), "Restore should emit events"); -} + let client = setup.client(); -// ===== RESTORE REJECTION TESTS ===== + let market_id = setup.create_resolved_market("test_state_consistency"); -#[test] -fn test_restore_fails_from_resolved_state() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_restore_resolved"); - - // Restore without archiving should fail - let reason = String::from_str(&setup.env, "Invalid restore"); - let result = client.try_restore_event(&setup.admin, &market_id, &reason); - match result { - Err(Ok(err)) => { - assert_eq!(err, Error::CannotRestoreFromState as u32, "Should return CannotRestoreFromState error"); - } - _ => panic!("Expected CannotRestoreFromState error"), - } -} + // Sanity: resolved and discoverable by status before archiving. + let (before, _) = client.query_events_by_status(&MarketState::Resolved, &0, &30); + assert!(collect_ids(&before).contains(&market_id)); -#[test] -fn test_restore_duplicate_rejected() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_restore_duplicate"); - - // Archive then restore + // Archive the market (non-destructive). client.archive_event(&setup.admin, &market_id); - let reason = String::from_str(&setup.env, "First restore"); - client.restore_event(&setup.admin, &market_id, &reason); - - // Second restore should fail - let reason2 = String::from_str(&setup.env, "Duplicate restore"); - let result = client.try_restore_event(&setup.admin, &market_id, &reason2); - match result { - Err(Ok(err)) => { - assert_eq!(err, Error::MarketAlreadyRestored as u32, "Should return MarketAlreadyRestored error"); - } - _ => panic!("Expected MarketAlreadyRestored error"), - } -} -#[test] -fn test_restore_requires_admin_authorization() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_restore_auth"); - client.archive_event(&setup.admin, &market_id); - - // Restore as non-admin should fail - let reason = String::from_str(&setup.env, "Unauthorized restore"); - let result = client.try_restore_event(&setup.user1, &market_id, &reason); - match result { - Err(Ok(err)) => { - assert_eq!(err, Error::Unauthorized as u32, "Should return Unauthorized error"); - } - _ => panic!("Expected Unauthorized error"), - } -} + // The archived event keeps its terminal Resolved state. + let (after, _) = client.query_events_by_status(&MarketState::Resolved, &0, &30); + assert!( + collect_ids(&after).contains(&market_id), + "archived (resolved) event must remain discoverable by status" + ); -#[test] -fn test_restore_nonexistent_market() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let nonexistent_id = Symbol::new(&setup.env, "nonexistent"); - let reason = String::from_str(&setup.env, "Invalid restore"); - - // Restore on nonexistent market should fail - let result = client.try_restore_event(&setup.admin, &nonexistent_id, &reason); - match result { - Err(Ok(err)) => { - assert_eq!(err, Error::MarketNotFound as u32, "Should return MarketNotFound error"); - } - _ => panic!("Expected MarketNotFound error"), - } + // Exposed via the direct archived view. + let (archived, _) = client.query_archived_events(&false, &0, &30); + assert!(collect_ids(&archived).contains(&market_id)); } -// ===== BOUNDARY AND EDGE CASE TESTS ===== - #[test] -fn test_archive_capacity_respected() { +fn test_archived_view_reports_archived_at() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - // Try to archive many markets to test capacity - // Note: This would require creating MAX_ARCHIVE_SIZE markets first - // For now, just verify that archive_size() can be queried - let size = client.get_archive_size(); - assert!(size >= 0, "Archive size should be non-negative"); -} + let client = setup.client(); -#[test] -fn test_archive_then_restore_lifecycle() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_full_lifecycle"); - - // Archive + let market_id = setup.create_resolved_market("test_archived_at"); client.archive_event(&setup.admin, &market_id); - - // Verify is_archived returns true - assert!(client.is_archived(&market_id), "Market should be archived"); - - // Restore - let reason = String::from_str(&setup.env, "Full lifecycle test"); - client.restore_event(&setup.admin, &market_id, &reason); - - // Verify is_restored returns true - assert!(client.is_restored(&market_id), "Market should be restored"); -} -#[test] -fn test_concurrent_archive_attempts_idempotent() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_concurrent_archive"); - - // First archive succeeds - let result1 = client.try_archive_event(&setup.admin, &market_id); - assert!(result1.is_ok(), "First archive should succeed"); - - // Second archive attempt (from same admin) should be rejected deterministically - let result2 = client.try_archive_event(&setup.admin, &market_id); - match result2 { - Err(Ok(err)) => { - // Either MarketAlreadyArchived or other archive-related error is acceptable - assert!( - err == Error::MarketAlreadyArchived as u32, - "Second archive should fail with MarketAlreadyArchived" - ); - } - _ => panic!("Expected error on duplicate archive"), - } -} - -#[test] -fn test_market_state_consistency_after_archive() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_state_consistency"); - - // Archive the market - client.archive_event(&setup.admin, &market_id); - - // Verify state is consistent - assert!(client.is_archived(&market_id), "is_archived() should return true"); - assert!(!client.is_restored(&market_id), "is_restored() should return false"); -} - -#[test] -fn test_market_state_consistency_after_restore() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_state_after_restore"); - - // Archive then restore - client.archive_event(&setup.admin, &market_id); - let reason = String::from_str(&setup.env, "Test state consistency"); - client.restore_event(&setup.admin, &market_id, &reason); - - // Verify state is consistent - assert!(!client.is_archived(&market_id), "is_archived() should return false after restore"); - assert!(client.is_restored(&market_id), "is_restored() should return true"); + let (archived, _) = client.query_archived_events(&false, &0, &30); + let entry = archived + .iter() + .find(|e| e.market_id == market_id) + .expect("archived market should be listed"); + assert!( + entry.archived_at.unwrap_or(0) > 0, + "archived entry must carry a non-zero archived_at marker" + ); } -// ===== REGRESSION TESTS ===== +// ===== PRUNING / CAPACITY TESTS ===== #[test] -fn test_archive_respects_existing_authorization() { +fn test_archive_capacity_queryable() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_auth_regression"); - - // Verify that admin can archive - let result = client.try_archive_event(&setup.admin, &market_id); - assert!(result.is_ok(), "Admin should be able to archive"); -} + let client = setup.client(); -#[test] -fn test_restore_respects_existing_authorization() { - let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - let market_id = setup.create_resolved_market("test_restore_auth_regression"); - client.archive_event(&setup.admin, &market_id); - - // Verify that admin can restore - let reason = String::from_str(&setup.env, "Auth regression test"); - let result = client.try_restore_event(&setup.admin, &market_id, &reason); - assert!(result.is_ok(), "Admin should be able to restore"); + let size = client.archive_size(); + assert_eq!(size, 0, "fresh deployment should start with an empty archive"); } -// ===== INTEGRATION TESTS ===== - #[test] fn test_multiple_archives_in_sequence() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - + let client = setup.client(); + // Create and archive multiple markets let m1 = setup.create_resolved_market("test_multi_1"); let m2 = setup.create_resolved_market("test_multi_2"); let m3 = setup.create_resolved_market("test_multi_3"); - + // Archive all client.archive_event(&setup.admin, &m1); client.archive_event(&setup.admin, &m2); client.archive_event(&setup.admin, &m3); - + // Verify all are archived - assert!(client.is_archived(&m1), "Market 1 should be archived"); - assert!(client.is_archived(&m2), "Market 2 should be archived"); - assert!(client.is_archived(&m3), "Market 3 should be archived"); + let (archived, _) = client.query_archived_events(&false, &0, &30); + let ids = collect_ids(&archived); + assert!(ids.contains(&m1), "Market 1 should be archived"); + assert!(ids.contains(&m2), "Market 2 should be archived"); + assert!(ids.contains(&m3), "Market 3 should be archived"); + assert_eq!(client.archive_size(), 3); } #[test] -fn test_mixed_archive_restore_operations() { +fn test_prune_archive_reduces_size() { let setup = TestSetup::new(); - let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); - - // Create markets - let m1 = setup.create_resolved_market("test_mixed_1"); - let m2 = setup.create_resolved_market("test_mixed_2"); - - // Archive both - client.archive_event(&setup.admin, &m1); - client.archive_event(&setup.admin, &m2); - - // Restore first, leave second archived - let reason = String::from_str(&setup.env, "Partial restore"); - client.restore_event(&setup.admin, &m1, &reason); - - // Verify states - assert!(client.is_restored(&m1), "Market 1 should be restored"); - assert!(!client.is_restored(&m1), "Market 1 should NOT be archived"); - assert!(client.is_archived(&m2), "Market 2 should still be archived"); + let client = setup.client(); + + for i in 0..3 { + let mid = setup.create_resolved_market(&format!("test_prune_{i}")); + client.archive_event(&setup.admin, &mid); + } + assert_eq!(client.archive_size(), 3); + + // Prune deterministically from the oldest entry. + let pruned = match client.try_prune_archive(&setup.admin, &1, &None) { + Ok(Ok((count, _))) => count, + _ => panic!("prune should succeed"), + }; + assert_eq!(pruned, 1, "exactly one entry should be pruned"); + assert_eq!(client.archive_size(), 2); } diff --git a/contracts/predictify-hybrid/tests/stateful.rs b/contracts/predictify-hybrid/tests/stateful.rs index 32844304..9375c263 100644 --- a/contracts/predictify-hybrid/tests/stateful.rs +++ b/contracts/predictify-hybrid/tests/stateful.rs @@ -41,7 +41,7 @@ use soroban_sdk::{ token::StellarAssetClient, vec, Address, Env, String as SorobanString, Symbol, Vec as SorobanVec, }; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet}; use std::string::String as StdString; use std::vec::Vec as StdVec; @@ -69,23 +69,23 @@ struct MarketModel { outcomes: StdVec, creator: Address, end_time: u64, - total_stakes: HashMap, - votes: HashMap, - bets: HashMap, + total_stakes: BTreeMap, + votes: BTreeMap, + bets: BTreeMap, resolved_outcome: Option, - claimed: HashSet
, + claimed: BTreeSet
, } /// Represents the entire test state -#[derive(Debug, Clone)] +#[derive(Clone)] struct TestState { env: Env, contract_id: Address, token_id: Address, admin: Address, users: StdVec
, - markets: HashMap, - balances: HashMap, + markets: BTreeMap, + balances: BTreeMap, } impl TestState { @@ -109,7 +109,7 @@ impl TestState { // Initialize contract let contract_id = env.register(PredictifyHybrid, ()); let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&1, &None, &None); + client.initialize(&admin, &None, &None); // Set token for staking env.as_contract(&contract_id, || { @@ -123,7 +123,7 @@ impl TestState { env.mock_all_auths(); stellar_client.mint(&admin, &INITIAL_BALANCE); - let mut balances = HashMap::new(); + let mut balances = BTreeMap::new(); balances.insert(admin.clone(), INITIAL_BALANCE); for user in users.iter() { @@ -137,7 +137,7 @@ impl TestState { token_id, admin, users, - markets: HashMap::new(), + markets: BTreeMap::new(), balances, } } @@ -313,16 +313,18 @@ impl Operation { &creator, &SorobanString::from_str(&state.env, "Test Market"), &outcomes, - duration_days, + &duration_days, &oracle_config, &None, &0, &None, &None, &None, + &None, + &None, ); - if let Ok(created_id) = result { + if let Ok(Ok(created_id)) = result { let end_time = state.current_time() + (*duration_days as u64 * 24 * 60 * 60); state.markets.insert( @@ -333,11 +335,11 @@ impl Operation { outcomes: outcome_names, creator: creator.clone(), end_time, - total_stakes: HashMap::new(), - votes: HashMap::new(), - bets: HashMap::new(), + total_stakes: BTreeMap::new(), + votes: BTreeMap::new(), + bets: BTreeMap::new(), resolved_outcome: None, - claimed: HashSet::new(), + claimed: BTreeSet::new(), }, ); } @@ -364,15 +366,15 @@ impl Operation { } let user = state.user(*user_idx).clone(); - let outcome_name = &market.outcomes[*outcome_idx % market.outcomes.len()]; + let outcome_name = market.outcomes[*outcome_idx % market.outcomes.len()].clone(); let client = state.client(); state.env.mock_all_auths(); let result = client.try_vote( &user, market_id, - &SorobanString::from_str(&state.env, outcome_name), - stake, + &SorobanString::from_str(&state.env, &outcome_name), + &stake, ); if result.is_ok() { @@ -407,15 +409,15 @@ impl Operation { } let user = state.user(*user_idx).clone(); - let outcome_name = &market.outcomes[*outcome_idx % market.outcomes.len()]; + let outcome_name = market.outcomes[*outcome_idx % market.outcomes.len()].clone(); let client = state.client(); state.env.mock_all_auths(); let result = client.try_place_bet( &user, market_id, - &SorobanString::from_str(&state.env, outcome_name), - amount, + &SorobanString::from_str(&state.env, &outcome_name), + &amount, &1000, // max_fee_bps: 10% max fee ); @@ -465,12 +467,12 @@ impl Operation { } let winning_outcome = - &market.outcomes[*winning_outcome_idx % market.outcomes.len()]; + market.outcomes[*winning_outcome_idx % market.outcomes.len()].clone(); // Simulate resolution by directly updating state if let Some(market_mut) = state.markets.get_mut(market_id) { market_mut.state = MarketState::Resolved; - market_mut.resolved_outcome = Some(winning_outcome.clone()); + market_mut.resolved_outcome = Some(winning_outcome); } Ok(()) @@ -569,6 +571,8 @@ fn validate_state_transition(market: &MarketModel) -> Result<(), String> { } MarketState::Closed => Ok(()), MarketState::Cancelled => Ok(()), + MarketState::Archived => Ok(()), + MarketState::Restored => Ok(()), } } @@ -646,6 +650,8 @@ proptest! { &None, &None, &None, + &None, + &None, ); // Advance time @@ -717,6 +723,8 @@ proptest! { &None, &None, &None, + &None, + &None, ); // Place first vote @@ -777,6 +785,8 @@ fn test_basic_market_creation() { &None, &None, &None, + &None, + &None, ); // Verify market exists @@ -823,6 +833,8 @@ fn test_vote_on_active_market() { &None, &None, &None, + &None, + &None, ); // Place a vote @@ -867,6 +879,8 @@ fn test_no_vote_after_market_ends() { &None, &None, &None, + &None, + &None, ); // Advance time past market end