diff --git a/contracts/src/admin.rs b/contracts/src/admin.rs index 4a7eb85..521103d 100644 --- a/contracts/src/admin.rs +++ b/contracts/src/admin.rs @@ -7,7 +7,7 @@ use crate::common::{ }; use crate::errors::ContractError; use crate::types::{ - AttestationConfig, AttestationConfigKey, DataKey, DataKeyCore, DataKeyExt, + AttestationConfig, AttestationConfigKey, DataKeyCore, DataKeyExt, DataKeyScoped, DeviationConfig, DeviationConfigKey, DeviationReferenceMode, HbGateConfig, HbGateKey, OracleHeartbeatRecord, OracleQuorumConfig, PolicyAction, ProtocolHealthStatus, Round, RuntimeMode, PENDING_WINNINGS_EXPIRY_KEY, PendingWinningsUpdatedAtKey, @@ -372,7 +372,7 @@ pub fn arm_oracle_deviation_override(env: Env) -> Result<(), ContractError> { /// Loads the deviation guardrail config, returning the `StartPrice` default if unset (Issue #266). pub fn _load_deviation_config(env: &Env) -> DeviationConfig { - let key = DeviationConfigKey::Config; + let key = DeviationConfigKey::DeviationConfig; if env.storage().persistent().has(&key) { env.storage().persistent().extend_ttl( &key, @@ -390,7 +390,7 @@ pub fn _load_deviation_config(env: &Env) -> DeviationConfig { } fn _save_deviation_config(env: &Env, config: &DeviationConfig) { - let key = DeviationConfigKey::Config; + let key = DeviationConfigKey::DeviationConfig; env.storage().persistent().set(&key, config); env.storage().persistent().extend_ttl( &key, @@ -459,7 +459,7 @@ pub fn get_deviation_window_samples(env: Env) -> u32 { /// Loads the attestation config, returning `key: None` (disabled) if unset (Issue #263). pub fn _load_attestation_config(env: &Env) -> AttestationConfig { - let key = AttestationConfigKey::Config; + let key = AttestationConfigKey::AttestationConfig; if env.storage().persistent().has(&key) { env.storage().persistent().extend_ttl( &key, @@ -488,7 +488,7 @@ pub fn set_attestation_key(env: Env, key: Option>) -> Result<(), Cont _emit_action_rejected(&env, &admin, symbol_short!("attkey"), e); })?; - let storage_key = AttestationConfigKey::Config; + let storage_key = AttestationConfigKey::AttestationConfig; env.storage() .persistent() .set(&storage_key, &AttestationConfig { key: key.clone() }); @@ -662,7 +662,7 @@ pub fn _consume_hb_override(env: &Env) -> bool { /// Loads the heartbeat gate config, returning defaults if unset. pub fn _load_hb_config(env: &Env) -> HbGateConfig { - let key = HbGateKey::Config; + let key = HbGateKey::HbConfig; if env.storage().persistent().has(&key) { env.storage().persistent().extend_ttl( &key, @@ -682,7 +682,7 @@ pub fn _load_hb_config(env: &Env) -> HbGateConfig { /// Saves the heartbeat gate config to persistent storage. pub fn _save_hb_config(env: &Env, config: &HbGateConfig) { - let key = HbGateKey::Config; + let key = HbGateKey::HbConfig; env.storage().persistent().set(&key, config); env.storage().persistent().extend_ttl( &key, @@ -715,7 +715,7 @@ pub fn update_oracle_heartbeat(env: Env, status: u32) -> Result<(), ContractErro .storage() .persistent() .get(&DataKeyCore::Oracle) - .ok_or(ContractError::OracleNotSet)?; + .ok_or(ContractError::AdminNotSet)?; oracle.require_auth(); let ts = env.ledger().timestamp(); @@ -816,6 +816,11 @@ pub fn get_protocol_health(env: Env) -> ProtocolHealthStatus { let schema_version = _schema_version(&env).unwrap_or(1); + // Issue #274: surface allowlist-gated access mode in the composite + // status so an indexer/dashboard can tell a deliberately-restricted + // protocol apart from an unhealthy one. + let access_restricted = crate::access_control::is_access_control_enabled(env.clone()); + let mut issues: u32 = 0; if paused { issues += 1; @@ -826,6 +831,9 @@ pub fn get_protocol_health(env: Env) -> ProtocolHealthStatus { if has_active_round && active_round_phase == 3 { issues += 1; } + if access_restricted { + issues += 1; + } let status_code = if paused { 1u32 // PAUSED @@ -835,6 +843,8 @@ pub fn get_protocol_health(env: Env) -> ProtocolHealthStatus { 2u32 // ORACLE_STALE } else if has_active_round && active_round_phase == 3 { 3u32 // ROUND_STALE + } else if access_restricted { + 6u32 // ACCESS_RESTRICTED } else if !has_active_round { 4u32 // NO_ACTIVE_ROUND } else { @@ -1044,9 +1054,9 @@ pub fn batch_touch_ttl(env: Env, keys: Vec) -> Result Result<(), Co if cfg.min_observations < DEFAULT_ORACLE_QUORUM_MIN_OBSERVATIONS || cfg.min_observations > MAX_ORACLE_OBSERVATIONS { - return Err(ContractError::TooFewObservations); + return Err(ContractError::OracleDeviationExceeded); } if cfg.quorum_threshold < DEFAULT_ORACLE_QUORUM_THRESHOLD || cfg.quorum_threshold > cfg.min_observations { - return Err(ContractError::InsufficientOracleQuorum); + return Err(ContractError::OracleDeviationExceeded); } if cfg.outlier_threshold_bps == 0 || cfg.outlier_threshold_bps > 10_000 { return Err(ContractError::WindowOutOfRange); @@ -1201,22 +1211,22 @@ pub fn reclaim_expired_pending_winnings(env: Env, user: Address) -> Result Result) -> Result<() .unwrap_or(0); let round_id = last_round_id .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; env.storage() .persistent() .set(&DataKeyCore::LastRoundId, &round_id); @@ -126,10 +126,10 @@ pub fn create_round(env: Env, start_price: u128, mode: Option) -> Result<() let start_ledger = env.ledger().sequence(); let bet_end_ledger = start_ledger .checked_add(bet_ledgers) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let end_ledger = start_ledger .checked_add(run_ledgers) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let start_timestamp = env.ledger().timestamp(); @@ -191,7 +191,7 @@ pub fn create_next_from_template(env: Env) -> Result { .storage() .persistent() .get(&DataKeyCore::RoundTemplate) - .ok_or(ContractError::NoRoundTemplate) + .ok_or(ContractError::NoActiveRound) .inspect_err(|&e| { _emit_action_rejected(&env, &admin, symbol_short!("nxttmpl"), e); })?; @@ -291,7 +291,7 @@ pub fn place_bet( // Deduct balance let new_balance = user_balance .checked_sub(amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; _set_balance(&env, user.clone(), new_balance); // Write single-user position key @@ -319,13 +319,13 @@ pub fn place_bet( round.pool_up = round .pool_up .checked_add(amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; } BetSide::Down => { round.pool_down = round .pool_down .checked_add(amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; } } env.storage() @@ -342,6 +342,8 @@ pub fn place_bet( (user, round.round_id, amount, side_value), ); + crate::commitments::_advance_pool_commitment(&env, &round); + Ok(()) } @@ -438,7 +440,7 @@ pub fn place_precision_prediction( // Deduct balance let new_balance = user_balance .checked_sub(amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; _set_balance(&env, user.clone(), new_balance); // Write single-user prediction key @@ -461,6 +463,9 @@ pub fn place_precision_prediction( (user, round.round_id, predicted_price, amount), ); + crate::commitments::_add_precision_commit_stake(&env, round.round_id, amount); + crate::commitments::_advance_pool_commitment(&env, &round); + Ok(()) } @@ -557,7 +562,7 @@ pub fn commit_prediction( // Deduct balance let new_balance = user_balance .checked_sub(amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; _set_balance(&env, user.clone(), new_balance); // Store commitment @@ -586,6 +591,9 @@ pub fn commit_prediction( (user, round.round_id, hash, amount), ); + crate::commitments::_add_precision_commit_stake(&env, round.round_id, amount); + crate::commitments::_advance_pool_commitment(&env, &round); + Ok(()) } @@ -665,6 +673,8 @@ pub fn reveal_prediction( (user, round.round_id, predicted_price, commitment.amount), ); + crate::commitments::_advance_pool_commitment(&env, &round); + Ok(()) } @@ -739,14 +749,14 @@ pub fn cash_out_early(env: Env, user: Address) -> Result<(), ContractError> { let penalty_bps_i128 = penalty_bps as i128; let forfeit = stake .checked_mul(penalty_bps_i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR; // If forfeit rounds down to zero (very small stake relative to penalty), // user gets full refund — still remove position from pool. let cashout = stake .checked_sub(forfeit) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; // Deduct full stake from the appropriate pool match position.side { @@ -754,13 +764,13 @@ pub fn cash_out_early(env: Env, user: Address) -> Result<(), ContractError> { round.pool_up = round .pool_up .checked_sub(stake) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; } BetSide::Down => { round.pool_down = round .pool_down .checked_sub(stake) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; } } env.storage() diff --git a/contracts/src/commitments.rs b/contracts/src/commitments.rs new file mode 100644 index 0000000..21c02f0 --- /dev/null +++ b/contracts/src/commitments.rs @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MIT +//! Pool-aggregate commitments published during betting (Observability / +//! ZK-friendly design). +//! +//! While a round is in its betting window, the raw `pool_up`/`pool_down` +//! (and the Precision aggregate stake) are never included in the events this +//! module emits — only a rolling SHA-256 commitment is. Once +//! `bet_end_ledger` is reached, [`open_pool_commitment`] (or automatic +//! opening at settlement — see `settlement::_archive_round`, which calls +//! [`_open_pool_commitment`] on every terminal round transition) reveals the +//! salt and final aggregate, so any observer can recompute the +//! commitment and check it against the last `("pool", "commit")` event. +//! +//! **Trust boundary (documented in `docs/POOL_COMMITMENTS.md`):** this does +//! not hide values from a party reading raw ledger storage directly — +//! Soroban persistent entries are public via RPC regardless of which +//! contract functions expose them. It reduces the *observability* surface +//! (events and the dedicated queries below) that a passive indexer sees +//! pre-close, and gives a verifiable binding that a future ZK proof could +//! sit behind without reshaping the surface. `get_round_pool_stats` is left +//! fully unchanged (Issue: additive-only, no breaking change) and remains +//! the legacy full-disclosure surface for callers who don't need the +//! fairness properties here. +//! +//! Commitment format: +//! ```text +//! commitment = sha256( +//! round_id.to_xdr() || seq.to_xdr() || pool_up.to_xdr() || +//! pool_down.to_xdr() || precision_total_stake.to_xdr() || salt.to_xdr() +//! ) +//! ``` +//! `salt` is generated once per round via `env.prng()` and never returned by +//! any getter until the round is opened. Soroban's PRNG is not suitable for +//! adversarial secrecy against validators (see `soroban_sdk::Env::prng` +//! docs) — this scheme is a fairness/binding commitment, not confidentiality. + +use crate::common::_extend_persistent_ttl; +use crate::errors::ContractError; +use crate::types::{DataKeyScoped, PoolCommitment, PoolOpening, Round, RoundMode}; +use soroban_sdk::xdr::ToXdr; +use soroban_sdk::{symbol_short, Bytes, BytesN, Env}; + +fn _pool_commitment_salt(env: &Env, round_id: u64) -> BytesN<32> { + let key = DataKeyScoped::PoolCommitmentSalt(round_id); + if let Some(salt) = env.storage().persistent().get::<_, BytesN<32>>(&key) { + _extend_persistent_ttl(env, &key); + return salt; + } + let raw: [u8; 32] = env.prng().gen(); + let salt = BytesN::from_array(env, &raw); + env.storage().persistent().set(&key, &salt); + _extend_persistent_ttl(env, &key); + salt +} + +fn _precision_total_stake(env: &Env, round_id: u64) -> i128 { + let key = DataKeyScoped::PrecisionCommitStake(round_id); + env.storage().persistent().get(&key).unwrap_or(0) +} + +/// O(1) increment to the running Precision-mode stake total, called by +/// `place_precision_prediction` and `commit_prediction` alongside their +/// existing storage writes. Keeps the pool commitment's Precision aggregate +/// gas-bounded (no participant scan). +pub fn _add_precision_commit_stake(env: &Env, round_id: u64, amount: i128) { + let key = DataKeyScoped::PrecisionCommitStake(round_id); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let updated = current.checked_add(amount).unwrap_or(current); + env.storage().persistent().set(&key, &updated); + _extend_persistent_ttl(env, &key); +} + +fn _compute_commitment( + env: &Env, + round_id: u64, + seq: u32, + pool_up: i128, + pool_down: i128, + precision_total_stake: i128, + salt: &BytesN<32>, +) -> BytesN<32> { + let mut preimage = Bytes::new(env); + preimage.append(&round_id.to_xdr(env)); + preimage.append(&seq.to_xdr(env)); + preimage.append(&pool_up.to_xdr(env)); + preimage.append(&pool_down.to_xdr(env)); + preimage.append(&precision_total_stake.to_xdr(env)); + preimage.append(&salt.to_xdr(env)); + env.crypto().sha256(&preimage).into() +} + +/// Advances a round's pool commitment. Called at the end of every mutating +/// betting action (`place_bet`, `place_precision_prediction`, +/// `commit_prediction`, `reveal_prediction`) — O(1): one salt lookup, one +/// hash over a handful of fixed-size fields, one commitment write, one +/// event. No iteration over participants. +pub fn _advance_pool_commitment(env: &Env, round: &Round) { + let round_id = round.round_id; + let salt = _pool_commitment_salt(env, round_id); + let precision_total_stake = match round.mode { + RoundMode::UpDown => 0, + RoundMode::Precision => _precision_total_stake(env, round_id), + }; + + let commit_key = DataKeyScoped::PoolCommitment(round_id); + let seq = env + .storage() + .persistent() + .get::<_, PoolCommitment>(&commit_key) + .map(|c| c.seq) + .unwrap_or(0) + .checked_add(1) + .unwrap_or(u32::MAX); + + let commitment = _compute_commitment( + env, + round_id, + seq, + round.pool_up, + round.pool_down, + precision_total_stake, + &salt, + ); + let ledger = env.ledger().sequence(); + + env.storage().persistent().set( + &commit_key, + &PoolCommitment { + round_id, + seq, + commitment: commitment.clone(), + ledger, + }, + ); + _extend_persistent_ttl(env, &commit_key); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("pool"), symbol_short!("commit")), + (round_id, seq, commitment, ledger), + ); +} + +/// Reveals the final aggregate for a round's pool commitment, if not already +/// opened. Idempotent no-op when already opened (used both by the +/// permissionless `open_pool_commitment` entrypoint and by the automatic +/// open-at-settlement hook in `settlement::_ensure_pool_commitment_opened`). +pub fn _open_pool_commitment(env: &Env, round: &Round) { + let round_id = round.round_id; + let opening_key = DataKeyScoped::PoolOpening(round_id); + if env.storage().persistent().has(&opening_key) { + return; + } + + let salt_key = DataKeyScoped::PoolCommitmentSalt(round_id); + let salt: BytesN<32> = env + .storage() + .persistent() + .get(&salt_key) + .unwrap_or_else(|| BytesN::from_array(env, &[0u8; 32])); + + let commit_key = DataKeyScoped::PoolCommitment(round_id); + let seq = env + .storage() + .persistent() + .get::<_, PoolCommitment>(&commit_key) + .map(|c| c.seq) + .unwrap_or(0); + + let precision_total_stake = match round.mode { + RoundMode::UpDown => 0, + RoundMode::Precision => _precision_total_stake(env, round_id), + }; + let opened_ledger = env.ledger().sequence(); + + let opening = PoolOpening { + round_id, + seq, + pool_up: round.pool_up, + pool_down: round.pool_down, + precision_total_stake, + salt, + opened_ledger, + }; + env.storage().persistent().set(&opening_key, &opening); + _extend_persistent_ttl(env, &opening_key); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("pool"), symbol_short!("open")), + ( + round_id, + seq, + opening.pool_up, + opening.pool_down, + opening.precision_total_stake, + opening.salt, + ), + ); +} + +/// Permissionless: reveals the pool commitment for `round_id` once betting +/// has closed. Anyone may call this — no auth required — so indexers get +/// verifiable numbers even if oracle resolution lags `bet_end_ledger`. +/// Errors `InvalidRevealWindow` if called too early, `AlreadyRevealed` if +/// already opened for this round. +pub fn open_pool_commitment(env: Env, round_id: u64) -> Result<(), ContractError> { + let round: Round = env + .storage() + .persistent() + .get(&crate::types::DataKeyCore::ActiveRound) + .filter(|r: &Round| r.round_id == round_id) + .ok_or(ContractError::NoActiveRound)?; + + if env.ledger().sequence() < round.bet_end_ledger { + return Err(ContractError::InvalidRevealWindow); + } + if env + .storage() + .persistent() + .has(&DataKeyScoped::PoolOpening(round_id)) + { + return Err(ContractError::AlreadyRevealed); + } + + _open_pool_commitment(&env, &round); + Ok(()) +} + +/// Read-only: the latest published commitment for `round_id` — a hash, a +/// sequence number, and a ledger. Always available (pre- or post-close); +/// never carries raw aggregate values. +pub fn get_pool_commitment(env: Env, round_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKeyScoped::PoolCommitment(round_id)) +} + +/// Read-only: the revealed opening for `round_id`, or `None` if the round +/// hasn't been opened yet — the fail-closed signal for indexers querying +/// before `bet_end_ledger` (or before settlement/force-open has run). +pub fn get_pool_opening(env: Env, round_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKeyScoped::PoolOpening(round_id)) +} diff --git a/contracts/src/common.rs b/contracts/src/common.rs index 7898616..b9f017b 100644 --- a/contracts/src/common.rs +++ b/contracts/src/common.rs @@ -3,15 +3,14 @@ extern crate alloc; use alloc::vec::Vec as StdVec; use crate::errors::ContractError; use crate::types::{ - ConfigChangeKind, ConfigChangePayload, DataKey, PendingWinningsUpdatedAtKey, Round, RoundPhase, + ConfigChangeKind, ConfigChangePayload, DataKeyCore, DataKeyScoped, PendingWinningsUpdatedAtKey, + Round, RoundPhase, }; use soroban_sdk::{symbol_short, Address, Env, IntoVal, Symbol, Val, Vec}; pub const DEFAULT_PENDING_WINNINGS_EXPIRY: u32 = 0; // 0 = disabled pub const MIN_PENDING_WINNINGS_EXPIRY: u32 = 128; // ~10 min at 5s ledgers pub const MAX_PENDING_WINNINGS_EXPIRY: u32 = 1_000_000; // ~58 days -use crate::types::{ConfigChangeKind, ConfigChangePayload, DataKeyCore, DataKeyScoped, Round, RoundPhase}; -use soroban_sdk::{symbol_short, Address, Env, IntoVal, Symbol, Val, Vec}; // ─── DataKey overflow workaround (DataKey has 51 variants, XDR limit is 50) ── // Moved out of DataKey to get under the limit. @@ -29,6 +28,11 @@ pub fn _legacy_positions_key() -> Symbol { pub const MAX_DISPUTE_LEDGERS: u32 = 120_960; pub const DEFAULT_DISPUTE_LEDGERS: u32 = 0; +// ─── Dual-approval governance (Issue #272) ──────────────────────────────────── +/// Default TTL (in ledgers) applied to a governance proposal when no custom +/// TTL is supplied and none has been configured via `set_gov_proposal_ttl`. +pub const DEFAULT_GOV_PROPOSAL_TTL_LEDGERS: u32 = 17_280; // ~1 day at 5s ledgers + // ─── Economic control limits ───────────────────────────────────────────────── pub const MIN_CAP_VALUE: i128 = 1; pub const MAX_MIN_PARTICIPANTS: u32 = 10_000; @@ -106,7 +110,6 @@ pub const MAX_TWAP_WINDOW_SAMPLES: u32 = 64; /// Bumps/extends the TTL of the given persistent storage key if its remaining TTL /// is less than the threshold. Enforces rent policy (Issue #142). pub fn _extend_persistent_ttl>(env: &Env, key: &K) { -pub fn _extend_persistent_ttl>(env: &Env, key: &T) { if env.storage().persistent().has(key) { env.storage() .persistent() @@ -152,8 +155,7 @@ pub fn payout_mul(a: i128, b: i128) -> Result { /// Accumulates `amount` into a user's pending winnings, enforcing the cap if set (Issue #120). pub fn _accumulate_pending(env: &Env, user: Address, amount: i128) -> Result<(), ContractError> { - let key = DataKey::PendingWinnings(user.clone()); - let key = DataKeyScoped::PendingWinnings(user); + let key = DataKeyScoped::PendingWinnings(user.clone()); let existing: i128 = env.storage().persistent().get(&key).unwrap_or(0); let new_pending = payout_add(existing, amount)?; diff --git a/contracts/src/config.rs b/contracts/src/config.rs index d171a7d..db18860 100644 --- a/contracts/src/config.rs +++ b/contracts/src/config.rs @@ -1,29 +1,21 @@ // SPDX-License-Identifier: MIT use crate::admin::{_ensure_normal_mode, _ensure_not_paused, _require_supported_schema}; use crate::common::{ - _emit_action_rejected, _emit_config_updated, _extend_persistent_ttl, _set_balance, balance, - payout_add, BPS_DENOMINATOR, CONFIG_TIMELOCK_LEDGERS, DEFAULT_ARCHIVE_RETENTION, - DEFAULT_BET_WINDOW_LEDGERS, DEFAULT_CLOSE_BUFFER_LEDGERS, DEFAULT_MAX_PRECISION_PARTICIPANTS, - DEFAULT_ORACLE_STALE_THRESHOLD, DEFAULT_ORACLE_TIMESTAMP_SKEW, DEFAULT_RUN_WINDOW_LEDGERS, - MAX_ARCHIVE_RETENTION, MAX_BET_WINDOW_LEDGERS, MAX_CLOSE_BUFFER_LEDGERS, MAX_MIN_PARTICIPANTS, - MAX_ORACLE_DEVIATION_BPS, MAX_ORACLE_STALE_THRESHOLD, MAX_ORACLE_TIMESTAMP_SKEW, - MAX_PRECISION_PARTICIPANTS_LIMIT, MAX_PROTOCOL_FEE_BPS, MAX_RUN_WINDOW_LEDGERS, - MAX_START_PRICE, MIN_ARCHIVE_RETENTION, MIN_CAP_VALUE, MIN_ORACLE_STALE_THRESHOLD, - MIN_ORACLE_TIMESTAMP_SKEW, MIN_START_PRICE, _emit_action_rejected, _emit_config_updated, _extend_persistent_ttl, _extend_ttl_symbol, _set_balance, balance, payout_add, BPS_DENOMINATOR, CONFIG_TIMELOCK_LEDGERS, DEFAULT_ARCHIVE_RETENTION, DEFAULT_BET_WINDOW_LEDGERS, DEFAULT_CLOSE_BUFFER_LEDGERS, DEFAULT_DISPUTE_LEDGERS, DEFAULT_MAX_PRECISION_PARTICIPANTS, DEFAULT_ORACLE_STALE_THRESHOLD, - DEFAULT_PENDING_WINNINGS_EXPIRY, DEFAULT_RUN_WINDOW_LEDGERS, MAX_ARCHIVE_RETENTION, - MAX_BET_WINDOW_LEDGERS, MAX_CLOSE_BUFFER_LEDGERS, MAX_DISPUTE_LEDGERS, MAX_MIN_PARTICIPANTS, - MAX_ORACLE_DEVIATION_BPS, MAX_ORACLE_STALE_THRESHOLD, MAX_PENDING_WINNINGS_EXPIRY, - MAX_PRECISION_PARTICIPANTS_LIMIT, MAX_PROTOCOL_FEE_BPS, MAX_RUN_WINDOW_LEDGERS, - MAX_START_PRICE, MIN_ARCHIVE_RETENTION, MIN_CAP_VALUE, MIN_ORACLE_STALE_THRESHOLD, + DEFAULT_ORACLE_TIMESTAMP_SKEW, DEFAULT_PENDING_WINNINGS_EXPIRY, DEFAULT_RUN_WINDOW_LEDGERS, + MAX_ARCHIVE_RETENTION, MAX_BET_WINDOW_LEDGERS, MAX_CLOSE_BUFFER_LEDGERS, MAX_DISPUTE_LEDGERS, + MAX_MIN_PARTICIPANTS, MAX_ORACLE_DEVIATION_BPS, MAX_ORACLE_STALE_THRESHOLD, + MAX_ORACLE_TIMESTAMP_SKEW, MAX_PENDING_WINNINGS_EXPIRY, MAX_PRECISION_PARTICIPANTS_LIMIT, + MAX_PROTOCOL_FEE_BPS, MAX_RUN_WINDOW_LEDGERS, MAX_START_PRICE, MIN_ARCHIVE_RETENTION, + MIN_CAP_VALUE, MIN_ORACLE_STALE_THRESHOLD, MIN_ORACLE_TIMESTAMP_SKEW, MIN_PENDING_WINNINGS_EXPIRY, MIN_START_PRICE, }; use crate::errors::ContractError; use crate::types::{ - ConfigChangeKind, ConfigChangePayload, DataKey, DataKeyCore, DataKeyScoped, FeeModel, + ConfigChangeKind, ConfigChangePayload, DataKeyCore, DataKeyScoped, FeeModel, PendingConfigChange, PrecisionPayoutPolicy, RoundTemplate, PENDING_WINNINGS_EXPIRY_KEY, }; use soroban_sdk::{symbol_short, Address, Env, Symbol}; @@ -840,13 +832,14 @@ pub fn set_early_cashout_bps(env: Env, bps: Option) -> Result<(), ContractE &env, &admin, symbol_short!("ec_bps"), - ContractError::InvalidProtocolFeeBps, + ContractError::WindowOutOfRange, ); - return Err(ContractError::InvalidProtocolFeeBps); + return Err(ContractError::WindowOutOfRange); } } let key = DataKeyCore::EarlyCashoutBps; + let old_bps: Option = env.storage().persistent().get(&key); if let Some(v) = bps { env.storage().persistent().set(&key, &v); _extend_persistent_ttl(&env, &key); @@ -985,7 +978,7 @@ pub fn _validate_oracle_max_deviation_bps(bps: Option) -> Result<(), Contra pub fn _validate_protocol_fee_bps(bps: Option) -> Result<(), ContractError> { if let Some(v) = bps { if v == 0 || v > MAX_PROTOCOL_FEE_BPS { - return Err(ContractError::InvalidProtocolFeeBps); + return Err(ContractError::WindowOutOfRange); } } Ok(()) @@ -1028,7 +1021,7 @@ pub fn _collect_protocol_fee( let current: i128 = env.storage().persistent().get(&treasury_key).unwrap_or(0); let new_treasury = current .checked_add(fee_amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; env.storage().persistent().set(&treasury_key, &new_treasury); _extend_persistent_ttl(env, &treasury_key); @@ -1060,13 +1053,13 @@ pub fn calculate_protocol_fee_updown( let total_pot = payout_add(winning_pool, losing_pool)?; total_pot .checked_mul(bps_value as i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR } FeeModel::FeeOnWinnings => { losing_pool .checked_mul(bps_value as i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR } }; @@ -1080,19 +1073,19 @@ pub fn calculate_protocol_fee_updown( let fee_from_losing = fee_amount.min(losing_pool); let fee_from_winning = fee_amount .checked_sub(fee_from_losing) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let dist_winning = winning_pool .checked_sub(fee_from_winning) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let dist_losing = losing_pool .checked_sub(fee_from_losing) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; Ok((dist_winning, dist_losing, fee_amount)) } FeeModel::FeeOnWinnings => { let dist_losing = losing_pool .checked_sub(fee_amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; Ok((winning_pool, dist_losing, fee_amount)) } } @@ -1130,7 +1123,7 @@ pub fn calculate_protocol_fee_precision( FeeModel::FeeOnWinnings => { let profit = total_pot .checked_sub(winner_stakes) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; if profit <= 0 { return Ok((total_pot, 0)); } @@ -1140,14 +1133,14 @@ pub fn calculate_protocol_fee_precision( let fee_amount = taxable_base .checked_mul(bps_value as i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR; if fee_amount == 0 { return Ok((total_pot, 0)); } let distributable = total_pot .checked_sub(fee_amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; Ok((distributable, fee_amount)) } @@ -1272,6 +1265,9 @@ pub fn _current_config_payload(env: &Env, kind: &ConfigChangeKind) -> ConfigChan .unwrap_or(DEFAULT_DISPUTE_LEDGERS), ), ConfigChangeKind::FeeModel => ConfigChangePayload::FeeModel(_read_fee_model(env)), + ConfigChangeKind::EarlyCashoutBps => ConfigChangePayload::EarlyCashoutBps( + env.storage().persistent().get(&DataKeyCore::EarlyCashoutBps), + ), } } @@ -1304,7 +1300,7 @@ pub fn _schedule_config_change( let scheduled_at_ledger = env.ledger().sequence(); let activation_ledger = scheduled_at_ledger .checked_add(CONFIG_TIMELOCK_LEDGERS) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let pending = PendingConfigChange { payload, @@ -1413,6 +1409,8 @@ pub fn _apply_config_payload( ) => { _validate_oracle_timestamp_skew(*seconds)?; env.storage().instance().set(&symbol_short!("otskew"), seconds); + } + ( ConfigChangeKind::PendingWinningsExpiry, ConfigChangePayload::PendingWinningsExpiry(ledgers), ) => { @@ -1517,6 +1515,16 @@ pub fn _apply_config_payload( env.storage().persistent().set(&key, max); _extend_persistent_ttl(env, &key); } + (ConfigChangeKind::EarlyCashoutBps, ConfigChangePayload::EarlyCashoutBps(bps)) => { + let key = DataKeyCore::EarlyCashoutBps; + if let Some(v) = bps { + env.storage().persistent().set(&key, v); + _extend_persistent_ttl(env, &key); + } else { + env.storage().persistent().remove(&key); + } + } + _ => {} } _emit_config_updated(env, kind.clone(), old_value, payload.clone()); Ok(()) diff --git a/contracts/src/contract.rs b/contracts/src/contract.rs index 94ceec1..de7985e 100644 --- a/contracts/src/contract.rs +++ b/contracts/src/contract.rs @@ -5,13 +5,17 @@ use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Map, Symbol, Vec}; +use crate::access_control; +use crate::commitments; use crate::errors::ContractError; use crate::governance; use crate::types::{ - ArchivedRoundSummary, BetSide, ConfigChangeKind, ConfigChangePayload, DataKeyCore, - DataKeyScoped, DeviationReferenceMode, LeaderboardEntry, MultiFeedPayload, OracleHeartbeatRecord, + AccessState, ArchivedRoundSummary, BetSide, ConfigChangeKind, ConfigChangePayload, DataKeyCore, + DataKeyScoped, DeviationReferenceMode, FeeModel, GovAction, GovProposal, LeaderboardEntry, + MultiFeedPayload, OneSidedPolicy, OracleHeartbeatRecord, OraclePayload, OracleQuorumConfig, OracleRotationProposal, PendingConfigChange, - PolicyAction, PrecisionPrediction, PriceSample, ProtocolHealthStatus, ProtocolStatus, Round, + PolicyAction, PoolCommitment, PoolOpening, PrecisionPrediction, PriceSample, + ProtocolHealthStatus, ProtocolStatus, Round, RoundArchiveStatus, RoundPhase, RoundPoolStats, RoundStatus, RoundTemplate, RuntimeMode, SeasonArchive, SeasonLeaderboardEntry, SimulationResult, UserPosition, UserRoundOutcome, UserStats, @@ -178,6 +182,8 @@ impl VirtualTokenContract { limit: u32, ) -> Vec { queries::get_user_archive_history(env, user, offset, limit) + } + /// Returns whether `action` is currently permitted under the PolicyGate /// for the contract's runtime mode (Issue #261). Read-only; does not /// mutate state. See [`admin::_policy_gate`] for the full matrix. @@ -462,7 +468,7 @@ impl VirtualTokenContract { let proposed_at = env.ledger().timestamp(); let expires_at = proposed_at .checked_add(expires_in_seconds) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let proposal = OracleRotationProposal { new_oracle: new_oracle.clone(), @@ -513,7 +519,7 @@ impl VirtualTokenContract { let earliest_accept = proposal .proposed_at .checked_add(MIN_ROTATION_DELAY_SECONDS) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; if current_ts < earliest_accept { #[allow(deprecated)] env.events().publish( @@ -546,7 +552,7 @@ impl VirtualTokenContract { .storage() .persistent() .get(&oracle_key) - .ok_or(ContractError::OracleNotSet)?; + .ok_or(ContractError::AdminNotSet)?; env.storage() .persistent() @@ -677,6 +683,56 @@ impl VirtualTokenContract { governance::get_gov_proposal(env, proposal_id) } + /// Turns participant access control on/off (admin only, Issue #274). + pub fn set_access_control_enabled(env: Env, enabled: bool) -> Result<(), ContractError> { + access_control::set_access_control_enabled(env, enabled) + } + + /// Returns whether allowlist mode is currently enabled. + pub fn is_access_control_enabled(env: Env) -> bool { + access_control::is_access_control_enabled(env) + } + + /// Adds `user` to the allowlist (admin only). + pub fn add_allowlisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::add_allowlisted(env, user) + } + + /// Removes `user` from the allowlist (admin only). + pub fn remove_allowlisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::remove_allowlisted(env, user) + } + + /// Adds `user` to the denylist (admin only). + pub fn add_denylisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::add_denylisted(env, user) + } + + /// Removes `user` from the denylist (admin only). + pub fn remove_denylisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::remove_denylisted(env, user) + } + + /// Returns whether `user` is allowlisted. + pub fn is_user_allowlisted(env: Env, user: Address) -> bool { + access_control::is_allowlisted(env, user) + } + + /// Returns whether `user` is denylisted. + pub fn is_user_denylisted(env: Env, user: Address) -> bool { + access_control::is_denylisted(env, user) + } + + /// Returns the resolved access state for `user`. + pub fn get_access_state(env: Env, user: Address) -> AccessState { + access_control::get_access_state(env, user) + } + + /// Returns a human-facing policy summary: (allowlist enabled, user state). + pub fn get_access_policy(env: Env, user: Address) -> (bool, AccessState) { + access_control::get_access_policy(env, user) + } + /// Schedules a timelocked windows update (alias for [`Self::schedule_windows`]). /// bet_ledgers: Number of ledgers users can place bets /// run_ledgers: Total number of ledgers before round can be resolved @@ -1074,7 +1130,7 @@ impl VirtualTokenContract { } pub fn get_one_sided_policy(env: Env) -> OneSidedPolicy { - let active_round: Option = env.storage().persistent().get(&DataKey::ActiveRound); + let active_round: Option = env.storage().persistent().get(&DataKeyCore::ActiveRound); if let Some(round) = active_round { settlement::_select_one_sided_policy(&round) } else { @@ -1086,6 +1142,27 @@ impl VirtualTokenContract { queries::get_round_pool_stats(env) } + /// Permissionless: reveals the pool-aggregate commitment for `round_id` + /// once betting has closed (`ledger >= bet_end_ledger`). No auth + /// required — anyone (typically an indexer) may force the opening even + /// if oracle resolution hasn't happened yet. + pub fn open_pool_commitment(env: Env, round_id: u64) -> Result<(), ContractError> { + commitments::open_pool_commitment(env, round_id) + } + + /// Latest published pool-aggregate commitment for `round_id` — a hash, + /// sequence number, and ledger. Always available; never carries raw + /// aggregate values. See `docs/POOL_COMMITMENTS.md`. + pub fn get_pool_commitment(env: Env, round_id: u64) -> Option { + commitments::get_pool_commitment(env, round_id) + } + + /// The revealed pool-aggregate opening for `round_id`, or `None` if not + /// yet opened (fail-closed: absence, not stale/default data). + pub fn get_pool_opening(env: Env, round_id: u64) -> Option { + commitments::get_pool_opening(env, round_id) + } + pub fn get_round_phase(env: Env) -> Result { queries::get_round_phase(env) } @@ -1422,7 +1499,7 @@ impl VirtualTokenContract { fn _validate_protocol_fee_bps(bps: Option) -> Result<(), ContractError> { if let Some(v) = bps { if v == 0 || v > MAX_PROTOCOL_FEE_BPS { - return Err(ContractError::InvalidProtocolFeeBps); + return Err(ContractError::WindowOutOfRange); } } Ok(()) @@ -1458,7 +1535,7 @@ impl VirtualTokenContract { let current: i128 = env.storage().persistent().get(&treasury_key).unwrap_or(0); let new_treasury = current .checked_add(fee_amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; env.storage().persistent().set(&treasury_key, &new_treasury); Self::_extend_persistent_ttl(env, &treasury_key); @@ -1497,7 +1574,7 @@ impl VirtualTokenContract { let total_pot = Self::payout_add(winning_pool, losing_pool)?; let fee_amount = total_pot .checked_mul(bps_value as i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR; if fee_amount == 0 { return Ok((winning_pool, losing_pool, 0)); @@ -1505,13 +1582,13 @@ impl VirtualTokenContract { let fee_from_losing = fee_amount.min(losing_pool); let fee_from_winning = fee_amount .checked_sub(fee_from_losing) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let dist_winning = winning_pool .checked_sub(fee_from_winning) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let dist_losing = losing_pool .checked_sub(fee_from_losing) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; Self::_collect_protocol_fee(env, round_id, fee_amount, Some(bps_value))?; Ok((dist_winning, dist_losing, fee_amount)) } @@ -1531,11 +1608,11 @@ impl VirtualTokenContract { let bps_value = bps.unwrap(); let fee_amount = total_pot .checked_mul(bps_value as i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR; let distributable = total_pot .checked_sub(fee_amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; if fee_amount > 0 { Self::_collect_protocol_fee(env, round_id, fee_amount, Some(bps_value))?; } diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index a3fa868..41efe50 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -1,92 +1,89 @@ -// SPDX-License-Identifier: MIT -//! Contract error types for the XLM Price Prediction Market. - -use soroban_sdk::contracterror; - -/// Contract error types -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ContractError { - AlreadyInitialized = 1, - AdminNotSet = 2, - OracleNotSet = 3, - InvalidBetAmount = 6, - NoActiveRound = 7, - RoundEnded = 8, - InsufficientBalance = 9, - AlreadyBet = 10, - Overflow = 11, - InvalidPrice = 12, - InvalidDuration = 13, - InvalidMode = 14, - WrongModeForPrediction = 15, - RoundNotEnded = 16, - StaleOracleData = 18, - InvalidOracleRound = 19, - RoundAlreadyActive = 20, - ContractPaused = 22, - WindowOutOfRange = 23, - FutureOracleData = 24, - PayoutOverflow = 25, - RoundNotCancellable = 27, - StakeExceedsMax = 28, - ExposureCapExceeded = 29, - PendingWinningsCapExceeded = 30, - InvalidStartPrice = 31, - OracleNonceReused = 33, - InvalidMinParticipants = 35, - InvalidPrecisionCap = 38, - PrecisionCapExceeded = 39, - OracleDeviationExceeded = 41, - UnsupportedSchemaVersion = 42, - MigrationActiveRound = 44, - CommitmentNotFound = 45, - AlreadyRevealed = 46, - InvalidRevealWindow = 47, - HashMismatch = 48, - OracleNetworkMismatch = 49, - InvalidProtocolFeeBps = 51, - MintLimitExceeded = 53, - NoPendingRotation = 54, - /// Oracle rotation delay has not elapsed yet (must wait MIN_ROTATION_DELAY_SECONDS) - RotationDelayNotElapsed = 55, - /// Invalid archive retention limit - InvalidArchiveRetention = 62, - InvalidCommitment = 63, - InvalidSalt = 64, - NoRoundTemplate = 65, - /// Oracle payload timestamp is outside the round-relative economic window - OracleTimestampOutsideWindow = 66, - /// Pending winnings entry exists but has not yet reached the configured - /// expiry threshold — caller must wait before reclaiming. - PendingWinningsNotExpired = 66, - /// Epoch mint budget has been fully consumed - EpochBudgetExceeded = 67, - /// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) - OracleNotLive = 68, - /// Invalid precision payout policy - InvalidPayoutPolicy = 69, - /// Stake amount is below the configured minimum bet (dust protection, Issue #269) - BelowMinBet = 70, - /// Multi-feed resolution: fewer observations survived outlier rejection - /// than the configured quorum threshold. - InsufficientOracleQuorum = 71, - /// Multi-feed resolution: payload contains fewer observations than the - /// configured minimum. - TooFewObservations = 72, - /// Multi-feed resolution: outlier observations would dominate the result - /// (too many rejected, cannot form quorum). - OracleOutlierRejected = 73, - /// Multi-feed payload contains duplicate source identifiers. - DuplicateOracleSource = 74, - /// Multi-feed payload has observations that are not sorted or sources - /// are out of expected range. - InvalidObservationOrder = 75, - /// The requested data key is not allowed for batch TTL touch operations. - UnsupportedDataKeyForTtlTouch = 76, - /// Pending winnings entry does not exist or expiry is not configured. - PendingWinningsNotFound = 77, - /// Pending winnings expiry is not configured (value is 0). - ExpiryNotConfigured = 78, -} +// SPDX-License-Identifier: MIT +//! Contract error types for the XLM Price Prediction Market. +//! +//! `#[contracterror]` compiles to a Soroban contract-spec XDR union, which is +//! hard-capped at 50 cases (`VecM`). This enum +//! is at that cap — semantically related failure modes intentionally share a +//! variant (see the doc comment on each) rather than growing past it. +use soroban_sdk::contracterror; + +/// Contract error types +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ContractError { + AlreadyInitialized = 1, + AdminNotSet = 2, + InvalidBetAmount = 6, + NoActiveRound = 7, + RoundEnded = 8, + InsufficientBalance = 9, + AlreadyBet = 10, + InvalidPrice = 12, + InvalidDuration = 13, + InvalidMode = 14, + WrongModeForPrediction = 15, + RoundNotEnded = 16, + StaleOracleData = 18, + InvalidOracleRound = 19, + RoundAlreadyActive = 20, + ContractPaused = 22, + WindowOutOfRange = 23, + FutureOracleData = 24, + PayoutOverflow = 25, + RoundNotCancellable = 27, + StakeExceedsMax = 28, + ExposureCapExceeded = 29, + PendingWinningsCapExceeded = 30, + InvalidStartPrice = 31, + OracleNonceReused = 33, + InvalidMinParticipants = 35, + InvalidPrecisionCap = 38, + PrecisionCapExceeded = 39, + /// Also covers multi-feed quorum/outlier/observation-count rejection + /// (`InsufficientOracleQuorum`, `TooFewObservations`, + /// `DuplicateOracleSource` in earlier drafts) — all are "oracle payload + /// failed a data-quality gate" and share this code to stay under the + /// contracterror 50-case cap. + OracleDeviationExceeded = 41, + UnsupportedSchemaVersion = 42, + MigrationActiveRound = 44, + CommitmentNotFound = 45, + AlreadyRevealed = 46, + /// Also covers "pool commitment not yet openable" (Issue: pool-aggregate + /// commitments) — both are "action attempted outside its valid window". + InvalidRevealWindow = 47, + HashMismatch = 48, + OracleNetworkMismatch = 49, + MintLimitExceeded = 53, + NoPendingRotation = 54, + /// Oracle rotation delay has not elapsed yet (must wait MIN_ROTATION_DELAY_SECONDS) + RotationDelayNotElapsed = 55, + /// Oracle payload timestamp is outside the round-relative economic window + OracleTimestampOutsideWindow = 66, + /// Pending winnings entry exists but has not yet reached the configured + /// expiry threshold — caller must wait before reclaiming. Also covers + /// "expiry not configured" and "no pending winnings entry" — all are + /// "nothing reclaimable right now". + PendingWinningsNotExpired = 79, + /// Epoch mint budget has been fully consumed + EpochBudgetExceeded = 67, + /// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) + OracleNotLive = 68, + /// Stake amount is below the configured minimum bet (dust protection, Issue #269) + BelowMinBet = 70, + /// Caller is not authorized for the requested access-controlled action. + AccessDenied = 80, + /// Governance proposal is not in a state that allows the requested + /// transition. Also covers "proposal not found" — an absent proposal id + /// is treated as an invalid-state transition. + GovInvalidState = 81, + /// Caller is not an authorized governance admin/approver. + GovUnauthorized = 82, + /// Requested state transition is not legal from the round's current phase. + IllegalPhaseTransition = 83, + /// Oracle heartbeat is unhealthy (stale/degraded) and strict mode blocks the action. + OracleHeartbeatUnhealthy = 84, + /// Governance proposal's expiry ledger has passed. + ProposalExpired = 85, +} diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs index 07a4d26..586baad 100644 --- a/contracts/src/governance.rs +++ b/contracts/src/governance.rs @@ -4,18 +4,18 @@ use crate::admin::{_require_supported_schema, _set_mode}; use crate::common::{_emit_action_rejected, _extend_persistent_ttl, DEFAULT_GOV_PROPOSAL_TTL_LEDGERS}; use crate::errors::ContractError; -use crate::types::{DataKey, GovAction, GovProposal, GovProposalStatus, RuntimeMode}; +use crate::types::{DataKeyCore, DataKeyScoped, GovAction, GovProposal, GovProposalStatus, RuntimeMode}; use soroban_sdk::{symbol_short, Address, Env}; /// Returns whether `user` is an authorized governance administrator or approver. pub fn _is_authorized_gov_user(env: &Env, user: &Address) -> bool { - let admin: Option
= env.storage().persistent().get(&DataKey::Admin); + let admin: Option
= env.storage().persistent().get(&DataKeyCore::Admin); if let Some(ref a) = admin { if a == user { return true; } } - let approver: Option
= env.storage().persistent().get(&DataKey::GovApprover); + let approver: Option
= env.storage().persistent().get(&DataKeyCore::GovApprover); if let Some(ref ap) = approver { if ap == user { return true; @@ -26,7 +26,7 @@ pub fn _is_authorized_gov_user(env: &Env, user: &Address) -> bool { /// Returns whether dual governance approval is currently active (a secondary approver is set). pub fn _is_gov_approver_set(env: &Env) -> bool { - let key = DataKey::GovApprover; + let key = DataKeyCore::GovApprover; if env.storage().persistent().has(&key) { _extend_persistent_ttl(env, &key); true @@ -41,11 +41,11 @@ pub fn set_gov_approver(env: Env, approver: Address) -> Result<(), ContractError let admin: Address = env .storage() .persistent() - .get(&DataKey::Admin) + .get(&DataKeyCore::Admin) .ok_or(ContractError::AdminNotSet)?; admin.require_auth(); - let key = DataKey::GovApprover; + let key = DataKeyCore::GovApprover; env.storage().persistent().set(&key, &approver); _extend_persistent_ttl(&env, &key); @@ -60,7 +60,7 @@ pub fn set_gov_approver(env: Env, approver: Address) -> Result<(), ContractError /// Returns the configured secondary governance approver address, if set. pub fn get_gov_approver(env: Env) -> Option
{ - let key = DataKey::GovApprover; + let key = DataKeyCore::GovApprover; _extend_persistent_ttl(&env, &key); env.storage().persistent().get(&key) } @@ -71,7 +71,7 @@ pub fn set_gov_proposal_ttl(env: Env, ttl_ledgers: u32) -> Result<(), ContractEr let admin: Address = env .storage() .persistent() - .get(&DataKey::Admin) + .get(&DataKeyCore::Admin) .ok_or(ContractError::AdminNotSet)?; admin.require_auth(); @@ -79,7 +79,7 @@ pub fn set_gov_proposal_ttl(env: Env, ttl_ledgers: u32) -> Result<(), ContractEr return Err(ContractError::WindowOutOfRange); } - let key = DataKey::GovProposalTtlLedgers; + let key = DataKeyCore::GovProposalTtlLedgers; env.storage().persistent().set(&key, &ttl_ledgers); _extend_persistent_ttl(&env, &key); Ok(()) @@ -87,7 +87,7 @@ pub fn set_gov_proposal_ttl(env: Env, ttl_ledgers: u32) -> Result<(), ContractEr /// Returns the configured default proposal TTL in ledgers. pub fn get_gov_proposal_ttl(env: Env) -> u32 { - let key = DataKey::GovProposalTtlLedgers; + let key = DataKeyCore::GovProposalTtlLedgers; _extend_persistent_ttl(&env, &key); env.storage() .persistent() @@ -128,7 +128,7 @@ pub fn propose( return Err(ContractError::GovUnauthorized); } - let id_key = DataKey::NextGovProposalId; + let id_key = DataKeyCore::NextGovProposalId; let proposal_id: u64 = env.storage().persistent().get(&id_key).unwrap_or(1); env.storage().persistent().set(&id_key, &(proposal_id + 1)); _extend_persistent_ttl(&env, &id_key); @@ -147,7 +147,7 @@ pub fn propose( status: GovProposalStatus::Pending, }; - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); env.storage().persistent().set(&p_key, &proposal); _extend_persistent_ttl(&env, &p_key); @@ -175,12 +175,12 @@ pub fn approve(env: Env, approver: Address, proposal_id: u64) -> Result<(), Cont return Err(ContractError::GovUnauthorized); } - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); let mut proposal: GovProposal = env .storage() .persistent() .get(&p_key) - .ok_or(ContractError::ProposalNotFound)?; + .ok_or(ContractError::GovInvalidState)?; let current_ledger = env.ledger().sequence(); if current_ledger > proposal.expires_at_ledger { @@ -243,12 +243,12 @@ pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), Cont return Err(ContractError::GovUnauthorized); } - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); let mut proposal: GovProposal = env .storage() .persistent() .get(&p_key) - .ok_or(ContractError::ProposalNotFound)?; + .ok_or(ContractError::GovInvalidState)?; let current_ledger = env.ledger().sequence(); if current_ledger > proposal.expires_at_ledger { @@ -282,7 +282,7 @@ pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), Cont } GovAction::SetProtocolFeeBps(bps) => { crate::config::_validate_protocol_fee_bps(bps.clone())?; - let key = DataKey::ProtocolFeeBps; + let key = DataKeyCore::ProtocolFeeBps; if let Some(ref v) = bps { env.storage().persistent().set(&key, v); _extend_persistent_ttl(&env, &key); @@ -294,16 +294,16 @@ pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), Cont _execute_withdraw_fee(&env, &recipient, *amount)?; } GovAction::SetTreasuryAddress(treasury) => { - env.storage().persistent().set(&DataKey::ProtocolFeeTreasury, &treasury); - _extend_persistent_ttl(&env, &DataKey::ProtocolFeeTreasury); + env.storage().persistent().set(&DataKeyCore::ProtocolFeeTreasury, &treasury); + _extend_persistent_ttl(&env, &DataKeyCore::ProtocolFeeTreasury); } GovAction::SetAdmin(new_admin) => { - env.storage().persistent().set(&DataKey::Admin, &new_admin); - _extend_persistent_ttl(&env, &DataKey::Admin); + env.storage().persistent().set(&DataKeyCore::Admin, &new_admin); + _extend_persistent_ttl(&env, &DataKeyCore::Admin); } GovAction::SetOracle(new_oracle) => { - env.storage().persistent().set(&DataKey::Oracle, &new_oracle); - _extend_persistent_ttl(&env, &DataKey::Oracle); + env.storage().persistent().set(&DataKeyCore::Oracle, &new_oracle); + _extend_persistent_ttl(&env, &DataKeyCore::Oracle); } } @@ -329,7 +329,7 @@ fn _execute_withdraw_fee( if amount <= 0 { return Err(ContractError::InvalidBetAmount); } - let treasury_key = DataKey::ProtocolFeeTreasury; + let treasury_key = DataKeyCore::ProtocolFeeTreasury; let current: i128 = env.storage().persistent().get(&treasury_key).unwrap_or(0); if amount > current { return Err(ContractError::InsufficientBalance); @@ -368,12 +368,12 @@ pub fn cancel(env: Env, canceller: Address, proposal_id: u64) -> Result<(), Cont return Err(ContractError::GovUnauthorized); } - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); let mut proposal: GovProposal = env .storage() .persistent() .get(&p_key) - .ok_or(ContractError::ProposalNotFound)?; + .ok_or(ContractError::GovInvalidState)?; match proposal.status { GovProposalStatus::Executed => return Err(ContractError::GovInvalidState), @@ -396,7 +396,7 @@ pub fn cancel(env: Env, canceller: Address, proposal_id: u64) -> Result<(), Cont /// Queries details for a governance proposal. pub fn get_gov_proposal(env: Env, proposal_id: u64) -> Option { - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); _extend_persistent_ttl(&env, &p_key); let mut proposal: GovProposal = env.storage().persistent().get(&p_key)?; diff --git a/contracts/src/leaderboard.rs b/contracts/src/leaderboard.rs index 6604004..ba62e22 100644 --- a/contracts/src/leaderboard.rs +++ b/contracts/src/leaderboard.rs @@ -306,11 +306,11 @@ pub fn _update_season_stats_win(env: &Env, user: Address) -> Result<(), Contract stats.total_wins = stats .total_wins .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; stats.current_streak = stats .current_streak .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; if stats.current_streak > stats.best_streak { stats.best_streak = stats.current_streak; } @@ -335,7 +335,7 @@ pub fn _update_season_stats_loss(env: &Env, user: Address) -> Result<(), Contrac stats.total_losses = stats .total_losses .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; stats.current_streak = 0; env.storage().persistent().set(&key, &stats); @@ -431,7 +431,7 @@ pub fn reset_leaderboard_season(env: Env) -> Result { env.storage().persistent().set(&archive_key, &archive); _extend_persistent_ttl(&env, &archive_key); - let new_season_id = season_id.checked_add(1).ok_or(ContractError::Overflow)?; + let new_season_id = season_id.checked_add(1).ok_or(ContractError::PayoutOverflow)?; let season_key = DataKeyCore::Ext(DataKeyExt::SeasonId); env.storage().persistent().set(&season_key, &new_season_id); _extend_persistent_ttl(&env, &season_key); diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 6284e7b..cca1d5a 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,11 +1,9 @@ // SPDX-License-Identifier: MIT #![no_std] -extern crate alloc; //! # XLM Price Prediction Market //! //! Secure Soroban-based prediction market for XLM price movements. //! Users bet on price direction (UP/DOWN) using virtual XLM tokens - //! //! ## Key Features //! - Role-based access control (Admin, Oracle, Users) @@ -13,15 +11,20 @@ extern crate alloc; //! - Proportional payout distribution //! - Comprehensive error handling +extern crate alloc; + #[cfg(test)] extern crate std; +mod access_control; mod admin; mod betting; pub mod common; +mod commitments; mod config; mod contract; mod errors; +mod governance; mod leaderboard; mod queries; mod settlement; diff --git a/contracts/src/queries.rs b/contracts/src/queries.rs index 419b217..37e55bf 100644 --- a/contracts/src/queries.rs +++ b/contracts/src/queries.rs @@ -9,7 +9,7 @@ use crate::config::{ }; use crate::errors::ContractError; use crate::types::{ - ArchivedRoundSummary, BetSide, DataKey, DataKeyCore, DataKeyScoped, LeaderboardEntry, + ArchivedRoundSummary, BetSide, DataKeyCore, DataKeyScoped, LeaderboardEntry, PrecisionCommitment, PrecisionPrediction, PendingWinningsUpdatedAtKey, Round, RoundMode, RoundPhase, RoundPoolStats, RoundTemplate, SeasonArchive, SimulationResult, UserOutcomeType, UserPosition, UserRoundOutcome, UserStats, @@ -218,7 +218,7 @@ pub fn get_user_archive_history( let user_rounds: Vec = env .storage() .persistent() - .get(&DataKey::UserArchivedRoundIds(user)) + .get(&DataKeyScoped::UserArchivedRoundIds(user)) .unwrap_or(Vec::new(env_ref)); let total = user_rounds.len(); @@ -235,7 +235,7 @@ pub fn get_user_archive_history( if let Some(summary) = env .storage() .persistent() - .get(&DataKey::ArchivedRound(round_id)) + .get(&DataKeyScoped::ArchivedRound(round_id)) { result.push_back(summary); } @@ -625,7 +625,7 @@ pub fn simulate_payout(env: Env, final_price: u128) -> Result Result<(), ContractErr .storage() .persistent() .get(&DataKeyCore::Oracle) - .ok_or(ContractError::OracleNotSet)?; + .ok_or(ContractError::AdminNotSet)?; oracle.require_auth(); _ensure_not_paused(&env).inspect_err(|&e| { _emit_action_rejected(&env, &oracle, symbol_short!("resolve"), e); })?; - // Heartbeat health enforcement (Issue #264) — must come before any - // state mutation (nonce consumption) so a stale oracle cannot race - // the admin override. - _enforce_heartbeat_health(&env, &oracle)?; - let round: Round = env .storage() .persistent() @@ -371,10 +367,10 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr let round_start = round.start_timestamp; let round_duration_ledgers = (round.end_ledger) .checked_sub(round.start_ledger) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let round_end_estimate = round_start - .checked_add((round_duration_ledgers as u64).checked_mul(SECONDS_PER_LEDGER).ok_or(ContractError::Overflow)?) - .ok_or(ContractError::Overflow)?; + .checked_add((round_duration_ledgers as u64).checked_mul(SECONDS_PER_LEDGER).ok_or(ContractError::PayoutOverflow)?) + .ok_or(ContractError::PayoutOverflow)?; let lower_bound = round_start.saturating_sub(skew); let upper_bound = round_end_estimate.saturating_add(skew); @@ -414,20 +410,20 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr payload .price .checked_sub(reference_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? } else { reference_price .checked_sub(payload.price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? }; let diff_bps_u128 = diff .checked_mul(10_000u128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / reference_price; let diff_bps: u32 = diff_bps_u128 .try_into() - .map_err(|_| ContractError::Overflow)?; + .map_err(|_| ContractError::PayoutOverflow)?; let override_armed: bool = env .storage() @@ -501,6 +497,11 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr } } + // Heartbeat health enforcement (Issue #264) — must come before any + // state mutation (nonce consumption) so a stale oracle cannot race + // the admin override. + _enforce_heartbeat_health(&env, &oracle, round.round_id)?; + let nonce_key = DataKeyScoped::ConsumedOracleNonce(round.round_id, payload.nonce); if env.storage().persistent().has(&nonce_key) { _emit_action_rejected( @@ -513,42 +514,6 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr } env.storage().persistent().set(&nonce_key, &true); - // ─── Oracle heartbeat health gate (Issue #264) ────────────────────────── - // - // When `HbGateConfig.strict_mode` is enabled, `resolve_round` verifies - // that the oracle heartbeat is live before allowing settlement. - let hb_config = crate::admin::_load_hb_config(&env); - - if hb_config.strict_mode { - let hb_blocked = _check_heartbeat_health_blocked(&env, &hb_config); - - if hb_blocked { - if hb_config.override_armed { - // Consume the one-shot override - crate::admin::_consume_hb_override(&env); - - #[allow(deprecated)] - env.events().publish( - (symbol_short!("oracle"), symbol_short!("hoverride")), - (round.round_id,), - ); - } else { - #[allow(deprecated)] - env.events().publish( - (symbol_short!("oracle"), symbol_short!("hblocked")), - (round.round_id,), - ); - _emit_action_rejected( - &env, - &oracle, - symbol_short!("resolve"), - ContractError::OracleNotLive, - ); - return Err(ContractError::OracleNotLive); - } - } - } - let current_ledger = env.ledger().sequence(); if current_ledger < round.end_ledger { _emit_action_rejected( @@ -567,8 +532,9 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr // historical samples to draw from instead of starting empty. _record_twap_sample(&env, payload.price, payload.timestamp); - // Delegate to the shared settlement helper (passes confidence from legacy payload). - _settle_round_with_price(&env, &round, payload.price, payload.confidence) + // Delegate to the shared settlement helper (passes confidence from legacy payload), + // deferring to the dispute window when one is configured. + _settle_or_defer(&env, &round, payload.price, payload.confidence) } /// Resolves the active round using a multi-feed oracle payload. @@ -585,10 +551,10 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co // ── Basic payload validation ────────────────────────────────────────── if payload.prices.is_empty() || payload.sources.is_empty() { - return Err(ContractError::TooFewObservations); + return Err(ContractError::OracleDeviationExceeded); } if payload.prices.len() != payload.sources.len() { - return Err(ContractError::TooFewObservations); + return Err(ContractError::OracleDeviationExceeded); } // All prices must be non-zero @@ -607,7 +573,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co .storage() .persistent() .get(&DataKeyCore::Oracle) - .ok_or(ContractError::OracleNotSet)?; + .ok_or(ContractError::AdminNotSet)?; oracle.require_auth(); _ensure_not_paused(&env).inspect_err(|&e| { @@ -620,7 +586,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co .storage() .persistent() .get(&DataKeyCore::OracleQuorum) - .ok_or(ContractError::OracleNotSet)?; + .ok_or(ContractError::AdminNotSet)?; // ── Load active round ───────────────────────────────────────────────── let round: Round = env @@ -712,9 +678,9 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co &env, &oracle, symbol_short!("resolve"), - ContractError::TooFewObservations, + ContractError::OracleDeviationExceeded, ); - return Err(ContractError::TooFewObservations); + return Err(ContractError::OracleDeviationExceeded); } // Reject excessive observations to prevent gas abuse from O(N²) sort if n > MAX_ORACLE_OBSERVATIONS { @@ -722,9 +688,9 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co &env, &oracle, symbol_short!("resolve"), - ContractError::TooFewObservations, + ContractError::OracleDeviationExceeded, ); - return Err(ContractError::TooFewObservations); + return Err(ContractError::OracleDeviationExceeded); } // ── Check for duplicate source identifiers ──────────────────────────── @@ -737,9 +703,9 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co &env, &oracle, symbol_short!("resolve"), - ContractError::DuplicateOracleSource, + ContractError::OracleDeviationExceeded, ); - return Err(ContractError::DuplicateOracleSource); + return Err(ContractError::OracleDeviationExceeded); } } } @@ -752,7 +718,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co if let Some(price) = payload.prices.get(i) { let mut inserted = false; for j in 0..sorted_prices.len() { - if price < sorted_prices.get(j).ok_or(ContractError::Overflow)? { + if price < sorted_prices.get(j).ok_or(ContractError::PayoutOverflow)? { sorted_prices.insert(j, price); inserted = true; break; @@ -766,13 +732,13 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co // Compute median let median_price: u128 = if n % 2 == 1 { - sorted_prices.get(n / 2).ok_or(ContractError::Overflow)? + sorted_prices.get(n / 2).ok_or(ContractError::PayoutOverflow)? } else { - let mid1 = sorted_prices.get(n / 2 - 1).ok_or(ContractError::Overflow)?; - let mid2 = sorted_prices.get(n / 2).ok_or(ContractError::Overflow)?; + let mid1 = sorted_prices.get(n / 2 - 1).ok_or(ContractError::PayoutOverflow)?; + let mid2 = sorted_prices.get(n / 2).ok_or(ContractError::PayoutOverflow)?; mid1 .checked_add(mid2) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / 2 }; @@ -797,19 +763,19 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co let diff = if median_price >= start_price { median_price .checked_sub(start_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? } else { start_price .checked_sub(median_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? }; let diff_bps_u128 = diff .checked_mul(10_000u128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / start_price; let diff_bps: u32 = diff_bps_u128 .try_into() - .map_err(|_| ContractError::Overflow)?; + .map_err(|_| ContractError::PayoutOverflow)?; let override_armed: bool = env .storage() @@ -858,26 +824,26 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co let diff = if price >= median_price { price .checked_sub(median_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? } else { median_price .checked_sub(price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? }; // diff_bps = diff * 10000 / median_price let diff_bps_u128 = diff .checked_mul(10_000u128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / median_price; let diff_bps: u32 = diff_bps_u128 .try_into() - .map_err(|_| ContractError::Overflow)?; + .map_err(|_| ContractError::PayoutOverflow)?; if diff_bps <= quorum_cfg.outlier_threshold_bps { survivors = survivors .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; } } } @@ -893,7 +859,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co quorum_cfg.quorum_threshold, ), ); - return Err(ContractError::InsufficientOracleQuorum); + return Err(ContractError::OracleDeviationExceeded); } // ── Emit multi-feed summary event ───────────────────────────────────── @@ -913,7 +879,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co _record_twap_sample(&env, median_price, payload.timestamp); // ── Settle the round using the computed median price ────────────────── - _settle_round_with_price(&env, &round, median_price, None) + _settle_or_defer(&env, &round, median_price, None) } /// Internal helper: dispatches settlement after the settlement price has been @@ -939,9 +905,6 @@ fn _settle_round_with_price( let threshold_participants: Vec
= env .storage() .persistent() - .get(&DataKey::RoundParticipants(round_id)) - .unwrap_or(Vec::new(&env)); - if threshold_participants.len() < min { .get(&DataKeyScoped::RoundParticipants(round_id)) .unwrap_or(Vec::new(env)); let count = threshold_participants.len(); @@ -950,19 +913,16 @@ fn _settle_round_with_price( env, round, RoundArchiveStatus::FallbackRefund, - payload.price, - &threshold_participants, final_price, - count, + &threshold_participants, 0, - None, + confidence, ); - _refund_under_threshold(&env, &round, &threshold_participants)?; _refund_under_threshold(env, round, &threshold_participants)?; #[allow(deprecated)] env.events().publish( (symbol_short!("round"), symbol_short!("fallback")), - (round_id, participant_count, min), + (round_id, count, min), ); return Ok(()); } @@ -983,33 +943,29 @@ fn _settle_round_with_price( } RoundMode::Precision => _resolve_precision_mode(env, round_id, final_price, false)?, }; + let _ = total_pot; let participants: Vec
= env .storage() .persistent() - .get(&DataKey::RoundParticipants(round_id)) - .unwrap_or(Vec::new(&env)); .get(&DataKeyScoped::RoundParticipants(round_id)) .unwrap_or(Vec::new(env)); - let participant_count = participants.len(); _archive_round( env, round, RoundArchiveStatus::Resolved, - payload.price, - &participants, final_price, - participant_count, + &participants, fee_amount, - payload.confidence, + confidence, ); // Mode-scoped position cleanup (eliminates redundant storage delete lookups) match round.mode { RoundMode::UpDown => { - for i in 0..raw_participants.len() { - if let Some(user) = raw_participants.get(i) { + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { env.storage() .persistent() .remove(&DataKeyScoped::Position(round_id, user)); @@ -1017,8 +973,8 @@ fn _settle_round_with_price( } } RoundMode::Precision => { - for i in 0..raw_participants.len() { - if let Some(user) = raw_participants.get(i) { + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { env.storage() .persistent() .remove(&DataKeyScoped::PrecisionPosition(round_id, user.clone())); @@ -1058,6 +1014,169 @@ fn _settle_round_with_price( Ok(()) } +/// Settles immediately when no dispute window is configured (`dispute_ledgers +/// == 0`, the default — unchanged behaviour for every existing round), or +/// stages the computed outcome behind a dispute window otherwise, leaving the +/// round active until `void_round` or `finalize_round` is called. +fn _settle_or_defer( + env: &Env, + round: &Round, + final_price: u128, + confidence: Option, +) -> Result<(), ContractError> { + let dispute_ledgers = crate::config::get_dispute_ledgers(env); + if dispute_ledgers == 0 { + return _settle_round_with_price(env, round, final_price, confidence); + } + + let expires_at_ledger = env.ledger().sequence().saturating_add(dispute_ledgers); + let dispute_key = DataKeyScoped::PendingDispute(round.round_id); + env.storage().persistent().set( + &dispute_key, + &PendingDispute { + final_price, + confidence, + expires_at_ledger, + }, + ); + _extend_persistent_ttl(env, &dispute_key); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("round"), symbol_short!("dispute")), + (round.round_id, final_price, expires_at_ledger), + ); + + Ok(()) +} + +/// Refunds every participant's full stake for a round awaiting the dispute +/// window and marks it `Voided`. Permissionless; only valid while +/// `ledger < expires_at_ledger`. +pub fn void_round(env: Env, round_id: u64) -> Result<(), ContractError> { + let round: Round = env + .storage() + .persistent() + .get(&DataKeyCore::ActiveRound) + .filter(|r: &Round| r.round_id == round_id) + .ok_or(ContractError::NoActiveRound)?; + + let dispute_key = DataKeyScoped::PendingDispute(round_id); + let pending: PendingDispute = env + .storage() + .persistent() + .get(&dispute_key) + .ok_or(ContractError::IllegalPhaseTransition)?; + if env.ledger().sequence() >= pending.expires_at_ledger { + return Err(ContractError::IllegalPhaseTransition); + } + + let participants: Vec
= env + .storage() + .persistent() + .get(&DataKeyScoped::RoundParticipants(round_id)) + .unwrap_or(Vec::new(&env)); + + match round.mode { + RoundMode::UpDown => { + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { + let pos_key = DataKeyScoped::Position(round_id, user.clone()); + if let Some(position) = + env.storage().persistent().get::<_, UserPosition>(&pos_key) + { + _accumulate_pending(&env, user.clone(), position.amount)?; + let prediction_side = match position.side { + BetSide::Up => 0, + BetSide::Down => 1, + }; + _persist_user_outcome( + &env, + round_id, + 0, + &user, + prediction_side, + 0, + position.amount, + position.amount, + UserOutcomeType::Void, + ); + } + } + } + } + RoundMode::Precision => { + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { + let pred_key = DataKeyScoped::PrecisionPosition(round_id, user.clone()); + let commit_key = DataKeyScoped::PrecisionCommitment(round_id, user.clone()); + let amount = if let Some(pred) = env + .storage() + .persistent() + .get::<_, PrecisionPrediction>(&pred_key) + { + Some(pred.amount) + } else { + env.storage() + .persistent() + .get::<_, PrecisionCommitment>(&commit_key) + .map(|c| c.amount) + }; + if let Some(amount) = amount { + _accumulate_pending(&env, user.clone(), amount)?; + _persist_user_outcome( + &env, round_id, 1, &user, 2, 0, amount, amount, UserOutcomeType::Void, + ); + } + } + } + } + } + + _archive_round( + &env, + &round, + RoundArchiveStatus::Voided, + 0, + &participants, + 0, + None, + ); + + clear_round_storage(&env, round_id, &participants); + env.storage().persistent().remove(&dispute_key); + + #[allow(deprecated)] + env.events() + .publish((symbol_short!("round"), symbol_short!("voided")), (round_id,)); + + Ok(()) +} + +/// Distributes the settlement computed at `resolve_round`/`resolve_round_multi` +/// time for a round whose dispute window has elapsed. Permissionless. +pub fn finalize_round(env: Env, round_id: u64) -> Result<(), ContractError> { + let round: Round = env + .storage() + .persistent() + .get(&DataKeyCore::ActiveRound) + .filter(|r: &Round| r.round_id == round_id) + .ok_or(ContractError::NoActiveRound)?; + + let dispute_key = DataKeyScoped::PendingDispute(round_id); + let pending: PendingDispute = env + .storage() + .persistent() + .get(&dispute_key) + .ok_or(ContractError::IllegalPhaseTransition)?; + if env.ledger().sequence() < pending.expires_at_ledger { + return Err(ContractError::IllegalPhaseTransition); + } + + env.storage().persistent().remove(&dispute_key); + _settle_round_with_price(&env, &round, pending.final_price, pending.confidence) +} + // ─── Internal helpers ──────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] @@ -1067,7 +1186,12 @@ pub fn _resolve_updown_mode( final_price: u128, skip_payout: bool, ) -> Result<(bool, i128), ContractError> { - let participants = sort_addresses(raw_participants.clone()); + let raw_participants: Vec
= env + .storage() + .persistent() + .get(&DataKeyScoped::RoundParticipants(round.round_id)) + .unwrap_or(Vec::new(env)); + let participants = sort_addresses(raw_participants); // Pure price-direction classification and one-sided check delegated to // settlement_math for auditability and golden-vector coverage. @@ -1089,7 +1213,7 @@ pub fn _resolve_updown_mode( let positions: Map = if participants.is_empty() { env.storage() .persistent() - .get(&DataKey::UpDownPositions) + .get(&DataKeyCore::UpDownPositions) .unwrap_or(Map::new(env)) } else { Map::new(env) @@ -1340,7 +1464,7 @@ pub fn _resolve_precision_mode( round_id: u64, final_price: u128, skip_payout: bool, -) -> Result { +) -> Result<(i128, i128), ContractError> { let mut participants: Vec
= env .storage() .persistent() @@ -1397,7 +1521,7 @@ pub fn _resolve_precision_mode( total_pot = total_pot .checked_add(amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; participant_amounts.push(amount); participant_prices.push(cached_price); is_winner_mask.push(false); @@ -1406,11 +1530,11 @@ pub fn _resolve_precision_mode( let diff = if pred.predicted_price >= final_price { pred.predicted_price .checked_sub(final_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? } else { final_price .checked_sub(pred.predicted_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? }; let idx = i as usize; @@ -1448,7 +1572,7 @@ pub fn _resolve_precision_mode( if let Some(w) = winners.get(i) { winner_stakes = winner_stakes .checked_add(w.amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; } } let (payout_pool, fee) = _apply_protocol_fee_precision(env, round_id, total_pot, winner_stakes)?; @@ -1557,11 +1681,11 @@ pub fn _resolve_precision_legacy( let diff = if pred.predicted_price >= final_price { pred.predicted_price .checked_sub(final_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? } else { final_price .checked_sub(pred.predicted_price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? }; match min_diff { @@ -1598,7 +1722,7 @@ pub fn _resolve_precision_legacy( if let Some(w) = winners.get(i) { winner_stakes = winner_stakes .checked_add(w.amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; } } let (payout_pool, fee) = _apply_protocol_fee_precision(env, round_id, total_pot, winner_stakes)?; @@ -1779,15 +1903,47 @@ pub fn _record_winnings_indexed( Ok(fee_amount) } +/// Selects the one-sided settlement policy for a round (Issue #270). Always +/// `Refund` today; `Void`/`CarryForward` exist as forward-compatible +/// variants for a future admin-configurable policy selector. +pub fn _select_one_sided_policy(_round: &Round) -> OneSidedPolicy { + OneSidedPolicy::Refund +} + +/// Applies the deterministic one-sided settlement policy: refunds every +/// participant's full stake and collects no protocol fee. `Void` and +/// `CarryForward` currently fall back to the same full-refund behaviour +/// ("with refund fallback if unsupported", per `ROUND_LIFECYCLE.md`). +pub fn _apply_one_sided_policy( + env: &Env, + round: &Round, + _policy: OneSidedPolicy, + participants: &Vec
, + positions: &Option>, +) -> Result { + if !participants.is_empty() { + _record_refunds_indexed(env, round.round_id, 0, participants)?; + } else if let Some(positions) = positions { + _record_refunds_legacy(env, round.round_id, positions)?; + } + Ok(0) +} + pub fn _archive_round( env: &Env, round: &Round, status: RoundArchiveStatus, final_price: u128, - participants: &[Address], + participants: &Vec
, fee_amount: i128, confidence: Option, ) { + // Guarantee every terminal round transition has a pool-commitment opening + // on record (fail-closed policy: a round can never leave the active + // state with its pool aggregate still hidden). No-op if already opened + // via the permissionless `open_pool_commitment` entrypoint. + crate::commitments::_open_pool_commitment(env, round); + let status_val = status.clone() as u32; let participant_count = participants.len() as u32; let settled_at_ledger = env.ledger().sequence(); @@ -1806,7 +1962,7 @@ pub fn _archive_round( // Record per-user participation index for paginated history queries. for i in 0..participants.len() { if let Some(user) = participants.get(i) { - let index_key = DataKey::UserArchivedRoundIds(user.clone()); + let index_key = DataKeyScoped::UserArchivedRoundIds(user.clone()); let mut user_rounds: Vec = env .storage() .persistent() @@ -1871,8 +2027,6 @@ pub fn _archive_round( } } - let fee_model_value: u32 = _read_fee_model(env) as u32; - #[allow(deprecated)] env.events().publish( (symbol_short!("round"), symbol_short!("summary")), @@ -1890,8 +2044,6 @@ pub fn _archive_round( fee_amount, settled_at_ledger, confidence, - status_val, - fee_model_value, ), ); @@ -1936,7 +2088,6 @@ pub fn _archive_round( } else { break; } - recent.remove(0); } env.storage() @@ -2126,7 +2277,7 @@ pub fn _twap_reference_price(env: &Env, window_samples: u32) -> Result bool current_time > deadline } +/// Shared oracle-heartbeat health gate (Issue #264), used by `resolve_round` +/// and `resolve_round_multi`. When `HbGateConfig.strict_mode` is enabled and +/// the heartbeat is blocked, either consumes a one-shot admin override or +/// rejects with `OracleNotLive`. Runs before any state mutation (nonce +/// consumption) so a stale oracle cannot race the admin override. +pub fn _enforce_heartbeat_health( + env: &Env, + oracle: &Address, + round_id: u64, +) -> Result<(), ContractError> { + let hb_config = crate::admin::_load_hb_config(env); + + if hb_config.strict_mode { + let hb_blocked = _check_heartbeat_health_blocked(env, &hb_config); + + if hb_blocked { + if hb_config.override_armed { + crate::admin::_consume_hb_override(env); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("oracle"), symbol_short!("hoverride")), + (round_id,), + ); + } else { + #[allow(deprecated)] + env.events().publish( + (symbol_short!("oracle"), symbol_short!("hblocked")), + (round_id,), + ); + _emit_action_rejected(env, oracle, symbol_short!("resolve"), ContractError::OracleNotLive); + return Err(ContractError::OracleNotLive); + } + } + } + + Ok(()) +} + pub fn _update_stats_win(env: &Env, user: Address) -> Result<(), ContractError> { let key = DataKeyScoped::UserStats(user.clone()); let mut stats: UserStats = env.storage().persistent().get(&key).unwrap_or(UserStats { @@ -2221,11 +2411,11 @@ pub fn _update_stats_win(env: &Env, user: Address) -> Result<(), ContractError> stats.total_wins = stats .total_wins .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; stats.current_streak = stats .current_streak .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; if stats.current_streak > stats.best_streak { stats.best_streak = stats.current_streak; @@ -2250,7 +2440,7 @@ pub fn _update_stats_loss(env: &Env, user: Address) -> Result<(), ContractError> stats.total_losses = stats .total_losses .checked_add(1) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; stats.current_streak = 0; env.storage().persistent().set(&key, &stats); diff --git a/contracts/src/settlement_math.rs b/contracts/src/settlement_math.rs index bb226cc..153a0b5 100644 --- a/contracts/src/settlement_math.rs +++ b/contracts/src/settlement_math.rs @@ -70,7 +70,7 @@ pub fn compute_updown_fee( let total_pot = payout_add(winning_pool, losing_pool)?; let fee_amount = total_pot .checked_mul(bps_value as i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR; if fee_amount == 0 { return Ok((winning_pool, losing_pool, 0)); @@ -78,13 +78,13 @@ pub fn compute_updown_fee( let fee_from_losing = fee_amount.min(losing_pool); let fee_from_winning = fee_amount .checked_sub(fee_from_losing) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let dist_winning = winning_pool .checked_sub(fee_from_winning) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; let dist_losing = losing_pool .checked_sub(fee_from_losing) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; Ok((dist_winning, dist_losing, fee_amount)) } @@ -100,11 +100,11 @@ pub fn compute_precision_fee( let bps_value = fee_bps.unwrap(); let fee_amount = total_pot .checked_mul(bps_value as i128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / BPS_DENOMINATOR; let distributable = total_pot .checked_sub(fee_amount) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::PayoutOverflow)?; Ok((distributable, fee_amount)) } @@ -237,7 +237,7 @@ pub fn split_pot_among_winners( let payout = if i == 0 { per_winner .checked_add(remainder) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? } else { per_winner }; @@ -437,19 +437,19 @@ pub fn compute_deviation_bps( let diff = if price >= reference { price .checked_sub(reference) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? } else { reference .checked_sub(price) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? }; let diff_bps_u128 = diff .checked_mul(10_000u128) - .ok_or(ContractError::Overflow)? + .ok_or(ContractError::PayoutOverflow)? / reference; diff_bps_u128 .try_into() - .map_err(|_| ContractError::Overflow) + .map_err(|_| ContractError::PayoutOverflow) } // ─── Median computation (for multi-feed) ───────────────────────────────────── diff --git a/contracts/src/storage.rs b/contracts/src/storage.rs index 4120fd8..ac92ce7 100644 --- a/contracts/src/storage.rs +++ b/contracts/src/storage.rs @@ -11,7 +11,7 @@ //! (`Position` + `PrecisionPosition` + `PrecisionCommitment`) should route //! through `clear_user_positions` or `clear_round_storage`. -use crate::types::DataKey; +use crate::types::{DataKeyCore, DataKeyScoped}; use soroban_sdk::{Address, Env, Vec}; /// Removes **all** position storage keys for a single participant, @@ -28,13 +28,13 @@ use soroban_sdk::{Address, Env, Vec}; pub fn clear_user_positions(env: &Env, round_id: u64, user: &Address) { env.storage() .persistent() - .remove(&DataKey::Position(round_id, user.clone())); + .remove(&DataKeyScoped::Position(round_id, user.clone())); env.storage() .persistent() - .remove(&DataKey::PrecisionPosition(round_id, user.clone())); + .remove(&DataKeyScoped::PrecisionPosition(round_id, user.clone())); env.storage() .persistent() - .remove(&DataKey::PrecisionCommitment(round_id, user.clone())); + .remove(&DataKeyScoped::PrecisionCommitment(round_id, user.clone())); } /// Removes all position storage keys for every participant in a round, @@ -64,15 +64,15 @@ pub fn clear_round_storage(env: &Env, round_id: u64, participants: &Vec
// Clear shared keys env.storage() .persistent() - .remove(&DataKey::RoundParticipants(round_id)); - env.storage().persistent().remove(&DataKey::ActiveRound); + .remove(&DataKeyScoped::RoundParticipants(round_id)); + env.storage().persistent().remove(&DataKeyCore::ActiveRound); // Legacy keys — safe no-op when absent - env.storage().persistent().remove(&DataKey::Positions); + env.storage().persistent().remove(&DataKeyCore::Positions); env.storage() .persistent() - .remove(&DataKey::UpDownPositions); + .remove(&DataKeyCore::UpDownPositions); env.storage() .persistent() - .remove(&DataKey::PrecisionPositions); + .remove(&DataKeyCore::PrecisionPositions); } diff --git a/contracts/src/tests/archive_participation.rs b/contracts/src/tests/archive_participation.rs index 2fa778a..40b6cae 100644 --- a/contracts/src/tests/archive_participation.rs +++ b/contracts/src/tests/archive_participation.rs @@ -22,6 +22,8 @@ fn resolve_active_round( nonce, network_id: env.ledger().network_id(), contract_addr: client.address.clone(), + confidence: None, + attestation: None, }); round_id } diff --git a/contracts/src/tests/archive_retention.rs b/contracts/src/tests/archive_retention.rs index 85bd216..72e1ccc 100644 --- a/contracts/src/tests/archive_retention.rs +++ b/contracts/src/tests/archive_retention.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: MIT +use super::config_helpers::apply_windows; use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; use crate::errors::ContractError; use crate::types::{ArchivedRoundSummary, DataKeyCore, DataKeyScoped, OraclePayload}; @@ -32,6 +33,14 @@ fn create_and_resolve_round( li.sequence_number = start_ledger; li.timestamp = 1000; }); + // Widen the round's run window to exactly 100 ledgers: large enough + // that the round-relative oracle timestamp check (Issue: + // oracle-round-relative-timestamp-window) accepts the fixed + // payload.timestamp=1800 resolved below (needs >=100 ledgers: round_start + // 1000 + 100*5 + 300s skew == 1800), yet no larger than the fixed + // `start_ledger + 100` advance below, which must reach round.end_ledger + // for resolve_round's RoundNotEnded gate to pass. + apply_windows(env, client, 6, 100); client.create_round(&1_0000000, &None); env.ledger().with_mut(|li| { @@ -297,6 +306,8 @@ fn test_user_archived_participation_returns_none_after_prune() { li.sequence_number = 0; li.timestamp = 1000; }); + // Widen the run window — see the comment in create_and_resolve_round. + apply_windows(&env, &client, 6, 100); client.create_round(&1_0000000, &None); client.place_bet(&user, &100_0000000, &crate::types::BetSide::Up); @@ -315,22 +326,48 @@ fn test_user_archived_participation_returns_none_after_prune() { attestation: None, }); - // User participation is available before prune - let outcome = client.get_user_archived_participation(&user, &0); + // User participation is available before prune. Round ids are + // 1-indexed (create_round increments from a last-id of 0), so round 1's + // id is 1, not the OraclePayload.round_id field above (which is + // matched against round.start_ledger, a different value). + let outcome = client.get_user_archived_participation(&user, &1); assert!(outcome.is_some(), "outcome should exist before prune"); - // Create and resolve round 2 — this should prune round 1 - create_and_resolve_round(&env, &client, &contract_id_obj, 200, 1); + // Create round 2, with the same user participating, and resolve — this + // should prune round 1. + apply_windows(&env, &client, 6, 100); + env.ledger().with_mut(|li| { + li.sequence_number = 200; + li.timestamp = 1000; + }); + client.create_round(&1_0000000, &None); + client.place_bet(&user, &100_0000000, &crate::types::BetSide::Up); + + env.ledger().with_mut(|li| { + li.sequence_number = 300; + li.timestamp = 2000; + }); + client.resolve_round(&OraclePayload { + price: 2_0000000, + timestamp: 1800, + round_id: 200, + nonce: 1, + network_id: env.ledger().network_id(), + contract_addr: contract_id_obj.clone(), + confidence: None, + attestation: None, + }); - // After prune, get_user_archived_participation should return None - let outcome = client.get_user_archived_participation(&user, &0); + // After prune, get_user_archived_participation should return None for + // round 1 + let outcome = client.get_user_archived_participation(&user, &1); assert!( outcome.is_none(), "get_user_archived_participation should return None for pruned round" ); - // Round 2 still has its outcome - let outcome2 = client.get_user_archived_participation(&user, &1); + // Round 2 (id=2) still has its outcome + let outcome2 = client.get_user_archived_participation(&user, &2); assert!(outcome2.is_some(), "outcome should exist for retained round"); } @@ -362,7 +399,7 @@ fn test_prune_cleans_cancelled_round_marker() { assert!( env.storage() .persistent() - .has(&DataKeyScoped::CancelledRound(0u64)) + .has(&DataKeyScoped::CancelledRound(1u64)) ); }); @@ -379,7 +416,7 @@ fn test_prune_cleans_cancelled_round_marker() { assert!( !env.storage() .persistent() - .has(&DataKeyScoped::CancelledRound(0u64)), + .has(&DataKeyScoped::CancelledRound(1u64)), "CancelledRound marker should be cleaned up during prune" ); }); diff --git a/contracts/src/tests/cei_ordering.rs b/contracts/src/tests/cei_ordering.rs index 96003bd..0f698ac 100644 --- a/contracts/src/tests/cei_ordering.rs +++ b/contracts/src/tests/cei_ordering.rs @@ -14,7 +14,7 @@ use crate::errors::ContractError; use crate::types::{BetSide, ConfigChangeKind}; use soroban_sdk::{ symbol_short, - testutils::{Address as _, Ledger as _}, + testutils::{Address as _, Events as _, Ledger as _}, Address, Env, TryIntoVal, }; @@ -251,6 +251,7 @@ fn test_claim_winnings_respects_runtime_mode() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); let pending = client.get_pending_winnings(&alice); @@ -284,6 +285,7 @@ fn test_claim_winnings_respects_runtime_mode() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); let pending2 = client.get_pending_winnings(&alice); @@ -321,6 +323,7 @@ fn test_claim_winnings_respects_runtime_mode() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); let pending3 = client.get_pending_winnings(&alice); diff --git a/contracts/src/tests/conservation.rs b/contracts/src/tests/conservation.rs index f474d4a..9aff5fe 100644 --- a/contracts/src/tests/conservation.rs +++ b/contracts/src/tests/conservation.rs @@ -60,7 +60,7 @@ //! wiring is required for CI inclusion. use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::types::{BetSide, DataKey, DataKeyCore, DataKeyScoped, OraclePayload, RoundArchiveStatus}; +use crate::types::{BetSide, DataKeyCore, OraclePayload, RoundArchiveStatus}; use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, diff --git a/contracts/src/tests/cost_benchmarks.rs b/contracts/src/tests/cost_benchmarks.rs index b64d19a..3df9c18 100644 --- a/contracts/src/tests/cost_benchmarks.rs +++ b/contracts/src/tests/cost_benchmarks.rs @@ -278,6 +278,7 @@ fn bench_cost_resolve_round_medium_set() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }; let (cpu, mem, _) = measure(&env, || client.resolve_round(&payload)); report("resolve_round_medium_n25", cpu, mem); @@ -311,6 +312,7 @@ fn bench_cost_resolve_round_max_cap() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }; let (cpu, mem, _) = measure(&env, || client.resolve_round(&payload)); report("resolve_round_max_cap_n100", cpu, mem); @@ -339,6 +341,7 @@ fn bench_cost_resolve_precision_round_max_cap() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }; let (cpu, mem, _) = measure(&env, || client.resolve_round(&payload)); report("resolve_precision_max_cap_n100", cpu, mem); diff --git a/contracts/src/tests/drill.rs b/contracts/src/tests/drill.rs index 7f9a6fe..18839b0 100644 --- a/contracts/src/tests/drill.rs +++ b/contracts/src/tests/drill.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT //! Automated emergency "claims-only" drill tests for protocol pause/incident behavior. +use super::config_helpers::apply_windows; use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; use crate::errors::ContractError; -use crate::types::{BetSide, DataKey, OraclePayload, ProtocolStatus}; +use crate::types::{BetSide, DataKeyCore, OraclePayload, ProtocolStatus}; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, Address, Env, BytesN, @@ -33,13 +34,17 @@ fn test_claims_only_matrix_verification() { client.mint_initial(&user1); client.mint_initial(&user2); - // Round 0 + // Round 0. Widen the run window so the round-relative oracle timestamp + // check (Issue: oracle-round-relative-timestamp-window) accepts the + // timestamp=1000 resolved below, and advance sequence_number to match + // (resolve_round also requires current_ledger >= round.end_ledger). + apply_windows(&env, &client, 6, 200); client.create_round(&1_0000000, &None); client.place_bet(&user1, &100_0000000, &BetSide::Up); client.place_bet(&user2, &100_0000000, &BetSide::Down); env.ledger().with_mut(|li| { - li.sequence_number = 65; + li.sequence_number = 200; li.timestamp = 1000; }); @@ -51,13 +56,14 @@ fn test_claims_only_matrix_verification() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert!(client.get_pending_winnings(&user1) > 0); // Seed protocol fee treasury for test fee withdrawal validation env.as_contract(&contract_id, || { - env.storage().persistent().set(&DataKey::ProtocolFeeTreasury, &5000_0000000i128); + env.storage().persistent().set(&DataKeyCore::ProtocolFeeTreasury, &5000_0000000i128); }); // ─── ENTER CLAIMS-ONLY MODE ──────────────────────────────────────────────── @@ -106,23 +112,26 @@ fn test_claims_only_matrix_verification() { // Active Round 1 exists, admin cancels it client.cancel_round(&1); - // Create Round 2 in ClaimsOnly mode + // Create Round 2 in ClaimsOnly mode. The run window widened above is a + // contract-wide setting, so round 2 inherits it too — it can't be + // rescheduled here since config changes require Normal runtime mode. client.create_round(&2_0000000, &None); // 5. Resolution -> ALLOWED env.ledger().with_mut(|li| { - li.sequence_number = 130; + li.sequence_number = 400; li.timestamp = 2000; }); let resolve_res = client.try_resolve_round(&OraclePayload { price: 2_1000000, timestamp: env.ledger().timestamp(), - round_id: 65, + round_id: 200, nonce: 101, network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(resolve_res, Ok(Ok(()))); @@ -179,6 +188,7 @@ fn test_fully_paused_matrix_verification() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(resolve_res, Err(Ok(ContractError::ContractPaused))); @@ -211,6 +221,11 @@ fn test_emergency_incident_simulation_lifecycle() { client.mint_initial(&user1); client.mint_initial(&user2); + // Widen the run window so the round-relative oracle timestamp check + // (Issue: oracle-round-relative-timestamp-window) accepts the + // timestamp=1000 resolved below, and advance sequence_number to match + // (resolve_round also requires current_ledger >= round.end_ledger). + apply_windows(&env, &client, 6, 200); client.create_round(&1_0000000, &None); client.place_bet(&user1, &100_0000000, &BetSide::Up); client.place_bet(&user2, &100_0000000, &BetSide::Down); @@ -230,7 +245,7 @@ fn test_emergency_incident_simulation_lifecycle() { // Step D: In-Flight Round Resolution in Claims-Only Mode env.ledger().with_mut(|li| { - li.sequence_number = 65; + li.sequence_number = 200; li.timestamp = 1000; }); @@ -242,6 +257,7 @@ fn test_emergency_incident_simulation_lifecycle() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Step E: Claim Winnings Executed Successfully During Emergency Mode diff --git a/contracts/src/tests/event_coverage.rs b/contracts/src/tests/event_coverage.rs index 77058ba..6ee0561 100644 --- a/contracts/src/tests/event_coverage.rs +++ b/contracts/src/tests/event_coverage.rs @@ -277,8 +277,11 @@ fn test_event_coverage_place_bet() { client.place_bet(&user, &100_0000000, &BetSide::Up); + // The pool-aggregate commitment event (Observability/ZK-friendly design) + // is published last on every mutating betting action, so the "bet" + // event itself is second-to-last here. let events = env.events().all(); - let last_event = events.last().unwrap(); + let last_event = events.get(events.len() - 2).unwrap(); let (_contract, topics, data) = last_event; assert_eq!(topics.len(), 2); @@ -319,8 +322,11 @@ fn test_event_coverage_commit_and_reveal() { let committed_hash: BytesN<32> = hash.into(); client.commit_prediction(&user, &committed_hash.clone(), &100_0000000); + // The pool-aggregate commitment event (Observability/ZK-friendly design) + // is published last on every mutating betting action, so the "commit" + // event itself is second-to-last here. let events = env.events().all(); - let last_event = events.last().unwrap(); + let last_event = events.get(events.len() - 2).unwrap(); let (_contract, topics, data) = last_event; assert_eq!(topics.len(), 2); @@ -344,8 +350,9 @@ fn test_event_coverage_commit_and_reveal() { client.reveal_prediction(&user, &price, &salt); + // Same reasoning as above — pool commitment event trails "reveal". let events = env.events().all(); - let last_event = events.last().unwrap(); + let last_event = events.get(events.len() - 2).unwrap(); let (_contract, topics, data) = last_event; assert_eq!(topics.len(), 2); @@ -386,19 +393,21 @@ fn test_event_coverage_resolve_round() { confidence: None, attestation: None, }); + // Locate the canonical round-summary event by topic rather than + // assuming it's last: the pool-aggregate commitment event and the + // legacy ("round","resolved") terminal event both publish after it. let events = env.events().all(); - let last_event = events.last().unwrap(); - let (_contract, topics, data) = last_event; + let summary_event = events + .iter() + .find(|(_contract, topics, _data)| { + topics.len() == 2 + && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("round")) + && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("summary")) + }) + .expect("round summary event must be emitted"); + let (_contract, topics, data) = summary_event; assert_eq!(topics.len(), 2); - assert_eq!( - topics.get(0).unwrap().try_into_val(&env), - Ok(symbol_short!("round")) - ); - assert_eq!( - topics.get(1).unwrap().try_into_val(&env), - Ok(symbol_short!("summary")) - ); let canon: (u32, u64, u32, u32, u128, u128, i128, i128, u32, i128, i128, u32, Option) = data.try_into_val(&env).unwrap(); assert_eq!(canon.0, 0u32); // version @@ -414,12 +423,6 @@ fn test_event_coverage_resolve_round() { assert_eq!(canon.10, 0i128); // fee_amount assert_eq!(canon.11, 12u32); // settled_at_ledger assert_eq!(canon.12, None); // confidence - Ok(symbol_short!("resolved")) - ); - assert_eq!( - data.try_into_val(&env), - Ok((1u64, 1_2000000u128, 0u32, Option::::None, 0u32)) - ); } #[test] diff --git a/contracts/src/tests/fee_model.rs b/contracts/src/tests/fee_model.rs index bf9e148..e66a5b5 100644 --- a/contracts/src/tests/fee_model.rs +++ b/contracts/src/tests/fee_model.rs @@ -8,7 +8,7 @@ //! - Edge cases: one-sided pools, all-unrevealed, ties, zero-profit use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::types::{BetSide, DataKey, FeeModel, OraclePayload, PrecisionPrediction}; +use crate::types::{BetSide, DataKeyCore, FeeModel, OraclePayload, PrecisionPrediction}; use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, @@ -34,7 +34,7 @@ fn set_fee_bps_now(env: &Env, contract_id: &Address, bps: u32) { env.as_contract(contract_id, || { env.storage() .persistent() - .set(&DataKey::ProtocolFeeBps, &bps); + .set(&DataKeyCore::ProtocolFeeBps, &bps); }); } @@ -42,7 +42,7 @@ fn set_fee_model_now(env: &Env, contract_id: &Address, model: FeeModel) { env.as_contract(contract_id, || { env.storage() .persistent() - .set(&DataKey::FeeModel, &model); + .set(&DataKeyCore::FeeModel, &model); }); } @@ -63,6 +63,7 @@ fn resolve_at( network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); } @@ -134,7 +135,9 @@ fn fee_zero_both_models_produce_identical_updown() { client.place_bet(&charlie2, &5, &BetSide::Down); set_fee_model_now(&env, &contract_id, FeeModel::FeeOnWinnings); - env.ledger().with_mut(|li| li.sequence_number = 13); + // Round 2 was created while sequence_number was still 12 (left over + // from round 1's resolve above), so its end_ledger is 12+12=24, not 13. + env.ledger().with_mut(|li| li.sequence_number = 24); let treasury_before2 = client.get_protocol_fee_treasury(); resolve_at(&env, &client, &contract_id, 2_000u128); @@ -187,7 +190,9 @@ fn fee_zero_both_models_produce_identical_precision() { client.place_precision_prediction(&bob2, &30, &1_100u128); set_fee_model_now(&env, &contract_id, FeeModel::FeeOnWinnings); - env.ledger().with_mut(|li| li.sequence_number = 13); + // Round 2 was created while sequence_number was still 12 (left over + // from round 1's resolve above), so its end_ledger is 12+12=24, not 13. + env.ledger().with_mut(|li| li.sequence_number = 24); let treasury_before2 = client.get_protocol_fee_treasury(); resolve_at(&env, &client, &contract_id, 1_006u128); @@ -563,15 +568,15 @@ proptest! { positions.set(alice.clone(), UserPosition { amount: a_up, side: BetSide::Up }); positions.set(bob.clone(), UserPosition { amount: b_up, side: BetSide::Up }); positions.set(charlie.clone(), UserPosition { amount: c_down, side: BetSide::Down }); - env.storage().persistent().set(&DataKey::UpDownPositions, &positions); + env.storage().persistent().set(&DataKeyCore::UpDownPositions, &positions); - let mut round: crate::types::Round = env.storage().persistent().get(&DataKey::ActiveRound).unwrap(); + let mut round: crate::types::Round = env.storage().persistent().get(&DataKeyCore::ActiveRound).unwrap(); round.pool_up = total_up; round.pool_down = total_down; - env.storage().persistent().set(&DataKey::ActiveRound, &round); + env.storage().persistent().set(&DataKeyCore::ActiveRound, &round); - env.storage().persistent().set(&DataKey::ProtocolFeeBps, &fee_bps_raw); - env.storage().persistent().set(&DataKey::FeeModel, &FeeModel::FeeOnWinnings); + env.storage().persistent().set(&DataKeyCore::ProtocolFeeBps, &fee_bps_raw); + env.storage().persistent().set(&DataKeyCore::FeeModel, &FeeModel::FeeOnWinnings); }); let treasury_before = client.get_protocol_fee_treasury(); @@ -586,6 +591,7 @@ proptest! { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); let alice_pending = client.get_pending_winnings(&alice); @@ -639,10 +645,10 @@ proptest! { predictions.set(alice.clone(), PrecisionPrediction { user: alice.clone(), predicted_price: price_a, amount: amount_a }); predictions.set(bob.clone(), PrecisionPrediction { user: bob.clone(), predicted_price: price_b, amount: amount_b }); predictions.set(charlie.clone(), PrecisionPrediction { user: charlie.clone(), predicted_price: price_c, amount: amount_c }); - env.storage().persistent().set(&DataKey::PrecisionPositions, &predictions); + env.storage().persistent().set(&DataKeyCore::PrecisionPositions, &predictions); - env.storage().persistent().set(&DataKey::ProtocolFeeBps, &fee_bps_raw); - env.storage().persistent().set(&DataKey::FeeModel, &FeeModel::FeeOnWinnings); + env.storage().persistent().set(&DataKeyCore::ProtocolFeeBps, &fee_bps_raw); + env.storage().persistent().set(&DataKeyCore::FeeModel, &FeeModel::FeeOnWinnings); }); let treasury_before = client.get_protocol_fee_treasury(); @@ -657,6 +663,7 @@ proptest! { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); let alice_pending = client.get_pending_winnings(&alice); diff --git a/contracts/src/tests/fuzz_lifecycle.rs b/contracts/src/tests/fuzz_lifecycle.rs index d59348e..311208d 100644 --- a/contracts/src/tests/fuzz_lifecycle.rs +++ b/contracts/src/tests/fuzz_lifecycle.rs @@ -163,16 +163,21 @@ fn fuzz_protocol_lifecycle_invariants() { } } LifecycleAction::CancelRound => { - let _ = client.try_cancel_round(); + let _ = client.try_cancel_round(&0u32); } LifecycleAction::ResolveRound { price_up } => { if let Some(active) = client.get_active_round() { env.ledger().with_mut(|li| li.sequence_number = active.end_ledger); let price = if *price_up { 2_0000000u128 } else { 5000000u128 }; let _ = client.try_resolve_round(&OraclePayload { - round_id: active.round_id, + round_id: active.start_ledger, price, timestamp: env.ledger().timestamp(), + nonce: step_idx as u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, }); } } diff --git a/contracts/src/tests/invariant_harness.rs b/contracts/src/tests/invariant_harness.rs index d83a5ad..65b26c9 100644 --- a/contracts/src/tests/invariant_harness.rs +++ b/contracts/src/tests/invariant_harness.rs @@ -6,9 +6,10 @@ extern crate std; use proptest::prelude::*; use proptest::strategy::ValueTree; use proptest::test_runner::{Config, RngSeed, TestRunner}; -use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::{Address as _, Ledger as _}; use soroban_sdk::{Address, Env}; use std::env; +use std::format; use std::string::String; use std::vec::Vec; use rand::{rngs::StdRng, SeedableRng}; @@ -109,15 +110,12 @@ fn differential_invariant_harness() { client.update_oracle_heartbeat(&0u32); let users: std::vec::Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + let mut model = ReferenceModel::new(); for u in &users { client.mint_initial(u); + model.deposit(u, 1000_0000000); } - for u in &users { - client.mint_initial(u); - model.deposit(u, 1000_0000000); - } - let mut current_round_id = 0u64; for (step_idx, act) in actions.iter().enumerate() { @@ -131,14 +129,14 @@ fn differential_invariant_harness() { } Action::BetUp { user_idx, amount } => { let user = &users[*user_idx % users.len()]; - let res = client.try_place_bet(user, &(*amount as u128), &BetSide::Up); + let res = client.try_place_bet(user, amount, &BetSide::Up); if res.is_ok() { model.place_bet(user, *amount, true); } } Action::BetDown { user_idx, amount } => { let user = &users[*user_idx % users.len()]; - let res = client.try_place_bet(user, &(*amount as u128), &BetSide::Down); + let res = client.try_place_bet(user, amount, &BetSide::Down); if res.is_ok() { model.place_bet(user, *amount, false); } @@ -152,10 +150,9 @@ fn differential_invariant_harness() { env.ledger().with_mut(|li| li.sequence_number = active.end_ledger); let price = if *price_up { 2_0000000u128 } else { 5000000u128 }; let res = client.try_resolve_round(&crate::types::OraclePayload { - round_id: active.round_id, price, timestamp: env.ledger().timestamp(), - round_id: 0, + round_id: active.start_ledger, nonce: 1u64, network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), @@ -168,7 +165,7 @@ fn differential_invariant_harness() { } } Action::CancelRound => { - let res = client.try_cancel_round(); + let res = client.try_cancel_round(&0u32); if res.is_ok() { model.cancel_round(); } diff --git a/contracts/src/tests/leaderboard.rs b/contracts/src/tests/leaderboard.rs index 6505da6..46bbc4a 100644 --- a/contracts/src/tests/leaderboard.rs +++ b/contracts/src/tests/leaderboard.rs @@ -44,9 +44,9 @@ fn test_leaderboard_ordered_by_wins() { // Query wins leaderboard with cursor = None (first page) let page = client.get_leaderboard_by_wins(&None, &10); - assert_eq!(page.items.len(), 3); + assert_eq!(page.0.len(), 3); - let entries = page.items; + let entries = page.0; // Expected order: Bob (5), Alice (3), Carol (2) assert_eq!(entries.get(0).unwrap().user, user_b); assert_eq!(entries.get(0).unwrap().stats.total_wins, 5); @@ -58,7 +58,7 @@ fn test_leaderboard_ordered_by_wins() { assert_eq!(entries.get(2).unwrap().stats.total_wins, 2); // next_cursor should be Some (the last entry's address) - assert!(page.next_cursor.is_some()); + assert!(page.1.is_some()); } #[test] @@ -104,9 +104,9 @@ fn test_leaderboard_ordered_by_streak() { // Query streak leaderboard with cursor = None let page = client.get_leaderboard_by_streak(&None, &10); - assert_eq!(page.items.len(), 3); + assert_eq!(page.0.len(), 3); - let entries = page.items; + let entries = page.0; // Expected order: Carol (4), Alice (3), Bob (2) assert_eq!(entries.get(0).unwrap().user, user_c); assert_eq!(entries.get(0).unwrap().stats.best_streak, 4); @@ -150,27 +150,27 @@ fn test_leaderboard_cursor_pagination() { // First page: cursor = None, limit = 1 -> should return Bob (5 wins) let page0 = client.get_leaderboard_by_wins(&None, &1); - assert_eq!(page0.items.len(), 1); - assert_eq!(page0.items.get(0).unwrap().user, user_b); - assert!(page0.next_cursor.is_some()); + assert_eq!(page0.0.len(), 1); + assert_eq!(page0.0.get(0).unwrap().user, user_b); + assert!(page0.1.is_some()); // Second page: cursor from page0 -> should return Alice (3 wins) - let page1 = client.get_leaderboard_by_wins(&page0.next_cursor, &1); - assert_eq!(page1.items.len(), 1); - assert_eq!(page1.items.get(0).unwrap().user, user_a); - assert!(page1.next_cursor.is_some()); + let page1 = client.get_leaderboard_by_wins(&page0.1, &1); + assert_eq!(page1.0.len(), 1); + assert_eq!(page1.0.get(0).unwrap().user, user_a); + assert!(page1.1.is_some()); // Third page: cursor from page1 -> should return Carol (2 wins) - let page2 = client.get_leaderboard_by_wins(&page1.next_cursor, &1); - assert_eq!(page2.items.len(), 1); - assert_eq!(page2.items.get(0).unwrap().user, user_c); + let page2 = client.get_leaderboard_by_wins(&page1.1, &1); + assert_eq!(page2.0.len(), 1); + assert_eq!(page2.0.get(0).unwrap().user, user_c); // Last page: next_cursor should be None (exhausted) - assert!(page2.next_cursor.is_none()); + assert!(page2.1.is_none()); // Fourth page: using last cursor -> empty - let page3 = client.get_leaderboard_by_wins(&page2.next_cursor, &1); - assert_eq!(page3.items.len(), 0); - assert!(page3.next_cursor.is_none()); + let page3 = client.get_leaderboard_by_wins(&page2.1, &1); + assert_eq!(page3.0.len(), 0); + assert!(page3.1.is_none()); } #[test] @@ -199,11 +199,11 @@ fn test_leaderboard_deterministic_tie_breaking() { // Query wins leaderboard with cursor = None let page = client.get_leaderboard_by_wins(&None, &10); - assert_eq!(page.items.len(), 2); + assert_eq!(page.0.len(), 2); // Expected order: sorted by Address ascending - let first = page.items.get(0).unwrap().user; - let second = page.items.get(1).unwrap().user; + let first = page.0.get(0).unwrap().user; + let second = page.0.get(1).unwrap().user; assert!(first < second); } @@ -236,7 +236,7 @@ fn test_leaderboard_limit_capped_at_max_page_size() { let page = client.get_leaderboard_by_wins(&None, &150); // With 50 participants, we should get at most 50 results, all ≤ 100. assert!( - page.items.len() <= 100, + page.0.len() <= 100, "result count should be capped at 100" ); } @@ -254,12 +254,12 @@ fn test_leaderboard_empty_when_no_users() { // No round created, no users → empty leaderboard let page = client.get_leaderboard_by_wins(&None, &10); - assert_eq!(page.items.len(), 0); - assert!(page.next_cursor.is_none()); + assert_eq!(page.0.len(), 0); + assert!(page.1.is_none()); let page2 = client.get_leaderboard_by_streak(&None, &10); - assert_eq!(page2.items.len(), 0); - assert!(page2.next_cursor.is_none()); + assert_eq!(page2.0.len(), 0); + assert!(page2.1.is_none()); } #[test] @@ -285,6 +285,6 @@ fn test_leaderboard_zero_limit_is_empty() { // limit = 0 → empty page let page = client.get_leaderboard_by_wins(&None, &0); - assert_eq!(page.items.len(), 0); - assert!(page.next_cursor.is_none()); + assert_eq!(page.0.len(), 0); + assert!(page.1.is_none()); } diff --git a/contracts/src/tests/lifecycle.rs b/contracts/src/tests/lifecycle.rs index db7e30d..42b3f76 100644 --- a/contracts/src/tests/lifecycle.rs +++ b/contracts/src/tests/lifecycle.rs @@ -348,7 +348,6 @@ fn test_create_round_fails_without_admin_auth() { }, }]); client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); // No mocking all auths, so create_round should fail let result = client.try_create_round(&1_0000000, &None); @@ -376,7 +375,6 @@ fn test_place_bet_fails_without_user_auth() { }, }]); client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); env.mock_auths(&[soroban_sdk::testutils::MockAuth { address: &user, @@ -424,7 +422,6 @@ fn test_resolve_round_fails_without_oracle_auth() { }, }]); client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); env.mock_auths(&[soroban_sdk::testutils::MockAuth { address: &admin, @@ -474,7 +471,6 @@ fn test_claim_winnings_fails_without_user_auth() { }, }]); client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); env.mock_auths(&[soroban_sdk::testutils::MockAuth { address: &user, @@ -1059,7 +1055,7 @@ fn test_create_next_from_template_requires_template() { client.update_oracle_heartbeat(&0u32); let result = client.try_create_next_from_template(); - assert_eq!(result, Err(Ok(ContractError::CommitmentNotFound))); + assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); assert_eq!(client.get_active_round(), None); } @@ -1217,6 +1213,6 @@ fn test_create_next_from_template_after_clear_fails() { client.clear_round_template(); let result = client.try_create_next_from_template(); - assert_eq!(result, Err(Ok(ContractError::CommitmentNotFound))); + assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); assert_eq!(client.get_active_round(), None); } diff --git a/contracts/src/tests/migration_versioning.rs b/contracts/src/tests/migration_versioning.rs index 2d39981..0632813 100644 --- a/contracts/src/tests/migration_versioning.rs +++ b/contracts/src/tests/migration_versioning.rs @@ -153,7 +153,7 @@ fn test_dry_run_v1_to_v2_passes_validation() { // Dry-run should succeed. let res = client.try_migrate_schema_v1_to_v2(&true); - assert_eq!(res, Ok(())); + assert_eq!(res, Ok(Ok(()))); // Schema version must NOT have changed. assert_eq!(client.get_schema_version(), 1u32); @@ -182,7 +182,7 @@ fn test_dry_run_v2_to_v3_passes_validation() { // Dry-run should succeed. let res = client.try_migrate_schema_v2_to_v3(&true); - assert_eq!(res, Ok(())); + assert_eq!(res, Ok(Ok(()))); // Schema version must NOT have changed. assert_eq!(client.get_schema_version(), 2u32); diff --git a/contracts/src/tests/mod.rs b/contracts/src/tests/mod.rs index bc23413..90f0d1c 100644 --- a/contracts/src/tests/mod.rs +++ b/contracts/src/tests/mod.rs @@ -31,6 +31,7 @@ mod overflow_tests; mod pause; mod pending_winnings_expiry; mod policy_gate; +mod pool_commitments; mod property_invariants; mod reference_model; mod resolution; diff --git a/contracts/src/tests/mode_tests.rs b/contracts/src/tests/mode_tests.rs index fae1265..86868a3 100644 --- a/contracts/src/tests/mode_tests.rs +++ b/contracts/src/tests/mode_tests.rs @@ -4,11 +4,11 @@ use super::config_helpers::{apply_max_stake, apply_max_user_exposure, apply_windows}; use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; use crate::errors::ContractError; -use crate::types::{BetSide, DataKey, OraclePayload, RoundMode}; +use crate::types::{BetSide, DataKeyCore, DataKeyScoped, OraclePayload, RoundMode}; use soroban_sdk::{ symbol_short, testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, TryIntoVal, + Address, BytesN, Env, TryIntoVal, Vec, }; /// Salt satisfying on-chain minimum entropy (non-zero, non-constant). @@ -1652,6 +1652,7 @@ fn test_alternation_updown_after_precision_no_stale_data() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // --- Step 2: Run an UpDown round (mode switch) --- @@ -1669,13 +1670,14 @@ fn test_alternation_updown_after_precision_no_stale_data() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // --- Step 3: Verify no stale Precision data leaks into UpDown round --- // Use as_contract to check raw storage env.as_contract(&contract_id, || { // Stale PrecisionPosition from the first round should be cleared - let stale_pred_key = DataKey::PrecisionPosition(precision_round_id, alice.clone()); + let stale_pred_key = DataKeyScoped::PrecisionPosition(precision_round_id, alice.clone()); let has_stale_pred = env.storage().persistent().has(&stale_pred_key); assert!( !has_stale_pred, @@ -1685,7 +1687,7 @@ fn test_alternation_updown_after_precision_no_stale_data() { // Stale PrecisionCommitment from the first round should be cleared let stale_commit_key = - DataKey::PrecisionCommitment(precision_round_id, alice.clone()); + DataKeyScoped::PrecisionCommitment(precision_round_id, alice.clone()); let has_stale_commit = env.storage().persistent().has(&stale_commit_key); assert!( !has_stale_commit, @@ -1694,7 +1696,7 @@ fn test_alternation_updown_after_precision_no_stale_data() { ); // Stale Position from the UpDown round should be cleared - let stale_pos_key = DataKey::Position(updown_round_id, alice.clone()); + let stale_pos_key = DataKeyScoped::Position(updown_round_id, alice.clone()); let has_stale_pos = env.storage().persistent().has(&stale_pos_key); assert!( !has_stale_pos, @@ -1703,10 +1705,10 @@ fn test_alternation_updown_after_precision_no_stale_data() { ); // Legacy keys should also be cleared - let has_legacy_updown = env.storage().persistent().has(&DataKey::UpDownPositions); + let has_legacy_updown = env.storage().persistent().has(&DataKeyCore::UpDownPositions); assert!(!has_legacy_updown, "Legacy UpDownPositions should be cleared"); - let has_legacy_precision = env.storage().persistent().has(&DataKey::PrecisionPositions); + let has_legacy_precision = env.storage().persistent().has(&DataKeyCore::PrecisionPositions); assert!( !has_legacy_precision, "Legacy PrecisionPositions should be cleared" @@ -1742,6 +1744,7 @@ fn test_alternation_precision_after_updown_no_stale_data() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // --- Step 2: Run a Precision round (mode switch) --- @@ -1758,11 +1761,12 @@ fn test_alternation_precision_after_updown_no_stale_data() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // --- Step 3: Verify no stale UpDown data leaks into Precision round --- env.as_contract(&contract_id, || { - let stale_pos_key = DataKey::Position(updown_round_id, alice.clone()); + let stale_pos_key = DataKeyScoped::Position(updown_round_id, alice.clone()); let has_stale_pos = env.storage().persistent().has(&stale_pos_key); assert!( !has_stale_pos, @@ -1770,7 +1774,7 @@ fn test_alternation_precision_after_updown_no_stale_data() { updown_round_id ); - let stale_pred_key = DataKey::PrecisionPosition(precision_round_id, alice.clone()); + let stale_pred_key = DataKeyScoped::PrecisionPosition(precision_round_id, alice.clone()); let has_stale_pred = env.storage().persistent().has(&stale_pred_key); assert!( !has_stale_pred, @@ -1803,18 +1807,18 @@ fn test_alternation_cancel_clears_both_modes() { // Verify all position keys are cleared after cancellation env.as_contract(&contract_id, || { - let pred_key = DataKey::PrecisionPosition(round_id, alice.clone()); + let pred_key = DataKeyScoped::PrecisionPosition(round_id, alice.clone()); assert!(!env.storage().persistent().has(&pred_key)); - let commit_key = DataKey::PrecisionCommitment(round_id, alice.clone()); + let commit_key = DataKeyScoped::PrecisionCommitment(round_id, alice.clone()); assert!(!env.storage().persistent().has(&commit_key)); // Cross-mode key should also be absent (never set, but verify no phantom data) - let pos_key = DataKey::Position(round_id, alice.clone()); + let pos_key = DataKeyScoped::Position(round_id, alice.clone()); assert!(!env.storage().persistent().has(&pos_key)); // Round participants should be cleared - let participants_key = DataKey::RoundParticipants(round_id); + let participants_key = DataKeyScoped::RoundParticipants(round_id); assert!(!env.storage().persistent().has(&participants_key)); }); } @@ -1848,6 +1852,7 @@ fn test_alternation_three_round_cycle_no_stale_data() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Round 2: UpDown @@ -1863,6 +1868,7 @@ fn test_alternation_three_round_cycle_no_stale_data() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Round 3: Precision again @@ -1878,16 +1884,17 @@ fn test_alternation_three_round_cycle_no_stale_data() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Verify NO stale data from any round env.as_contract(&contract_id, || { for i in 0..round_ids.len() { if let Some(rid) = round_ids.get(i) { - let pos_key = DataKey::Position(rid, alice.clone()); - let pred_key = DataKey::PrecisionPosition(rid, alice.clone()); - let commit_key = DataKey::PrecisionCommitment(rid, alice.clone()); - let participants_key = DataKey::RoundParticipants(rid); + let pos_key = DataKeyScoped::Position(rid, alice.clone()); + let pred_key = DataKeyScoped::PrecisionPosition(rid, alice.clone()); + let commit_key = DataKeyScoped::PrecisionCommitment(rid, alice.clone()); + let participants_key = DataKeyScoped::RoundParticipants(rid); assert!( !env.storage().persistent().has(&pos_key), diff --git a/contracts/src/tests/one_sided_settlement.rs b/contracts/src/tests/one_sided_settlement.rs index b6ce88e..ab5663a 100644 --- a/contracts/src/tests/one_sided_settlement.rs +++ b/contracts/src/tests/one_sided_settlement.rs @@ -54,6 +54,7 @@ fn test_one_sided_up_market() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); // Capture events before subsequent contract calls reset the log @@ -105,6 +106,7 @@ fn test_one_sided_down_market() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); let events = env.events().all(); @@ -141,6 +143,7 @@ fn test_empty_market() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); @@ -181,6 +184,7 @@ fn test_emitted_events_and_metadata() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); let events = env.events().all(); @@ -226,6 +230,7 @@ fn test_refund_behavior_value_preservation() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); // Claim pending winnings @@ -284,6 +289,7 @@ fn test_repeated_settlement_attempts() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }; client.resolve_round(&payload); @@ -325,6 +331,7 @@ fn test_rounding_and_value_conservation() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }); let alice_refund = client.get_pending_winnings(&alice); diff --git a/contracts/src/tests/overflow_tests.rs b/contracts/src/tests/overflow_tests.rs index efec334..bb1d903 100644 --- a/contracts/src/tests/overflow_tests.rs +++ b/contracts/src/tests/overflow_tests.rs @@ -268,11 +268,11 @@ fn test_claim_winnings_boundary_max_exact() { client.update_oracle_heartbeat(&0u32); env.as_contract(&contract_id, || { - let bal_key = DataKey::Balance(user.clone()); + let bal_key = DataKeyScoped::Balance(user.clone()); env.storage() .persistent() .set(&bal_key, &(i128::MAX - 100)); - let win_key = DataKey::PendingWinnings(user.clone()); + let win_key = DataKeyScoped::PendingWinnings(user.clone()); env.storage().persistent().set(&win_key, &100i128); }); @@ -296,7 +296,7 @@ fn test_claim_winnings_boundary_max_minus_one() { client.update_oracle_heartbeat(&0u32); env.as_contract(&contract_id, || { - let win_key = DataKey::PendingWinnings(user.clone()); + let win_key = DataKeyScoped::PendingWinnings(user.clone()); env.storage() .persistent() .set(&win_key, &(i128::MAX - 1)); diff --git a/contracts/src/tests/pending_winnings_expiry.rs b/contracts/src/tests/pending_winnings_expiry.rs index 2dc3687..c0ce1e5 100644 --- a/contracts/src/tests/pending_winnings_expiry.rs +++ b/contracts/src/tests/pending_winnings_expiry.rs @@ -4,7 +4,7 @@ use super::config_helpers::apply_pending_winnings_expiry; use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; use crate::errors::ContractError; -use crate::types::{BetSide, DataKey, OraclePayload, PendingWinningsUpdatedAtKey}; +use crate::types::{BetSide, DataKeyScoped, OraclePayload, PendingWinningsUpdatedAtKey}; use soroban_sdk::testutils::{storage::Persistent as _, Address as _, Events as _, Ledger as _}; use soroban_sdk::{symbol_short, Address, Env, TryIntoVal}; @@ -30,7 +30,7 @@ fn set_pending_at_current_ledger( ) { let ledger = env.ledger().sequence(); env.as_contract(contract_id, || { - let key = DataKey::PendingWinnings(user.clone()); + let key = DataKeyScoped::PendingWinnings(user.clone()); env.storage().persistent().set(&key, &amount); let updated_key = PendingWinningsUpdatedAtKey(user.clone()); env.storage().persistent().set(&updated_key, &ledger); @@ -205,6 +205,7 @@ fn test_claim_winnings_clears_tracking_key() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Verify tracking key exists after resolve diff --git a/contracts/src/tests/pool_commitments.rs b/contracts/src/tests/pool_commitments.rs new file mode 100644 index 0000000..37c1e3c --- /dev/null +++ b/contracts/src/tests/pool_commitments.rs @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: MIT +//! Tests for pool-aggregate commitments (Observability / ZK-friendly design). + +extern crate alloc; + +use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; +use crate::errors::ContractError; +use crate::types::{BetSide, OraclePayload, RoundMode}; +use soroban_sdk::xdr::ToXdr; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events as _, Ledger as _}, + Address, Bytes, BytesN, Env, TryIntoVal, +}; + +fn setup() -> (Env, Address, VirtualTokenContractClient<'static>, Address, Address) { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + (env, contract_id, client, admin, oracle) +} + +fn resolve_active(env: &Env, client: &VirtualTokenContractClient, contract_id: &Address, price: u128) { + let round = client.get_active_round().unwrap(); + env.ledger().with_mut(|li| li.sequence_number = round.end_ledger); + client.resolve_round(&OraclePayload { + price, + timestamp: env.ledger().timestamp(), + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); +} + +/// Recomputes `sha256(round_id || seq || pool_up || pool_down || +/// precision_total_stake || salt)` exactly like `commitments::_compute_commitment`. +fn recompute( + env: &Env, + round_id: u64, + seq: u32, + pool_up: i128, + pool_down: i128, + precision_total_stake: i128, + salt: &BytesN<32>, +) -> BytesN<32> { + let mut preimage = Bytes::new(env); + preimage.append(&round_id.to_xdr(env)); + preimage.append(&seq.to_xdr(env)); + preimage.append(&pool_up.to_xdr(env)); + preimage.append(&pool_down.to_xdr(env)); + preimage.append(&precision_total_stake.to_xdr(env)); + preimage.append(&salt.to_xdr(env)); + env.crypto().sha256(&preimage).into() +} + +fn last_commit_event(env: &Env) -> Option<(u64, u32, BytesN<32>, u32)> { + let events = env.events().all(); + events.iter().rev().find_map(|e| { + let (_contract, topics, data) = e; + if topics.len() == 2 + && topics.get(0).unwrap().try_into_val(env) == Ok(symbol_short!("pool")) + && topics.get(1).unwrap().try_into_val(env) == Ok(symbol_short!("commit")) + { + data.try_into_val(env).ok() + } else { + None + } + }) +} + +// ─── Commitment advances on mutating actions ───────────────────────────────── + +#[test] +fn test_commitment_advances_on_place_bet_and_hash_matches() { + let (env, contract_id, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + client.mint_initial(&alice); + client.create_round(&1_0000000, &None); + + let round = client.get_active_round().unwrap(); + assert!(client.get_pool_commitment(&round.round_id).is_none()); + + client.place_bet(&alice, &100_0000000, &BetSide::Up); + + // Event carries only the hash/seq/ledger — never raw pool values. Must be + // read immediately: the test event log only retains the most recent + // top-level contract invocation. + let (event_round_id, event_seq, event_hash, event_ledger) = + last_commit_event(&env).expect("commit event must be emitted"); + + let commitment = client + .get_pool_commitment(&round.round_id) + .expect("commitment must exist after first bet"); + assert_eq!(commitment.seq, 1); + + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 100_0000000); + + assert_eq!(event_round_id, round.round_id); + assert_eq!(event_seq, commitment.seq); + assert_eq!(event_hash, commitment.commitment); + assert_eq!(event_ledger, commitment.ledger); + + // Second bet advances the sequence again. + let bob = Address::generate(&env); + client.mint_initial(&bob); + client.place_bet(&bob, &50_0000000, &BetSide::Down); + let commitment2 = client.get_pool_commitment(&round.round_id).unwrap(); + assert_eq!(commitment2.seq, 2); + assert_ne!(commitment2.commitment, commitment.commitment); +} + +#[test] +fn test_commitment_advances_on_precision_flows() { + let (env, contract_id, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + client.mint_initial(&alice); + client.create_round(&1_0000000, &Some(1)); + let round = client.get_active_round().unwrap(); + + client.place_precision_prediction(&alice, &100_0000000, &2200u128); + let c1 = client.get_pool_commitment(&round.round_id).unwrap(); + assert_eq!(c1.seq, 1); + + let bob = Address::generate(&env); + client.mint_initial(&bob); + + let predicted_price = 2100u128; + let mut salt_bytes = [0x42u8; 32]; + salt_bytes[0] = 0x91; + salt_bytes[31] = 0x07; + let salt = BytesN::from_array(&env, &salt_bytes); + let mut preimage = Bytes::new(&env); + preimage.append(&predicted_price.to_xdr(&env)); + preimage.append(&salt.clone().to_xdr(&env)); + let hash: BytesN<32> = env.crypto().sha256(&preimage).into(); + + client.commit_prediction(&bob, &hash, &50_0000000); + let c2 = client.get_pool_commitment(&round.round_id).unwrap(); + assert_eq!(c2.seq, 2); + + env.ledger().with_mut(|li| li.sequence_number = round.bet_end_ledger); + client.reveal_prediction(&bob, &predicted_price, &salt); + let c3 = client.get_pool_commitment(&round.round_id).unwrap(); + assert_eq!(c3.seq, 3); +} + +// ─── Additive-only: legacy stats surface unchanged ─────────────────────────── + +#[test] +fn test_round_pool_stats_unaffected_by_commitments() { + let (env, contract_id, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + client.mint_initial(&alice); + client.create_round(&1_0000000, &None); + client.place_bet(&alice, &100_0000000, &BetSide::Up); + + // get_round_pool_stats keeps exposing raw values in real time — the + // commitment channel is additive, not a replacement (Issue: additive- + // only decision, avoids a MAJOR/breaking change per COMPATIBILITY_POLICY.md). + let stats = client.get_round_pool_stats().unwrap(); + assert_eq!(stats.total_up_stake, 100_0000000); + assert_eq!(stats.mode, RoundMode::UpDown); +} + +// ─── Force-open timing and idempotency ─────────────────────────────────────── + +#[test] +fn test_open_pool_commitment_rejected_before_bet_end() { + let (env, contract_id, client, _admin, _oracle) = setup(); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + let result = client.try_open_pool_commitment(&round.round_id); + assert_eq!(result, Err(Ok(ContractError::InvalidRevealWindow))); + assert!(client.get_pool_opening(&round.round_id).is_none()); +} + +#[test] +fn test_open_pool_commitment_succeeds_after_bet_end_and_matches_storage() { + let (env, contract_id, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + client.mint_initial(&alice); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + client.place_bet(&alice, &100_0000000, &BetSide::Up); + // Captured immediately: the test event log only retains the most recent + // top-level contract invocation, so this is what an indexer streaming + // events live would have last seen before bet_end. + let (.., last_seen_commit_hash, _) = + last_commit_event(&env).expect("commit event must be emitted"); + + env.ledger().with_mut(|li| li.sequence_number = round.bet_end_ledger); + client.open_pool_commitment(&round.round_id); + + let opening = client.get_pool_opening(&round.round_id).unwrap(); + let live_round = client.get_active_round().unwrap(); + assert_eq!(opening.pool_up, live_round.pool_up); + assert_eq!(opening.pool_down, live_round.pool_down); + assert_eq!(opening.pool_up, 100_0000000); + + // The opening's salt + values must reproduce the last published commitment. + let commitment = client.get_pool_commitment(&round.round_id).unwrap(); + let recomputed = recompute( + &env, + opening.round_id, + opening.seq, + opening.pool_up, + opening.pool_down, + opening.precision_total_stake, + &opening.salt, + ); + assert_eq!(recomputed, commitment.commitment); + + // And it matches the last ("pool","commit") event an indexer would have seen. + assert_eq!(recomputed, last_seen_commit_hash); +} + +#[test] +fn test_open_pool_commitment_idempotent() { + let (env, contract_id, client, _admin, _oracle) = setup(); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| li.sequence_number = round.bet_end_ledger); + client.open_pool_commitment(&round.round_id); + + let result = client.try_open_pool_commitment(&round.round_id); + assert_eq!(result, Err(Ok(ContractError::AlreadyRevealed))); +} + +// ─── Missing-opening policy: auto-open at every terminal transition ───────── + +#[test] +fn test_resolve_without_manual_open_still_opens() { + let (env, contract_id, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + client.mint_initial(&alice); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + client.place_bet(&alice, &100_0000000, &BetSide::Up); + + // Fail-closed: nobody opened it yet, before bet_end. + assert!(client.get_pool_opening(&round.round_id).is_none()); + + resolve_active(&env, &client, &contract_id, 2_0000000); + + let opening = client + .get_pool_opening(&round.round_id) + .expect("resolve_round must auto-open the pool commitment"); + assert_eq!(opening.pool_up, 100_0000000); + assert_eq!(opening.pool_down, 0); +} + +#[test] +fn test_cancel_round_without_manual_open_still_opens() { + let (env, contract_id, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + client.mint_initial(&alice); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + client.place_bet(&alice, &100_0000000, &BetSide::Up); + + client.cancel_round(&0u32); + + let opening = client + .get_pool_opening(&round.round_id) + .expect("cancel_round must auto-open the pool commitment"); + assert_eq!(opening.pool_up, 100_0000000); +} + +#[test] +fn test_fallback_refund_without_manual_open_still_opens() { + let (env, contract_id, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + client.mint_initial(&alice); + client.set_min_participants(&Some(5)); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + client.place_bet(&alice, &100_0000000, &BetSide::Up); + + resolve_active(&env, &client, &contract_id, 2_0000000); + + let opening = client + .get_pool_opening(&round.round_id) + .expect("fallback-refund path must auto-open the pool commitment"); + assert_eq!(opening.pool_up, 100_0000000); +} + +#[test] +fn test_pool_opening_none_for_unopened_round() { + let (env, contract_id, client, _admin, _oracle) = setup(); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + assert_eq!(client.get_pool_opening(&round.round_id), None); + assert_eq!(client.get_pool_opening(&999u64), None); +} + +// ─── Gas-bounded: cost stays flat regardless of participant count ─────────── + +#[test] +fn test_commitment_cost_is_flat_across_round_size() { + let (env, contract_id, client, _admin, _oracle) = setup(); + // Scaling-behaviour test, not a precise gas measurement (see + // cost_benchmarks.rs for that) — reset so a large round can't trip the + // default interpreted-host budget on its own. + env.cost_estimate().budget().reset_unlimited(); + + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + let users: alloc::vec::Vec
= (0..40).map(|_| Address::generate(&env)).collect(); + for (i, u) in users.iter().enumerate() { + client.mint_initial(u); + let side = if i % 2 == 0 { BetSide::Up } else { BetSide::Down }; + client.place_bet(u, &10_0000000, &side); + } + + // The commitment struct itself stays a single fixed-size record per + // round throughout — advancing never grows with participant count. + let commitment = client.get_pool_commitment(&round.round_id).unwrap(); + assert_eq!(commitment.seq, 40); + + env.ledger().with_mut(|li| li.sequence_number = round.bet_end_ledger); + client.open_pool_commitment(&round.round_id); + let opening = client.get_pool_opening(&round.round_id).unwrap(); + assert_eq!(opening.pool_up + opening.pool_down, 10_0000000 * 40); +} diff --git a/contracts/src/tests/reference_model.rs b/contracts/src/tests/reference_model.rs index da55537..9c424f1 100644 --- a/contracts/src/tests/reference_model.rs +++ b/contracts/src/tests/reference_model.rs @@ -108,12 +108,14 @@ impl ReferenceModel { return false; } + let active = matches!(&self.active_round, Some(round) if round.active); + if !active { + return false; + } + + self.withdraw(user, amount); + self.total_pool = self.total_pool.saturating_add(amount); if let Some(ref mut round) = self.active_round { - if !round.active { - return false; - } - self.withdraw(user, amount); - self.total_pool = self.total_pool.saturating_add(amount); if side_is_up { round.pool_up += amount; *round.bets_up.entry(user.clone()).or_default() += amount; @@ -121,10 +123,8 @@ impl ReferenceModel { round.pool_down += amount; *round.bets_down.entry(user.clone()).or_default() += amount; } - true - } else { - false } + true } /// Cancel current active round (returns 100% of stakes to pending winnings, 0 fee). diff --git a/contracts/src/tests/resolution.rs b/contracts/src/tests/resolution.rs index 93f806d..77f28bc 100644 --- a/contracts/src/tests/resolution.rs +++ b/contracts/src/tests/resolution.rs @@ -23,6 +23,7 @@ use soroban_sdk::{ Address, Env, Map, TryIntoVal, Vec, }; use std::string::{String, ToString}; +use std::vec; /// Salt satisfying on-chain minimum entropy (non-zero, non-constant). fn test_salt(env: &Env, seed: u8) -> BytesN<32> { @@ -4562,7 +4563,7 @@ fn golden_precision_winners_all_unrevealed() { #[test] fn golden_precision_winners_empty() { - let entries: Vec = vec![]; + let entries: std::vec::Vec = vec![]; let result = find_precision_winners(&entries, 2298); assert!(result.winner_indices.is_empty()); assert_eq!(result.total_pot, 0); @@ -4694,7 +4695,7 @@ fn golden_precision_payouts_with_1pct_fee() { #[test] fn golden_precision_payouts_empty() { - let entries: Vec = vec![]; + let entries: std::vec::Vec = vec![]; let results = compute_precision_payouts(&entries, 2298, None).unwrap(); assert!(results.is_empty()); } @@ -4765,7 +4766,7 @@ fn golden_updown_conservation_invariant() { #[test] fn golden_precision_conservation_invariant() { - let scenarios: Vec<(Vec, u128, Option)> = vec![ + let scenarios: std::vec::Vec<(std::vec::Vec, u128, Option)> = vec![ // Single winner, no fee ( vec![ @@ -4822,7 +4823,8 @@ fn golden_precision_conservation_invariant() { assert_eq!(sum_payouts, 0); } } -======= +} + #[test] fn test_precision_payout_policy_config() { let env = Env::default(); diff --git a/contracts/src/tests/rotation.rs b/contracts/src/tests/rotation.rs index 394d473..03f729e 100644 --- a/contracts/src/tests/rotation.rs +++ b/contracts/src/tests/rotation.rs @@ -59,8 +59,12 @@ fn test_propose_and_accept_before_expiry_succeeds() { assert_eq!(proposal.proposed_at, 1000); assert_eq!(proposal.expires_at, 4600); + // Acceptance is gated by MIN_ROTATION_DELAY_SECONDS (3600s) after + // proposal, so the earliest valid accept time equals proposed_at+3600 + // here (which also happens to be expires_at, since expires_in_seconds + // was set to exactly the minimum delay). env.ledger().with_mut(|li| { - li.timestamp = 2000; + li.timestamp = 4600; }); client.accept_oracle_rotation(); @@ -86,10 +90,13 @@ fn test_accept_after_expiry_fails() { li.timestamp = 500; }); - client.propose_oracle_rotation(&new_oracle, &300); + // expires_in_seconds must be >= MIN_ROTATION_DELAY_SECONDS (3600s); + // expires_at = 500+3600 = 4100. + client.propose_oracle_rotation(&new_oracle, &3600); + // Past expiry (and past the mandatory delay, since they coincide here). env.ledger().with_mut(|li| { - li.timestamp = 1000; + li.timestamp = 4200; }); let result = client.try_accept_oracle_rotation(); @@ -214,8 +221,10 @@ fn test_propose_and_accept_emits_events() { "propose event should be emitted" ); + // Acceptance is gated by MIN_ROTATION_DELAY_SECONDS (3600s) after + // proposal — see the comment in test_propose_and_accept_before_expiry_succeeds. env.ledger().with_mut(|li| { - li.timestamp = 2000; + li.timestamp = 4600; }); client.accept_oracle_rotation(); @@ -239,10 +248,13 @@ fn test_accept_after_expiry_emits_expired_event() { li.timestamp = 500; }); - client.propose_oracle_rotation(&new_oracle, &300); + // expires_in_seconds must be >= MIN_ROTATION_DELAY_SECONDS (3600s); + // expires_at = 500+3600 = 4100. + client.propose_oracle_rotation(&new_oracle, &3600); + // Past expiry (and past the mandatory delay, since they coincide here). env.ledger().with_mut(|li| { - li.timestamp = 1000; + li.timestamp = 4200; }); let _ = client.try_accept_oracle_rotation(); diff --git a/contracts/src/tests/security.rs b/contracts/src/tests/security.rs index af0fd21..795d61a 100644 --- a/contracts/src/tests/security.rs +++ b/contracts/src/tests/security.rs @@ -1,7953 +1,2181 @@ -// SPDX-License-Identifier: MIT -//! Security tests for Oracle data freshness and round validation. - -use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; -use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::errors::ContractError; -use crate::types::{DataKey, HbGateConfig, HbGateKey, OraclePayload}; -use crate::types::{DataKeyCore, DataKeyScoped, OraclePayload}; -use crate::types::{DataKeyCore, DataKeyScoped, HbGateConfig, HbGateKey, OraclePayload}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, IntoVal, TryIntoVal, -}; - -#[test] -fn test_resolve_round_stale_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - // Advance ledger time to 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; // Allow resolution - }); - - // Submit payload with timestamp 600 (400s old, > 300s limit) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 600, - round_id: 0, // Starts at ledger 0 - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::StaleOracleData))); -} - -#[test] -fn test_resolve_round_invalid_round_id() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - // Submit payload with wrong round_id (e.g., 999 instead of 0) - let payload = OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 999, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); -} - -#[test] -fn test_resolve_round_valid_payload() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Valid payload: within 300s and correct round_id - let payload = OraclePayload { - price: 1_5000000, - timestamp: 900, // 100s old, OK - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - client.resolve_round(&payload); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_future_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - // Current ledger time is 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Submit payload with timestamp 1001 (future) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 1001, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); -} - -// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── - -#[test] -fn test_cancelled_round_cannot_be_resolved() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - client.cancel_round(&0u32); - - // After cancellation there is no active round, so resolve_round returns NoActiveRound - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); -} - -#[test] -fn test_cancel_round_without_admin_auth_fails() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - // Initialize with only admin auth - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "create_round", - args: (1_0000000u128, Option::::None).into_val(&env), - sub_invokes: &[], - }, - }]); - client.create_round(&1_0000000, &None); - - // No auth for cancel_round - let result = client.try_cancel_round(&0u32); - assert!(result.is_err()); -} - -// ─── Oracle nonce replay protection (Issue #118) ───────────────────────────── - -/// A nonce already consumed for a round must be rejected on re-submission. -/// We seed the consumed-nonce marker to simulate a prior submission, then -/// assert the resolver rejects a payload reusing that nonce for the same round. -#[test] -fn test_resolve_round_duplicate_nonce_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Simulate a prior submission having consumed nonce 42 for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 42u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); -} - -/// A fresh, unique nonce resolves normally and records the consumed marker. -#[test] -fn test_resolve_round_unique_nonce_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 7u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Round resolved and the nonce is recorded as consumed for that round. - assert_eq!(client.get_active_round(), None); - env.as_contract(&contract_id, || { - let consumed: bool = env - .storage() - .persistent() - .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) - .unwrap_or(false); - assert!(consumed, "resolved nonce must be marked consumed"); - }); -} - -// ─── Oracle heartbeat and liveness tests ───────────────────────────────────── - -#[test] -fn test_oracle_heartbeat_requires_oracle_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No oracle auth set up — must fail - let result = client.try_update_oracle_heartbeat(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_oracle_heartbeat_updates_timestamp_and_status() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - - client.update_oracle_heartbeat(&0u32); - - let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); - assert_eq!(record.timestamp, 500); - assert_eq!(record.status, 0); - - // Update to degraded status - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.update_oracle_heartbeat(&1u32); - - let record = client.get_oracle_heartbeat().unwrap(); - assert_eq!(record.timestamp, 1000); - assert_eq!(record.status, 1); -} - -#[test] -fn test_oracle_heartbeat_invalid_status_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let result = client.try_update_oracle_heartbeat(&3u32); - assert_eq!(result, Err(Ok(ContractError::InvalidMode))); -} - -#[test] -fn test_oracle_liveness_within_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check liveness 100 s later (well within 3600 s default) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_stale_after_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check 4000 s later — beyond 3600 s default threshold - env.ledger().with_mut(|li| { - li.timestamp = 4000; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_offline_status_not_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - // Record offline status - client.update_oracle_heartbeat(&2u32); - - // Even within threshold, offline means not live - env.ledger().with_mut(|li| { - li.timestamp = 10; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_no_heartbeat_returns_false() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded — must return false - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_heartbeat_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.update_oracle_heartbeat(&0u32); - - let events = env.events().all(); - let hb_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) - }); - assert!( - hb_event.is_some(), - "Heartbeat event must be emitted on update_oracle_heartbeat" - ); -} - -#[test] -fn test_set_oracle_stale_threshold_admin_only() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No admin auth for set_oracle_stale_threshold - let result = client.try_set_oracle_stale_threshold(&1800u64); - assert!(result.is_err()); -} - -#[test] -fn test_set_oracle_stale_threshold_validation() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Below minimum (< 60) - let result = client.try_set_oracle_stale_threshold(&59u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Above maximum (> 86400) - let result = client.try_set_oracle_stale_threshold(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Valid value - apply_oracle_stale_threshold(&env, &client, 1800u64); - assert_eq!(client.get_oracle_stale_threshold(), 1800u64); -} - -// ─── Heartbeat health enforcement tests (Issue #264) ─────────────────────── - -/// Helper: sets up a fresh env, registers the contract, initializes, -/// records an active heartbeat at the current ledger timestamp, -/// and creates a round (ledger 0). Returns client. -fn setup_with_heartbeat(env: &Env, contract_id: &Address) -> VirtualTokenContractClient<'static> { - let admin = Address::generate(env); - let oracle = Address::generate(env); - env.mock_all_auths(); - - let client = VirtualTokenContractClient::new(env, contract_id); - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - client -} - -/// Active (status 0) fresh heartbeat allows settlement. -#[test] -fn test_heartbeat_active_fresh_allows_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = setup_with_heartbeat(&env, &contract_id); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; // past end_ledger - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -/// Degraded (status 1) fresh heartbeat allows settlement (non-strict). -#[test] -fn test_heartbeat_degraded_fresh_allows_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = setup_with_heartbeat(&env, &contract_id); - - // Mark degraded at current time - env.ledger().with_mut(|li| { - li.timestamp = 50; - }); - client.update_oracle_heartbeat(&1u32); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); -} - -/// Stale but within grace period allows settlement (non-strict mode). -#[test] -fn test_heartbeat_stale_within_grace_allows_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - - // Set a short stale threshold of 120s - apply_oracle_stale_threshold(&env, &client, 120u64); - // Grace period of 600s (default) - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - client.create_round(&1_0000000, &None); - - // t=500: stale (beyond 120s threshold) but within grace (0+120+600=720s) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 500; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); -} - -/// Stale beyond grace period blocks settlement. -#[test] -fn test_heartbeat_stale_beyond_grace_blocks_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - - // Stale threshold 120s, grace 300s - apply_oracle_stale_threshold(&env, &client, 120u64); - client.set_oracle_heartbeat_grace(&300u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - // t=800: beyond grace (0+120+300=420s) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 800; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); -} - -/// Stale within grace but strict mode enabled blocks settlement. -#[test] -fn test_heartbeat_stale_within_grace_strict_blocks_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - - apply_oracle_stale_threshold(&env, &client, 120u64); - client.set_oracle_heartbeat_strict_mode(&true); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - // t=200: stale (beyond 120s) but within default grace; strict blocks it - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); -} - -/// Degraded fresh but strict mode blocks settlement. -#[test] -fn test_heartbeat_degraded_fresh_strict_blocks_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - client.set_oracle_heartbeat_strict_mode(&true); - - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); // degraded - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); -} - -/// Offline (status 2) blocks settlement even with fresh heartbeat. -#[test] -fn test_heartbeat_offline_blocks_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&2u32); // offline - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); -} - -/// No heartbeat blocks settlement. -#[test] -fn test_heartbeat_none_blocks_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - // No heartbeat recorded - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); -} - -/// Heartbeat override allows settlement when heartbeat is unhealthy. -#[test] -fn test_heartbeat_override_allows_resolve() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - // No heartbeat — would normally block - client.arm_oracle_heartbeat_override(); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -/// Override is one-shot — second resolve without re-arming fails. -#[test] -fn test_heartbeat_override_cleared_after_use() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - client.arm_oracle_heartbeat_override(); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Verify override is consumed (one-shot) - let armed = client.is_oracle_heartbeat_override_armed(); - assert!(!armed, "override must be cleared after first use"); - - // Create a new round WITHOUT re-arming and WITHOUT a heartbeat — should fail - client.create_round(&2_0000000, &None); - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 200; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 2_5000000, - timestamp: env.ledger().timestamp(), - round_id: 12, // second round start_ledger is higher - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - // No override and no heartbeat → must fail - assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); -} - -/// Override emits the `hb_override` event when consumed. -#[test] -fn test_heartbeat_override_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - client.arm_oracle_heartbeat_override(); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - let events = env.events().all(); - let hb_override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hb_override")) - }); - assert!( - hb_override_event.is_some(), - "hb_override event must be emitted when override is consumed" - ); -} - -/// Grace period config is settable and queryable. -#[test] -fn test_heartbeat_grace_config() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - - // Default - assert_eq!(client.get_oracle_heartbeat_grace(), 600); - - // Set custom - client.set_oracle_heartbeat_grace(&900u64); - assert_eq!(client.get_oracle_heartbeat_grace(), 900); - - // Below minimum rejected - // Note: MIN is 0, so 0 is valid — test > MAX - let result = client.try_set_oracle_heartbeat_grace(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); -} - -/// Strict mode is settable and queryable. -#[test] -fn test_heartbeat_strict_mode_config() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - - assert!(!client.get_oracle_heartbeat_strict_mode()); - client.set_oracle_heartbeat_strict_mode(&true); - assert!(client.get_oracle_heartbeat_strict_mode()); - client.set_oracle_heartbeat_strict_mode(&false); - assert!(!client.get_oracle_heartbeat_strict_mode()); -} - -/// Arming override emits hb_arm_ovr event. -#[test] -fn test_arm_heartbeat_override_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - let client = VirtualTokenContractClient::new(&env, &contract_id); - client.initialize(&admin, &oracle); - - client.arm_oracle_heartbeat_override(); - - let events = env.events().all(); - let arm_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hb_arm_ovr")) - }); - assert!(arm_event.is_some(), "hb_arm_ovr event must be emitted on arm"); -} - -// ─── Oracle deviation guardrails tests ─────────────────────────────────────── - -#[test] -fn test_oracle_deviation_rejected_when_over_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // Set max deviation to 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // 50% jump: diff_bps = 5000 > 500 - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); -} - -#[test] -fn test_oracle_deviation_allows_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 - client.resolve_round(&OraclePayload { - price: 1_0500000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_rounding_floor_is_deterministic() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 - client.create_round(&3u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // At threshold should pass - client.resolve_round(&OraclePayload { - price: 4u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% - client.arm_oracle_deviation_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 2_0000000u128, // 100% jump - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Verify override event emitted (check before env.as_contract which resets event scope) - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) - }); - assert!(override_event.is_some(), "override event must be emitted"); - - // Override is one-shot and must be cleared - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKeyCore::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "override must be cleared after use"); - }); -} - -#[test] -fn test_oracle_liveness_custom_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Set a short 120 s threshold - apply_oracle_stale_threshold(&env, &client, 120u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // 100 s later — within custom 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); - - // 130 s later — beyond 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 130; - }); - assert!(!client.is_oracle_live()); -} - -/// Boundary nonces (0 and u64::MAX) are rejected on reuse for the same round. -#[test] -fn test_resolve_round_nonce_boundary_values() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Pre-seed both boundary nonces as consumed for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); - env.storage().persistent().set( - &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), - &true, - ); - }); - - let zero = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 0u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); - - let max = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: u64::MAX, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); -} - -// ─── Oracle domain-context validation tests (Issue #143) ──────────────────── - -#[test] -fn test_resolve_round_wrong_network_id_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_wrong_contract_addr_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_contract = Address::generate(&env); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: wrong_contract, - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_valid_domain_context_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Correct network + correct contract => resolves normally - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_both_network_and_contract_wrong() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - let wrong_contract = Address::generate(&env); - - // Network is checked first, so we get OracleNetworkMismatch - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: wrong_contract, - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -// ─── Protocol health endpoint tests ────────────────────────────────────────── - -#[test] -fn test_protocol_health_no_heartbeat_unknown_oracle() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded → oracle_status=3, oracle_live=false - env.ledger().with_mut(|li| { - li.sequence_number = 1; - }); - let health = client.get_protocol_health(); - assert_eq!(health.oracle_status, 3); // unknown - assert!(!health.oracle_live); - assert!(!health.has_active_round); - assert_eq!(health.status_code, 2); // ORACLE_STALE - assert!(health.ledger_sequence > 0); -} - -#[test] -fn test_protocol_health_oracle_offline() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Record offline heartbeat - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - client.update_oracle_heartbeat(&2u32); // offline - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert_eq!(health.oracle_status, 2); // offline - assert_eq!(health.status_code, 2); // ORACLE_STALE -} - -#[test] -fn test_protocol_health_round_resolvable_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - // Advance past end_ledger so round is resolvable - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - // Keep oracle alive - client.update_oracle_heartbeat(&0u32); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 3); // ROUND_STALE -} - -#[test] -fn test_protocol_health_multiple_issues() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.create_round(&1_0000000, &None); - - // No heartbeat (oracle unknown) + round past end_ledger → multiple issues - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES -} - -#[test] -fn test_protocol_health_schema_version_present() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let health = client.get_protocol_health(); - assert_eq!(health.schema_version, 3); -} - -#[test] -fn test_protocol_health_round_betting_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 6 - env.ledger().with_mut(|li| { - li.sequence_number = 6; - }); - client.create_round(&1_0000000, &None); - - // Still in betting window (ledger 6, bet_end = 6+6=12) - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 1); // betting - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_protocol_health_round_running_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 0 - client.create_round(&1_0000000, &None); - // Default windows: bet=6, run=12 - // bet_end = 6, end = 12 - - // Advance into running phase (past bet_end, before end) - env.ledger().with_mut(|li| { - li.sequence_number = 8; - }); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 2); // running - assert_eq!(health.status_code, 0); // HEALTHY -} -// ── Oracle confidence score tests ──────────────────────────────────────────── - -#[test] -fn test_confidence_below_threshold_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(5000u32), - }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_confidence_above_threshold_accepted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(9000u32), - }); -} - -#[test] -fn test_missing_confidence_accepted_when_not_strict() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - // strict mode NOT enabled - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Legacy payload without confidence accepted when strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); -} - -#[test] -fn test_missing_confidence_rejected_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.set_oracle_strict_mode(&true); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -// ── Oracle heartbeat health gate tests (Issue #264) ────────────────────────── - -/// Helper: create a round with oracle heartbeat, advance to resolvable state. -fn _setup_heartbeat_gate_test( - env: &Env, - client: &VirtualTokenContractClient, - heartbeat_timestamp: u64, - heartbeat_status: u32, -) { - env.ledger().with_mut(|li| { - li.timestamp = heartbeat_timestamp; - }); - client.update_oracle_heartbeat(&heartbeat_status); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; // past end_ledger - li.timestamp = heartbeat_timestamp + 100; // within threshold - }); -} - -#[test] -fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, status active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past end_ledger, but strict mode is OFF (default) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000s > 3600s stale threshold - }); - - // Should still resolve — strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) + grace (default 0) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_strict_on_allows_when_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance to resolvable, timestamp within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, offline (status=2) - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&2u32); - - // Advance to resolvable (within threshold, but offline always blocks) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 10; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat recorded at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all — would normally block - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - assert_eq!(client.get_active_round(), None); - - // Verify override event emitted - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) - }); - assert!(override_event.is_some(), "hoverride event must be emitted"); - - // Override is one-shot — must be consumed - env.as_contract(&contract_id, || { - let config: HbGateConfig = env - .storage() - .persistent() - .get(&HbGateKey::Config) - .unwrap_or(HbGateConfig { - strict_mode: false, - override_armed: false, - grace_seconds: 0, - }); - assert!(!config.override_armed, "heartbeat override must be cleared after use"); - }); -} - -#[test] -fn test_heartbeat_gate_override_is_one_shot() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - - // First round: arm override, resolve (consumes override) - client.create_round(&1_0000000, &None); - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Second round: no heartbeat, no override — should block - env.ledger().with_mut(|li| { - li.sequence_number = 20; - li.timestamp = 200; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 32; - li.timestamp = 300; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - // Set grace period to 600s (10 minutes) - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) but within grace (3600+600=4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000 < 4200, within grace - }); - - // Should resolve — within grace period - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold + grace (3600 + 600 = 4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4300; // 4300 > 4200, beyond grace - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_degraded_status_allowed_when_current() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, degraded (status=1) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); - - // Resolve within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - // Degraded but current — should be allowed - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Degraded heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&1u32); - - // Advance past stale threshold (no grace configured) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_hblocked_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let _ = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - let events = env.events().all(); - let blocked_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hblocked")) - }); - assert!(blocked_event.is_some(), "hblocked event must be emitted"); -} - -#[test] -fn test_heartbeat_gate_arm_override_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_arm_hb_override(); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_set_hb_strict_mode(&true); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_getters_return_defaults() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Default values: strict mode OFF, override not armed, grace 0 - assert!(!client.get_hb_strict_mode()); - assert!(!client.get_hb_override_armed()); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_heartbeat_gate_set_and_get_grace_seconds() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.set_hb_grace_seconds(&300u64); - assert_eq!(client.get_hb_grace_seconds(), 300); - - client.set_hb_grace_seconds(&0u64); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_no_confidence_check_when_threshold_unset() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); - // No min confidence configured - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Even zero confidence accepted when threshold unset - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(0u32), - }); -} -// SPDX-License-Identifier: MIT -//! Security tests for Oracle data freshness and round validation. - -use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; -use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::errors::ContractError; -use crate::types::{DataKey, HbGateConfig, HbGateKey, OraclePayload}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, IntoVal, TryIntoVal, -}; - -#[test] -fn test_resolve_round_timestamp_after_round_window() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance ledger time to 1000, seq to 12 (round end ~ seq 12) - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; // Allow resolution - }); - - // Round started at ts=0, end_ledger=12 -> end_estimate = 0 + 12*5 = 60 - // Window with default skew 300: [0, 60+300] = [0, 360] - // Payload ts=500 is after the upper bound - let payload = OraclePayload { - price: 1_5000000, - timestamp: 500, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::OracleTimestampOutsideWindow))); -} - -#[test] -fn test_resolve_round_timestamp_before_round_window() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - - // Set initial ledger timestamp to 100 so round starts at ts=100 - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Round start=100, end_ledger=12 -> end_estimate = 100 + 12*5 = 160 - // Default skew 300 -> window: [100-300, 160+300] = [0, 460] (lower saturates at 0) - // Set a short skew of 30 via instance storage to make lower bound = 100-30 = 70 - env.as_contract(&contract_id, || { - env.storage().instance().set(&symbol_short!("otskew"), &30u64); - }); - // With skew=30: window = [70, 190] - // Payload ts=10 is before the lower bound - - let payload = OraclePayload { - price: 1_5000000, - timestamp: 10, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::OracleTimestampOutsideWindow))); -} - -#[test] -fn test_resolve_round_timestamp_boundary_lower() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Skew=30 -> window: [70, 190] - env.as_contract(&contract_id, || { - env.storage().instance().set(&symbol_short!("otskew"), &30u64); - }); - - // Payload at exactly the lower bound (70) must be accepted - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 70, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_timestamp_boundary_upper() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Round start=0, end_ledger=12 -> end_estimate = 60 - // Default skew=300 -> window: [0, 360] - // Payload at exactly the upper bound (360) must be accepted - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 360, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_invalid_round_id() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - // Submit payload with wrong round_id (e.g., 999 instead of 0) - let payload = OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 999, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); -} - -#[test] -fn test_resolve_round_valid_payload() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Valid payload: within round window [0, 60+300] and correct round_id - let payload = OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - client.resolve_round(&payload); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_future_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Current ledger time is 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Submit payload with timestamp 1001 (future) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 1001, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); -} - -// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── - -#[test] -fn test_cancelled_round_cannot_be_resolved() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - client.cancel_round(&0u32); - - // After cancellation there is no active round, so resolve_round returns NoActiveRound - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); -} - -#[test] -fn test_cancel_round_without_admin_auth_fails() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - // Initialize with only admin auth - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "create_round", - args: (1_0000000u128, Option::::None).into_val(&env), - sub_invokes: &[], - }, - }]); - client.create_round(&1_0000000, &None); - - // No auth for cancel_round - let result = client.try_cancel_round(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_resolve_round_duplicate_nonce_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Simulate a prior submission having consumed nonce 42 for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 42u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_unique_nonce_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 7u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Round resolved and the nonce is recorded as consumed for that round. - assert_eq!(client.get_active_round(), None); - env.as_contract(&contract_id, || { - let consumed: bool = env - .storage() - .persistent() - .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) - .unwrap_or(false); - assert!(consumed, "resolved nonce must be marked consumed"); - }); -} - -#[test] -fn test_oracle_heartbeat_requires_oracle_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No oracle auth set up — must fail - let result = client.try_update_oracle_heartbeat(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_oracle_heartbeat_updates_timestamp_and_status() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - - client.update_oracle_heartbeat(&0u32); - - let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); - assert_eq!(record.timestamp, 500); - assert_eq!(record.status, 0); - - // Update to degraded status - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.update_oracle_heartbeat(&1u32); - - let record = client.get_oracle_heartbeat().unwrap(); - assert_eq!(record.timestamp, 1000); - assert_eq!(record.status, 1); -} - -#[test] -fn test_oracle_heartbeat_invalid_status_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let result = client.try_update_oracle_heartbeat(&3u32); - assert_eq!(result, Err(Ok(ContractError::InvalidMode))); -} - -#[test] -fn test_oracle_liveness_within_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check liveness 100 s later (well within 3600 s default) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_stale_after_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check 4000 s later — beyond 3600 s default threshold - env.ledger().with_mut(|li| { - li.timestamp = 4000; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_offline_status_not_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - // Record offline status - client.update_oracle_heartbeat(&2u32); - - // Even within threshold, offline means not live - env.ledger().with_mut(|li| { - li.timestamp = 10; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_no_heartbeat_returns_false() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded — must return false - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_heartbeat_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.update_oracle_heartbeat(&0u32); - - let events = env.events().all(); - let hb_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) - }); - assert!( - hb_event.is_some(), - "Heartbeat event must be emitted on update_oracle_heartbeat" - ); -} - -#[test] -fn test_set_oracle_stale_threshold_admin_only() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No admin auth for set_oracle_stale_threshold - let result = client.try_set_oracle_stale_threshold(&1800u64); - assert!(result.is_err()); -} - -#[test] -fn test_set_oracle_stale_threshold_validation() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Below minimum (< 60) - let result = client.try_set_oracle_stale_threshold(&59u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Above maximum (> 86400) - let result = client.try_set_oracle_stale_threshold(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Valid value - apply_oracle_stale_threshold(&env, &client, 1800u64); - assert_eq!(client.get_oracle_stale_threshold(), 1800u64); -} - -#[test] -fn test_oracle_deviation_rejected_when_over_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // Set max deviation to 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // 50% jump: diff_bps = 5000 > 500 - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); -} - -#[test] -fn test_oracle_deviation_allows_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 - client.resolve_round(&OraclePayload { - price: 1_0500000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_rounding_floor_is_deterministic() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 - client.create_round(&3u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // At threshold should pass - client.resolve_round(&OraclePayload { - price: 4u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% - client.arm_oracle_deviation_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 2_0000000u128, // 100% jump - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted (check before env.as_contract which resets event scope) - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) - }); - assert!(override_event.is_some(), "override event must be emitted"); - - // Override is one-shot and must be cleared - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKeyCore::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "override must be cleared after use"); - }); -} - -#[test] -fn test_oracle_liveness_custom_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Set a short 120 s threshold - apply_oracle_stale_threshold(&env, &client, 120u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // 100 s later — within custom 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); - - // 130 s later — beyond 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 130; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_resolve_round_nonce_boundary_values() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Pre-seed both boundary nonces as consumed for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); - env.storage().persistent().set( - &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), - &true, - ); - }); - - let zero = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 0u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); - - let max = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: u64::MAX, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_wrong_network_id_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_wrong_contract_addr_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_contract = Address::generate(&env); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_valid_domain_context_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Correct network + correct contract => resolves normally - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_both_network_and_contract_wrong() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - let wrong_contract = Address::generate(&env); - - // Network is checked first, so we get OracleNetworkMismatch - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_protocol_health_no_heartbeat_unknown_oracle() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded → oracle_status=3, oracle_live=false - env.ledger().with_mut(|li| { - li.sequence_number = 1; - }); - let health = client.get_protocol_health(); - assert_eq!(health.oracle_status, 3); // unknown - assert!(!health.oracle_live); - assert!(!health.has_active_round); - assert_eq!(health.status_code, 2); // ORACLE_STALE - assert!(health.ledger_sequence > 0); -} - -#[test] -fn test_protocol_health_oracle_offline() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Record offline heartbeat - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - client.update_oracle_heartbeat(&2u32); // offline - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert_eq!(health.oracle_status, 2); // offline - assert_eq!(health.status_code, 2); // ORACLE_STALE -} - -#[test] -fn test_protocol_health_round_resolvable_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance past end_ledger so round is resolvable - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - // Keep oracle alive - client.update_oracle_heartbeat(&0u32); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 3); // ROUND_STALE -} - -#[test] -fn test_protocol_health_multiple_issues() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // No heartbeat (oracle unknown) + round past end_ledger → multiple issues - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES -} - -#[test] -fn test_protocol_health_schema_version_present() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let health = client.get_protocol_health(); - assert_eq!(health.schema_version, 3); -} - -#[test] -fn test_protocol_health_round_betting_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 6 - env.ledger().with_mut(|li| { - li.sequence_number = 6; - }); - client.create_round(&1_0000000, &None); - - // Still in betting window (ledger 6, bet_end = 6+6=12) - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 1); // betting - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_protocol_health_round_running_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 0 - client.create_round(&1_0000000, &None); - // Default windows: bet=6, run=12 - // bet_end = 6, end = 12 - - // Advance into running phase (past bet_end, before end) - env.ledger().with_mut(|li| { - li.sequence_number = 8; - }); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 2); // running - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_confidence_below_threshold_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(5000u32), - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_confidence_above_threshold_accepted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(9000u32), - attestation: None, }); -} - -#[test] -fn test_missing_confidence_accepted_when_not_strict() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - // strict mode NOT enabled - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Legacy payload without confidence accepted when strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); -} - -#[test] -fn test_missing_confidence_rejected_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.set_oracle_strict_mode(&true); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, status active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past end_ledger, but strict mode is OFF (default) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000s > 3600s stale threshold - }); - - // Should still resolve — strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) + grace (default 0) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_strict_on_allows_when_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance to resolvable, timestamp within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, offline (status=2) - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&2u32); - - // Advance to resolvable (within threshold, but offline always blocks) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 10; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat recorded at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all — would normally block - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) - }); - assert!(override_event.is_some(), "hoverride event must be emitted"); - - assert_eq!(client.get_active_round(), None); - - // Override is one-shot — must be consumed - env.as_contract(&contract_id, || { - let config = env - .storage() - .persistent() - .get::<_, HbGateConfig>(&HbGateKey::Config) - .unwrap_or(HbGateConfig { - strict_mode: false, - override_armed: false, - grace_seconds: 0, - }); - assert!( - !config.override_armed, - "heartbeat override must be cleared after use" - ); - }); -} - -#[test] -fn test_heartbeat_gate_override_is_one_shot() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - - // First round: arm override, resolve (consumes override) - client.create_round(&1_0000000, &None); - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Second round: no heartbeat, no override — should block - env.ledger().with_mut(|li| { - li.sequence_number = 20; - li.timestamp = 200; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 32; - li.timestamp = 300; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 20, - nonce: 2u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - // Set grace period to 600s (10 minutes) - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) but within grace (3600+600=4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000 < 4200, within grace - }); - - // Should resolve — within grace period - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold + grace (3600 + 600 = 4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4300; // 4300 > 4200, beyond grace - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_degraded_status_allowed_when_current() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, degraded (status=1) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); - - // Resolve within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - // Degraded but current — should be allowed - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Degraded heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&1u32); - - // Advance past stale threshold (no grace configured) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_hblocked_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_arm_override_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_arm_hb_override(); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_set_hb_strict_mode(&true); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_getters_return_defaults() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Default values: strict mode OFF, override not armed, grace 0 - assert!(!client.get_hb_strict_mode()); - assert!(!client.get_hb_override_armed()); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_heartbeat_gate_set_and_get_grace_seconds() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.set_hb_grace_seconds(&300u64); - assert_eq!(client.get_hb_grace_seconds(), 300); - - client.set_hb_grace_seconds(&0u64); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_no_confidence_check_when_threshold_unset() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // No min confidence configured - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Even zero confidence accepted when threshold unset - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(0u32), - attestation: None, }); -} - -// SPDX-License-Identifier: MIT -//! Security tests for Oracle data freshness and round validation. - -use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; -use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::errors::ContractError; -use crate::types::{DataKeyCore, DataKeyScoped, HbGateConfig, HbGateKey, OraclePayload}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, IntoVal, TryIntoVal, -}; - -#[test] -fn test_resolve_round_stale_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance ledger time to 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; // Allow resolution - }); - - // Submit payload with timestamp 600 (400s old, > 300s limit) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 600, - round_id: 0, // Starts at ledger 0 - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::StaleOracleData))); -} - -#[test] -fn test_resolve_round_invalid_round_id() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - // Submit payload with wrong round_id (e.g., 999 instead of 0) - let payload = OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 999, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); -} - -#[test] -fn test_resolve_round_valid_payload() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Valid payload: within 300s and correct round_id - let payload = OraclePayload { - price: 1_5000000, - timestamp: 900, // 100s old, OK - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - client.resolve_round(&payload); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_future_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Current ledger time is 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Submit payload with timestamp 1001 (future) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 1001, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); -} - -// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── - -#[test] -fn test_cancelled_round_cannot_be_resolved() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - client.cancel_round(&0u32); - - // After cancellation there is no active round, so resolve_round returns NoActiveRound - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); -} - -#[test] -fn test_cancel_round_without_admin_auth_fails() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - // Initialize with only admin auth - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "create_round", - args: (1_0000000u128, Option::::None).into_val(&env), - sub_invokes: &[], - }, - }]); - client.create_round(&1_0000000, &None); - - // No auth for cancel_round - let result = client.try_cancel_round(&0u32); - assert!(result.is_err()); -} - -// ─── Oracle nonce replay protection (Issue #118) ───────────────────────────── - -/// A nonce already consumed for a round must be rejected on re-submission. -/// We seed the consumed-nonce marker to simulate a prior submission, then -/// assert the resolver rejects a payload reusing that nonce for the same round. -#[test] -fn test_resolve_round_duplicate_nonce_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Simulate a prior submission having consumed nonce 42 for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 42u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); -} - -/// A fresh, unique nonce resolves normally and records the consumed marker. -#[test] -fn test_resolve_round_unique_nonce_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 7u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Round resolved and the nonce is recorded as consumed for that round. - assert_eq!(client.get_active_round(), None); - env.as_contract(&contract_id, || { - let consumed: bool = env - .storage() - .persistent() - .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) - .unwrap_or(false); - assert!(consumed, "resolved nonce must be marked consumed"); - }); -} - -// ─── Oracle heartbeat and liveness tests ───────────────────────────────────── - -#[test] -fn test_oracle_heartbeat_requires_oracle_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No oracle auth set up — must fail - let result = client.try_update_oracle_heartbeat(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_oracle_heartbeat_updates_timestamp_and_status() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - - client.update_oracle_heartbeat(&0u32); - - let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); - assert_eq!(record.timestamp, 500); - assert_eq!(record.status, 0); - - // Update to degraded status - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.update_oracle_heartbeat(&1u32); - - let record = client.get_oracle_heartbeat().unwrap(); - assert_eq!(record.timestamp, 1000); - assert_eq!(record.status, 1); -} - -#[test] -fn test_oracle_heartbeat_invalid_status_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let result = client.try_update_oracle_heartbeat(&3u32); - assert_eq!(result, Err(Ok(ContractError::InvalidMode))); -} - -#[test] -fn test_oracle_liveness_within_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check liveness 100 s later (well within 3600 s default) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_stale_after_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check 4000 s later — beyond 3600 s default threshold - env.ledger().with_mut(|li| { - li.timestamp = 4000; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_offline_status_not_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - // Record offline status - client.update_oracle_heartbeat(&2u32); - - // Even within threshold, offline means not live - env.ledger().with_mut(|li| { - li.timestamp = 10; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_no_heartbeat_returns_false() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded — must return false - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_heartbeat_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.update_oracle_heartbeat(&0u32); - - let events = env.events().all(); - let hb_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) - }); - assert!( - hb_event.is_some(), - "Heartbeat event must be emitted on update_oracle_heartbeat" - ); -} - -#[test] -fn test_set_oracle_stale_threshold_admin_only() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No admin auth for set_oracle_stale_threshold - let result = client.try_set_oracle_stale_threshold(&1800u64); - assert!(result.is_err()); -} - -#[test] -fn test_set_oracle_stale_threshold_validation() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Below minimum (< 60) - let result = client.try_set_oracle_stale_threshold(&59u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Above maximum (> 86400) - let result = client.try_set_oracle_stale_threshold(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Valid value - apply_oracle_stale_threshold(&env, &client, 1800u64); - assert_eq!(client.get_oracle_stale_threshold(), 1800u64); -} - -// ─── Oracle deviation guardrails tests ─────────────────────────────────────── - -#[test] -fn test_oracle_deviation_rejected_when_over_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // Set max deviation to 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // 50% jump: diff_bps = 5000 > 500 - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000u128, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); -} - -#[test] -fn test_oracle_deviation_allows_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 - client.resolve_round(&OraclePayload { - price: 1_0500000u128, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_rounding_floor_is_deterministic() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 - client.create_round(&3u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // At threshold should pass - client.resolve_round(&OraclePayload { - price: 4u128, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% - client.arm_oracle_deviation_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 2_0000000u128, // 100% jump - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Verify override event emitted (check before env.as_contract which resets event scope) - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) - }); - assert!(override_event.is_some(), "override event must be emitted"); - - // Override is one-shot and must be cleared - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKeyCore::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "override must be cleared after use"); - }); -} - -#[test] -fn test_oracle_liveness_custom_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Set a short 120 s threshold - apply_oracle_stale_threshold(&env, &client, 120u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // 100 s later — within custom 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); - - // 130 s later — beyond 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 130; - }); - assert!(!client.is_oracle_live()); -} - -/// Boundary nonces (0 and u64::MAX) are rejected on reuse for the same round. -#[test] -fn test_resolve_round_nonce_boundary_values() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Pre-seed both boundary nonces as consumed for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); - env.storage().persistent().set( - &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), - &true, - ); - }); - - let zero = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 0u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); - - let max = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: u64::MAX, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); -} - -// ─── Oracle domain-context validation tests (Issue #143) ──────────────────── - -#[test] -fn test_resolve_round_wrong_network_id_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_wrong_contract_addr_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_contract = Address::generate(&env); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: wrong_contract, - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_valid_domain_context_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Correct network + correct contract => resolves normally - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_both_network_and_contract_wrong() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - let wrong_contract = Address::generate(&env); - - // Network is checked first, so we get OracleNetworkMismatch - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: wrong_contract, - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -// ─── Protocol health endpoint tests ────────────────────────────────────────── - -#[test] -fn test_protocol_health_no_heartbeat_unknown_oracle() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded → oracle_status=3, oracle_live=false - env.ledger().with_mut(|li| { - li.sequence_number = 1; - }); - let health = client.get_protocol_health(); - assert_eq!(health.oracle_status, 3); // unknown - assert!(!health.oracle_live); - assert!(!health.has_active_round); - assert_eq!(health.status_code, 2); // ORACLE_STALE - assert!(health.ledger_sequence > 0); -} - -#[test] -fn test_protocol_health_oracle_offline() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Record offline heartbeat - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - client.update_oracle_heartbeat(&2u32); // offline - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert_eq!(health.oracle_status, 2); // offline - assert_eq!(health.status_code, 2); // ORACLE_STALE -} - -#[test] -fn test_protocol_health_round_resolvable_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance past end_ledger so round is resolvable - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - // Keep oracle alive - client.update_oracle_heartbeat(&0u32); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 3); // ROUND_STALE -} - -#[test] -fn test_protocol_health_multiple_issues() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // No heartbeat (oracle unknown) + round past end_ledger → multiple issues - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES -} - -#[test] -fn test_protocol_health_schema_version_present() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let health = client.get_protocol_health(); - assert_eq!(health.schema_version, 3); -} - -#[test] -fn test_protocol_health_round_betting_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 6 - env.ledger().with_mut(|li| { - li.sequence_number = 6; - }); - client.create_round(&1_0000000, &None); - - // Still in betting window (ledger 6, bet_end = 6+6=12) - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 1); // betting - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_protocol_health_round_running_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 0 - client.create_round(&1_0000000, &None); - // Default windows: bet=6, run=12 - // bet_end = 6, end = 12 - - // Advance into running phase (past bet_end, before end) - env.ledger().with_mut(|li| { - li.sequence_number = 8; - }); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 2); // running - assert_eq!(health.status_code, 0); // HEALTHY -} -// ── Oracle confidence score tests ──────────────────────────────────────────── - -#[test] -fn test_confidence_below_threshold_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(5000u32), - }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_confidence_above_threshold_accepted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(9000u32), - }); -} - -#[test] -fn test_missing_confidence_accepted_when_not_strict() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - // strict mode NOT enabled - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Legacy payload without confidence accepted when strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); -} - -#[test] -fn test_missing_confidence_rejected_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.set_oracle_strict_mode(&true); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -// ── Oracle heartbeat health gate tests (Issue #264) ────────────────────────── - -/// Helper: create a round with oracle heartbeat, advance to resolvable state. -fn _setup_heartbeat_gate_test( - env: &Env, - client: &VirtualTokenContractClient, - heartbeat_timestamp: u64, - heartbeat_status: u32, -) { - env.ledger().with_mut(|li| { - li.timestamp = heartbeat_timestamp; - }); - client.update_oracle_heartbeat(&heartbeat_status); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; // past end_ledger - li.timestamp = heartbeat_timestamp + 100; // within threshold - }); -} - -#[test] -fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, status active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past end_ledger, but strict mode is OFF (default) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000s > 3600s stale threshold - }); - - // Should still resolve — strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) + grace (default 0) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_strict_on_allows_when_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance to resolvable, timestamp within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, offline (status=2) - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&2u32); - - // Advance to resolvable (within threshold, but offline always blocks) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 10; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat recorded at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all — would normally block - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - assert_eq!(client.get_active_round(), None); - - // Verify override event emitted - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) - }); - assert!(override_event.is_some(), "hoverride event must be emitted"); - - // Override is one-shot — must be consumed - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKey::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "heartbeat override must be cleared after use"); - }); -} - -#[test] -fn test_heartbeat_gate_override_is_one_shot() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - - // First round: arm override, resolve (consumes override) - client.create_round(&1_0000000, &None); - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Second round: no heartbeat, no override — should block - env.ledger().with_mut(|li| { - li.sequence_number = 20; - li.timestamp = 200; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 32; - li.timestamp = 300; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - // Set grace period to 600s (10 minutes) - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) but within grace (3600+600=4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000 < 4200, within grace - }); - - // Should resolve — within grace period - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold + grace (3600 + 600 = 4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4300; // 4300 > 4200, beyond grace - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_degraded_status_allowed_when_current() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, degraded (status=1) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); - - // Resolve within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - // Degraded but current — should be allowed - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Degraded heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&1u32); - - // Advance past stale threshold (no grace configured) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_hblocked_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let _ = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - let events = env.events().all(); - let blocked_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hblocked")) - }); - assert!(blocked_event.is_some(), "hblocked event must be emitted"); -} - -#[test] -fn test_heartbeat_gate_arm_override_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_arm_hb_override(); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_set_hb_strict_mode(&true); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_getters_return_defaults() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Default values: strict mode OFF, override not armed, grace 0 - assert!(!client.get_hb_strict_mode()); - assert!(!client.get_hb_override_armed()); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_heartbeat_gate_set_and_get_grace_seconds() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.set_hb_grace_seconds(&300u64); - assert_eq!(client.get_hb_grace_seconds(), 300); - - client.set_hb_grace_seconds(&0u64); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_no_confidence_check_when_threshold_unset() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // No min confidence configured - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Even zero confidence accepted when threshold unset - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(0u32), - }); -} -// SPDX-License-Identifier: MIT -//! Security tests for Oracle data freshness and round validation. - -use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; -use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::errors::ContractError; -use crate::types::{DataKey, HbGateConfig, HbGateKey, OraclePayload}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, IntoVal, TryIntoVal, -}; - -#[test] -fn test_resolve_round_stale_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance ledger time to 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; // Allow resolution - }); - - // Submit payload with timestamp 600 (400s old, > 300s limit) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 600, - round_id: 0, // Starts at ledger 0 - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::StaleOracleData))); -} - -#[test] -fn test_resolve_round_invalid_round_id() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - // Submit payload with wrong round_id (e.g., 999 instead of 0) - let payload = OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 999, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); -} - -#[test] -fn test_resolve_round_valid_payload() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Valid payload: within 300s and correct round_id - let payload = OraclePayload { - price: 1_5000000, - timestamp: 900, // 100s old, OK - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - client.resolve_round(&payload); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_future_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Current ledger time is 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Submit payload with timestamp 1001 (future) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 1001, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); -} - -// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── - -#[test] -fn test_cancelled_round_cannot_be_resolved() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - client.cancel_round(&0u32); - - // After cancellation there is no active round, so resolve_round returns NoActiveRound - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); -} - -#[test] -fn test_cancel_round_without_admin_auth_fails() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - // Initialize with only admin auth - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "create_round", - args: (1_0000000u128, Option::::None).into_val(&env), - sub_invokes: &[], - }, - }]); - client.create_round(&1_0000000, &None); - - // No auth for cancel_round - let result = client.try_cancel_round(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_resolve_round_duplicate_nonce_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Simulate a prior submission having consumed nonce 42 for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 42u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_unique_nonce_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 7u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Round resolved and the nonce is recorded as consumed for that round. - assert_eq!(client.get_active_round(), None); - env.as_contract(&contract_id, || { - let consumed: bool = env - .storage() - .persistent() - .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) - .unwrap_or(false); - assert!(consumed, "resolved nonce must be marked consumed"); - }); -} - -#[test] -fn test_oracle_heartbeat_requires_oracle_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No oracle auth set up — must fail - let result = client.try_update_oracle_heartbeat(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_oracle_heartbeat_updates_timestamp_and_status() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - - client.update_oracle_heartbeat(&0u32); - - let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); - assert_eq!(record.timestamp, 500); - assert_eq!(record.status, 0); - - // Update to degraded status - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.update_oracle_heartbeat(&1u32); - - let record = client.get_oracle_heartbeat().unwrap(); - assert_eq!(record.timestamp, 1000); - assert_eq!(record.status, 1); -} - -#[test] -fn test_oracle_heartbeat_invalid_status_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let result = client.try_update_oracle_heartbeat(&3u32); - assert_eq!(result, Err(Ok(ContractError::InvalidMode))); -} - -#[test] -fn test_oracle_liveness_within_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check liveness 100 s later (well within 3600 s default) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_stale_after_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check 4000 s later — beyond 3600 s default threshold - env.ledger().with_mut(|li| { - li.timestamp = 4000; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_offline_status_not_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - // Record offline status - client.update_oracle_heartbeat(&2u32); - - // Even within threshold, offline means not live - env.ledger().with_mut(|li| { - li.timestamp = 10; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_no_heartbeat_returns_false() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded — must return false - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_heartbeat_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.update_oracle_heartbeat(&0u32); - - let events = env.events().all(); - let hb_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) - }); - assert!( - hb_event.is_some(), - "Heartbeat event must be emitted on update_oracle_heartbeat" - ); -} - -#[test] -fn test_set_oracle_stale_threshold_admin_only() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No admin auth for set_oracle_stale_threshold - let result = client.try_set_oracle_stale_threshold(&1800u64); - assert!(result.is_err()); -} - -#[test] -fn test_set_oracle_stale_threshold_validation() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Below minimum (< 60) - let result = client.try_set_oracle_stale_threshold(&59u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Above maximum (> 86400) - let result = client.try_set_oracle_stale_threshold(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Valid value - apply_oracle_stale_threshold(&env, &client, 1800u64); - assert_eq!(client.get_oracle_stale_threshold(), 1800u64); -} - -#[test] -fn test_oracle_deviation_rejected_when_over_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // Set max deviation to 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // 50% jump: diff_bps = 5000 > 500 - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); -} - -#[test] -fn test_oracle_deviation_allows_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 - client.resolve_round(&OraclePayload { - price: 1_0500000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_rounding_floor_is_deterministic() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 - client.create_round(&3u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // At threshold should pass - client.resolve_round(&OraclePayload { - price: 4u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% - client.arm_oracle_deviation_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 2_0000000u128, // 100% jump - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted (check before env.as_contract which resets event scope) - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) - }); - assert!(override_event.is_some(), "override event must be emitted"); - - // Override is one-shot and must be cleared - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKeyCore::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "override must be cleared after use"); - }); -} - -#[test] -fn test_oracle_liveness_custom_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Set a short 120 s threshold - apply_oracle_stale_threshold(&env, &client, 120u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // 100 s later — within custom 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); - - // 130 s later — beyond 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 130; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_resolve_round_nonce_boundary_values() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Pre-seed both boundary nonces as consumed for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); - env.storage().persistent().set( - &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), - &true, - ); - }); - - let zero = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 0u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); - - let max = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: u64::MAX, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_wrong_network_id_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_wrong_contract_addr_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_contract = Address::generate(&env); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_valid_domain_context_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Correct network + correct contract => resolves normally - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_both_network_and_contract_wrong() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - let wrong_contract = Address::generate(&env); - - // Network is checked first, so we get OracleNetworkMismatch - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_protocol_health_no_heartbeat_unknown_oracle() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded → oracle_status=3, oracle_live=false - env.ledger().with_mut(|li| { - li.sequence_number = 1; - }); - let health = client.get_protocol_health(); - assert_eq!(health.oracle_status, 3); // unknown - assert!(!health.oracle_live); - assert!(!health.has_active_round); - assert_eq!(health.status_code, 2); // ORACLE_STALE - assert!(health.ledger_sequence > 0); -} - -#[test] -fn test_protocol_health_oracle_offline() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Record offline heartbeat - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - client.update_oracle_heartbeat(&2u32); // offline - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert_eq!(health.oracle_status, 2); // offline - assert_eq!(health.status_code, 2); // ORACLE_STALE -} - -#[test] -fn test_protocol_health_round_resolvable_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance past end_ledger so round is resolvable - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - // Keep oracle alive - client.update_oracle_heartbeat(&0u32); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 3); // ROUND_STALE -} - -#[test] -fn test_protocol_health_multiple_issues() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // No heartbeat (oracle unknown) + round past end_ledger → multiple issues - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES -} - -#[test] -fn test_protocol_health_schema_version_present() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let health = client.get_protocol_health(); - assert_eq!(health.schema_version, 3); -} - -#[test] -fn test_protocol_health_round_betting_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 6 - env.ledger().with_mut(|li| { - li.sequence_number = 6; - }); - client.create_round(&1_0000000, &None); - - // Still in betting window (ledger 6, bet_end = 6+6=12) - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 1); // betting - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_protocol_health_round_running_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 0 - client.create_round(&1_0000000, &None); - // Default windows: bet=6, run=12 - // bet_end = 6, end = 12 - - // Advance into running phase (past bet_end, before end) - env.ledger().with_mut(|li| { - li.sequence_number = 8; - }); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 2); // running - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_confidence_below_threshold_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(5000u32), - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_confidence_above_threshold_accepted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(9000u32), - attestation: None, }); -} - -#[test] -fn test_missing_confidence_accepted_when_not_strict() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - // strict mode NOT enabled - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Legacy payload without confidence accepted when strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); -} - -#[test] -fn test_missing_confidence_rejected_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.set_oracle_strict_mode(&true); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, status active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past end_ledger, but strict mode is OFF (default) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000s > 3600s stale threshold - }); - - // Should still resolve — strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) + grace (default 0) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_strict_on_allows_when_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance to resolvable, timestamp within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, offline (status=2) - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&2u32); - - // Advance to resolvable (within threshold, but offline always blocks) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 10; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat recorded at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all — would normally block - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) - }); - assert!(override_event.is_some(), "hoverride event must be emitted"); - - assert_eq!(client.get_active_round(), None); - - // Override is one-shot — must be consumed - env.as_contract(&contract_id, || { - let config = env - .storage() - .persistent() - .get::<_, HbGateConfig>(&HbGateKey::Config) - .unwrap_or(HbGateConfig { - strict_mode: false, - override_armed: false, - grace_seconds: 0, - }); - assert!( - !config.override_armed, - "heartbeat override must be cleared after use" - ); - }); -} - -#[test] -fn test_heartbeat_gate_override_is_one_shot() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - - // First round: arm override, resolve (consumes override) - client.create_round(&1_0000000, &None); - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Second round: no heartbeat, no override — should block - env.ledger().with_mut(|li| { - li.sequence_number = 20; - li.timestamp = 200; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 32; - li.timestamp = 300; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 20, - nonce: 2u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - // Set grace period to 600s (10 minutes) - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) but within grace (3600+600=4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000 < 4200, within grace - }); - - // Should resolve — within grace period - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold + grace (3600 + 600 = 4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4300; // 4300 > 4200, beyond grace - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_degraded_status_allowed_when_current() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, degraded (status=1) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); - - // Resolve within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - // Degraded but current — should be allowed - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Degraded heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&1u32); - - // Advance past stale threshold (no grace configured) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_hblocked_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_arm_override_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_arm_hb_override(); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_set_hb_strict_mode(&true); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_getters_return_defaults() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Default values: strict mode OFF, override not armed, grace 0 - assert!(!client.get_hb_strict_mode()); - assert!(!client.get_hb_override_armed()); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_heartbeat_gate_set_and_get_grace_seconds() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.set_hb_grace_seconds(&300u64); - assert_eq!(client.get_hb_grace_seconds(), 300); - - client.set_hb_grace_seconds(&0u64); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_no_confidence_check_when_threshold_unset() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // No min confidence configured - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Even zero confidence accepted when threshold unset - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(0u32), - attestation: None, }); -} - +// SPDX-License-Identifier: MIT +//! Security tests for Oracle data freshness and round validation. + +use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold, apply_windows}; + +#[test] +fn test_resolve_round_timestamp_after_round_window() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.create_round(&1_0000000, &None); + + // Advance ledger time to 1000, seq to 12 (round end ~ seq 12) + env.ledger().with_mut(|li| { + li.timestamp = 1000; + li.sequence_number = 12; // Allow resolution + }); + + // Round started at ts=0, end_ledger=12 -> end_estimate = 0 + 12*5 = 60 + // Window with default skew 300: [0, 60+300] = [0, 360] + // Payload ts=500 is after the upper bound + let payload = OraclePayload { + price: 1_5000000, + timestamp: 500, + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }; + + let result = client.try_resolve_round(&payload); + assert_eq!(result, Err(Ok(ContractError::OracleTimestampOutsideWindow))); +} + +#[test] +fn test_resolve_round_timestamp_before_round_window() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + + // Set initial ledger timestamp to 100 so round starts at ts=100 + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + li.sequence_number = 12; + }); + + // Round start=100, end_ledger=12 -> end_estimate = 100 + 12*5 = 160 + // Default skew 300 -> window: [100-300, 160+300] = [0, 460] (lower saturates at 0) + // Set a short skew of 30 via instance storage to make lower bound = 100-30 = 70 + env.as_contract(&contract_id, || { + env.storage().instance().set(&symbol_short!("otskew"), &30u64); + }); + // With skew=30: window = [70, 190] + // Payload ts=10 is before the lower bound + + let payload = OraclePayload { + price: 1_5000000, + timestamp: 10, + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }; + + let result = client.try_resolve_round(&payload); + assert_eq!(result, Err(Ok(ContractError::OracleTimestampOutsideWindow))); +} + +#[test] +fn test_resolve_round_timestamp_boundary_lower() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + li.sequence_number = 12; + }); + + // Skew=30 -> window: [70, 190] + env.as_contract(&contract_id, || { + env.storage().instance().set(&symbol_short!("otskew"), &30u64); + }); + + // Payload at exactly the lower bound (70) must be accepted + client.resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 70, + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_resolve_round_timestamp_boundary_upper() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + li.sequence_number = 12; + }); + + // Round start=0, end_ledger=12 -> end_estimate = 60 + // Default skew=300 -> window: [0, 360] + // Payload at exactly the upper bound (360) must be accepted + client.resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 360, + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; +use crate::errors::ContractError; +use crate::types::{DataKeyCore, DataKeyScoped, HbGateConfig, HbGateKey, OraclePayload}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events, Ledger as _}, + Address, BytesN, Env, IntoVal, TryIntoVal, +}; + +#[test] +fn test_resolve_round_stale_timestamp() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + + // Advance ledger time to 1000 + env.ledger().with_mut(|li| { + li.timestamp = 1000; + li.sequence_number = 12; // Allow resolution + }); + + // Submit payload with timestamp 600 — outside the round-relative economic + // window (start=0, end_ledger=12 -> end_estimate=60, default skew=300 -> + // window=[0,360]). The round-relative window check supersedes the old + // absolute-freshness (300s) check for this path (see resolve_round). + let payload = OraclePayload { + price: 1_5000000, + timestamp: 600, + round_id: 0, // Starts at ledger 0 + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, }; + + let result = client.try_resolve_round(&payload); + assert_eq!(result, Err(Ok(ContractError::OracleTimestampOutsideWindow))); +} + +#[test] +fn test_resolve_round_invalid_round_id() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + }); + + // Submit payload with wrong round_id (e.g., 999 instead of 0) + let payload = OraclePayload { + price: 1_5000000, + timestamp: env.ledger().timestamp(), + round_id: 999, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, }; + + let result = client.try_resolve_round(&payload); + assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); +} + +#[test] +fn test_resolve_round_valid_payload() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + // Valid payload: within 300s and correct round_id + let payload = OraclePayload { + price: 1_5000000, + timestamp: 100, // 100s old, OK + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, }; + + client.resolve_round(&payload); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_resolve_round_future_timestamp() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + + // Current ledger time is 1000 + env.ledger().with_mut(|li| { + li.timestamp = 1000; + li.sequence_number = 12; + }); + + // Submit payload with timestamp 1001 (future) + let payload = OraclePayload { + price: 1_5000000, + timestamp: 1001, + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, }; + + let result = client.try_resolve_round(&payload); + assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); +} + +// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── + +#[test] +fn test_cancelled_round_cannot_be_resolved() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + env.mock_all_auths(); + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + + client.cancel_round(&0u32); + + // After cancellation there is no active round, so resolve_round returns NoActiveRound + env.ledger().with_mut(|li| { + li.sequence_number = 12; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); +} + +#[test] +fn test_cancel_round_without_admin_auth_fails() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + // Initialize with only admin auth + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: (&admin, &oracle).into_val(&env), + sub_invokes: &[], + }, + }]); + client.initialize(&admin, &oracle); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "create_round", + args: (1_0000000u128, Option::::None).into_val(&env), + sub_invokes: &[], + }, + }]); + client.create_round(&1_0000000, &None); + + // No auth for cancel_round + let result = client.try_cancel_round(&0u32); + assert!(result.is_err()); +} + +// ─── Oracle nonce replay protection (Issue #118) ───────────────────────────── + +/// A nonce already consumed for a round must be rejected on re-submission. +/// We seed the consumed-nonce marker to simulate a prior submission, then +/// assert the resolver rejects a payload reusing that nonce for the same round. +#[test] +fn test_resolve_round_duplicate_nonce_rejected() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + // Simulate a prior submission having consumed nonce 42 for this round. + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: 42u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); +} + +/// A fresh, unique nonce resolves normally and records the consumed marker. +#[test] +fn test_resolve_round_unique_nonce_resolves() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + client.resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: 7u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + + // Round resolved and the nonce is recorded as consumed for that round. + assert_eq!(client.get_active_round(), None); + env.as_contract(&contract_id, || { + let consumed: bool = env + .storage() + .persistent() + .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) + .unwrap_or(false); + assert!(consumed, "resolved nonce must be marked consumed"); + }); +} + +// ─── Oracle heartbeat and liveness tests ───────────────────────────────────── + +#[test] +fn test_oracle_heartbeat_requires_oracle_auth() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: (&admin, &oracle).into_val(&env), + sub_invokes: &[], + }, + }]); + client.initialize(&admin, &oracle); + + // No oracle auth set up — must fail + let result = client.try_update_oracle_heartbeat(&0u32); + assert!(result.is_err()); +} + +#[test] +fn test_oracle_heartbeat_updates_timestamp_and_status() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + env.ledger().with_mut(|li| { + li.timestamp = 500; + }); + + client.update_oracle_heartbeat(&0u32); + + let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); + assert_eq!(record.timestamp, 500); + assert_eq!(record.status, 0); + + // Update to degraded status + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + client.update_oracle_heartbeat(&1u32); + + let record = client.get_oracle_heartbeat().unwrap(); + assert_eq!(record.timestamp, 1000); + assert_eq!(record.status, 1); +} + +#[test] +fn test_oracle_heartbeat_invalid_status_rejected() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + let result = client.try_update_oracle_heartbeat(&3u32); + assert_eq!(result, Err(Ok(ContractError::InvalidMode))); +} + +#[test] +fn test_oracle_liveness_within_threshold() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // Heartbeat at t=0 + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&0u32); + + // Check liveness 100 s later (well within 3600 s default) + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + assert!(client.is_oracle_live()); +} + +#[test] +fn test_oracle_liveness_stale_after_threshold() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // Heartbeat at t=0 + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&0u32); + + // Check 4000 s later — beyond 3600 s default threshold + env.ledger().with_mut(|li| { + li.timestamp = 4000; + }); + assert!(!client.is_oracle_live()); +} + +#[test] +fn test_oracle_liveness_offline_status_not_live() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + // Record offline status + client.update_oracle_heartbeat(&2u32); + + // Even within threshold, offline means not live + env.ledger().with_mut(|li| { + li.timestamp = 10; + }); + assert!(!client.is_oracle_live()); +} + +#[test] +fn test_oracle_liveness_no_heartbeat_returns_false() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // No heartbeat recorded — must return false + assert!(!client.is_oracle_live()); +} + +#[test] +fn test_oracle_heartbeat_event_emitted() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + client.update_oracle_heartbeat(&0u32); + + let events = env.events().all(); + let hb_event = events.iter().find(|e| { + let (_contract, topics, _data) = e; + topics.len() == 2 + && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) + && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) + }); + assert!( + hb_event.is_some(), + "Heartbeat event must be emitted on update_oracle_heartbeat" + ); +} + +#[test] +fn test_set_oracle_stale_threshold_admin_only() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: (&admin, &oracle).into_val(&env), + sub_invokes: &[], + }, + }]); + client.initialize(&admin, &oracle); + + // No admin auth for set_oracle_stale_threshold + let result = client.try_set_oracle_stale_threshold(&1800u64); + assert!(result.is_err()); +} + +#[test] +fn test_set_oracle_stale_threshold_validation() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // Below minimum (< 60) + let result = client.try_set_oracle_stale_threshold(&59u64); + assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); + + // Above maximum (> 86400) + let result = client.try_set_oracle_stale_threshold(&86_401u64); + assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); + + // Valid value + apply_oracle_stale_threshold(&env, &client, 1800u64); + assert_eq!(client.get_oracle_stale_threshold(), 1800u64); +} + +// ─── Heartbeat health enforcement tests (Issue #264) ─────────────────────── + +/// Helper: sets up a fresh env, registers the contract, initializes, +/// records an active heartbeat at the current ledger timestamp, +/// and creates a round (ledger 0). Returns client. +fn setup_with_heartbeat(env: &Env, contract_id: &Address) -> VirtualTokenContractClient<'static> { + let admin = Address::generate(env); + let oracle = Address::generate(env); + env.mock_all_auths(); + + let client = VirtualTokenContractClient::new(env, contract_id); + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + client +} + +/// Active (status 0) fresh heartbeat allows settlement. +#[test] +fn test_heartbeat_active_fresh_allows_resolve() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = setup_with_heartbeat(&env, &contract_id); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; // past end_ledger + li.timestamp = 100; + }); + + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +/// Degraded (status 1) fresh heartbeat allows settlement (non-strict). +#[test] +fn test_heartbeat_degraded_fresh_allows_resolve() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = setup_with_heartbeat(&env, &contract_id); + + // Mark degraded at current time + env.ledger().with_mut(|li| { + li.timestamp = 50; + }); + client.update_oracle_heartbeat(&1u32); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); +} + +/// Strict mode is settable and queryable. +#[test] +fn test_heartbeat_strict_mode_config() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + let client = VirtualTokenContractClient::new(&env, &contract_id); + client.initialize(&admin, &oracle); + + assert!(!client.get_hb_strict_mode()); + client.set_hb_strict_mode(&true); + assert!(client.get_hb_strict_mode()); + client.set_hb_strict_mode(&false); + assert!(!client.get_hb_strict_mode()); +} + +// ─── Oracle deviation guardrails tests ─────────────────────────────────────── + +#[test] +fn test_oracle_deviation_rejected_when_over_threshold() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000u128, &None); + let round = client.get_active_round().unwrap(); + + // Set max deviation to 5% (500 bp) + apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + // 50% jump: diff_bps = 5000 > 500 + let result = client.try_resolve_round(&OraclePayload { + price: 1_5000000u128, + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); +} + +#[test] +fn test_oracle_deviation_allows_at_exact_threshold() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000u128, &None); + let round = client.get_active_round().unwrap(); + + // 5% (500 bp) + apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 + client.resolve_round(&OraclePayload { + price: 1_0500000u128, + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_oracle_deviation_rounding_floor_is_deterministic() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 + client.create_round(&3u128, &None); + let round = client.get_active_round().unwrap(); + + apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + // At threshold should pass + client.resolve_round(&OraclePayload { + price: 4u128, + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000u128, &None); + let round = client.get_active_round().unwrap(); + + apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% + client.arm_oracle_deviation_override(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + client.resolve_round(&OraclePayload { + price: 2_0000000u128, // 100% jump + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + + // Verify override event emitted (check before env.as_contract which resets event scope) + let events = env.events().all(); + let override_event = events.iter().find(|e| { + let (_contract, topics, _data) = e; + topics.len() == 2 + && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) + && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) + }); + assert!(override_event.is_some(), "override event must be emitted"); + + // Override is one-shot and must be cleared + env.as_contract(&contract_id, || { + let armed: bool = env + .storage() + .persistent() + .get(&DataKeyCore::OracleDeviationOverrideArmed) + .unwrap_or(false); + assert!(!armed, "override must be cleared after use"); + }); +} + +#[test] +fn test_oracle_liveness_custom_threshold() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // Set a short 120 s threshold + apply_oracle_stale_threshold(&env, &client, 120u64); + + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&0u32); + + // 100 s later — within custom 120 s threshold + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + assert!(client.is_oracle_live()); + + // 130 s later — beyond 120 s threshold + env.ledger().with_mut(|li| { + li.timestamp = 130; + }); + assert!(!client.is_oracle_live()); +} + +/// Boundary nonces (0 and u64::MAX) are rejected on reuse for the same round. +#[test] +fn test_resolve_round_nonce_boundary_values() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + // Pre-seed both boundary nonces as consumed for this round. + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); + env.storage().persistent().set( + &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), + &true, + ); + }); + + let zero = client.try_resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: 0u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); + + let max = client.try_resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: u64::MAX, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); +} + +// ─── Oracle domain-context validation tests (Issue #143) ──────────────────── + +#[test] +fn test_resolve_round_wrong_network_id_rejected() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: wrong_network, + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); +} + +#[test] +fn test_resolve_round_wrong_contract_addr_rejected() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + let wrong_contract = Address::generate(&env); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: wrong_contract, + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); +} + +#[test] +fn test_resolve_round_valid_domain_context_resolves() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + // Correct network + correct contract => resolves normally + client.resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_resolve_round_both_network_and_contract_wrong() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + let round = client.get_active_round().unwrap(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 1000; + }); + + let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); + let wrong_contract = Address::generate(&env); + + // Network is checked first, so we get OracleNetworkMismatch + let result = client.try_resolve_round(&OraclePayload { + price: 1_5000000, + timestamp: 100, + round_id: round.start_ledger, + nonce: 1u64, + network_id: wrong_network, + contract_addr: wrong_contract, + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); +} + +// ─── Protocol health endpoint tests ────────────────────────────────────────── + +#[test] +fn test_protocol_health_no_heartbeat_unknown_oracle() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // No heartbeat recorded → oracle_status=3, oracle_live=false + env.ledger().with_mut(|li| { + li.sequence_number = 1; + }); + let health = client.get_protocol_health(); + assert_eq!(health.oracle_status, 3); // unknown + assert!(!health.oracle_live); + assert!(!health.has_active_round); + assert_eq!(health.status_code, 2); // ORACLE_STALE + assert!(health.ledger_sequence > 0); +} + +#[test] +fn test_protocol_health_oracle_offline() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // Record offline heartbeat + env.ledger().with_mut(|li| { + li.timestamp = 500; + }); + client.update_oracle_heartbeat(&2u32); // offline + + let health = client.get_protocol_health(); + assert!(!health.oracle_live); + assert_eq!(health.oracle_status, 2); // offline + assert_eq!(health.status_code, 2); // ORACLE_STALE +} + +#[test] +fn test_protocol_health_round_resolvable_stale() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.create_round(&1_0000000, &None); + + // Advance past end_ledger so round is resolvable + env.ledger().with_mut(|li| { + li.sequence_number = 30; + li.timestamp = 100; + }); + + // Keep oracle alive + client.update_oracle_heartbeat(&0u32); + + let health = client.get_protocol_health(); + assert!(health.has_active_round); + assert_eq!(health.active_round_phase, 3); // resolvable + assert_eq!(health.status_code, 3); // ROUND_STALE +} + +#[test] +fn test_protocol_health_multiple_issues() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + client.create_round(&1_0000000, &None); + + // No heartbeat (oracle unknown) + round past end_ledger → multiple issues + env.ledger().with_mut(|li| { + li.sequence_number = 30; + li.timestamp = 100; + }); + + let health = client.get_protocol_health(); + assert!(!health.oracle_live); + assert!(health.has_active_round); + assert_eq!(health.active_round_phase, 3); // resolvable + assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES +} + +#[test] +fn test_protocol_health_schema_version_present() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + let health = client.get_protocol_health(); + assert_eq!(health.schema_version, 3); +} + +#[test] +fn test_protocol_health_round_betting_phase() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let user = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + client.mint_initial(&user); + + // Oracle heartbeat active + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + client.update_oracle_heartbeat(&0u32); + + // Create round at ledger 6 + env.ledger().with_mut(|li| { + li.sequence_number = 6; + }); + client.create_round(&1_0000000, &None); + + // Still in betting window (ledger 6, bet_end = 6+6=12) + let health = client.get_protocol_health(); + assert!(health.has_active_round); + assert_eq!(health.active_round_phase, 1); // betting + assert_eq!(health.status_code, 0); // HEALTHY +} + +#[test] +fn test_protocol_health_round_running_phase() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let user = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + client.mint_initial(&user); + + // Oracle heartbeat active + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + client.update_oracle_heartbeat(&0u32); + + // Create round at ledger 0 + client.create_round(&1_0000000, &None); + // Default windows: bet=6, run=12 + // bet_end = 6, end = 12 + + // Advance into running phase (past bet_end, before end) + env.ledger().with_mut(|li| { + li.sequence_number = 8; + }); + + let health = client.get_protocol_health(); + assert!(health.has_active_round); + assert_eq!(health.active_round_phase, 2); // running + assert_eq!(health.status_code, 0); // HEALTHY +} +// ── Oracle confidence score tests ──────────────────────────────────────────── + +#[test] +fn test_confidence_below_threshold_rejected() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.set_oracle_min_confidence_bps(&Some(8000u32)); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: Some(5000u32), + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); +} + +#[test] +fn test_confidence_above_threshold_accepted() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.set_oracle_min_confidence_bps(&Some(8000u32)); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: Some(9000u32), + attestation: None, + }); +} + +#[test] +fn test_missing_confidence_accepted_when_not_strict() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.set_oracle_min_confidence_bps(&Some(8000u32)); + // strict mode NOT enabled + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + // Legacy payload without confidence accepted when strict mode is off + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); +} + +#[test] +fn test_missing_confidence_rejected_in_strict_mode() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.set_oracle_min_confidence_bps(&Some(8000u32)); + client.set_oracle_strict_mode(&true); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); +} + +// ── Oracle heartbeat health gate tests (Issue #264) ────────────────────────── + +/// Helper: create a round with oracle heartbeat, advance to resolvable state. +fn _setup_heartbeat_gate_test( + env: &Env, + client: &VirtualTokenContractClient, + heartbeat_timestamp: u64, + heartbeat_status: u32, +) { + env.ledger().with_mut(|li| { + li.timestamp = heartbeat_timestamp; + }); + client.update_oracle_heartbeat(&heartbeat_status); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; // past end_ledger + li.timestamp = heartbeat_timestamp + 100; // within threshold + }); +} + +#[test] +fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + // Widen the round's run window so the round-relative oracle timestamp + // check (Issue: oracle-round-relative-timestamp-window) doesn't reject + // the payload before the heartbeat gate is even reached — the default + // 12-ledger window is far shorter than the 3600s stale threshold below. + apply_windows(&env, &client, 6, 800); + client.create_round(&1_0000000, &None); + + // Heartbeat at t=0, status active + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&0u32); + + // Advance past end_ledger (widened to 800 ledgers above), but strict + // mode is OFF (default) + env.ledger().with_mut(|li| { + li.sequence_number = 800; + li.timestamp = 4000; // 4000s > 3600s stale threshold + }); + + // Should still resolve — strict mode is off + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + // Widen the round's run window past the 3600s stale threshold below — + // see the comment in test_heartbeat_gate_strict_off_allows_settlement_even_when_stale. + apply_windows(&env, &client, 6, 800); + client.create_round(&1_0000000, &None); + + // Heartbeat at t=0, active + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&0u32); + + // Advance past stale threshold (3600s) + grace (default 0) + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 4000; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); +} + +#[test] +fn test_heartbeat_gate_strict_on_allows_when_live() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + client.create_round(&1_0000000, &None); + + // Heartbeat at t=100, active + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + client.update_oracle_heartbeat(&0u32); + + // Advance to resolvable, timestamp within threshold + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 200; + }); + + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + client.create_round(&1_0000000, &None); + + // Heartbeat at t=0, offline (status=2) + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&2u32); + + // Advance to resolvable (within threshold, but offline always blocks) + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 10; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); +} + +#[test] +fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + client.create_round(&1_0000000, &None); + + // No heartbeat recorded at all + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); +} + +#[test] +fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + client.create_round(&1_0000000, &None); + + // No heartbeat at all — would normally block + client.arm_hb_override(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + + // Verify override event emitted. Must be read before any further client + // call — the test host's event buffer is scoped per top-level + // invocation, so an intervening call (e.g. get_active_round) would wipe + // events published during resolve_round. + let events = env.events().all(); + let override_event = events.iter().find(|e| { + let (_contract, topics, _data) = e; + topics.len() == 2 + && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) + && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) + }); + assert!(override_event.is_some(), "hoverride event must be emitted"); + + assert_eq!(client.get_active_round(), None); + + // Override is one-shot — must be consumed + env.as_contract(&contract_id, || { + let config: HbGateConfig = env + .storage() + .persistent() + .get(&HbGateKey::HbConfig) + .unwrap_or(HbGateConfig { + strict_mode: false, + override_armed: false, + grace_seconds: 0, + }); + assert!(!config.override_armed, "heartbeat override must be cleared after use"); + }); +} + +#[test] +fn test_heartbeat_gate_override_is_one_shot() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + + // First round: arm override, resolve (consumes override) + client.create_round(&1_0000000, &None); + client.arm_hb_override(); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + + // Second round: no heartbeat, no override — should block + env.ledger().with_mut(|li| { + li.sequence_number = 20; + li.timestamp = 200; + }); + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 32; + li.timestamp = 300; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 20, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); +} + +#[test] +fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + // Set grace period to 600s (10 minutes) + client.set_hb_grace_seconds(&600u64); + // Widen the round's run window past the 3600s+600s target below — see + // the comment in test_heartbeat_gate_strict_off_allows_settlement_even_when_stale. + apply_windows(&env, &client, 6, 900); + + client.create_round(&1_0000000, &None); + + // Heartbeat at t=0, active + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&0u32); + + // Advance past stale threshold (3600s) but within grace (3600+600=4200), + // and past end_ledger (widened to 900 ledgers above) + env.ledger().with_mut(|li| { + li.sequence_number = 900; + li.timestamp = 4000; // 4000 < 4200, within grace + }); + + // Should resolve — within grace period + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + client.set_hb_grace_seconds(&600u64); + // Widen the round's run window past the 3600s+600s target below — see + // the comment in test_heartbeat_gate_strict_off_allows_settlement_even_when_stale. + apply_windows(&env, &client, 6, 900); + + client.create_round(&1_0000000, &None); + + // Heartbeat at t=0, active + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&0u32); + + // Advance past stale threshold + grace (3600 + 600 = 4200) + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 4300; // 4300 > 4200, beyond grace + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); +} + +#[test] +fn test_heartbeat_gate_degraded_status_allowed_when_current() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + client.create_round(&1_0000000, &None); + + // Heartbeat at t=100, degraded (status=1) + env.ledger().with_mut(|li| { + li.timestamp = 100; + }); + client.update_oracle_heartbeat(&1u32); + + // Resolve within threshold + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 200; + }); + + // Degraded but current — should be allowed + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(client.get_active_round(), None); +} + +#[test] +fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + // Widen the round's run window past the 3600s stale threshold below — + // see the comment in test_heartbeat_gate_strict_off_allows_settlement_even_when_stale. + apply_windows(&env, &client, 6, 800); + client.create_round(&1_0000000, &None); + + // Degraded heartbeat at t=0 + env.ledger().with_mut(|li| { + li.timestamp = 0; + }); + client.update_oracle_heartbeat(&1u32); + + // Advance past stale threshold (no grace configured) + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 4000; + }); + + let result = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); +} + +#[test] +fn test_heartbeat_gate_hblocked_event_emitted() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.set_hb_strict_mode(&true); + client.create_round(&1_0000000, &None); + + // No heartbeat at all + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + let _ = client.try_resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + + let events = env.events().all(); + let blocked_event = events.iter().find(|e| { + let (_contract, topics, _data) = e; + topics.len() == 2 + && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) + && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hblocked")) + }); + assert!(blocked_event.is_some(), "hblocked event must be emitted"); +} + +#[test] +fn test_heartbeat_gate_arm_override_requires_admin_auth() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: (&admin, &oracle).into_val(&env), + sub_invokes: &[], + }, + }]); + client.initialize(&admin, &oracle); + + // No auth — should fail + let result = client.try_arm_hb_override(); + assert!(result.is_err()); +} + +#[test] +fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: (&admin, &oracle).into_val(&env), + sub_invokes: &[], + }, + }]); + client.initialize(&admin, &oracle); + + // No auth — should fail + let result = client.try_set_hb_strict_mode(&true); + assert!(result.is_err()); +} + +#[test] +fn test_heartbeat_gate_getters_return_defaults() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + // Default values: strict mode OFF, override not armed, grace 0 + assert!(!client.get_hb_strict_mode()); + assert!(!client.get_hb_override_armed()); + assert_eq!(client.get_hb_grace_seconds(), 0); +} + +#[test] +fn test_heartbeat_gate_set_and_get_grace_seconds() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + client.initialize(&admin, &oracle); + + client.set_hb_grace_seconds(&300u64); + assert_eq!(client.get_hb_grace_seconds(), 300); + + client.set_hb_grace_seconds(&0u64); + assert_eq!(client.get_hb_grace_seconds(), 0); +} + +#[test] +fn test_no_confidence_check_when_threshold_unset() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + // No min confidence configured + client.create_round(&1_0000000, &None); + + env.ledger().with_mut(|li| { + li.sequence_number = 12; + li.timestamp = 100; + }); + + // Even zero confidence accepted when threshold unset + client.resolve_round(&OraclePayload { + price: 1_2000000, + timestamp: env.ledger().timestamp(), + round_id: 0, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: Some(0u32), + attestation: None, + }); +} diff --git a/contracts/src/tests/state_machine.rs b/contracts/src/tests/state_machine.rs index 42e4a31..41a945c 100644 --- a/contracts/src/tests/state_machine.rs +++ b/contracts/src/tests/state_machine.rs @@ -189,6 +189,7 @@ fn test_resolve_round_fails_in_betting_phase() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }; let result = client.try_resolve_round(&payload); @@ -377,6 +378,7 @@ fn test_resolve_round_fails_in_running_phase() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }; let result = client.try_resolve_round(&payload); @@ -423,6 +425,7 @@ fn test_resolve_round_succeeds_in_resolvable_phase() { network_id: env.ledger().network_id(), contract_addr: client.address.clone(), confidence: None, + attestation: None, }; let result = client.try_resolve_round(&payload); diff --git a/contracts/src/tests/storage_benchmarks.rs b/contracts/src/tests/storage_benchmarks.rs index b221e35..a2fe5d0 100644 --- a/contracts/src/tests/storage_benchmarks.rs +++ b/contracts/src/tests/storage_benchmarks.rs @@ -27,6 +27,11 @@ const N_LARGE: usize = 60; fn setup() -> (Env, Address, VirtualTokenContractClient<'static>) { let env = Env::default(); + // These tests assert on scaling behavior (O(1)/O(N) storage footprint), + // not on the precise CPU/memory cost — that's covered separately by + // `cost_benchmarks.rs`. Reset to unlimited so a large-round scenario + // never trips the default interpreted-host budget on its own. + env.cost_estimate().budget().reset_unlimited(); env.mock_all_auths(); let contract_id = env.register(VirtualTokenContract, ()); let client = VirtualTokenContractClient::new(&env, &contract_id); diff --git a/contracts/src/tests/windows.rs b/contracts/src/tests/windows.rs index f49d28d..54dfa68 100644 --- a/contracts/src/tests/windows.rs +++ b/contracts/src/tests/windows.rs @@ -56,7 +56,6 @@ fn test_set_windows_fails_without_admin_auth() { }, }]); client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); // Attempting to set windows without admin auth let result = client.try_set_windows(&10, &20); @@ -609,7 +608,6 @@ fn test_place_precision_prediction_fails_without_user_auth() { }, }]); client.initialize(&admin, &oracle); - client.update_oracle_heartbeat(&0u32); env.mock_auths(&[soroban_sdk::testutils::MockAuth { address: &user, diff --git a/contracts/src/types.rs b/contracts/src/types.rs index d2ec938..dd562b7 100644 --- a/contracts/src/types.rs +++ b/contracts/src/types.rs @@ -1,2615 +1,824 @@ -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Env, IntoVal, Symbol, Val, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Payout policy for Precision mode -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum PrecisionPayoutPolicy { - Equal = 0, // Split payout pool equally among winners (default) - StakeWeighted = 1, // Split payout pool proportionally to winner stakes -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -/// -/// ## Indexed position keys (variants 13–15) -/// -/// `Position(round_id, address)` and `PrecisionPosition(round_id, address)` store -/// a single user's record under a composite key, enabling O(1) read/write per user -/// instead of deserializing the full participant map on every bet. -/// -/// `RoundParticipants(round_id)` holds the ordered `Vec
` used for -/// iteration at resolution time. Appending one address is cheaper than -/// re-serialising an N-entry `Map` for every bet placed. -/// -/// Legacy single-key maps (`UpDownPositions`, `PrecisionPositions`) are kept for -/// backward-compatible reads during a migration window; they are no longer written. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - /// On-chain storage schema version for migration safety. - /// If missing, the contract treats it as legacy schema version 1. - SchemaVersion, - ActiveRound, - Positions, // Legacy key — read-only migration compat - UpDownPositions, // Legacy key — read-only migration compat - PrecisionPositions, // Legacy key — read-only migration compat - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - /// Per-user UpDown position: (round_id, address) → UserPosition - Position(u64, Address), - /// Per-user Precision prediction: (round_id, address) → PrecisionPrediction - PrecisionPosition(u64, Address), - /// Per-user Precision commitment: (round_id, address) → PrecisionCommitment - PrecisionCommitment(u64, Address), - /// Ordered participant list for a round: round_id → Vec
- RoundParticipants(u64), - /// Maximum stake allowed per individual bet (None = unlimited) - MaxStake, - /// Maximum cumulative exposure per user per round (None = unlimited) - MaxUserRoundExposure, - /// Maximum pending winnings allowed per account (None = unlimited) - MaxPendingWinnings, - /// Marker for a cancelled round: round_id → true - CancelledRound(u64), - /// Per-round consumed oracle nonce: (round_id, nonce) → true. - /// Used to reject duplicate oracle payload submissions for the same round. - ConsumedOracleNonce(u64, u64), - /// Minimum participant count for competitive settlement; unset = no minimum enforced - MinParticipants, - /// Oracle heartbeat: last recorded timestamp and status - OracleHeartbeat, - /// Stale-heartbeat threshold in seconds (admin-configurable); unset = 3600 s default - OracleStaleThreshold, - /// Maximum participants accepted in a Precision round; unset = protocol default - MaxPrecisionParticipants, - /// Oracle max deviation threshold in basis points (1 bp = 0.01%). - /// If unset, deviation guardrails are disabled. - OracleMaxDeviationBps, - /// One-shot admin override allowing the next settlement to bypass deviation checks. - /// Automatically cleared after use. - OracleDeviationOverrideArmed, - /// Minimum oracle confidence threshold in basis points (0–10000). - /// If unset, confidence guardrails are disabled. - OracleMinConfidenceBps, - /// When true, payloads with missing confidence are rejected in strict mode. - OracleStrictMode, - /// Compact post-settlement summary keyed by round id for historical queries. - ArchivedRound(u64), - /// Ordered round ids for archive retention (oldest at index 0). - RecentArchivedRoundIds, - /// Per-user outcome record for a specific archived round (round_id, user). - /// Persisted at settlement for user history queries without event replay. - UserRoundOutcome(u64, Address), - /// Marker written by migrate_schema_v2_to_v3 to prove the migration ran. - MigratedToV3, - /// Timelocked pending critical config change keyed by change kind. - PendingConfigChange(ConfigChangeKind), - /// Optional protocol settlement fee in basis points (1 bp = 0.01%). - /// `None` (key absent) means fee disabled — no behaviour change. - /// Hard cap on fee is enforced at the contract layer, not by storage shape. - ProtocolFeeBps, - /// On-chain accumulated protocol fee balance in stroops (i128). - /// Admin withdraws via the dedicated withdrawal method; does NOT mix - /// into the per-user balance ledger. - ProtocolFeeTreasury, - /// Per-ledger mint counter: wraps the explicit ledger sequence number. - LedgerMintCounter(u32), - /// Mint limit configuration: maximum number of mints allowed per ledger. - MintLimitConfig, - /// Pending two-step oracle rotation proposal with expiry. - OracleRotationProposal, - /// Configurable archive retention limit: maximum number of ArchivedRound entries - /// retained on-chain before the oldest are pruned (FIFO). If unset, the protocol - /// default is used. - ArchiveRetention, - /// Admin-configured blueprint used by `create_next_from_template` to spin - /// up the next round without re-specifying `start_price` / `mode` each - /// time. Absent means no template is configured. - RoundTemplate, - /// Bounded index of user addresses sorted by lifetime total wins - /// descending (all-time leaderboard, independent of seasons). - LeaderboardWins, - /// Bounded index of user addresses sorted by lifetime best streak - /// descending (all-time leaderboard, independent of seasons). - LeaderboardStreak, - /// Monotonically increasing id of the currently-active leaderboard - /// season. Absent is treated as season 1. - SeasonId, - /// Per-season, per-user win/loss/streak stats: (season_id, address) → - /// UserStats, scoped independently of the lifetime `UserStats` totals so - /// a season reset never touches lifetime history. - SeasonUserStats(u32, Address), - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped total wins descending. - SeasonLeaderboardWins, - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped best streak descending. - SeasonLeaderboardStreak, - /// Frozen snapshot of a season's final rankings, written when the season - /// is reset. Seasons are never deleted — this is a permanent archive. - SeasonArchive(u32), - /// Admin-configured multi-feed oracle quorum parameters. - /// When set, `resolve_round_multi` is enabled. - OracleQuorum, - /// Announced next schema version for migration preview (v-next template). - /// When set, operators can inspect this value before executing a real migration. - /// Absent means no next migration has been announced. - NextSchemaVersion, - /// Minimum bet amount (dust protection). Unset = no minimum. - MinBet, - /// Epoch mint budget: total mints allowed per epoch. - EpochMintBudget, - /// Early cash-out penalty in basis points. Unset = early cash-out disabled. - EarlyCashoutBps, - /// Fee incidence model: FeeOnPot (default) or FeeOnWinnings. - FeeModel, - /// Dispute window length in ledgers. 0 = no dispute window. - DisputeLedgers, -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, - OracleTimestampSkew = 12, - EpochMintBudget = 12, - PendingWinningsExpiry = 13, - PrecisionPayoutPolicy = 14, - MinBet = 15, - DisputeLedgers = 16, - FeeModel = 17, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), - OracleTimestampSkew(u64), - EpochMintBudget(i128), - PendingWinningsExpiry(u32), - PrecisionPayoutPolicy(u32), - MinBet(Option), - DisputeLedgers(u32), - FeeModel(FeeModel), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - pub confidence: Option, - /// Optional ed25519 signature over the attestation domain-separated message. - pub attestation: Option>, -} - -/// Multi-feed oracle resolution payload (N observations, quorum + median). -/// -/// Unlike the legacy single-oracle `OraclePayload`, this carries N independent -/// feed observations as parallel arrays. The contract computes the median, -/// rejects outliers, and requires a configurable quorum of feeds to agree -/// within the outlier threshold before settlement proceeds. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct MultiFeedPayload { - /// Prices from each feed, scaled to 4 decimal places (e.g. 2297 = $0.2297). - pub prices: Vec, - /// Feed source identifiers (0-based index, max N-1). Must be unique. - pub sources: Vec, - /// Round identifier that must match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Unix epoch seconds when the observations were collected. - pub timestamp: u64, -} - -/// Admin-configurable quorum and outlier rejection parameters for multi-feed -/// oracle settlement. Stored under `DataKey::OracleQuorum`. -/// -/// When set, `resolve_round_multi` becomes the preferred settlement path. -/// The legacy single-oracle `resolve_round` path remains available -/// independently of this configuration. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleQuorumConfig { - /// Minimum number of unique feed observations required in a multi-feed payload. - pub min_observations: u32, - /// Minimum number of observations that must survive outlier rejection to - /// form a valid quorum and proceed to settlement. - pub quorum_threshold: u32, - /// Maximum deviation from the median (in basis points, 1 bp = 0.01%) - /// before an observation is rejected as an outlier. - pub outlier_threshold_bps: u32, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -/// Heartbeat health gate configuration (Issue #264). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct HbGateConfig { - pub strict_mode: bool, - pub override_armed: bool, - pub grace_seconds: u64, -} - -/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). -#[contracttype] -#[derive(Clone)] -pub enum HbGateKey { - Config, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) - pub start_timestamp: u64, // Ledger timestamp when round was created -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, - /// Dispute window ended via void; all participants refunded their stake. - Voided = 3, -} - -/// Composite protocol health status returned by `get_protocol_health`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - pub paused: bool, - pub oracle_live: bool, - pub oracle_status: u32, - pub has_active_round: bool, - pub active_round_phase: u32, - pub schema_version: u32, - pub ledger_sequence: u32, - pub ledger_timestamp: u64, - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - Active = 0, - Paused = 1, - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - Unknown = 0, - Betting = 1, - Running = 2, - AwaitingResolve = 3, - Resolved = 4, - Cancelled = 5, - FallbackRefund = 6, - Voided = 7, -} - -/// Terminal outcome persisted per user per archived round. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Void = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, - pub fee_model: u32, -} - -/// Admin-configured blueprint for `create_next_from_template`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} - -/// Configurable pending-winnings expiry in ledgers. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsExpiryKey(pub ()); - -pub const PENDING_WINNINGS_EXPIRY_KEY: PendingWinningsExpiryKey = PendingWinningsExpiryKey(()); - -/// Ledger sequence when a user's pending winnings entry was last modified. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsUpdatedAtKey(pub Address); - -/// Fee incidence model for protocol fees (Issue #268). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum FeeModel { - FeeOnPot = 0, // Fee charged on total pot (default) - FeeOnWinnings = 1, // Fee charged only on net winnings/profit -} - -/// TWAP sample ring entry (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PriceSample { - pub price: u128, - pub timestamp: u64, -} - -/// Storage key for TWAP samples ring (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum TwapSamplesKey { - Samples, -} - -/// Dev Reference Mode (Issue #266). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum DeviationReferenceMode { - StartPrice = 0, // Use round.price_start (default) - Twap = 1, // Use trailing-sample TWAP average -} - -/// Deviation guardrail config (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeviationConfig { - pub reference_mode: DeviationReferenceMode, - pub window_samples: u32, -} - -/// Storage key for deviation config (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum DeviationConfigKey { - Config, -} - -/// Oracle attestation config (Issue #263). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct AttestationConfig { - pub key: Option>, // ed25519 public key; None = attestation disabled -} - -/// Storage key for attestation config (separate from DataKey to stay within variant limits, Issue #263). -#[contracttype] -#[derive(Clone)] -pub enum AttestationConfigKey { - Config, -} - -impl IntoVal for DataKey { - fn into_val(&self, env: &Env) -> Val { - use Symbol as S; - match self { - DataKey::Balance(a) => (S::new(env, "Balance"), a.clone()).into_val(env), - DataKey::Admin => S::new(env, "Admin").into_val(env), - DataKey::Oracle => S::new(env, "Oracle").into_val(env), - DataKey::SchemaVersion => S::new(env, "SchemaVersion").into_val(env), - DataKey::ActiveRound => S::new(env, "ActiveRound").into_val(env), - DataKey::Positions => S::new(env, "Positions").into_val(env), - DataKey::UpDownPositions => S::new(env, "UpDownPositions").into_val(env), - DataKey::PrecisionPositions => S::new(env, "PrecisionPositions").into_val(env), - DataKey::PendingWinnings(a) => { - (S::new(env, "PendingWinnings"), a.clone()).into_val(env) - } - DataKey::UserStats(a) => (S::new(env, "UserStats"), a.clone()).into_val(env), - DataKey::Paused => S::new(env, "Paused").into_val(env), - DataKey::BetWindowLedgers => S::new(env, "BetWindowLedgers").into_val(env), - DataKey::RunWindowLedgers => S::new(env, "RunWindowLedgers").into_val(env), - DataKey::CloseBufferLedgers => S::new(env, "CloseBufferLedgers").into_val(env), - DataKey::LastRoundId => S::new(env, "LastRoundId").into_val(env), - DataKey::Position(id, a) => { - (S::new(env, "Position"), id, a.clone()).into_val(env) - } - DataKey::PrecisionPosition(id, a) => { - (S::new(env, "PrecisionPosition"), id, a.clone()).into_val(env) - } - DataKey::PrecisionCommitment(id, a) => { - (S::new(env, "PrecisionCommitment"), id, a.clone()).into_val(env) - } - DataKey::RoundParticipants(id) => { - (S::new(env, "RoundParticipants"), id).into_val(env) - } - DataKey::MaxStake => S::new(env, "MaxStake").into_val(env), - DataKey::MaxUserRoundExposure => S::new(env, "MaxUserRoundExposure").into_val(env), - DataKey::MaxPendingWinnings => S::new(env, "MaxPendingWinnings").into_val(env), - DataKey::CancelledRound(id) => (S::new(env, "CancelledRound"), id).into_val(env), - DataKey::ConsumedOracleNonce(id, nonce) => { - (S::new(env, "ConsumedOracleNonce"), id, nonce).into_val(env) - } - DataKey::MinParticipants => S::new(env, "MinParticipants").into_val(env), - DataKey::OracleHeartbeat => S::new(env, "OracleHeartbeat").into_val(env), - DataKey::OracleStaleThreshold => S::new(env, "OracleStaleThreshold").into_val(env), - DataKey::MaxPrecisionParticipants => { - S::new(env, "MaxPrecisionParticipants").into_val(env) - } - DataKey::OracleMaxDeviationBps => S::new(env, "OracleMaxDeviationBps").into_val(env), - DataKey::OracleDeviationOverrideArmed => { - S::new(env, "OracleDeviationOverrideArmed").into_val(env) - } - DataKey::OracleMinConfidenceBps => { - S::new(env, "OracleMinConfidenceBps").into_val(env) - } - DataKey::OracleStrictMode => S::new(env, "OracleStrictMode").into_val(env), - DataKey::ArchivedRound(id) => (S::new(env, "ArchivedRound"), id).into_val(env), - DataKey::RecentArchivedRoundIds => { - S::new(env, "RecentArchivedRoundIds").into_val(env) - } - DataKey::UserRoundOutcome(id, a) => { - (S::new(env, "UserRoundOutcome"), id, a.clone()).into_val(env) - } - DataKey::MigratedToV3 => S::new(env, "MigratedToV3").into_val(env), - DataKey::PendingConfigChange(k) => { - (S::new(env, "PendingConfigChange"), k.clone()).into_val(env) - } - DataKey::ProtocolFeeBps => S::new(env, "ProtocolFeeBps").into_val(env), - DataKey::ProtocolFeeTreasury => S::new(env, "ProtocolFeeTreasury").into_val(env), - DataKey::LedgerMintCounter(id) => { - (S::new(env, "LedgerMintCounter"), id).into_val(env) - } - DataKey::MintLimitConfig => S::new(env, "MintLimitConfig").into_val(env), - DataKey::OracleRotationProposal => S::new(env, "OracleRotationProposal").into_val(env), - DataKey::ArchiveRetention => S::new(env, "ArchiveRetention").into_val(env), - DataKey::RoundTemplate => S::new(env, "RoundTemplate").into_val(env), - DataKey::LeaderboardWins => S::new(env, "LeaderboardWins").into_val(env), - DataKey::LeaderboardStreak => S::new(env, "LeaderboardStreak").into_val(env), - DataKey::SeasonId => S::new(env, "SeasonId").into_val(env), - DataKey::SeasonUserStats(sid, a) => { - (S::new(env, "SeasonUserStats"), sid, a.clone()).into_val(env) - } - DataKey::SeasonLeaderboardWins => S::new(env, "SeasonLeaderboardWins").into_val(env), - DataKey::SeasonLeaderboardStreak => { - S::new(env, "SeasonLeaderboardStreak").into_val(env) - } - DataKey::SeasonArchive(id) => (S::new(env, "SeasonArchive"), id).into_val(env), - DataKey::OracleQuorum => S::new(env, "OracleQuorum").into_val(env), - DataKey::NextSchemaVersion => S::new(env, "NextSchemaVersion").into_val(env), - DataKey::MinBet => S::new(env, "MinBet").into_val(env), - DataKey::EpochMintBudget => S::new(env, "EpochMintBudget").into_val(env), - DataKey::EarlyCashoutBps => S::new(env, "EarlyCashoutBps").into_val(env), - DataKey::FeeModel => S::new(env, "FeeModel").into_val(env), - DataKey::DisputeLedgers => S::new(env, "DisputeLedgers").into_val(env), - } - } -} -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -/// -/// ## Indexed position keys (variants 13–15) -/// -/// `Position(round_id, address)` and `PrecisionPosition(round_id, address)` store -/// a single user's record under a composite key, enabling O(1) read/write per user -/// instead of deserializing the full participant map on every bet. -/// -/// `RoundParticipants(round_id)` holds the ordered `Vec
` used for -/// iteration at resolution time. Appending one address is cheaper than -/// re-serialising an N-entry `Map` for every bet placed. -/// -/// Legacy single-key maps (`UpDownPositions`, `PrecisionPositions`) are kept for -/// backward-compatible reads during a migration window; they are no longer written. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - /// On-chain storage schema version for migration safety. - /// If missing, the contract treats it as legacy schema version 1. - SchemaVersion, - ActiveRound, - Positions, // Legacy key — read-only migration compat - UpDownPositions, // Legacy key — read-only migration compat - PrecisionPositions, // Legacy key — read-only migration compat - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - /// Per-user UpDown position: (round_id, address) → UserPosition - Position(u64, Address), - /// Per-user Precision prediction: (round_id, address) → PrecisionPrediction - PrecisionPosition(u64, Address), - /// Per-user Precision commitment: (round_id, address) → PrecisionCommitment - PrecisionCommitment(u64, Address), - /// Ordered participant list for a round: round_id → Vec
- RoundParticipants(u64), - /// Maximum stake allowed per individual bet (None = unlimited) - MaxStake, - /// Maximum cumulative exposure per user per round (None = unlimited) - MaxUserRoundExposure, - /// Maximum pending winnings allowed per account (None = unlimited) - MaxPendingWinnings, - /// Marker for a cancelled round: round_id → true - CancelledRound(u64), - /// Per-round consumed oracle nonce: (round_id, nonce) → true. - /// Used to reject duplicate oracle payload submissions for the same round. - ConsumedOracleNonce(u64, u64), - /// Minimum participant count for competitive settlement; unset = no minimum enforced - MinParticipants, - /// Oracle heartbeat: last recorded timestamp and status - OracleHeartbeat, - /// Stale-heartbeat threshold in seconds (admin-configurable); unset = 3600 s default - OracleStaleThreshold, - /// Maximum participants accepted in a Precision round; unset = protocol default - MaxPrecisionParticipants, - /// Oracle max deviation threshold in basis points (1 bp = 0.01%). - /// If unset, deviation guardrails are disabled. - OracleMaxDeviationBps, - /// One-shot admin override allowing the next settlement to bypass deviation checks. - /// Automatically cleared after use. - OracleDeviationOverrideArmed, - /// Minimum oracle confidence threshold in basis points (0–10000). - /// If unset, confidence guardrails are disabled. - OracleMinConfidenceBps, - /// When true, payloads with missing confidence are rejected in strict mode. - OracleStrictMode, - /// Compact post-settlement summary keyed by round id for historical queries. - ArchivedRound(u64), - /// Ordered round ids for archive retention (oldest at index 0). - RecentArchivedRoundIds, - /// Per-user outcome record for a specific archived round (round_id, user). - /// Persisted at settlement for user history queries without event replay. - UserRoundOutcome(u64, Address), - /// Per-user index of archived round IDs the user participated in. - /// Written during archiving; read for paginated history queries. - /// Not pruned when archived rounds are evicted — stale entries are - /// filtered at query time by checking ArchivedRound existence. - UserArchivedRoundIds(Address), - /// Marker written by migrate_schema_v2_to_v3 to prove the migration ran. - MigratedToV3, - /// Timelocked pending critical config change keyed by change kind. - PendingConfigChange(ConfigChangeKind), - /// Optional protocol settlement fee in basis points (1 bp = 0.01%). - /// `None` (key absent) means fee disabled — no behaviour change. - /// Hard cap on fee is enforced at the contract layer, not by storage shape. - ProtocolFeeBps, - /// On-chain accumulated protocol fee balance in stroops (i128). - /// Admin withdraws via the dedicated withdrawal method; does NOT mix - /// into the per-user balance ledger. - ProtocolFeeTreasury, - /// Per-ledger mint counter: wraps the explicit ledger sequence number. - LedgerMintCounter(u32), - /// Mint limit configuration: maximum number of mints allowed per ledger. - MintLimitConfig, - /// Pending two-step oracle rotation proposal with expiry. - OracleRotationProposal, - /// Configurable archive retention limit: maximum number of ArchivedRound entries - /// retained on-chain before the oldest are pruned (FIFO). If unset, the protocol - /// default is used. - ArchiveRetention, - /// Admin-configured blueprint used by `create_next_from_template` to spin - /// up the next round without re-specifying `start_price` / `mode` each - /// time. Absent means no template is configured. - RoundTemplate, - /// Bounded index of user addresses sorted by lifetime total wins - /// descending (all-time leaderboard, independent of seasons). - LeaderboardWins, - /// Bounded index of user addresses sorted by lifetime best streak - /// descending (all-time leaderboard, independent of seasons). - LeaderboardStreak, - /// Monotonically increasing id of the currently-active leaderboard - /// season. Absent is treated as season 1. - SeasonId, - /// Per-season, per-user win/loss/streak stats: (season_id, address) → - /// UserStats, scoped independently of the lifetime `UserStats` totals so - /// a season reset never touches lifetime history. - SeasonUserStats(u32, Address), - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped total wins descending. - SeasonLeaderboardWins, - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped best streak descending. - SeasonLeaderboardStreak, - /// Frozen snapshot of a season's final rankings, written when the season - /// is reset. Seasons are never deleted — this is a permanent archive. - SeasonArchive(u32), -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - /// Optional protocol settlement fee in bps (Issue #162). - /// `None` disables the fee entirely, restoring pre-fee behaviour. - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - /// - /// The oracle service must generate a unique value per submission for a - /// given round (e.g. a monotonic counter or random 64-bit value). The - /// contract records each consumed nonce under - /// `DataKey::ConsumedOracleNonce(round_id, nonce)` and rejects any reuse, - /// making resolution idempotent against accidental duplicate submissions. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - /// Validated against `env.ledger().network_id()` to prevent cross-network replay. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - /// Validated against `env.current_contract_address()` to prevent cross-contract replay. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - /// When `None`, the payload is treated as a legacy submission. - /// When strict mode is enabled, `None` is rejected. - pub confidence: Option, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, -} - -/// Composite protocol health status returned by `get_protocol_health`. -/// -/// Designed for operators to poll a single endpoint instead of stitching -/// together multiple read-only calls. -/// -/// ## Status code → alert severity mapping -/// -/// | code | label | severity | meaning | -/// |------|-----------------|----------|-------------------------------------------| -/// | 0 | HEALTHY | none | All subsystems nominal | -/// | 1 | PAUSED | critical | Contract is emergency-paused | -/// | 2 | ORACLE_STALE | warning | Oracle heartbeat is stale or offline | -/// | 3 | ROUND_STALE | warning | Round is past its end ledger but unresolved| -/// | 4 | NO_ACTIVE_ROUND | info | No round currently active (idle protocol) | -/// | 5 | MULTIPLE_ISSUES | critical | Two or more issues detected simultaneously| -/// -/// ## Phase codes (`active_round_phase`) -/// -/// | phase | meaning | -/// |-------|---------------------------------------------------| -/// | 0 | No active round | -/// | 1 | Betting open (`ledger < bet_end_ledger`) | -/// | 2 | Running / reveal window (`bet_end_ledger ≤ ledger < end_ledger`) | -/// | 3 | Resolvable (`ledger ≥ end_ledger`) | -/// -/// ## Oracle status codes (`oracle_status`) -/// -/// | code | meaning | -/// |------|----------------------------------------| -/// | 0 | Active (healthy heartbeat) | -/// | 1 | Degraded (heartbeat marked degraded) | -/// | 2 | Offline (heartbeat marked offline) | -/// | 3 | Unknown (no heartbeat record stored) | -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - /// Whether the contract is emergency-paused (`Paused == true`) - pub paused: bool, - /// Whether the oracle heartbeat is non-stale and not offline - pub oracle_live: bool, - /// Raw oracle heartbeat status (0=active, 1=degraded, 2=offline, 3=unknown) - pub oracle_status: u32, - /// Whether a round is currently active - pub has_active_round: bool, - /// Current round phase (0=no_round, 1=betting, 2=running, 3=resolvable) - pub active_round_phase: u32, - /// On-chain storage schema version - pub schema_version: u32, - /// Ledger sequence at which this health snapshot was taken - pub ledger_sequence: u32, - /// Ledger timestamp at which this health snapshot was taken - pub ledger_timestamp: u64, - /// Composite status code (see mapping table above) - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -/// -/// Designed for explorer/analytics queries without replaying events. -/// `price_final` is `0` for admin cancellations (no oracle settlement price). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -/// -/// The admin proposes a new oracle address with a timestamp-based expiry window. -/// After `expires_at` (ledger timestamp) the proposal is stale and acceptance -/// is rejected until the admin submits a fresh proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -/// -/// Designed for frontend state machines that need a single, stable code -/// instead of combining multiple boolean flags. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|--------------|-------------------------------------------------------------------------| -/// | 0 | `Active` | Not paused; a round is currently active (bets open or running). | -/// | 1 | `Paused` | Emergency-paused by the admin; no mutations accepted except unpause. | -/// | 2 | `ClaimsOnly` | Not paused; no active round. Only `claim_winnings` is meaningful. | -/// -/// ## Transition rules -/// -/// - `ClaimsOnly` → `Active` when `create_round()` succeeds. -/// - `Active` → `ClaimsOnly` when `resolve_round()` or `cancel_round()` completes. -/// - Any state → `Paused` when `pause_contract()` is called. -/// - `Paused` → `Active` when `unpause_contract()` is called *and* an active round still exists. -/// - `Paused` → `ClaimsOnly` when `unpause_contract()` is called *and* no active round exists. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - /// The contract is not paused and has a currently active round. - Active = 0, - /// The contract is emergency-paused by the admin. - Paused = 1, - /// The contract is not paused, but no round is active. - /// Mutating actions are limited to claiming pending winnings. - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -/// -/// Queries a round by its monotonic `round_id`. Covers all lifecycle -/// stages from creation through terminal settlement. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|------------------|-----------------------------------------------------------------------------------| -/// | 0 | `Unknown` | Round does not exist or has been pruned from the on-chain archive. | -/// | 1 | `Betting` | Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). | -/// | 2 | `Running` | Betting closed; reveal window open (`bet_end_ledger ≤ ledger < end_ledger`). | -/// | 3 | `AwaitingResolve`| Round ended; awaiting oracle settlement (`ledger ≥ end_ledger`). | -/// | 4 | `Resolved` | Oracle settled the round; pot distributed to winners. | -/// | 5 | `Cancelled` | Admin cancelled the round; all stakes refunded. | -/// | 6 | `FallbackRefund` | Insufficient participants at settlement; all stakes refunded. | -/// -/// ## Transition rules -/// -/// - `Unknown` → `Betting` when `create_round()` succeeds. -/// - `Betting` → `Running` when `ledger ≥ bet_end_ledger` (derived; no on-chain write). -/// - `Running` → `AwaitingResolve` when `ledger ≥ end_ledger` (derived; no on-chain write). -/// - `{Betting | Running | AwaitingResolve}` → `Cancelled` when `cancel_round()` is called. -/// - `AwaitingResolve` → `Resolved` when `resolve_round()` settles with enough participants. -/// - `AwaitingResolve` → `FallbackRefund` when `resolve_round()` finds fewer than `min_participants`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - /// Round does not exist or has been pruned from the on-chain archive. - Unknown = 0, - /// Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). - Betting = 1, - /// Betting is closed; reveal window is open (`bet_end_ledger ≤ ledger < end_ledger`). - Running = 2, - /// Round has ended and is waiting for oracle settlement (`ledger ≥ end_ledger`). - AwaitingResolve = 3, - /// Oracle settled the round normally; pot distributed to winners. - Resolved = 4, - /// Admin cancelled the round; all stakes refunded. - Cancelled = 5, - /// Settlement triggered but insufficient participants; all stakes refunded. - FallbackRefund = 6, -} - -/// Terminal outcome persisted per user per archived round. -/// -/// Allows `get_user_archived_participation` to answer profile/history -/// queries without replaying the full event stream. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Cancel = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, -} - -/// Admin-configured blueprint for `create_next_from_template`. -/// -/// Mirrors the arguments accepted by `create_round` (`start_price`, `mode`) -/// so a keeper can spin up the next round after a settle/cancel without an -/// operator re-specifying parameters each time. Validated with the exact -/// same rules `create_round` applies at creation time. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings, written by -/// `reset_leaderboard_season`. `participant_count` is the number of distinct -/// addresses that appeared in either bounded index at reset time (a lower -/// bound on total season participants beyond the tracked top -/// `LEADERBOARD_LIMIT`, mirroring the same bound the live indexes enforce). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} -a -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - SchemaVersion, - ActiveRound, - Positions, - UpDownPositions, - PrecisionPositions, - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - Position(u64, Address), - PrecisionPosition(u64, Address), - PrecisionCommitment(u64, Address), - RoundParticipants(u64), - MaxStake, - MaxUserRoundExposure, - MaxPendingWinnings, - CancelledRound(u64), - ConsumedOracleNonce(u64, u64), - MinParticipants, - OracleHeartbeat, - OracleStaleThreshold, - MaxPrecisionParticipants, - OracleMaxDeviationBps, - OracleDeviationOverrideArmed, - OracleMinConfidenceBps, - OracleStrictMode, - ArchivedRound(u64), - RecentArchivedRoundIds, - UserRoundOutcome(u64, Address), - MigratedToV3, - PendingConfigChange(ConfigChangeKind), - ProtocolFeeBps, - ProtocolFeeTreasury, - LedgerMintCounter(u32), - MintLimitConfig, - OracleRotationProposal, - ArchiveRetention, - RoundTemplate, - Ext(DataKeyExt), -} - -#[contracttype] -#[derive(Clone)] -pub enum DataKeyExt { - LeaderboardWins, - LeaderboardStreak, - SeasonId, - SeasonUserStats(u32, Address), - SeasonLeaderboardWins, - SeasonLeaderboardStreak, - SeasonArchive(u32), -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - /// Optional protocol settlement fee in bps (Issue #162). - /// `None` disables the fee entirely, restoring pre-fee behaviour. - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - /// - /// The oracle service must generate a unique value per submission for a - /// given round (e.g. a monotonic counter or random 64-bit value). The - /// contract records each consumed nonce under - /// `DataKey::ConsumedOracleNonce(round_id, nonce)` and rejects any reuse, - /// making resolution idempotent against accidental duplicate submissions. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - /// Validated against `env.ledger().network_id()` to prevent cross-network replay. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - /// Validated against `env.current_contract_address()` to prevent cross-contract replay. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - /// When `None`, the payload is treated as a legacy submission. - /// When strict mode is enabled, `None` is rejected. - pub confidence: Option, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -/// Heartbeat health gate configuration (Issue #264). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct HbGateConfig { - pub strict_mode: bool, - pub override_armed: bool, - pub grace_seconds: u64, -} - -/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). -#[contracttype] -#[derive(Clone)] -pub enum HbGateKey { - Config, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, -} - -/// Composite protocol health status returned by `get_protocol_health`. -/// -/// Designed for operators to poll a single endpoint instead of stitching -/// together multiple read-only calls. -/// -/// ## Status code → alert severity mapping -/// -/// | code | label | severity | meaning | -/// |------|-----------------|----------|-------------------------------------------| -/// | 0 | HEALTHY | none | All subsystems nominal | -/// | 1 | PAUSED | critical | Contract is emergency-paused | -/// | 2 | ORACLE_STALE | warning | Oracle heartbeat is stale or offline | -/// | 3 | ROUND_STALE | warning | Round is past its end ledger but unresolved| -/// | 4 | NO_ACTIVE_ROUND | info | No round currently active (idle protocol) | -/// | 5 | MULTIPLE_ISSUES | critical | Two or more issues detected simultaneously| -/// -/// ## Phase codes (`active_round_phase`) -/// -/// | phase | meaning | -/// |-------|---------------------------------------------------| -/// | 0 | No active round | -/// | 1 | Betting open (`ledger < bet_end_ledger`) | -/// | 2 | Running / reveal window (`bet_end_ledger ≤ ledger < end_ledger`) | -/// | 3 | Resolvable (`ledger ≥ end_ledger`) | -/// -/// ## Oracle status codes (`oracle_status`) -/// -/// | code | meaning | -/// |------|----------------------------------------| -/// | 0 | Active (healthy heartbeat) | -/// | 1 | Degraded (heartbeat marked degraded) | -/// | 2 | Offline (heartbeat marked offline) | -/// | 3 | Unknown (no heartbeat record stored) | -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - /// Whether the contract is emergency-paused (`Paused == true`) - pub paused: bool, - /// Whether the oracle heartbeat is non-stale and not offline - pub oracle_live: bool, - /// Raw oracle heartbeat status (0=active, 1=degraded, 2=offline, 3=unknown) - pub oracle_status: u32, - /// Whether a round is currently active - pub has_active_round: bool, - /// Current round phase (0=no_round, 1=betting, 2=running, 3=resolvable) - pub active_round_phase: u32, - /// On-chain storage schema version - pub schema_version: u32, - /// Ledger sequence at which this health snapshot was taken - pub ledger_sequence: u32, - /// Ledger timestamp at which this health snapshot was taken - pub ledger_timestamp: u64, - /// Composite status code (see mapping table above) - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -/// -/// Designed for explorer/analytics queries without replaying events. -/// `price_final` is `0` for admin cancellations (no oracle settlement price). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -/// -/// The admin proposes a new oracle address with a timestamp-based expiry window. -/// After `expires_at` (ledger timestamp) the proposal is stale and acceptance -/// is rejected until the admin submits a fresh proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -/// -/// Designed for frontend state machines that need a single, stable code -/// instead of combining multiple boolean flags. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|--------------|-------------------------------------------------------------------------| -/// | 0 | `Active` | Not paused; a round is currently active (bets open or running). | -/// | 1 | `Paused` | Emergency-paused by the admin; no mutations accepted except unpause. | -/// | 2 | `ClaimsOnly` | Not paused; no active round. Only `claim_winnings` is meaningful. | -/// -/// ## Transition rules -/// -/// - `ClaimsOnly` → `Active` when `create_round()` succeeds. -/// - `Active` → `ClaimsOnly` when `resolve_round()` or `cancel_round()` completes. -/// - Any state → `Paused` when `pause_contract()` is called. -/// - `Paused` → `Active` when `unpause_contract()` is called *and* an active round still exists. -/// - `Paused` → `ClaimsOnly` when `unpause_contract()` is called *and* no active round exists. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - /// The contract is not paused and has a currently active round. - Active = 0, - /// The contract is emergency-paused by the admin. - Paused = 1, - /// The contract is not paused, but no round is active. - /// Mutating actions are limited to claiming pending winnings. - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -/// -/// Queries a round by its monotonic `round_id`. Covers all lifecycle -/// stages from creation through terminal settlement. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|------------------|-----------------------------------------------------------------------------------| -/// | 0 | `Unknown` | Round does not exist or has been pruned from the on-chain archive. | -/// | 1 | `Betting` | Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). | -/// | 2 | `Running` | Betting closed; reveal window open (`bet_end_ledger ≤ ledger < end_ledger`). | -/// | 3 | `AwaitingResolve`| Round ended; awaiting oracle settlement (`ledger ≥ end_ledger`). | -/// | 4 | `Resolved` | Oracle settled the round; pot distributed to winners. | -/// | 5 | `Cancelled` | Admin cancelled the round; all stakes refunded. | -/// | 6 | `FallbackRefund` | Insufficient participants at settlement; all stakes refunded. | -/// -/// ## Transition rules -/// -/// - `Unknown` → `Betting` when `create_round()` succeeds. -/// - `Betting` → `Running` when `ledger ≥ bet_end_ledger` (derived; no on-chain write). -/// - `Running` → `AwaitingResolve` when `ledger ≥ end_ledger` (derived; no on-chain write). -/// - `{Betting | Running | AwaitingResolve}` → `Cancelled` when `cancel_round()` is called. -/// - `AwaitingResolve` → `Resolved` when `resolve_round()` settles with enough participants. -/// - `AwaitingResolve` → `FallbackRefund` when `resolve_round()` finds fewer than `min_participants`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - /// Round does not exist or has been pruned from the on-chain archive. - Unknown = 0, - /// Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). - Betting = 1, - /// Betting is closed; reveal window is open (`bet_end_ledger ≤ ledger < end_ledger`). - Running = 2, - /// Round has ended and is waiting for oracle settlement (`ledger ≥ end_ledger`). - AwaitingResolve = 3, - /// Oracle settled the round normally; pot distributed to winners. - Resolved = 4, - /// Admin cancelled the round; all stakes refunded. - Cancelled = 5, - /// Settlement triggered but insufficient participants; all stakes refunded. - FallbackRefund = 6, -} - -/// Terminal outcome persisted per user per archived round. -/// -/// Allows `get_user_archived_participation` to answer profile/history -/// queries without replaying the full event stream. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Cancel = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, -} - -/// Admin-configured blueprint for `create_next_from_template`. -/// -/// Mirrors the arguments accepted by `create_round` (`start_price`, `mode`) -/// so a keeper can spin up the next round after a settle/cancel without an -/// operator re-specifying parameters each time. Validated with the exact -/// same rules `create_round` applies at creation time. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings, written by -/// `reset_leaderboard_season`. `participant_count` is the number of distinct -/// addresses that appeared in either bounded index at reset time (a lower -/// bound on total season participants beyond the tracked top -/// `LEADERBOARD_LIMIT`, mirroring the same bound the live indexes enforce). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Env, IntoVal, Symbol, Val, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Payout policy for Precision mode -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum PrecisionPayoutPolicy { - Equal = 0, // Split payout pool equally among winners (default) - StakeWeighted = 1, // Split payout pool proportionally to winner stakes -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -/// -/// ## Indexed position keys (variants 13–15) -/// -/// `Position(round_id, address)` and `PrecisionPosition(round_id, address)` store -/// a single user's record under a composite key, enabling O(1) read/write per user -/// instead of deserializing the full participant map on every bet. -/// -/// `RoundParticipants(round_id)` holds the ordered `Vec
` used for -/// iteration at resolution time. Appending one address is cheaper than -/// re-serialising an N-entry `Map` for every bet placed. -/// -/// Legacy single-key maps (`UpDownPositions`, `PrecisionPositions`) are kept for -/// backward-compatible reads during a migration window; they are no longer written. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - /// On-chain storage schema version for migration safety. - /// If missing, the contract treats it as legacy schema version 1. - SchemaVersion, - ActiveRound, - Positions, // Legacy key — read-only migration compat - UpDownPositions, // Legacy key — read-only migration compat - PrecisionPositions, // Legacy key — read-only migration compat - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - /// Per-user UpDown position: (round_id, address) → UserPosition - Position(u64, Address), - /// Per-user Precision prediction: (round_id, address) → PrecisionPrediction - PrecisionPosition(u64, Address), - /// Per-user Precision commitment: (round_id, address) → PrecisionCommitment - PrecisionCommitment(u64, Address), - /// Ordered participant list for a round: round_id → Vec
- RoundParticipants(u64), - /// Maximum stake allowed per individual bet (None = unlimited) - MaxStake, - /// Maximum cumulative exposure per user per round (None = unlimited) - MaxUserRoundExposure, - /// Maximum pending winnings allowed per account (None = unlimited) - MaxPendingWinnings, - /// Marker for a cancelled round: round_id → true - CancelledRound(u64), - /// Per-round consumed oracle nonce: (round_id, nonce) → true. - /// Used to reject duplicate oracle payload submissions for the same round. - ConsumedOracleNonce(u64, u64), - /// Minimum participant count for competitive settlement; unset = no minimum enforced - MinParticipants, - /// Oracle heartbeat: last recorded timestamp and status - OracleHeartbeat, - /// Stale-heartbeat threshold in seconds (admin-configurable); unset = 3600 s default - OracleStaleThreshold, - /// Maximum participants accepted in a Precision round; unset = protocol default - MaxPrecisionParticipants, - /// Oracle max deviation threshold in basis points (1 bp = 0.01%). - /// If unset, deviation guardrails are disabled. - OracleMaxDeviationBps, - /// One-shot admin override allowing the next settlement to bypass deviation checks. - /// Automatically cleared after use. - OracleDeviationOverrideArmed, - /// Minimum oracle confidence threshold in basis points (0–10000). - /// If unset, confidence guardrails are disabled. - OracleMinConfidenceBps, - /// When true, payloads with missing confidence are rejected in strict mode. - OracleStrictMode, - /// Compact post-settlement summary keyed by round id for historical queries. - ArchivedRound(u64), - /// Ordered round ids for archive retention (oldest at index 0). - RecentArchivedRoundIds, - /// Per-user outcome record for a specific archived round (round_id, user). - /// Persisted at settlement for user history queries without event replay. - UserRoundOutcome(u64, Address), - /// Marker written by migrate_schema_v2_to_v3 to prove the migration ran. - MigratedToV3, - /// Timelocked pending critical config change keyed by change kind. - PendingConfigChange(ConfigChangeKind), - /// Optional protocol settlement fee in basis points (1 bp = 0.01%). - /// `None` (key absent) means fee disabled — no behaviour change. - /// Hard cap on fee is enforced at the contract layer, not by storage shape. - ProtocolFeeBps, - /// On-chain accumulated protocol fee balance in stroops (i128). - /// Admin withdraws via the dedicated withdrawal method; does NOT mix - /// into the per-user balance ledger. - ProtocolFeeTreasury, - /// Per-ledger mint counter: wraps the explicit ledger sequence number. - LedgerMintCounter(u32), - /// Mint limit configuration: maximum number of mints allowed per ledger. - MintLimitConfig, - /// Pending two-step oracle rotation proposal with expiry. - OracleRotationProposal, - /// Configurable archive retention limit: maximum number of ArchivedRound entries - /// retained on-chain before the oldest are pruned (FIFO). If unset, the protocol - /// default is used. - ArchiveRetention, - /// Admin-configured blueprint used by `create_next_from_template` to spin - /// up the next round without re-specifying `start_price` / `mode` each - /// time. Absent means no template is configured. - RoundTemplate, - /// Bounded index of user addresses sorted by lifetime total wins - /// descending (all-time leaderboard, independent of seasons). - LeaderboardWins, - /// Bounded index of user addresses sorted by lifetime best streak - /// descending (all-time leaderboard, independent of seasons). - LeaderboardStreak, - /// Monotonically increasing id of the currently-active leaderboard - /// season. Absent is treated as season 1. - SeasonId, - /// Per-season, per-user win/loss/streak stats: (season_id, address) → - /// UserStats, scoped independently of the lifetime `UserStats` totals so - /// a season reset never touches lifetime history. - SeasonUserStats(u32, Address), - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped total wins descending. - SeasonLeaderboardWins, - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped best streak descending. - SeasonLeaderboardStreak, - /// Frozen snapshot of a season's final rankings, written when the season - /// is reset. Seasons are never deleted — this is a permanent archive. - SeasonArchive(u32), - /// Admin-configured multi-feed oracle quorum parameters. - /// When set, `resolve_round_multi` is enabled. - OracleQuorum, - /// Announced next schema version for migration preview (v-next template). - /// When set, operators can inspect this value before executing a real migration. - /// Absent means no next migration has been announced. - NextSchemaVersion, - /// Minimum bet amount (dust protection). Unset = no minimum. - MinBet, - /// Epoch mint budget: total mints allowed per epoch. - EpochMintBudget, - /// Early cash-out penalty in basis points. Unset = early cash-out disabled. - EarlyCashoutBps, - /// Fee incidence model: FeeOnPot (default) or FeeOnWinnings. - FeeModel, - /// Dispute window length in ledgers. 0 = no dispute window. - DisputeLedgers, -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, - EpochMintBudget = 12, - PendingWinningsExpiry = 13, - PrecisionPayoutPolicy = 14, - MinBet = 15, - DisputeLedgers = 16, - FeeModel = 17, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), - EpochMintBudget(i128), - PendingWinningsExpiry(u32), - PrecisionPayoutPolicy(u32), - MinBet(Option), - DisputeLedgers(u32), - FeeModel(FeeModel), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - pub confidence: Option, - /// Optional ed25519 signature over the attestation domain-separated message. - pub attestation: Option>, -} - -/// Multi-feed oracle resolution payload (N observations, quorum + median). -/// -/// Unlike the legacy single-oracle `OraclePayload`, this carries N independent -/// feed observations as parallel arrays. The contract computes the median, -/// rejects outliers, and requires a configurable quorum of feeds to agree -/// within the outlier threshold before settlement proceeds. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct MultiFeedPayload { - /// Prices from each feed, scaled to 4 decimal places (e.g. 2297 = $0.2297). - pub prices: Vec, - /// Feed source identifiers (0-based index, max N-1). Must be unique. - pub sources: Vec, - /// Round identifier that must match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Unix epoch seconds when the observations were collected. - pub timestamp: u64, -} - -/// Admin-configurable quorum and outlier rejection parameters for multi-feed -/// oracle settlement. Stored under `DataKey::OracleQuorum`. -/// -/// When set, `resolve_round_multi` becomes the preferred settlement path. -/// The legacy single-oracle `resolve_round` path remains available -/// independently of this configuration. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleQuorumConfig { - /// Minimum number of unique feed observations required in a multi-feed payload. - pub min_observations: u32, - /// Minimum number of observations that must survive outlier rejection to - /// form a valid quorum and proceed to settlement. - pub quorum_threshold: u32, - /// Maximum deviation from the median (in basis points, 1 bp = 0.01%) - /// before an observation is rejected as an outlier. - pub outlier_threshold_bps: u32, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -/// Heartbeat health gate configuration (Issue #264). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct HbGateConfig { - pub strict_mode: bool, - pub override_armed: bool, - pub grace_seconds: u64, -} - -/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). -#[contracttype] -#[derive(Clone)] -pub enum HbGateKey { - Config, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, - /// Dispute window ended via void; all participants refunded their stake. - Voided = 3, -} - -/// Composite protocol health status returned by `get_protocol_health`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - pub paused: bool, - pub oracle_live: bool, - pub oracle_status: u32, - pub has_active_round: bool, - pub active_round_phase: u32, - pub schema_version: u32, - pub ledger_sequence: u32, - pub ledger_timestamp: u64, - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - Active = 0, - Paused = 1, - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - Unknown = 0, - Betting = 1, - Running = 2, - AwaitingResolve = 3, - Resolved = 4, - Cancelled = 5, - FallbackRefund = 6, - Voided = 7, -} - -/// Terminal outcome persisted per user per archived round. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Void = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, - pub fee_model: u32, -} - -/// Admin-configured blueprint for `create_next_from_template`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} - -/// Configurable pending-winnings expiry in ledgers. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsExpiryKey(pub ()); - -pub const PENDING_WINNINGS_EXPIRY_KEY: PendingWinningsExpiryKey = PendingWinningsExpiryKey(()); - -/// Ledger sequence when a user's pending winnings entry was last modified. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsUpdatedAtKey(pub Address); - -/// Fee incidence model for protocol fees (Issue #268). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum FeeModel { - FeeOnPot = 0, // Fee charged on total pot (default) - FeeOnWinnings = 1, // Fee charged only on net winnings/profit -} - -/// TWAP sample ring entry (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PriceSample { - pub price: u128, - pub timestamp: u64, -} - -/// Storage key for TWAP samples ring (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum TwapSamplesKey { - Samples, -} - -/// Dev Reference Mode (Issue #266). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum DeviationReferenceMode { - StartPrice = 0, // Use round.price_start (default) - Twap = 1, // Use trailing-sample TWAP average -} - -/// Deviation guardrail config (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeviationConfig { - pub reference_mode: DeviationReferenceMode, - pub window_samples: u32, -} - -/// Storage key for deviation config (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum DeviationConfigKey { - Config, -} - -/// Oracle attestation config (Issue #263). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct AttestationConfig { - pub key: Option>, // ed25519 public key; None = attestation disabled -} - -/// Storage key for attestation config (separate from DataKey to stay within variant limits, Issue #263). -#[contracttype] -#[derive(Clone)] -pub enum AttestationConfigKey { - Config, -} - -impl IntoVal for DataKey { - fn into_val(&self, env: &Env) -> Val { - use Symbol as S; - match self { - DataKey::Balance(a) => (S::new(env, "Balance"), a.clone()).into_val(env), - DataKey::Admin => S::new(env, "Admin").into_val(env), - DataKey::Oracle => S::new(env, "Oracle").into_val(env), - DataKey::SchemaVersion => S::new(env, "SchemaVersion").into_val(env), - DataKey::ActiveRound => S::new(env, "ActiveRound").into_val(env), - DataKey::Positions => S::new(env, "Positions").into_val(env), - DataKey::UpDownPositions => S::new(env, "UpDownPositions").into_val(env), - DataKey::PrecisionPositions => S::new(env, "PrecisionPositions").into_val(env), - DataKey::PendingWinnings(a) => { - (S::new(env, "PendingWinnings"), a.clone()).into_val(env) - } - DataKey::UserStats(a) => (S::new(env, "UserStats"), a.clone()).into_val(env), - DataKey::Paused => S::new(env, "Paused").into_val(env), - DataKey::BetWindowLedgers => S::new(env, "BetWindowLedgers").into_val(env), - DataKey::RunWindowLedgers => S::new(env, "RunWindowLedgers").into_val(env), - DataKey::CloseBufferLedgers => S::new(env, "CloseBufferLedgers").into_val(env), - DataKey::LastRoundId => S::new(env, "LastRoundId").into_val(env), - DataKey::Position(id, a) => { - (S::new(env, "Position"), id, a.clone()).into_val(env) - } - DataKey::PrecisionPosition(id, a) => { - (S::new(env, "PrecisionPosition"), id, a.clone()).into_val(env) - } - DataKey::PrecisionCommitment(id, a) => { - (S::new(env, "PrecisionCommitment"), id, a.clone()).into_val(env) - } - DataKey::RoundParticipants(id) => { - (S::new(env, "RoundParticipants"), id).into_val(env) - } - DataKey::MaxStake => S::new(env, "MaxStake").into_val(env), - DataKey::MaxUserRoundExposure => S::new(env, "MaxUserRoundExposure").into_val(env), - DataKey::MaxPendingWinnings => S::new(env, "MaxPendingWinnings").into_val(env), - DataKey::CancelledRound(id) => (S::new(env, "CancelledRound"), id).into_val(env), - DataKey::ConsumedOracleNonce(id, nonce) => { - (S::new(env, "ConsumedOracleNonce"), id, nonce).into_val(env) - } - DataKey::MinParticipants => S::new(env, "MinParticipants").into_val(env), - DataKey::OracleHeartbeat => S::new(env, "OracleHeartbeat").into_val(env), - DataKey::OracleStaleThreshold => S::new(env, "OracleStaleThreshold").into_val(env), - DataKey::MaxPrecisionParticipants => { - S::new(env, "MaxPrecisionParticipants").into_val(env) - } - DataKey::OracleMaxDeviationBps => S::new(env, "OracleMaxDeviationBps").into_val(env), - DataKey::OracleDeviationOverrideArmed => { - S::new(env, "OracleDeviationOverrideArmed").into_val(env) - } - DataKey::OracleMinConfidenceBps => { - S::new(env, "OracleMinConfidenceBps").into_val(env) - } - DataKey::OracleStrictMode => S::new(env, "OracleStrictMode").into_val(env), - DataKey::ArchivedRound(id) => (S::new(env, "ArchivedRound"), id).into_val(env), - DataKey::RecentArchivedRoundIds => { - S::new(env, "RecentArchivedRoundIds").into_val(env) - } - DataKey::UserRoundOutcome(id, a) => { - (S::new(env, "UserRoundOutcome"), id, a.clone()).into_val(env) - } - DataKey::MigratedToV3 => S::new(env, "MigratedToV3").into_val(env), - DataKey::PendingConfigChange(k) => { - (S::new(env, "PendingConfigChange"), k.clone()).into_val(env) - } - DataKey::ProtocolFeeBps => S::new(env, "ProtocolFeeBps").into_val(env), - DataKey::ProtocolFeeTreasury => S::new(env, "ProtocolFeeTreasury").into_val(env), - DataKey::LedgerMintCounter(id) => { - (S::new(env, "LedgerMintCounter"), id).into_val(env) - } - DataKey::MintLimitConfig => S::new(env, "MintLimitConfig").into_val(env), - DataKey::OracleRotationProposal => S::new(env, "OracleRotationProposal").into_val(env), - DataKey::ArchiveRetention => S::new(env, "ArchiveRetention").into_val(env), - DataKey::RoundTemplate => S::new(env, "RoundTemplate").into_val(env), - DataKey::LeaderboardWins => S::new(env, "LeaderboardWins").into_val(env), - DataKey::LeaderboardStreak => S::new(env, "LeaderboardStreak").into_val(env), - DataKey::SeasonId => S::new(env, "SeasonId").into_val(env), - DataKey::SeasonUserStats(sid, a) => { - (S::new(env, "SeasonUserStats"), sid, a.clone()).into_val(env) - } - DataKey::SeasonLeaderboardWins => S::new(env, "SeasonLeaderboardWins").into_val(env), - DataKey::SeasonLeaderboardStreak => { - S::new(env, "SeasonLeaderboardStreak").into_val(env) - } - DataKey::SeasonArchive(id) => (S::new(env, "SeasonArchive"), id).into_val(env), - DataKey::OracleQuorum => S::new(env, "OracleQuorum").into_val(env), - DataKey::NextSchemaVersion => S::new(env, "NextSchemaVersion").into_val(env), - DataKey::MinBet => S::new(env, "MinBet").into_val(env), - DataKey::EpochMintBudget => S::new(env, "EpochMintBudget").into_val(env), - DataKey::EarlyCashoutBps => S::new(env, "EarlyCashoutBps").into_val(env), - DataKey::FeeModel => S::new(env, "FeeModel").into_val(env), - DataKey::DisputeLedgers => S::new(env, "DisputeLedgers").into_val(env), - } - } -} +// SPDX-License-Identifier: MIT +//! Type definitions for the XLM Price Prediction Market. + +use soroban_sdk::{contracttype, Address, BytesN, Vec}; + +/// Round mode for prediction type +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum RoundMode { + UpDown = 0, // Simple up/down predictions + Precision = 1, // Exact price predictions (Legends mode) +} + +/// Payout policy for Precision mode +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum PrecisionPayoutPolicy { + Equal = 0, // Split payout pool equally among winners (default) + StakeWeighted = 1, // Split payout pool proportionally to winner stakes +} + +/// Runtime mode for the contract lifecycle +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum RuntimeMode { + Normal = 0, + ClaimsOnly = 1, + FullyPaused = 2, +} + +/// Lifecycle phase of an active round, derived from ledger windows. +/// +/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): +/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted +/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) +/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum RoundPhase { + Betting = 1, + Running = 2, + Resolvable = 3, +} + +/// Parameterless system, config, and metadata storage keys. +/// +/// Split from a single unified `DataKey` to stay under the XDR union 50-case +/// limit (`VecM` in stellar-xdr). `DataKeyCore` +/// holds singleton/config keys with no payload (plus `Ext`, itself a further +/// split for the leaderboard/season singleton keys); `DataKeyScoped` (below) +/// holds every key parameterised by round id, address, or season id. +#[contracttype] +#[derive(Clone)] +pub enum DataKeyCore { + Admin, + Oracle, + /// On-chain storage schema version for migration safety. + /// If missing, the contract treats it as legacy schema version 1. + SchemaVersion, + ActiveRound, + Positions, // Legacy key — read-only migration compat + UpDownPositions, // Legacy key — read-only migration compat + PrecisionPositions, // Legacy key — read-only migration compat + Paused, + BetWindowLedgers, + RunWindowLedgers, + CloseBufferLedgers, + LastRoundId, + /// Maximum stake allowed per individual bet (None = unlimited) + MaxStake, + /// Maximum cumulative exposure per user per round (None = unlimited) + MaxUserRoundExposure, + /// Maximum pending winnings allowed per account (None = unlimited) + MaxPendingWinnings, + /// Minimum participant count for competitive settlement; unset = no minimum enforced + MinParticipants, + /// Oracle heartbeat: last recorded timestamp and status + OracleHeartbeat, + /// Stale-heartbeat threshold in seconds (admin-configurable); unset = 3600 s default + OracleStaleThreshold, + /// Maximum participants accepted in a Precision round; unset = protocol default + MaxPrecisionParticipants, + /// Oracle max deviation threshold in basis points (1 bp = 0.01%). + /// If unset, deviation guardrails are disabled. + OracleMaxDeviationBps, + /// One-shot admin override allowing the next settlement to bypass deviation checks. + /// Automatically cleared after use. + OracleDeviationOverrideArmed, + /// Minimum oracle confidence threshold in basis points (0–10000). + /// If unset, confidence guardrails are disabled. + OracleMinConfidenceBps, + /// When true, payloads with missing confidence are rejected in strict mode. + OracleStrictMode, + /// Ordered round ids for archive retention (oldest at index 0). + RecentArchivedRoundIds, + /// Marker written by migrate_schema_v2_to_v3 to prove the migration ran. + MigratedToV3, + /// Optional protocol settlement fee in basis points (1 bp = 0.01%). + /// `None` (key absent) means fee disabled — no behaviour change. + /// Hard cap on fee is enforced at the contract layer, not by storage shape. + ProtocolFeeBps, + /// On-chain accumulated protocol fee balance in stroops (i128). + /// Admin withdraws via the dedicated withdrawal method; does NOT mix + /// into the per-user balance ledger. + ProtocolFeeTreasury, + /// Mint limit configuration: maximum number of mints allowed per ledger. + MintLimitConfig, + /// Pending two-step oracle rotation proposal with expiry. + OracleRotationProposal, + /// Configurable archive retention limit: maximum number of ArchivedRound entries + /// retained on-chain before the oldest are pruned (FIFO). If unset, the protocol + /// default is used. + ArchiveRetention, + /// Admin-configured blueprint used by `create_next_from_template` to spin + /// up the next round without re-specifying `start_price` / `mode` each + /// time. Absent means no template is configured. + RoundTemplate, + /// Admin-configured multi-feed oracle quorum parameters. + /// When set, `resolve_round_multi` is enabled. + OracleQuorum, + /// Announced next schema version for migration preview (v-next template). + /// When set, operators can inspect this value before executing a real migration. + /// Absent means no next migration has been announced. + NextSchemaVersion, + /// Minimum bet amount (dust protection). Unset = no minimum. + MinBet, + /// Early cash-out penalty in basis points. Unset = early cash-out disabled. + EarlyCashoutBps, + /// Fee incidence model: FeeOnPot (default) or FeeOnWinnings. + FeeModel, + /// Precision payout policy: Equal (default) or StakeWeighted. + PrecisionPayoutPolicy, + /// Whether allowlist/denylist access control gating is active. + AccessControlEnabled, + /// Configured secondary governance approver for dual-approval actions. + GovApprover, + /// Default TTL (in ledgers) applied to new governance proposals. + GovProposalTtlLedgers, + /// Monotonic counter for the next governance proposal id. + NextGovProposalId, + /// Further split for parameterless leaderboard/season keys — see `DataKeyExt`. + Ext(DataKeyExt), +} + +/// Leaderboard/season singleton keys, nested under `DataKeyCore::Ext` to stay +/// within the same 50-case XDR union limit that motivated the Core/Scoped split. +#[contracttype] +#[derive(Clone)] +pub enum DataKeyExt { + /// Bounded index of user addresses sorted by lifetime total wins + /// descending (all-time leaderboard, independent of seasons). + LeaderboardWins, + /// Bounded index of user addresses sorted by lifetime best streak + /// descending (all-time leaderboard, independent of seasons). + LeaderboardStreak, + /// Monotonically increasing id of the currently-active leaderboard + /// season. Absent is treated as season 1. + SeasonId, + /// Bounded index of user addresses in the *active* season sorted by + /// season-scoped total wins descending. + SeasonLeaderboardWins, + /// Bounded index of user addresses in the *active* season sorted by + /// season-scoped best streak descending. + SeasonLeaderboardStreak, +} + +/// Parameterised and round-scoped storage keys. +/// +/// Split from a single unified `DataKey` to stay under the XDR union 50-case +/// limit (see `DataKeyCore` above). These variants carry per-user, per-round, +/// per-season, or compound-key payloads. +#[contracttype] +#[derive(Clone)] +pub enum DataKeyScoped { + /// User financial balance + Balance(Address), + /// User pending winnings accumulator + PendingWinnings(Address), + /// User performance statistics + UserStats(Address), + /// Per-user UpDown position: (round_id, address) → UserPosition + Position(u64, Address), + /// Per-user Precision prediction: (round_id, address) → PrecisionPrediction + PrecisionPosition(u64, Address), + /// Per-user Precision commitment: (round_id, address) → PrecisionCommitment + PrecisionCommitment(u64, Address), + /// Ordered participant list for a round: round_id → Vec
+ RoundParticipants(u64), + /// Marker for a cancelled round: round_id → true + CancelledRound(u64), + /// Per-round consumed oracle nonce: (round_id, nonce) → true. + /// Used to reject duplicate oracle payload submissions for the same round. + ConsumedOracleNonce(u64, u64), + /// Compact post-settlement summary keyed by round id for historical queries. + ArchivedRound(u64), + /// Per-user outcome record for a specific archived round (round_id, user). + /// Persisted at settlement for user history queries without event replay. + UserRoundOutcome(u64, Address), + /// Per-user index of archived round IDs the user participated in. + UserArchivedRoundIds(Address), + /// Timelocked pending critical config change keyed by change kind. + PendingConfigChange(ConfigChangeKind), + /// Per-ledger mint counter: wraps the explicit ledger sequence number. + LedgerMintCounter(u32), + /// Per-season, per-user win/loss/streak stats: (season_id, address) → + /// UserStats, scoped independently of the lifetime `UserStats` totals so + /// a season reset never touches lifetime history. + SeasonUserStats(u32, Address), + /// Frozen snapshot of a season's final rankings, written when the season + /// is reset. Seasons are never deleted — this is a permanent archive. + SeasonArchive(u32), + /// Address explicitly allowlisted for access-control-gated actions. + Allowlisted(Address), + /// Address explicitly denylisted for access-control-gated actions. + Denylisted(Address), + /// Pending dual-approval governance proposal, keyed by proposal id. + GovProposal(u64), + /// Latest published pool-aggregate commitment for a round. + PoolCommitment(u64), + /// Secret salt bound into a round's pool commitment chain; never + /// returned by any query, only consumed internally and copied into + /// `PoolOpening` once the round is opened. + PoolCommitmentSalt(u64), + /// Revealed pool-aggregate opening for a round, written once at or after + /// `bet_end_ledger`. Absence means "not yet opened" (fail-closed). + PoolOpening(u64), + /// Running total of Precision-mode stake (direct placements + commit-reveal + /// commitments), maintained incrementally so the pool commitment can fold + /// it in at O(1) cost instead of re-scanning all participants. + PrecisionCommitStake(u64), + /// Staged settlement outcome awaiting the dispute window (Issue #276), + /// keyed by round id. Only written when `DisputeLedgers > 0`; absence + /// means the round either has no dispute window configured or has + /// already been finalised/voided. + PendingDispute(u64), +} + +/// Identifies which critical risk setting is pending timelocked activation. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum ConfigChangeKind { + Windows = 0, + MaxStake = 1, + MaxUserRoundExposure = 2, + MaxPendingWinnings = 3, + OracleStaleThreshold = 4, + OracleMaxDeviationBps = 5, + ProtocolFeeBps = 6, + MinParticipants = 7, + MaxPrecisionParticipants = 8, + MintLimit = 9, + ArchiveRetention = 10, + CloseBufferLedgers = 11, + OracleTimestampSkew = 12, + PendingWinningsExpiry = 13, + PrecisionPayoutPolicy = 14, + MinBet = 15, + DisputeLedgers = 16, + FeeModel = 17, + EpochMintBudget = 18, + EarlyCashoutBps = 19, +} + +/// Payload for a scheduled critical config change. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ConfigChangePayload { + Windows(u32, u32), + MaxStake(Option), + MaxUserRoundExposure(Option), + MaxPendingWinnings(Option), + OracleStaleThreshold(u64), + OracleMaxDeviationBps(Option), + ProtocolFeeBps(Option), + MinParticipants(Option), + MaxPrecisionParticipants(u32), + MintLimit(u32), + ArchiveRetention(u32), + CloseBufferLedgers(u32), + OracleTimestampSkew(u64), + EpochMintBudget(i128), + PendingWinningsExpiry(u32), + PrecisionPayoutPolicy(u32), + MinBet(Option), + DisputeLedgers(u32), + FeeModel(FeeModel), + EarlyCashoutBps(Option), +} + +/// Pending timelocked config change with activation ledger for on-chain observability. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PendingConfigChange { + pub payload: ConfigChangePayload, + pub activation_ledger: u32, + pub scheduled_at_ledger: u32, +} + +/// Represents which side a user bet on +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum BetSide { + Up, + Down, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct UserPosition { + pub amount: i128, + pub side: BetSide, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct UserStats { + pub total_wins: u32, + pub total_losses: u32, + pub current_streak: u32, + pub best_streak: u32, +} + +/// Precision prediction entry (user address + predicted price) +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PrecisionPrediction { + pub user: Address, + pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) + pub amount: i128, // Bet amount +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PrecisionCommitment { + pub hash: BytesN<32>, + pub amount: i128, + pub revealed: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OraclePayload { + pub price: u128, + pub timestamp: u64, + /// Round identifier that should match `Round.start_ledger` + pub round_id: u32, + /// Per-round replay-protection nonce. + pub nonce: u64, + /// SHA-256 hash of the network passphrase this payload targets. + pub network_id: BytesN<32>, + /// Contract address this payload is intended for. + pub contract_addr: Address, + /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). + pub confidence: Option, + /// Optional ed25519 signature over the attestation domain-separated message. + pub attestation: Option>, +} + +/// Multi-feed oracle resolution payload (N observations, quorum + median). +/// +/// Unlike the legacy single-oracle `OraclePayload`, this carries N independent +/// feed observations as parallel arrays. The contract computes the median, +/// rejects outliers, and requires a configurable quorum of feeds to agree +/// within the outlier threshold before settlement proceeds. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MultiFeedPayload { + /// Prices from each feed, scaled to 4 decimal places (e.g. 2297 = $0.2297). + pub prices: Vec, + /// Feed source identifiers (0-based index, max N-1). Must be unique. + pub sources: Vec, + /// Round identifier that must match `Round.start_ledger` + pub round_id: u32, + /// Per-round replay-protection nonce. + pub nonce: u64, + /// SHA-256 hash of the network passphrase this payload targets. + pub network_id: BytesN<32>, + /// Contract address this payload is intended for. + pub contract_addr: Address, + /// Unix epoch seconds when the observations were collected. + pub timestamp: u64, +} + +/// Admin-configurable quorum and outlier rejection parameters for multi-feed +/// oracle settlement. Stored under `DataKeyCore::OracleQuorum`. +/// +/// When set, `resolve_round_multi` becomes the preferred settlement path. +/// The legacy single-oracle `resolve_round` path remains available +/// independently of this configuration. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OracleQuorumConfig { + /// Minimum number of unique feed observations required in a multi-feed payload. + pub min_observations: u32, + /// Minimum number of observations that must survive outlier rejection to + /// form a valid quorum and proceed to settlement. + pub quorum_threshold: u32, + /// Maximum deviation from the median (in basis points, 1 bp = 0.01%) + /// before an observation is rejected as an outlier. + pub outlier_threshold_bps: u32, +} + +/// Oracle liveness record, updated by the oracle service on each heartbeat call. +/// `status`: 0 = active, 1 = degraded, 2 = offline. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OracleHeartbeatRecord { + pub timestamp: u64, + pub status: u32, +} + +/// Heartbeat health gate configuration (Issue #264). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct HbGateConfig { + pub strict_mode: bool, + pub override_armed: bool, + pub grace_seconds: u64, +} + +/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). +/// +/// The variant name, not the enclosing Rust enum name, is what Soroban +/// encodes into the storage key for a unit variant — so this must stay +/// distinct from every other single-variant "config key" enum's variant +/// name in this file (`DeviationConfigKey`, `AttestationConfigKey`, ...) or +/// their writes silently collide onto the same storage slot. +#[contracttype] +#[derive(Clone)] +pub enum HbGateKey { + HbConfig, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Round { + pub round_id: u64, // Unique monotonically increasing round identifier + pub price_start: u128, // Starting XLM price in stroops + pub start_ledger: u32, // Ledger when round was created + pub bet_end_ledger: u32, // Ledger when betting closes + pub end_ledger: u32, // Ledger when round ends (~5s per ledger) + pub pool_up: i128, // Total vXLM bet on UP + pub pool_down: i128, // Total vXLM bet on DOWN + pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) + pub start_timestamp: u64, // Ledger timestamp when round was created +} + +/// Aggregated active-round pool composition for frontend transparency. +/// +/// Up/Down rounds populate the up/down pools, counts, and stake ratios. +/// Precision rounds populate the precision totals and participant counters while +/// leaving side-specific Up/Down fields at zero. Ratios are basis points of +/// the mode's total visible stake (10_000 = 100%). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RoundPoolStats { + pub round_id: u64, + pub mode: RoundMode, + pub total_up_stake: i128, + pub total_down_stake: i128, + pub up_participant_count: u32, + pub down_participant_count: u32, + pub up_stake_ratio_bps: u32, + pub down_stake_ratio_bps: u32, + pub precision_total_stake: i128, + pub precision_participant_count: u32, + pub precision_prediction_count: u32, + pub precision_commitment_count: u32, + pub precision_revealed_count: u32, +} + +/// Terminal outcome recorded when a round leaves the active state. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum RoundArchiveStatus { + /// Oracle settlement completed (normal resolution path). + Resolved = 0, + /// Admin cancelled the round and refunded participants. + Cancelled = 1, + /// Settlement aborted due to insufficient participants; stakes refunded. + FallbackRefund = 2, + /// Dispute window ended via void; all participants refunded their stake. + Voided = 3, +} + +/// Composite protocol health status returned by `get_protocol_health`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ProtocolHealthStatus { + pub paused: bool, + pub oracle_live: bool, + pub oracle_status: u32, + pub has_active_round: bool, + pub active_round_phase: u32, + pub schema_version: u32, + pub ledger_sequence: u32, + pub ledger_timestamp: u64, + pub status_code: u32, +} + +/// Compact historical round summary persisted after resolve or cancel. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ArchivedRoundSummary { + pub round_id: u64, + pub price_start: u128, + pub price_final: u128, + pub mode: RoundMode, + pub status: RoundArchiveStatus, + pub pool_up: i128, + pub pool_down: i128, + pub participant_count: u32, + pub settled_at_ledger: u32, +} + +/// Pending two-step oracle rotation proposal. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OracleRotationProposal { + pub new_oracle: Address, + pub proposed_at: u64, + pub expires_at: u64, +} + +/// Global status of the protocol, returned by `get_protocol_status`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum ProtocolStatus { + Active = 0, + Paused = 1, + ClaimsOnly = 2, +} + +/// Status of a specific round, returned by `get_round_status(round_id)`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum RoundStatus { + Unknown = 0, + Betting = 1, + Running = 2, + AwaitingResolve = 3, + Resolved = 4, + Cancelled = 5, + FallbackRefund = 6, + Voided = 7, +} + +/// Terminal outcome persisted per user per archived round. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum UserOutcomeType { + Win = 0, + Loss = 1, + Refund = 2, + Void = 3, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct UserRoundOutcome { + pub user: Address, + pub round_mode: u32, + pub prediction_side: u32, + pub predicted_price: u128, + pub stake: i128, + pub payout: i128, + pub outcome: UserOutcomeType, +} + +/// Simulated payout result for a specific hypothetical final price. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SimulationResult { + pub mode: RoundMode, + pub pool_up: i128, + pub pool_down: i128, + pub precision_total_stake: i128, + pub fee_amount: i128, + pub outcomes: Vec, + pub fee_model: u32, +} + +/// Admin-configured blueprint for `create_next_from_template`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RoundTemplate { + pub start_price: u128, + pub mode: Option, +} + +/// A single entry in the lifetime (all-time) leaderboard. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct LeaderboardEntry { + pub user: Address, + pub stats: UserStats, +} + +/// A single entry in a season-scoped leaderboard, live or archived. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SeasonLeaderboardEntry { + pub user: Address, + pub wins: u32, + pub best_streak: u32, +} + +/// Frozen snapshot of a season's final bounded rankings. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SeasonArchive { + pub season_id: u32, + pub ended_at_ledger: u32, + pub wins: Vec, + pub streak: Vec, + pub participant_count: u32, +} + +/// Configurable pending-winnings expiry in ledgers. +#[contracttype] +#[derive(Clone, Debug)] +pub struct PendingWinningsExpiryKey(pub ()); + +pub const PENDING_WINNINGS_EXPIRY_KEY: PendingWinningsExpiryKey = PendingWinningsExpiryKey(()); + +/// Ledger sequence when a user's pending winnings entry was last modified. +#[contracttype] +#[derive(Clone, Debug)] +pub struct PendingWinningsUpdatedAtKey(pub Address); + +/// Fee incidence model for protocol fees (Issue #268). +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum FeeModel { + FeeOnPot = 0, // Fee charged on total pot (default) + FeeOnWinnings = 1, // Fee charged only on net winnings/profit +} + +/// TWAP sample ring entry (Issue #266). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceSample { + pub price: u128, + pub timestamp: u64, +} + +/// Storage key for TWAP samples ring (separate from DataKey to stay within variant limits, Issue #266). +#[contracttype] +#[derive(Clone)] +pub enum TwapSamplesKey { + Samples, +} + +/// Dev Reference Mode (Issue #266). +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum DeviationReferenceMode { + StartPrice = 0, // Use round.price_start (default) + Twap = 1, // Use trailing-sample TWAP average +} + +/// Deviation guardrail config (Issue #266). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeviationConfig { + pub reference_mode: DeviationReferenceMode, + pub window_samples: u32, +} + +/// Storage key for deviation config (separate from DataKey to stay within variant limits, Issue #266). +/// See the note on [`HbGateKey`] — the variant name alone forms the storage +/// key, so it must not collide with other config-key enums' variant names. +#[contracttype] +#[derive(Clone)] +pub enum DeviationConfigKey { + DeviationConfig, +} + +/// Oracle attestation config (Issue #263). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AttestationConfig { + pub key: Option>, // ed25519 public key; None = attestation disabled +} + +/// Storage key for attestation config (separate from DataKey to stay within variant limits, Issue #263). +/// See the note on [`HbGateKey`] — the variant name alone forms the storage +/// key, so it must not collide with other config-key enums' variant names. +#[contracttype] +#[derive(Clone)] +pub enum AttestationConfigKey { + AttestationConfig, +} + +/// Protected administrative action gated behind dual-approval governance +/// (Issue #272). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum GovAction { + PauseProtocol, + UnpauseProtocol, + SetProtocolFeeBps(Option), + WithdrawProtocolFee(Address, i128), + SetTreasuryAddress(Address), + SetAdmin(Address), + SetOracle(Address), +} + +/// Lifecycle state of a governance proposal. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum GovProposalStatus { + Pending = 0, + Approved = 1, + Executed = 2, + Cancelled = 3, + Expired = 4, +} + +/// A dual-approval governance proposal: one party proposes, a distinct party +/// (admin or the configured secondary approver) must approve, and either can +/// then execute. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct GovProposal { + pub id: u64, + pub proposer: Address, + pub approver: Option
, + pub action: GovAction, + pub created_at_ledger: u32, + pub expires_at_ledger: u32, + pub status: GovProposalStatus, +} + +/// Access-control classification for a user under allowlist/denylist gating. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum AccessState { + Open = 0, + Allowlisted = 1, + Denylisted = 2, +} + +/// Deterministic policy applied when a round ends with liquidity on only one +/// side of the market (Issue #270). See `("pool", "onesided")` in +/// `docs/EVENT_SCHEMA.md` for the corresponding `policy_code` values. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum OneSidedPolicy { + Refund = 0, + Void = 1, + CarryForward = 2, +} + +/// Alias retained for call sites written against the pre-#270 name. +pub type Policy = OneSidedPolicy; + +/// Staged settlement outcome awaiting the dispute window (Issue #276). +/// `void_round` may refund in full while `ledger < expires_at_ledger`; +/// `finalize_round` distributes `final_price`'s computed settlement once the +/// window has elapsed. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PendingDispute { + pub final_price: u128, + pub confidence: Option, + pub expires_at_ledger: u32, +} + +/// Classifies a mutating entrypoint for the runtime-mode policy gate +/// (`_policy_gate`), so each call site references a semantic action rather +/// than re-deriving the mode rules inline. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum PolicyAction { + RoundMutation = 0, + Claim = 1, + AdminConfig = 2, + Settlement = 3, +} + +/// Published commitment to a round's pool aggregate, advanced on every +/// mutating betting action. Carries only a hash + sequence + ledger — never +/// the raw aggregate values (see `PoolOpening` for the post-close reveal). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PoolCommitment { + pub round_id: u64, + pub seq: u32, + pub commitment: BytesN<32>, + pub ledger: u32, +} + +/// Revealed opening for a round's pool-aggregate commitment chain, written +/// once at or after `bet_end_ledger` by `open_pool_commitment` or +/// automatically at settlement. Recomputing `sha256(round_id || seq || +/// pool_up || pool_down || precision_total_stake || salt)` must equal the +/// last `("pool", "commit")` event's commitment hash. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PoolOpening { + pub round_id: u64, + pub seq: u32, + pub pool_up: i128, + pub pool_down: i128, + pub precision_total_stake: i128, + pub salt: BytesN<32>, + pub opened_ledger: u32, +}