diff --git a/contract/contracts/predifi-contract/src/lib.rs b/contract/contracts/predifi-contract/src/lib.rs index f3ee912..0f4abbd 100644 --- a/contract/contracts/predifi-contract/src/lib.rs +++ b/contract/contracts/predifi-contract/src/lib.rs @@ -394,53 +394,89 @@ pub struct UserPredictionDetail { /// Internal storage keys for contract data. /// -/// This enum defines all the keys used to store and retrieve data from -/// Soroban's storage. Each variant corresponds to a specific data type. +/// All variants use PascalCase. Abbreviated names are preserved for existing +/// on-chain keys to avoid storage migration (Soroban uses the variant name as +/// the XDR discriminant). New variants added here use full descriptive names. +/// +/// # Naming conventions +/// - Existing abbreviated variants (e.g. `OutStake`, `UsrPrdCnt`) are kept +/// verbatim to preserve on-chain discriminant values. +/// - New variants added after the initial deployment use full PascalCase names +/// (e.g. `OracleConfig`, `PriceFeed`, `PriceCondition`). +/// - All variants are documented with their storage type mapping. #[contracttype] #[derive(Clone)] pub enum DataKey { - /// Pool data by pool ID: Pool(pool_id) -> Pool + // ── Pool data ──────────────────────────────────────────────────────────── + + /// Pool data by pool ID: `Pool(pool_id)` -> `Pool` Pool(u64), - /// User prediction by user address and pool ID: Pred(user, pool_id) -> Prediction - Pred(Address, u64), - /// Pool ID counter for generating unique pool IDs. + /// Pool ID counter for generating unique pool IDs: `PoolIdCtr` -> `u64` PoolIdCtr, - /// Tracks whether a user has claimed winnings for a pool: Claimed(user, pool_id) -> bool + /// Participant count for a pool: `PartCnt(pool_id)` -> `u32` + PartCnt(u64), + + // ── Predictions & stakes ───────────────────────────────────────────────── + + /// User prediction by user address and pool ID: `Pred(user, pool_id)` -> `Prediction` + Pred(Address, u64), + /// Tracks whether a user has claimed winnings for a pool: `Claimed(user, pool_id)` -> `bool` Claimed(Address, u64), - /// Stake amount for a specific outcome: OutStake(pool_id, outcome) -> i128 + /// Stake amount for a specific outcome (backward-compat individual key): + /// `OutStake(pool_id, outcome)` -> `i128` OutStake(u64, u32), - /// Optimized storage for markets with many outcomes (e.g., 32+ teams). - /// Stores all outcome stakes as a single `Vec` to reduce storage reads. + /// Optimized batch storage for all outcome stakes in a pool: + /// `OutStakes(pool_id)` -> `Vec` + /// + /// Preferred over `OutStake` for pools with many outcomes. Falls back to + /// `OutStake` for backward compatibility when this key is absent. OutStakes(u64), - /// User prediction count: UsrPrdCnt(user) -> u32 + /// User prediction count: `UsrPrdCnt(user)` -> `u32` UsrPrdCnt(Address), - /// User prediction index: UsrPrdIdx(user, index) -> UserPredictionDetail + /// User prediction index: `UsrPrdIdx(user, index)` -> `UserPredictionDetail` UsrPrdIdx(Address, u32), - /// Global protocol configuration: Config -> Config + + // ── Protocol configuration ─────────────────────────────────────────────── + + /// Global protocol configuration: `Config` -> `Config` Config, - /// Contract pause state: Paused -> bool + /// Contract pause state: `Paused` -> `bool` Paused, - /// Reentrancy guard: RentGuard -> bool + /// Contract version for safe upgrade migrations: `Version` -> `u32` + Version, + /// Referral cut in basis points: `ReferralCutBps` -> `u32` + ReferralCutBps, + /// Reentrancy guard (temporary storage): `RentGuard` -> `bool` RentGuard, - /// Category pool count: CatPoolCt(category) -> u32 + + // ── Token whitelist ────────────────────────────────────────────────────── + + /// Token whitelist entry: `TokenWl(token_address)` -> `bool` + /// + /// Present (with value `true`) when the token is allowed for betting. + TokenWl(Address), + + // ── Categories ─────────────────────────────────────────────────────────── + + /// Category pool count: `CatPoolCt(category)` -> `u32` CatPoolCt(Symbol), - /// Category pool index: CatPoolIx(category, index) -> u64 + /// Category pool index: `CatPoolIx(category, index)` -> `u64` (pool_id) CatPoolIx(Symbol, u32), - /// Token whitelist: TokenWl(token_address) -> true if allowed for betting. - TokenWl(Address), - /// Participant count for a pool: PartCnt(pool_id) -> u32 - PartCnt(u64), - /// Tracks if an oracle has already voted: ResVote(pool_id, oracle_address) + + // ── Resolution voting ──────────────────────────────────────────────────── + + /// Tracks if an oracle/operator has already voted: `ResVote(pool_id, voter_address)` -> `()` ResVote(u64, Address), - /// Tracks vote count for a specific outcome: ResVoteCt(pool_id, outcome) + /// Vote count for a specific outcome: `ResVoteCt(pool_id, outcome)` -> `u32` ResVoteCt(u64, u32), - /// Tracks total number of votes cast for a pool: ResTotal(pool_id) + /// Total number of votes cast for a pool: `ResTotal(pool_id)` -> `u32` ResTotal(u64), - /// Referral cut in basis points: ReferralCutBps -> u32 - ReferralCutBps, - /// Referred volume for a referrer and pool: ReferredVolume(referrer, pool_id) -> i128 + + // ── Referrals ──────────────────────────────────────────────────────────── + + /// Referred volume for a referrer and pool: `ReferredVolume(referrer, pool_id)` -> `i128` ReferredVolume(Address, u64), - /// Referrer address for a user and pool: Referrer(user, pool_id) -> Address + /// Referrer address for a user and pool: `Referrer(user, pool_id)` -> `Address` /// /// FUTURE: Multiple referrers per user per pool /// Currently a user can only have one referrer per pool. If multiple referrers are needed @@ -451,7 +487,10 @@ pub enum DataKey { /// and distribute proportional cuts. Until that requirement is confirmed, the single-referrer /// model is kept for simplicity and gas efficiency. Referrer(Address, u64), - /// User whitelist for private pools: Whitelist(pool_id, user_address) + + // ── Private pools ──────────────────────────────────────────────────────── + + /// User whitelist for private pools: `Whitelist(pool_id, user_address)` -> `()` Whitelist(u64, Address), /// Contract version for safe upgrade migrations. Version, diff --git a/contract/contracts/predifi-contract/src/price_feed.rs b/contract/contracts/predifi-contract/src/price_feed.rs index bb9cd95..fdb4b23 100644 --- a/contract/contracts/predifi-contract/src/price_feed.rs +++ b/contract/contracts/predifi-contract/src/price_feed.rs @@ -12,7 +12,7 @@ //! 4. **Resolve Pool**: Once the market ends, call `resolve_pool_from_price` to automatically //! determine the winning outcome based on the latest valid price data. -use crate::PredifiError; +use crate::{DataKey, PredifiError}; use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; /// Price feed data structure for external oracle integration. @@ -101,19 +101,13 @@ pub struct OracleConfig { /// Storage keys for price feed data. /// -/// This enum defines all storage keys used by the price feed system. -#[derive(Clone)] -#[contracttype] -pub enum PriceFeedDataKey { - /// Oracle configuration: OracleConfig -> OracleConfig - OracleConfig, - /// Registered price feeds: PriceFeed(feed_pair) -> PriceFeed - PriceFeed(Symbol), - /// Price conditions for pools: PriceCondition(pool_id) -> PriceCondition - PriceCondition(u64), - /// Last update timestamp for each feed: LastUpdate(feed_pair) -> u64 - LastUpdate(Symbol), -} +/// Deprecated: use `DataKey` from `lib.rs` directly. This type alias is kept +/// for documentation purposes only and will be removed in a future version. +/// +/// All price-feed storage now uses the canonical `DataKey` variants: +/// - `DataKey::OracleConfig` — oracle configuration +/// - `DataKey::PriceFeed(feed_pair)` — price feed data +/// - `DataKey::PriceCondition(pool_id)` — per-pool price conditions /// Price feed adapter for external oracle integration #[allow(dead_code)] @@ -143,7 +137,7 @@ impl PriceFeedAdapter { env.storage() .persistent() - .set(&PriceFeedDataKey::OracleConfig, &config); + .set(&DataKey::OracleConfig, &config); Ok(()) } @@ -152,7 +146,7 @@ impl PriceFeedAdapter { pub fn get_oracle_config(env: &Env) -> OracleConfig { env.storage() .persistent() - .get(&PriceFeedDataKey::OracleConfig) + .get(&DataKey::OracleConfig) .expect("Oracle config not initialized") } @@ -192,11 +186,10 @@ impl PriceFeedAdapter { // Store price feed data env.storage() .persistent() - .set(&PriceFeedDataKey::PriceFeed(feed_pair.clone()), &feed); + .set(&DataKey::PriceFeed(feed_pair.clone()), &feed); - env.storage() - .persistent() - .set(&PriceFeedDataKey::LastUpdate(feed_pair), ×tamp); + // Note: last-update timestamp is embedded in PriceFeed.timestamp; + // no separate LastUpdate key is needed. Ok(()) } @@ -206,7 +199,7 @@ impl PriceFeedAdapter { let feed: Option = env .storage() .persistent() - .get(&PriceFeedDataKey::PriceFeed(feed_pair.clone())); + .get(&DataKey::PriceFeed(feed_pair.clone())); feed } @@ -243,7 +236,7 @@ impl PriceFeedAdapter { ) -> Result<(), PredifiError> { env.storage() .persistent() - .set(&PriceFeedDataKey::PriceCondition(pool_id), &condition); + .set(&DataKey::PriceCondition(pool_id), &condition); Ok(()) } @@ -252,7 +245,7 @@ impl PriceFeedAdapter { pub fn get_price_condition(env: &Env, pool_id: u64) -> Option { env.storage() .persistent() - .get(&PriceFeedDataKey::PriceCondition(pool_id)) + .get(&DataKey::PriceCondition(pool_id)) } /// Evaluate price condition against current price data diff --git a/contract/contracts/predifi-contract/src/price_feed_simple.rs b/contract/contracts/predifi-contract/src/price_feed_simple.rs index 54b7241..9965635 100644 --- a/contract/contracts/predifi-contract/src/price_feed_simple.rs +++ b/contract/contracts/predifi-contract/src/price_feed_simple.rs @@ -1,11 +1,44 @@ use crate::{DataKey, PredifiError}; -use soroban_sdk::{Address, Env, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +/// Oracle configuration stored under `DataKey::OracleConfig`. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SimplePriceFeed { + /// The asset pair identifier (e.g., "ETH/USD"). + pub pair: Symbol, + /// Current price in base token units. + pub price: i128, + /// Confidence interval (± value). + pub confidence: i128, + /// Unix timestamp when the price was last updated. + pub timestamp: u64, + /// Unix timestamp when this price data expires. + pub expires_at: u64, +} + +/// Oracle configuration stored under `DataKey::OracleConfig`. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SimpleOracleConfig { + /// Pyth Network oracle contract address. + pub pyth_contract: Address, + /// Maximum age of price data in seconds before it is considered stale. + pub max_price_age: u64, + /// Minimum confidence ratio in basis points. + pub min_confidence_ratio: u32, +} -/// Price feed adapter for external oracle integration (simplified version) +/// Price feed adapter for external oracle integration (simplified version). +/// +/// Uses `DataKey::OracleConfig` for oracle configuration, +/// `DataKey::PriceFeed(feed_pair)` for price data, and +/// `DataKey::PriceCondition(pool_id)` for per-pool price conditions — +/// all defined in the canonical `DataKey` enum in `lib.rs`. pub struct PriceFeedAdapter; impl PriceFeedAdapter { - /// Initialize oracle configuration + /// Initialize oracle configuration. pub fn init_oracle( env: &Env, admin: &Address, @@ -15,18 +48,25 @@ impl PriceFeedAdapter { ) -> Result<(), PredifiError> { admin.require_auth(); - // Store oracle config using existing storage keys - let config_key = DataKey::TokenWl(pyth_contract.clone()); + let config = SimpleOracleConfig { + pyth_contract, + max_price_age, + min_confidence_ratio, + }; - // Store config values as tuple env.storage() .persistent() - .set(&config_key, &(max_price_age, min_confidence_ratio)); + .set(&DataKey::OracleConfig, &config); Ok(()) } - /// Update price feed data (called by oracle keeper) + /// Get oracle configuration. + pub fn get_oracle_config(env: &Env) -> Option { + env.storage().persistent().get(&DataKey::OracleConfig) + } + + /// Update price feed data (called by oracle keeper). pub fn update_price_feed( env: &Env, oracle: &Address, @@ -38,7 +78,6 @@ impl PriceFeedAdapter { ) -> Result<(), PredifiError> { oracle.require_auth(); - // Validate price data if price <= 0 || confidence < 0 { return Err(PredifiError::InvalidAmount); } @@ -47,60 +86,52 @@ impl PriceFeedAdapter { return Err(PredifiError::InvalidPoolState); } - // Store price data using existing storage pattern - // Use OutcomeStake with a fixed pool_id for price data - let price_key = DataKey::OutStake(999999, 0); // Fixed pool_id for price feeds - env.storage().persistent().set( - &price_key, - &(feed_pair, price, confidence, timestamp, expires_at), - ); + let feed = SimplePriceFeed { + pair: feed_pair.clone(), + price, + confidence, + timestamp, + expires_at, + }; + + env.storage() + .persistent() + .set(&DataKey::PriceFeed(feed_pair), &feed); Ok(()) } - /// Get current price feed data - pub fn get_price_feed(env: &Env, feed_pair: &Symbol) -> Option<(i128, i128, u64, u64)> { - let price_key = DataKey::OutStake(999999, 0); // Fixed pool_id for price feeds - - // Get all price data and find matching feed - if let Some(price_data) = env - .storage() + /// Get current price feed data for a given pair. + pub fn get_price_feed(env: &Env, feed_pair: &Symbol) -> Option { + env.storage() .persistent() - .get::(&price_key) - { - let (stored_pair, price, confidence, timestamp, expires_at) = price_data; - if stored_pair == *feed_pair { - return Some((price, confidence, timestamp, expires_at)); - } - } - - None + .get(&DataKey::PriceFeed(feed_pair.clone())) } - /// Check if price data is valid and fresh - pub fn is_price_valid(env: &Env, price_data: &(i128, i128, u64, u64), max_age: u64) -> bool { - let (price, confidence, timestamp, expires_at) = price_data; + /// Check if price data is valid and fresh. + pub fn is_price_valid(env: &Env, feed: &SimplePriceFeed, max_age: u64) -> bool { let current_time = env.ledger().timestamp(); - // Check if price data is expired - if current_time > *expires_at { + if current_time > feed.expires_at { return false; } - // Check if price data is too old - if current_time > timestamp + max_age { + if current_time > feed.timestamp + max_age { return false; } - // Basic confidence check - if *confidence > *price / 100 { + // Basic confidence check: confidence must be <= 1% of price + if feed.confidence > feed.price / 100 { return false; } true } - /// Set price condition for a pool + /// Set price condition for a pool. + /// + /// Stores `(feed_pair, target_price, operator, tolerance_bps)` under + /// `DataKey::PriceCondition(pool_id)`. pub fn set_price_condition( env: &Env, pool_id: u64, @@ -109,23 +140,22 @@ impl PriceFeedAdapter { operator: u32, tolerance_bps: u32, ) -> Result<(), PredifiError> { - // Store condition using existing storage pattern - let condition_key = DataKey::OutStake(pool_id, 1); env.storage().persistent().set( - &condition_key, + &DataKey::PriceCondition(pool_id), &(feed_pair, target_price, operator, tolerance_bps), ); Ok(()) } - /// Get price condition for a pool + /// Get price condition for a pool. pub fn get_price_condition(env: &Env, pool_id: u64) -> Option<(Symbol, i128, u32, u32)> { - let condition_key = DataKey::OutStake(pool_id, 1); - env.storage().persistent().get(&condition_key) + env.storage() + .persistent() + .get(&DataKey::PriceCondition(pool_id)) } - /// Evaluate price condition against current price data + /// Evaluate price condition against current price data. pub fn evaluate_price_condition( env: &Env, condition: &(Symbol, i128, u32, u32), @@ -133,40 +163,26 @@ impl PriceFeedAdapter { ) -> Result { let (feed_pair, target_price, operator_type, tolerance_bps) = condition; - let price_data = + let feed = Self::get_price_feed(env, feed_pair).ok_or(PredifiError::PriceFeedNotFound)?; - // Validate price data - if !Self::is_price_valid(env, &price_data, max_age) { + if !Self::is_price_valid(env, &feed, max_age) { return Err(PredifiError::PriceDataInvalid); } - let (price, _confidence, _timestamp, _expires_at) = price_data; - - // Calculate tolerance amount let tolerance_amount = (target_price * *tolerance_bps as i128) / 10000; - // Evaluate condition based on operator let result = match operator_type { - 0 => { - // Equal - price >= target_price - tolerance_amount && price <= target_price + tolerance_amount - } - 1 => { - // Greater than - price > target_price + tolerance_amount - } - 2 => { - // Less than - price < target_price - tolerance_amount - } + 0 => feed.price >= target_price - tolerance_amount && feed.price <= target_price + tolerance_amount, + 1 => feed.price > target_price + tolerance_amount, + 2 => feed.price < target_price - tolerance_amount, _ => return Err(PredifiError::InvalidPoolState), }; Ok(result) } - /// Resolve pool based on price condition + /// Resolve pool based on price condition. pub fn resolve_pool_from_price( env: &Env, pool_id: u64, @@ -177,17 +193,10 @@ impl PriceFeedAdapter { let condition_met = Self::evaluate_price_condition(env, &condition, max_age)?; - // Return outcome: 1 if condition met, 0 if not met Ok(if condition_met { 1 } else { 0 }) } - /// Get oracle configuration - pub fn get_oracle_config(env: &Env, pyth_contract: &Address) -> Option<(u64, u32)> { - let config_key = DataKey::TokenWl(pyth_contract.clone()); - env.storage().persistent().get(&config_key) - } - - /// Batch update multiple price feeds + /// Batch update multiple price feeds. pub fn batch_update_price_feeds( env: &Env, oracle: &Address, @@ -212,15 +221,9 @@ impl PriceFeedAdapter { Ok(()) } - /// Clean up expired price feeds + /// Clean up expired price feeds (placeholder — requires storage scanning). pub fn cleanup_expired_feeds(env: &Env, _max_age: u64) -> Result { let _current_time = env.ledger().timestamp(); - let cleaned_count = 0u32; - - // This would typically scan all price feeds and remove expired ones - // For now, return count as placeholder - // Implementation depends on storage scanning capabilities - - Ok(cleaned_count) + Ok(0u32) } } diff --git a/contract/contracts/predifi-contract/src/storage_test.rs b/contract/contracts/predifi-contract/src/storage_test.rs index 8b13789..6daa948 100644 --- a/contract/contracts/predifi-contract/src/storage_test.rs +++ b/contract/contracts/predifi-contract/src/storage_test.rs @@ -1 +1,449 @@ +//! Storage key standardization tests. +//! +//! Verifies that: +//! 1. All `DataKey` variants are stored in the correct storage tier +//! (instance vs persistent vs temporary). +//! 2. No ad-hoc keys (magic numbers, reused variants) are used for +//! oracle/price-feed data. +//! 3. `DataKey::OracleConfig` is distinct from `DataKey::TokenWl`. +//! 4. `DataKey::PriceFeed` and `DataKey::PriceCondition` are distinct from +//! `DataKey::OutStake`. +//! 5. All variants round-trip correctly through storage. +#[cfg(test)] +mod tests { + use crate::{ + price_feed_simple::{PriceFeedAdapter, SimplePriceFeed, SimpleOracleConfig}, + DataKey, PredifiContract, PredifiContractClient, + }; + use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Ledger}, + Address, Env, Symbol, + }; + + // ── helpers ────────────────────────────────────────────────────────────── + + mod dummy_ac { + use soroban_sdk::{contract, contractimpl, Address, Env, Symbol}; + + #[contract] + pub struct DummyAC; + + #[contractimpl] + impl DummyAC { + pub fn grant_role(env: Env, user: Address, role: u32) { + env.storage() + .instance() + .set(&(Symbol::new(&env, "role"), user, role), &true); + } + pub fn has_role(env: Env, user: Address, role: u32) -> bool { + env.storage() + .instance() + .get(&(Symbol::new(&env, "role"), user, role)) + .unwrap_or(false) + } + } + } + + fn setup(env: &Env) -> (PredifiContractClient, Address, Address) { + env.mock_all_auths(); + let ac = env.register(dummy_ac::DummyAC, ()); + let ac_client = dummy_ac::DummyACClient::new(env, &ac); + let admin = Address::generate(env); + ac_client.grant_role(&admin, &0u32); + + let cid = env.register(PredifiContract, ()); + let client = PredifiContractClient::new(env, &cid); + let treasury = Address::generate(env); + client.init(&ac, &treasury, &0u32, &0u64, &3600u64); + (client, cid, admin) + } + + // ── DataKey variant distinctness ───────────────────────────────────────── + + /// OracleConfig must be stored under DataKey::OracleConfig, not TokenWl. + #[test] + fn test_oracle_config_uses_dedicated_key() { + let env = Env::default(); + env.mock_all_auths(); + let (client, contract_id, admin) = setup(&env); + + let pyth = Address::generate(&env); + let token = Address::generate(&env); + + // Whitelist a token — uses DataKey::TokenWl + client.add_token_to_whitelist(&admin, &token); + + // Store oracle config via price_feed_simple — uses DataKey::OracleConfig + env.as_contract(&contract_id, || { + PriceFeedAdapter::init_oracle(&env, &admin, pyth.clone(), 300, 100).unwrap(); + }); + + env.as_contract(&contract_id, || { + // TokenWl for the pyth address must NOT exist (we never whitelisted it) + let wl_key = DataKey::TokenWl(pyth.clone()); + assert!( + !env.storage().persistent().has(&wl_key), + "OracleConfig must not be stored under TokenWl" + ); + + // OracleConfig key must exist + let oc_key = DataKey::OracleConfig; + assert!( + env.storage().persistent().has(&oc_key), + "OracleConfig must be stored under DataKey::OracleConfig" + ); + + // TokenWl for the whitelisted token must still exist + let token_key = DataKey::TokenWl(token.clone()); + assert!( + env.storage().persistent().has(&token_key), + "TokenWl must still work for token whitelist" + ); + }); + } + + /// PriceFeed must be stored under DataKey::PriceFeed, not OutStake. + #[test] + fn test_price_feed_uses_dedicated_key() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let oracle = Address::generate(&env); + let pair = symbol_short!("ETHUSD"); + let ts = env.ledger().timestamp(); + + env.as_contract(&contract_id, || { + PriceFeedAdapter::update_price_feed( + &env, &oracle, pair.clone(), 3000, 10, ts, ts + 60, + ) + .unwrap(); + }); + + env.as_contract(&contract_id, || { + // Must be stored under PriceFeed(pair) + let pf_key = DataKey::PriceFeed(pair.clone()); + assert!( + env.storage().persistent().has(&pf_key), + "Price feed must be stored under DataKey::PriceFeed" + ); + + // Must NOT be stored under OutStake with magic pool_id + let magic_key = DataKey::OutStake(999999, 0); + assert!( + !env.storage().persistent().has(&magic_key), + "Price feed must not be stored under DataKey::OutStake with magic id" + ); + }); + } + + /// PriceCondition must be stored under DataKey::PriceCondition, not OutStake. + #[test] + fn test_price_condition_uses_dedicated_key() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let pair = symbol_short!("BTCUSD"); + let pool_id: u64 = 42; + + env.as_contract(&contract_id, || { + PriceFeedAdapter::set_price_condition(&env, pool_id, pair.clone(), 60000, 1, 100) + .unwrap(); + }); + + env.as_contract(&contract_id, || { + // Must be stored under PriceCondition(pool_id) + let pc_key = DataKey::PriceCondition(pool_id); + assert!( + env.storage().persistent().has(&pc_key), + "Price condition must be stored under DataKey::PriceCondition" + ); + + // Must NOT be stored under OutStake(pool_id, 1) + let bad_key = DataKey::OutStake(pool_id, 1); + assert!( + !env.storage().persistent().has(&bad_key), + "Price condition must not be stored under DataKey::OutStake" + ); + }); + } + + // ── Round-trip tests ───────────────────────────────────────────────────── + + /// OracleConfig round-trips correctly through DataKey::OracleConfig. + #[test] + fn test_oracle_config_round_trip() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, admin) = setup(&env); + + let pyth = Address::generate(&env); + + env.as_contract(&contract_id, || { + PriceFeedAdapter::init_oracle(&env, &admin, pyth.clone(), 300, 100).unwrap(); + }); + + env.as_contract(&contract_id, || { + let cfg: Option = + env.storage().persistent().get(&DataKey::OracleConfig); + let cfg = cfg.expect("OracleConfig must be present"); + assert_eq!(cfg.pyth_contract, pyth); + assert_eq!(cfg.max_price_age, 300); + assert_eq!(cfg.min_confidence_ratio, 100); + }); + } + + /// PriceFeed round-trips correctly through DataKey::PriceFeed. + #[test] + fn test_price_feed_round_trip() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let oracle = Address::generate(&env); + let pair = symbol_short!("ETHUSD"); + let ts = env.ledger().timestamp(); + + env.as_contract(&contract_id, || { + PriceFeedAdapter::update_price_feed( + &env, &oracle, pair.clone(), 3000, 10, ts, ts + 60, + ) + .unwrap(); + }); + + env.as_contract(&contract_id, || { + let feed: Option = + env.storage().persistent().get(&DataKey::PriceFeed(pair.clone())); + let feed = feed.expect("PriceFeed must be present"); + assert_eq!(feed.pair, pair); + assert_eq!(feed.price, 3000); + assert_eq!(feed.confidence, 10); + assert_eq!(feed.timestamp, ts); + assert_eq!(feed.expires_at, ts + 60); + }); + } + + /// PriceCondition round-trips correctly through DataKey::PriceCondition. + #[test] + fn test_price_condition_round_trip() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let pair = symbol_short!("BTCUSD"); + let pool_id: u64 = 7; + + env.as_contract(&contract_id, || { + PriceFeedAdapter::set_price_condition(&env, pool_id, pair.clone(), 60000, 1, 50) + .unwrap(); + }); + + env.as_contract(&contract_id, || { + let cond: Option<(Symbol, i128, u32, u32)> = + env.storage().persistent().get(&DataKey::PriceCondition(pool_id)); + let (fp, tp, op, tol) = cond.expect("PriceCondition must be present"); + assert_eq!(fp, pair); + assert_eq!(tp, 60000); + assert_eq!(op, 1); + assert_eq!(tol, 50); + }); + } + + // ── Storage tier tests ─────────────────────────────────────────────────── + + /// Instance-storage keys (Config, Paused, Version, PoolIdCtr, ReferralCutBps) + /// must be in instance storage, not persistent. + #[test] + fn test_instance_keys_in_instance_storage() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + env.as_contract(&contract_id, || { + assert!( + env.storage().instance().has(&DataKey::Config), + "Config must be in instance storage" + ); + assert!( + env.storage().instance().has(&DataKey::PoolIdCtr), + "PoolIdCtr must be in instance storage" + ); + assert!( + env.storage().instance().has(&DataKey::Version), + "Version must be in instance storage" + ); + // These must NOT be in persistent storage + assert!( + !env.storage().persistent().has(&DataKey::Config), + "Config must not be in persistent storage" + ); + }); + } + + /// RentGuard must be in temporary storage only. + #[test] + fn test_rent_guard_in_temporary_storage() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + env.as_contract(&contract_id, || { + // RentGuard should not exist when no re-entrant call is in progress + assert!( + !env.storage().temporary().has(&DataKey::RentGuard), + "RentGuard must not be set outside of a guarded call" + ); + assert!( + !env.storage().persistent().has(&DataKey::RentGuard), + "RentGuard must not be in persistent storage" + ); + assert!( + !env.storage().instance().has(&DataKey::RentGuard), + "RentGuard must not be in instance storage" + ); + }); + } + + // ── Isolation tests ────────────────────────────────────────────────────── + + /// Different pool IDs must produce distinct PriceCondition keys. + #[test] + fn test_price_condition_keys_are_pool_scoped() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let pair = symbol_short!("ETHUSD"); + + env.as_contract(&contract_id, || { + PriceFeedAdapter::set_price_condition(&env, 1, pair.clone(), 3000, 1, 100).unwrap(); + PriceFeedAdapter::set_price_condition(&env, 2, pair.clone(), 4000, 0, 50).unwrap(); + }); + + env.as_contract(&contract_id, || { + let c1: (Symbol, i128, u32, u32) = env + .storage() + .persistent() + .get(&DataKey::PriceCondition(1)) + .unwrap(); + let c2: (Symbol, i128, u32, u32) = env + .storage() + .persistent() + .get(&DataKey::PriceCondition(2)) + .unwrap(); + + assert_eq!(c1.1, 3000, "Pool 1 target price must be 3000"); + assert_eq!(c2.1, 4000, "Pool 2 target price must be 4000"); + assert_ne!(c1.1, c2.1, "Different pools must have independent conditions"); + }); + } + + /// Different feed pairs must produce distinct PriceFeed keys. + #[test] + fn test_price_feed_keys_are_pair_scoped() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let oracle = Address::generate(&env); + let eth = symbol_short!("ETHUSD"); + let btc = symbol_short!("BTCUSD"); + let ts = env.ledger().timestamp(); + + env.as_contract(&contract_id, || { + PriceFeedAdapter::update_price_feed(&env, &oracle, eth.clone(), 3000, 5, ts, ts + 60) + .unwrap(); + PriceFeedAdapter::update_price_feed(&env, &oracle, btc.clone(), 60000, 50, ts, ts + 60) + .unwrap(); + }); + + env.as_contract(&contract_id, || { + let eth_feed: SimplePriceFeed = env + .storage() + .persistent() + .get(&DataKey::PriceFeed(eth.clone())) + .unwrap(); + let btc_feed: SimplePriceFeed = env + .storage() + .persistent() + .get(&DataKey::PriceFeed(btc.clone())) + .unwrap(); + + assert_eq!(eth_feed.price, 3000); + assert_eq!(btc_feed.price, 60000); + assert_ne!(eth_feed.price, btc_feed.price); + }); + } + + // ── Validity tests ─────────────────────────────────────────────────────── + + /// is_price_valid returns false for expired feeds. + #[test] + fn test_price_validity_expired() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let oracle = Address::generate(&env); + let pair = symbol_short!("ETHUSD"); + let ts = 1000u64; + env.ledger().with_mut(|l| l.timestamp = ts); + + env.as_contract(&contract_id, || { + PriceFeedAdapter::update_price_feed( + &env, &oracle, pair.clone(), 3000, 10, ts, ts + 30, + ) + .unwrap(); + }); + + // Advance past expiry + env.ledger().with_mut(|l| l.timestamp = ts + 60); + + env.as_contract(&contract_id, || { + let feed: SimplePriceFeed = env + .storage() + .persistent() + .get(&DataKey::PriceFeed(pair.clone())) + .unwrap(); + assert!( + !PriceFeedAdapter::is_price_valid(&env, &feed, 300), + "Expired feed must be invalid" + ); + }); + } + + /// is_price_valid returns true for fresh feeds. + #[test] + fn test_price_validity_fresh() { + let env = Env::default(); + env.mock_all_auths(); + let (_, contract_id, _) = setup(&env); + + let oracle = Address::generate(&env); + let pair = symbol_short!("ETHUSD"); + let ts = 1000u64; + env.ledger().with_mut(|l| l.timestamp = ts); + + env.as_contract(&contract_id, || { + PriceFeedAdapter::update_price_feed( + &env, &oracle, pair.clone(), 3000, 10, ts, ts + 300, + ) + .unwrap(); + }); + + env.as_contract(&contract_id, || { + let feed: SimplePriceFeed = env + .storage() + .persistent() + .get(&DataKey::PriceFeed(pair.clone())) + .unwrap(); + assert!( + PriceFeedAdapter::is_price_valid(&env, &feed, 300), + "Fresh feed must be valid" + ); + }); + } +}