From ed1bbb63df247b6b3df3c10f17b8f1c323753608 Mon Sep 17 00:00:00 2001 From: dee-john Date: Wed, 26 Aug 2026 11:07:36 +0100 Subject: [PATCH] fix: resolve contract compilation errors, clean split keys, and repair event assertions --- contracts/src/common.rs | 6 +- contracts/src/config.rs | 11 +- contracts/src/contract.rs | 4 +- contracts/src/errors.rs | 2 +- contracts/src/settlement.rs | 152 +- contracts/src/tests/event_coverage.rs | 12 +- contracts/src/types.rs | 2380 +++---------------------- 7 files changed, 418 insertions(+), 2149 deletions(-) diff --git a/contracts/src/common.rs b/contracts/src/common.rs index af8b9ac..ea76974 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, Round, RoundPhase, + PendingWinningsUpdatedAtKey, }; 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. @@ -140,7 +139,6 @@ pub fn sort_addresses(addresses: Vec
) -> Vec
{ /// 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 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 09dc0e8..83069e2 100644 --- a/contracts/src/config.rs +++ b/contracts/src/config.rs @@ -1,15 +1,6 @@ // 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, @@ -23,7 +14,7 @@ use crate::common::{ }; 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}; diff --git a/contracts/src/contract.rs b/contracts/src/contract.rs index 84e00e9..56c8e8d 100644 --- a/contracts/src/contract.rs +++ b/contracts/src/contract.rs @@ -1068,7 +1068,7 @@ impl VirtualTokenContract { /// Anyone may call `finalize_round` after the dispute window expires to /// distribute winnings to winners (normal settlement outcome). pub fn finalize_round(env: Env, round_id: u64) -> Result<(), ContractError> { - settlement::finalize_round(env, round_id) + settlement::_finalize_round(env, round_id) } pub fn get_active_round(env: Env) -> Option { @@ -1076,7 +1076,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 { diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index a3fa868..7ed8d09 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -60,7 +60,7 @@ pub enum ContractError { OracleTimestampOutsideWindow = 66, /// Pending winnings entry exists but has not yet reached the configured /// expiry threshold — caller must wait before reclaiming. - PendingWinningsNotExpired = 66, + PendingWinningsNotExpired = 79, /// Epoch mint budget has been fully consumed EpochBudgetExceeded = 67, /// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) diff --git a/contracts/src/settlement.rs b/contracts/src/settlement.rs index 712f6a0..06d4a14 100644 --- a/contracts/src/settlement.rs +++ b/contracts/src/settlement.rs @@ -1,12 +1,13 @@ // SPDX-License-Identifier: MIT +extern crate alloc; +use alloc::vec::Vec as StdVec; use crate::admin::{ _ensure_not_paused, _load_attestation_config, _load_deviation_config, _require_supported_schema, }; use crate::common::{ _accumulate_pending, _emit_action_rejected, _extend_persistent_ttl, _set_balance, balance, payout_add, payout_mul, sort_addresses, DEFAULT_ARCHIVE_RETENTION, - DEFAULT_ORACLE_TIMESTAMP_SKEW, SECONDS_PER_LEDGER, - payout_add, payout_mul, sort_addresses, DEFAULT_ARCHIVE_RETENTION, MAX_ORACLE_OBSERVATIONS, + DEFAULT_ORACLE_TIMESTAMP_SKEW, SECONDS_PER_LEDGER, MAX_ORACLE_OBSERVATIONS, TTL_BUMP_AMOUNT, TTL_BUMP_THRESHOLD, }; use crate::config::{ @@ -24,9 +25,10 @@ use crate::types::{ OraclePayload, OracleQuorumConfig, PrecisionCommitment, PrecisionPayoutPolicy, PrecisionPrediction, PriceSample, PendingWinningsUpdatedAtKey, Round, RoundArchiveStatus, RoundMode, TwapSamplesKey, UserOutcomeType, UserPosition, UserRoundOutcome, UserStats, + OneSidedPolicy, Policy, }; use soroban_sdk::xdr::ToXdr; -use soroban_sdk::{symbol_short, Address, Bytes, Env, Map, Vec}; +use soroban_sdk::{symbol_short, Address, Bytes, Env, Map, Vec, IntoVal, Val}; /// Cancels the active round and deterministically refunds all participant stakes. pub fn cancel_round(env: Env, _reason: u32) -> Result<(), ContractError> { @@ -947,8 +949,10 @@ fn _settle_round_with_price( env, round, RoundArchiveStatus::FallbackRefund, - payload.price, + final_price, &threshold_participants, + 0, + None, ); _refund_under_threshold(env, round, &threshold_participants)?; #[allow(deprecated)] @@ -987,19 +991,17 @@ fn _settle_round_with_price( 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)); @@ -1007,8 +1009,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())); @@ -1057,7 +1059,7 @@ pub fn _resolve_updown_mode( final_price: u128, skip_payout: bool, ) -> Result<(bool, i128), ContractError> { - let participants = sort_addresses(raw_participants.clone()); + let participants = sort_addresses(participants.clone()); // Pure price-direction classification and one-sided check delegated to // settlement_math for auditability and golden-vector coverage. @@ -1079,7 +1081,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) @@ -1330,7 +1332,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() @@ -1774,7 +1776,7 @@ pub fn _archive_round( round: &Round, status: RoundArchiveStatus, final_price: u128, - participants: &[Address], + participants: &Vec
, fee_amount: i128, confidence: Option, ) { @@ -1796,7 +1798,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() @@ -1864,25 +1866,27 @@ pub fn _archive_round( let fee_model_value: u32 = _read_fee_model(env) as u32; #[allow(deprecated)] + let event_data: soroban_sdk::Vec = soroban_sdk::vec![ + env, + 0u32.into_val(env), + round.round_id.into_val(env), + status_val.into_val(env), + (round.mode.clone() as u32).into_val(env), + round.price_start.into_val(env), + final_price.into_val(env), + round.pool_up.into_val(env), + round.pool_down.into_val(env), + (participant_count as u32).into_val(env), + total_pot.into_val(env), + fee_amount.into_val(env), + settled_at_ledger.into_val(env), + confidence.into_val(env), + status_val.into_val(env), + fee_model_value.into_val(env), + ]; env.events().publish( (symbol_short!("round"), symbol_short!("summary")), - ( - 0u32, - round.round_id, - status_val, - round.mode.clone() as u32, - round.price_start, - final_price, - round.pool_up, - round.pool_down, - participant_count, - total_pot, - fee_amount, - settled_at_ledger, - confidence, - status_val, - fee_model_value, - ), + event_data, ); let mut recent: Vec = env @@ -2248,4 +2252,82 @@ pub fn _update_stats_loss(env: &Env, user: Address) -> Result<(), ContractError> crate::leaderboard::_update_leaderboards(env, user.clone()); crate::leaderboard::_update_season_stats_loss(env, user)?; Ok(()) -} \ No newline at end of file +} + +fn _enforce_heartbeat_health(env: &Env, oracle: &Address) -> 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 { + // Let it pass (override is armed). + } else { + _emit_action_rejected( + env, + oracle, + symbol_short!("resolve"), + ContractError::OracleNotLive, + ); + return Err(ContractError::OracleNotLive); + } + } + } + Ok(()) +} + +/// Deterministically selects the active one-sided settlement policy for a round. +pub fn _select_one_sided_policy(_round: &Round) -> OneSidedPolicy { + OneSidedPolicy::Refund +} + +/// Applies deterministic one-sided settlement policy for degenerate markets. +pub fn _apply_one_sided_policy( + env: &Env, + round: &Round, + policy: OneSidedPolicy, + participants: &Vec
, + positions: &Option>, +) -> Result { + let affected_side: u32 = if round.pool_up > 0 { + 0 + } else if round.pool_down > 0 { + 1 + } else { + 2 + }; + + let (refund_amount, carry_amount) = match policy { + OneSidedPolicy::Refund | OneSidedPolicy::Void => { + if !participants.is_empty() { + _record_refunds_indexed(env, round.round_id, 0, participants)?; + } else if let Some(pos_map) = positions { + _record_refunds_legacy(env, round.round_id, pos_map)?; + } + (round.pool_up + round.pool_down, 0i128) + } + OneSidedPolicy::CarryForward => { + if !participants.is_empty() { + _record_refunds_indexed(env, round.round_id, 0, participants)?; + } else if let Some(pos_map) = positions { + _record_refunds_legacy(env, round.round_id, pos_map)?; + } + (0i128, round.pool_up + round.pool_down) + } + }; + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("pool"), symbol_short!("onesided")), + ( + round.round_id, + policy as u32, + affected_side, + refund_amount, + carry_amount, + round.pool_up, + round.pool_down, + ), + ); + + Ok(0) +} diff --git a/contracts/src/tests/event_coverage.rs b/contracts/src/tests/event_coverage.rs index 77058ba..0dab6bf 100644 --- a/contracts/src/tests/event_coverage.rs +++ b/contracts/src/tests/event_coverage.rs @@ -414,11 +414,21 @@ 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 + + let resolved_event = events.iter().find(|e| { + let (_contract, topics, _data) = e; + 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!("resolved")) + }).unwrap(); + let (_contract, topics, data) = resolved_event; + assert_eq!( + topics.get(1).unwrap().try_into_val(&env), Ok(symbol_short!("resolved")) ); assert_eq!( data.try_into_val(&env), - Ok((1u64, 1_2000000u128, 0u32, Option::::None, 0u32)) + Ok((1u64, 1_2000000u128, 1u32, 0i128, Option::::None)) ); } diff --git a/contracts/src/types.rs b/contracts/src/types.rs index d2ec938..564e985 100644 --- a/contracts/src/types.rs +++ b/contracts/src/types.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT //! Type definitions for the XLM Price Prediction Market. -use soroban_sdk::{contracttype, Address, BytesN, Env, IntoVal, Symbol, Val, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Vec}; /// Round mode for prediction type #[contracttype] @@ -12,15 +12,6 @@ pub enum RoundMode { 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)] @@ -46,24 +37,13 @@ pub enum RoundPhase { Resolvable = 3, } -/// Storage keys for contract data -/// -/// ## Indexed position keys (variants 13–15) +/// Parameterless system, config, and metadata storage keys. /// -/// `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. +/// Split from `DataKey` to stay under the XDR union 50-case limit +/// (`VecM` in stellar-xdr). #[contracttype] #[derive(Clone)] -pub enum DataKey { - Balance(Address), +pub enum DataKeyCore { Admin, Oracle, /// On-chain storage schema version for migration safety. @@ -73,32 +53,17 @@ pub enum DataKey { 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 @@ -118,17 +83,10 @@ pub enum DataKey { 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. @@ -137,8 +95,6 @@ pub enum DataKey { /// 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. @@ -160,36 +116,66 @@ pub enum DataKey { /// 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, + Ext(DataKeyExt), + PrecisionPayoutPolicy, +} + +/// Parameterised and round-scoped storage keys. +/// +/// Split from `DataKey` to stay under the XDR union 50-case limit. +/// These variants carry per-user, per-round, 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), + /// 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), + /// Timelocked pending critical config change keyed by change kind. + PendingConfigChange(ConfigChangeKind), + /// Per-ledger mint counter: wraps the explicit ledger sequence number. + LedgerMintCounter(u32), + /// Compact post-settlement summary keyed by round id for historical queries. + ArchivedRound(u64), + /// 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), + UserArchivedRoundIds(Address), } /// Identifies which critical risk setting is pending timelocked activation. @@ -209,13 +195,14 @@ pub enum ConfigChangeKind { MintLimit = 9, ArchiveRetention = 10, CloseBufferLedgers = 11, - OracleTimestampSkew = 12, EpochMintBudget = 12, PendingWinningsExpiry = 13, - PrecisionPayoutPolicy = 14, - MinBet = 15, - DisputeLedgers = 16, - FeeModel = 17, + DisputeLedgers = 14, + FeeModel = 15, + OracleTimestampSkew = 16, + MinBet = 17, + PrecisionPayoutPolicy = 18, + EarlyCashoutBps = 19, } /// Payload for a scheduled critical config change. @@ -234,13 +221,14 @@ pub enum ConfigChangePayload { MintLimit(u32), ArchiveRetention(u32), CloseBufferLedgers(u32), - OracleTimestampSkew(u64), EpochMintBudget(i128), PendingWinningsExpiry(u32), - PrecisionPayoutPolicy(u32), - MinBet(Option), DisputeLedgers(u32), FeeModel(FeeModel), + OracleTimestampSkew(u64), + MinBet(Option), + PrecisionPayoutPolicy(u32), + EarlyCashoutBps(Option), } /// Pending timelocked config change with activation ledger for on-chain observability. @@ -308,54 +296,9 @@ pub struct OraclePayload { 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] @@ -365,34 +308,18 @@ pub struct OracleHeartbeatRecord { 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 + pub round_id: u64, + pub price_start: u128, + pub start_ledger: u32, + pub bet_end_ledger: u32, + pub end_ledger: u32, + pub pool_up: i128, + pub pool_down: i128, + pub mode: RoundMode, + pub start_timestamp: u64, } /// Aggregated active-round pool composition for frontend transparency. @@ -424,32 +351,72 @@ pub struct RoundPoolStats { #[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, + Resolved, + Cancelled, + FallbackRefund, + Voided, } /// 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 { @@ -465,6 +432,10 @@ pub struct ArchivedRoundSummary { } /// 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 { @@ -474,31 +445,88 @@ pub struct OracleRotationProposal { } /// 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, Voided = 7, } /// 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)] @@ -530,11 +558,16 @@ pub struct SimulationResult { pub pool_down: i128, pub precision_total_stake: i128, pub fee_amount: i128, - pub outcomes: Vec, pub fee_model: u32, + 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 { @@ -559,7 +592,11 @@ pub struct SeasonLeaderboardEntry { pub best_streak: u32, } -/// Frozen snapshot of a season's final bounded rankings. +/// 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 { @@ -570,17 +607,21 @@ pub struct SeasonArchive { pub participant_count: u32, } -/// Configurable pending-winnings expiry in ledgers. + +/// Deterministic settlement policy governing degenerate (one-sided) market rounds. #[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsExpiryKey(pub ()); +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum OneSidedPolicy { + /// Full stake refund to all participants (active protocol policy). + Refund = 0, + /// Void round releasing stakes without mutating stats. + Void = 1, + /// Carry-forward pool stakes to subsequent round (extensibility placeholder). + CarryForward = 2, +} -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); +pub type Policy = OneSidedPolicy; /// Fee incidence model for protocol fees (Issue #268). #[contracttype] @@ -644,1972 +685,119 @@ 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), - } - } +/// Oracle heartbeat health gate configuration (Issue #264) +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct HbGateConfig { + pub strict_mode: bool, + pub grace_seconds: u64, + pub override_armed: bool, } -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. -use soroban_sdk::{contracttype, Address, BytesN, Vec}; +/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). +#[contracttype] +#[derive(Clone)] +pub enum HbGateKey { + Config, +} -/// Round mode for prediction type +/// Multi-feed aggregation payload (Issue #262). #[contracttype] #[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) +pub struct MultiFeedPayload { + pub prices: Vec, + pub sources: Vec, + pub round_id: u32, + pub nonce: u64, + pub network_id: BytesN<32>, + pub contract_addr: Address, + pub timestamp: u64, } -/// Runtime mode for the contract lifecycle +/// Oracle quorum threshold configuration (Issue #262). #[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, +#[derive(Clone, Debug, PartialEq)] +pub struct OracleQuorumConfig { + pub min_observations: u32, + pub quorum_threshold: u32, + pub outlier_threshold_bps: u32, } -/// 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)] +#[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); + +/// Payout calculation policy for precision mode (Issue #265). +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] #[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, +pub enum PrecisionPayoutPolicy { + Equal = 0, // Split winning pool proportionally to stake + StakeWeighted = 1, // Split winnings based on accuracy and stake } -/// 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). +pub enum DataKeyExt { 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, -} +/// Actions protected by dual-approval governance (Issue #272) #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, +pub enum GovAction { + /// Emergency pause of all contract state mutations + PauseProtocol, + /// Unpause contract resuming normal operations + UnpauseProtocol, + /// Update protocol settlement fee in basis points + SetProtocolFeeBps(Option), + /// Withdraw accumulated protocol fees from treasury + WithdrawProtocolFee(Address, i128), + /// Update treasury recipient address + SetTreasuryAddress(Address), + /// Transfer contract primary admin role + SetAdmin(Address), + /// Rotate oracle provider address + SetOracle(Address), } -/// Precision prediction entry (user address + predicted price) +/// Lifecycle status of a dual-approval governance proposal #[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 +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum GovProposalStatus { + Pending = 0, + Approved = 1, + Executed = 2, + Cancelled = 3, + Expired = 4, } +/// Governance proposal record requiring dual approval before execution #[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), - } - } +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, }