diff --git a/contracts/src/access_control.rs b/contracts/src/access_control.rs index 448a9642..4f89f7ae 100644 --- a/contracts/src/access_control.rs +++ b/contracts/src/access_control.rs @@ -189,6 +189,11 @@ pub fn is_allowlisted(env: Env, user: Address) -> bool { .has(&DataKeyScoped::Allowlisted(user)) } +/// Alias for `is_allowlisted`. +pub fn is_user_allowlisted(env: Env, user: Address) -> bool { + is_allowlisted(env, user) +} + /// Returns whether `user` is denylisted (read-only). pub fn is_denylisted(env: Env, user: Address) -> bool { env.storage() @@ -196,6 +201,11 @@ pub fn is_denylisted(env: Env, user: Address) -> bool { .has(&DataKeyScoped::Denylisted(user)) } +/// Alias for `is_denylisted`. +pub fn is_user_denylisted(env: Env, user: Address) -> bool { + is_denylisted(env, user) +} + /// Returns the resolved access state for `user` (read-only). /// /// Denylist takes precedence over allowlist. An address that is neither marked diff --git a/contracts/src/admin.rs b/contracts/src/admin.rs index 4a7eb85f..d750cf8e 100644 --- a/contracts/src/admin.rs +++ b/contracts/src/admin.rs @@ -7,7 +7,7 @@ use crate::common::{ }; use crate::errors::ContractError; use crate::types::{ - AttestationConfig, AttestationConfigKey, DataKey, DataKeyCore, DataKeyExt, + AttestationConfig, AttestationConfigKey, DataKeyCore, DataKeyScoped, DeviationConfig, DeviationConfigKey, DeviationReferenceMode, HbGateConfig, HbGateKey, OracleHeartbeatRecord, OracleQuorumConfig, PolicyAction, ProtocolHealthStatus, Round, RuntimeMode, PENDING_WINNINGS_EXPIRY_KEY, PendingWinningsUpdatedAtKey, @@ -827,6 +827,12 @@ pub fn get_protocol_health(env: Env) -> ProtocolHealthStatus { issues += 1; } + let access_restricted: bool = env + .storage() + .persistent() + .get(&DataKeyCore::AccessControlEnabled) + .unwrap_or(false); + let status_code = if paused { 1u32 // PAUSED } else if issues > 1 { @@ -837,6 +843,8 @@ pub fn get_protocol_health(env: Env) -> ProtocolHealthStatus { 3u32 // ROUND_STALE } else if !has_active_round { 4u32 // NO_ACTIVE_ROUND + } else if access_restricted { + 6u32 // ACCESS_RESTRICTED } else { 0u32 // HEALTHY }; @@ -932,6 +940,7 @@ pub fn _policy_gate(env: &Env, action: PolicyAction) -> Result<(), ContractError PolicyAction::Claim | PolicyAction::AdminConfig | PolicyAction::Settlement => { mode == RuntimeMode::FullyPaused } + _ => mode == RuntimeMode::FullyPaused, }; if blocked { return Err(ContractError::ContractPaused); @@ -1000,11 +1009,11 @@ pub fn _is_ttl_touch_allowed(key: &DataKeyCore) -> bool { | DataKeyCore::MigratedToV3 | DataKeyCore::ArchiveRetention | DataKeyCore::RoundTemplate - | DataKeyCore::Ext(DataKeyExt::LeaderboardWins) - | DataKeyCore::Ext(DataKeyExt::LeaderboardStreak) - | DataKeyCore::Ext(DataKeyExt::SeasonId) - | DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardWins) - | DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardStreak) + | DataKeyCore::LeaderboardWins + | DataKeyCore::LeaderboardStreak + | DataKeyCore::SeasonId + | DataKeyCore::SeasonLeaderboardWins + | DataKeyCore::SeasonLeaderboardStreak | DataKeyCore::LastRoundId | DataKeyCore::OracleRotationProposal | DataKeyCore::MintLimitConfig @@ -1044,9 +1053,9 @@ pub fn batch_touch_ttl(env: Env, keys: Vec) -> Result Result<(), Co if cfg.min_observations < DEFAULT_ORACLE_QUORUM_MIN_OBSERVATIONS || cfg.min_observations > MAX_ORACLE_OBSERVATIONS { - return Err(ContractError::TooFewObservations); + return Err(ContractError::InvalidMinParticipants); } if cfg.quorum_threshold < DEFAULT_ORACLE_QUORUM_THRESHOLD || cfg.quorum_threshold > cfg.min_observations { - return Err(ContractError::InsufficientOracleQuorum); + return Err(ContractError::InvalidMinParticipants); } if cfg.outlier_threshold_bps == 0 || cfg.outlier_threshold_bps > 10_000 { return Err(ContractError::WindowOutOfRange); @@ -1201,22 +1210,22 @@ pub fn reclaim_expired_pending_winnings(env: Env, user: Address) -> Result Result Result i128 { } } _ => { - soroban_sdk::panic_with_error!(&env, ContractError::EpochBudgetExceeded); + soroban_sdk::panic_with_error!(&env, ContractError::MintLimitExceeded); } } } diff --git a/contracts/src/common.rs b/contracts/src/common.rs index af8b9ace..05057e78 100644 --- a/contracts/src/common.rs +++ b/contracts/src/common.rs @@ -1,17 +1,13 @@ // SPDX-License-Identifier: MIT -extern crate alloc; -use alloc::vec::Vec as StdVec; use crate::errors::ContractError; use crate::types::{ - ConfigChangeKind, ConfigChangePayload, DataKey, PendingWinningsUpdatedAtKey, Round, RoundPhase, + ConfigChangeKind, ConfigChangePayload, DataKeyCore, DataKeyScoped, PendingWinningsUpdatedAtKey, Round, RoundPhase, }; use soroban_sdk::{symbol_short, Address, Env, IntoVal, Symbol, Val, Vec}; pub const DEFAULT_PENDING_WINNINGS_EXPIRY: u32 = 0; // 0 = disabled pub const MIN_PENDING_WINNINGS_EXPIRY: u32 = 128; // ~10 min at 5s ledgers pub const MAX_PENDING_WINNINGS_EXPIRY: u32 = 1_000_000; // ~58 days -use crate::types::{ConfigChangeKind, ConfigChangePayload, DataKeyCore, DataKeyScoped, Round, RoundPhase}; -use soroban_sdk::{symbol_short, Address, Env, IntoVal, Symbol, Val, Vec}; // ─── DataKey overflow workaround (DataKey has 51 variants, XDR limit is 50) ── // Moved out of DataKey to get under the limit. @@ -58,6 +54,7 @@ pub const DEFAULT_CLOSE_BUFFER_LEDGERS: u32 = 0; pub const MAX_BET_WINDOW_LEDGERS: u32 = 1_440; pub const MAX_RUN_WINDOW_LEDGERS: u32 = 2_880; pub const MAX_CLOSE_BUFFER_LEDGERS: u32 = 1_440; +pub const DEFAULT_GOV_PROPOSAL_TTL_LEDGERS: u32 = 100; // ─── Oracle deviation guardrails ───────────────────────────────────────────── pub const MAX_ORACLE_DEVIATION_BPS: u32 = 100_000; @@ -126,22 +123,26 @@ pub fn sort_addresses(addresses: Vec
) -> Vec
{ if addresses.len() <= 1 { return addresses; } - let mut native_vec: StdVec
= StdVec::with_capacity(addresses.len() as usize); - for addr in addresses.iter() { - native_vec.push(addr); - } - native_vec.sort_unstable(); let mut sorted = Vec::new(addresses.env()); - for addr in native_vec { - sorted.push_back(addr); + for addr in addresses.iter() { + let mut inserted = false; + for i in 0..sorted.len() { + if addr < sorted.get(i).unwrap() { + sorted.insert(i, addr.clone()); + inserted = true; + break; + } + } + if !inserted { + sorted.push_back(addr); + } } sorted } /// Accumulates `amount` into a user's pending winnings, enforcing the cap if set (Issue #120). pub fn _accumulate_pending(env: &Env, user: Address, amount: i128) -> Result<(), ContractError> { - let key = DataKey::PendingWinnings(user.clone()); - let key = DataKeyScoped::PendingWinnings(user); + let key = DataKeyScoped::PendingWinnings(user.clone()); let existing: i128 = env.storage().persistent().get(&key).unwrap_or(0); let new_pending = payout_add(existing, amount)?; @@ -204,7 +205,7 @@ pub fn _derive_round_phase(ledger_sequence: u32, round: &Round) -> RoundPhase { pub fn _enforce_min_bet(env: &Env, amount: i128) -> Result<(), ContractError> { if let Some(min_bet) = env.storage().persistent().get::<_, i128>(&DataKeyCore::MinBet) { if amount < min_bet { - return Err(ContractError::BelowMinBet); + return Err(ContractError::InvalidBetAmount); } } Ok(()) diff --git a/contracts/src/config.rs b/contracts/src/config.rs index 09dc0e8c..11af374f 100644 --- a/contracts/src/config.rs +++ b/contracts/src/config.rs @@ -1,29 +1,21 @@ // SPDX-License-Identifier: MIT use crate::admin::{_ensure_normal_mode, _ensure_not_paused, _require_supported_schema}; use crate::common::{ - _emit_action_rejected, _emit_config_updated, _extend_persistent_ttl, _set_balance, balance, - payout_add, BPS_DENOMINATOR, CONFIG_TIMELOCK_LEDGERS, DEFAULT_ARCHIVE_RETENTION, - DEFAULT_BET_WINDOW_LEDGERS, DEFAULT_CLOSE_BUFFER_LEDGERS, DEFAULT_MAX_PRECISION_PARTICIPANTS, - DEFAULT_ORACLE_STALE_THRESHOLD, DEFAULT_ORACLE_TIMESTAMP_SKEW, DEFAULT_RUN_WINDOW_LEDGERS, - MAX_ARCHIVE_RETENTION, MAX_BET_WINDOW_LEDGERS, MAX_CLOSE_BUFFER_LEDGERS, MAX_MIN_PARTICIPANTS, - MAX_ORACLE_DEVIATION_BPS, MAX_ORACLE_STALE_THRESHOLD, MAX_ORACLE_TIMESTAMP_SKEW, - MAX_PRECISION_PARTICIPANTS_LIMIT, MAX_PROTOCOL_FEE_BPS, MAX_RUN_WINDOW_LEDGERS, - MAX_START_PRICE, MIN_ARCHIVE_RETENTION, MIN_CAP_VALUE, MIN_ORACLE_STALE_THRESHOLD, - MIN_ORACLE_TIMESTAMP_SKEW, MIN_START_PRICE, _emit_action_rejected, _emit_config_updated, _extend_persistent_ttl, _extend_ttl_symbol, _set_balance, balance, payout_add, BPS_DENOMINATOR, CONFIG_TIMELOCK_LEDGERS, DEFAULT_ARCHIVE_RETENTION, DEFAULT_BET_WINDOW_LEDGERS, DEFAULT_CLOSE_BUFFER_LEDGERS, DEFAULT_DISPUTE_LEDGERS, DEFAULT_MAX_PRECISION_PARTICIPANTS, DEFAULT_ORACLE_STALE_THRESHOLD, - DEFAULT_PENDING_WINNINGS_EXPIRY, DEFAULT_RUN_WINDOW_LEDGERS, MAX_ARCHIVE_RETENTION, - MAX_BET_WINDOW_LEDGERS, MAX_CLOSE_BUFFER_LEDGERS, MAX_DISPUTE_LEDGERS, MAX_MIN_PARTICIPANTS, - MAX_ORACLE_DEVIATION_BPS, MAX_ORACLE_STALE_THRESHOLD, MAX_PENDING_WINNINGS_EXPIRY, - MAX_PRECISION_PARTICIPANTS_LIMIT, MAX_PROTOCOL_FEE_BPS, MAX_RUN_WINDOW_LEDGERS, - MAX_START_PRICE, MIN_ARCHIVE_RETENTION, MIN_CAP_VALUE, MIN_ORACLE_STALE_THRESHOLD, + DEFAULT_ORACLE_TIMESTAMP_SKEW, DEFAULT_PENDING_WINNINGS_EXPIRY, DEFAULT_RUN_WINDOW_LEDGERS, + MAX_ARCHIVE_RETENTION, MAX_BET_WINDOW_LEDGERS, MAX_CLOSE_BUFFER_LEDGERS, MAX_DISPUTE_LEDGERS, + MAX_MIN_PARTICIPANTS, MAX_ORACLE_DEVIATION_BPS, MAX_ORACLE_STALE_THRESHOLD, + MAX_ORACLE_TIMESTAMP_SKEW, MAX_PENDING_WINNINGS_EXPIRY, MAX_PRECISION_PARTICIPANTS_LIMIT, + MAX_PROTOCOL_FEE_BPS, MAX_RUN_WINDOW_LEDGERS, MAX_START_PRICE, MIN_ARCHIVE_RETENTION, + MIN_CAP_VALUE, MIN_ORACLE_STALE_THRESHOLD, MIN_ORACLE_TIMESTAMP_SKEW, MIN_PENDING_WINNINGS_EXPIRY, MIN_START_PRICE, }; use crate::errors::ContractError; use crate::types::{ - ConfigChangeKind, ConfigChangePayload, DataKey, DataKeyCore, DataKeyScoped, FeeModel, + ConfigChangeKind, ConfigChangePayload, DataKeyCore, DataKeyScoped, FeeModel, PendingConfigChange, PrecisionPayoutPolicy, RoundTemplate, PENDING_WINNINGS_EXPIRY_KEY, }; use soroban_sdk::{symbol_short, Address, Env, Symbol}; @@ -847,6 +839,7 @@ pub fn set_early_cashout_bps(env: Env, bps: Option) -> Result<(), ContractE } let key = DataKeyCore::EarlyCashoutBps; + let old_bps: Option = env.storage().persistent().get(&key); if let Some(v) = bps { env.storage().persistent().set(&key, &v); _extend_persistent_ttl(&env, &key); @@ -1272,6 +1265,9 @@ pub fn _current_config_payload(env: &Env, kind: &ConfigChangeKind) -> ConfigChan .unwrap_or(DEFAULT_DISPUTE_LEDGERS), ), ConfigChangeKind::FeeModel => ConfigChangePayload::FeeModel(_read_fee_model(env)), + ConfigChangeKind::EarlyCashoutBps => ConfigChangePayload::EarlyCashoutBps( + env.storage().persistent().get(&DataKeyCore::EarlyCashoutBps), + ), } } @@ -1519,6 +1515,16 @@ pub fn _apply_config_payload( env.storage().persistent().set(&key, max); _extend_persistent_ttl(env, &key); } + (ConfigChangeKind::EarlyCashoutBps, ConfigChangePayload::EarlyCashoutBps(bps)) => { + let key = DataKeyCore::EarlyCashoutBps; + if let Some(v) = bps { + env.storage().persistent().set(&key, v); + _extend_persistent_ttl(env, &key); + } else { + env.storage().persistent().remove(&key); + } + } + _ => {} } _emit_config_updated(env, kind.clone(), old_value, payload.clone()); Ok(()) diff --git a/contracts/src/contract.rs b/contracts/src/contract.rs index 84e00e9f..ba9282ac 100644 --- a/contracts/src/contract.rs +++ b/contracts/src/contract.rs @@ -8,13 +8,13 @@ use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Ma use crate::errors::ContractError; use crate::governance; use crate::types::{ - ArchivedRoundSummary, BetSide, ConfigChangeKind, ConfigChangePayload, DataKeyCore, - DataKeyScoped, DeviationReferenceMode, LeaderboardEntry, MultiFeedPayload, OracleHeartbeatRecord, - OraclePayload, OracleQuorumConfig, OracleRotationProposal, PendingConfigChange, - PolicyAction, PrecisionPrediction, PriceSample, ProtocolHealthStatus, ProtocolStatus, Round, - RoundArchiveStatus, RoundPhase, RoundPoolStats, RoundStatus, RoundTemplate, RuntimeMode, - SeasonArchive, SeasonLeaderboardEntry, SimulationResult, UserPosition, - UserRoundOutcome, UserStats, + AccessState, ArchivedRoundSummary, BetSide, ConfigChangeKind, ConfigChangePayload, DataKeyCore, + DataKeyScoped, DeviationReferenceMode, FeeModel, GovAction, GovProposal, GovProposalStatus, + LeaderboardEntry, MultiFeedPayload, OneSidedPolicy, OracleHeartbeatRecord, OraclePayload, + OracleQuorumConfig, OracleRotationProposal, PendingConfigChange, PolicyAction, PrecisionPrediction, + PriceSample, ProtocolHealthStatus, ProtocolStatus, Round, RoundArchiveStatus, RoundPhase, + RoundPoolStats, RoundStatus, RoundTemplate, RuntimeMode, SeasonArchive, SeasonLeaderboardEntry, + SimulationResult, UserPosition, UserRoundOutcome, UserStats, }; // ─── Economic control limits ───────────────────────────────────────────────── @@ -88,6 +88,7 @@ const MAX_ARCHIVE_RETENTION: u32 = 10_000; /// Ledgers to wait before a scheduled critical config change may be applied (~2 hours). const CONFIG_TIMELOCK_LEDGERS: u32 = 1440; +use crate::access_control; use crate::admin; use crate::betting; use crate::common; @@ -106,6 +107,68 @@ impl VirtualTokenContract { admin::initialize(env, admin, oracle) } + // ─── Participant Access Control (Issue #274 / #392) ───────────────────── + + /// Turns participant access control on (`true`) or off (`false`) (admin only). + pub fn set_access_control_enabled(env: Env, enabled: bool) -> Result<(), ContractError> { + access_control::set_access_control_enabled(env, enabled) + } + + /// Returns whether allowlist mode is currently enabled (`false` = open). + pub fn is_access_control_enabled(env: Env) -> bool { + access_control::is_access_control_enabled(env) + } + + /// Adds `user` to the allowlist and clears any stale denylist entry (admin only). + pub fn add_allowlisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::add_allowlisted(env, user) + } + + /// Removes `user` from the allowlist (admin only). + pub fn remove_allowlisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::remove_allowlisted(env, user) + } + + /// Adds `user` to the denylist and clears any stale allowlist entry (admin only). + pub fn add_denylisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::add_denylisted(env, user) + } + + /// Removes `user` from the denylist (admin only). + pub fn remove_denylisted(env: Env, user: Address) -> Result<(), ContractError> { + access_control::remove_denylisted(env, user) + } + + /// Returns whether `user` is allowlisted (read-only). + pub fn is_allowlisted(env: Env, user: Address) -> bool { + access_control::is_allowlisted(env, user) + } + + /// Alias for `is_allowlisted`. + pub fn is_user_allowlisted(env: Env, user: Address) -> bool { + access_control::is_user_allowlisted(env, user) + } + + /// Returns whether `user` is denylisted (read-only). + pub fn is_denylisted(env: Env, user: Address) -> bool { + access_control::is_denylisted(env, user) + } + + /// Alias for `is_denylisted`. + pub fn is_user_denylisted(env: Env, user: Address) -> bool { + access_control::is_user_denylisted(env, user) + } + + /// Returns the resolved access state for `user` (read-only). + pub fn get_access_state(env: Env, user: Address) -> AccessState { + access_control::get_access_state(env, user) + } + + /// Returns a human-facing policy summary: (allowlist enabled, user state). + pub fn get_access_policy(env: Env, user: Address) -> (bool, AccessState) { + access_control::get_access_policy(env, user) + } + /// Returns the stored schema version. If unset, returns legacy version 1. pub fn get_schema_version(env: Env) -> u32 { admin::get_schema_version(env) @@ -526,7 +589,7 @@ impl VirtualTokenContract { earliest_accept, ), ); - return Err(ContractError::RotationDelayNotElapsed); + return Err(ContractError::WindowOutOfRange); } if current_ts > proposal.expires_at { @@ -1059,24 +1122,12 @@ impl VirtualTokenContract { config::get_dispute_ledgers(&env) } - /// Anyone may call `void_round` during the dispute window to refund all - /// participants their full stakes (void-to-refund path). - pub fn void_round(env: Env, round_id: u64) -> Result<(), ContractError> { - settlement::void_round(env, round_id) - } - - /// 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) - } - pub fn get_active_round(env: Env) -> Option { queries::get_active_round(env) } 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 a3fa8683..3aeb140f 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -1,92 +1,61 @@ -// SPDX-License-Identifier: MIT -//! Contract error types for the XLM Price Prediction Market. - -use soroban_sdk::contracterror; - -/// Contract error types -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ContractError { - AlreadyInitialized = 1, - AdminNotSet = 2, - OracleNotSet = 3, - InvalidBetAmount = 6, - NoActiveRound = 7, - RoundEnded = 8, - InsufficientBalance = 9, - AlreadyBet = 10, - Overflow = 11, - InvalidPrice = 12, - InvalidDuration = 13, - InvalidMode = 14, - WrongModeForPrediction = 15, - RoundNotEnded = 16, - StaleOracleData = 18, - InvalidOracleRound = 19, - RoundAlreadyActive = 20, - ContractPaused = 22, - WindowOutOfRange = 23, - FutureOracleData = 24, - PayoutOverflow = 25, - RoundNotCancellable = 27, - StakeExceedsMax = 28, - ExposureCapExceeded = 29, - PendingWinningsCapExceeded = 30, - InvalidStartPrice = 31, - OracleNonceReused = 33, - InvalidMinParticipants = 35, - InvalidPrecisionCap = 38, - PrecisionCapExceeded = 39, - OracleDeviationExceeded = 41, - UnsupportedSchemaVersion = 42, - MigrationActiveRound = 44, - CommitmentNotFound = 45, - AlreadyRevealed = 46, - InvalidRevealWindow = 47, - HashMismatch = 48, - OracleNetworkMismatch = 49, - InvalidProtocolFeeBps = 51, - MintLimitExceeded = 53, - NoPendingRotation = 54, - /// Oracle rotation delay has not elapsed yet (must wait MIN_ROTATION_DELAY_SECONDS) - RotationDelayNotElapsed = 55, - /// Invalid archive retention limit - InvalidArchiveRetention = 62, - InvalidCommitment = 63, - InvalidSalt = 64, - NoRoundTemplate = 65, - /// Oracle payload timestamp is outside the round-relative economic window - OracleTimestampOutsideWindow = 66, - /// Pending winnings entry exists but has not yet reached the configured - /// expiry threshold — caller must wait before reclaiming. - PendingWinningsNotExpired = 66, - /// Epoch mint budget has been fully consumed - EpochBudgetExceeded = 67, - /// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) - OracleNotLive = 68, - /// Invalid precision payout policy - InvalidPayoutPolicy = 69, - /// Stake amount is below the configured minimum bet (dust protection, Issue #269) - BelowMinBet = 70, - /// Multi-feed resolution: fewer observations survived outlier rejection - /// than the configured quorum threshold. - InsufficientOracleQuorum = 71, - /// Multi-feed resolution: payload contains fewer observations than the - /// configured minimum. - TooFewObservations = 72, - /// Multi-feed resolution: outlier observations would dominate the result - /// (too many rejected, cannot form quorum). - OracleOutlierRejected = 73, - /// Multi-feed payload contains duplicate source identifiers. - DuplicateOracleSource = 74, - /// Multi-feed payload has observations that are not sorted or sources - /// are out of expected range. - InvalidObservationOrder = 75, - /// The requested data key is not allowed for batch TTL touch operations. - UnsupportedDataKeyForTtlTouch = 76, - /// Pending winnings entry does not exist or expiry is not configured. - PendingWinningsNotFound = 77, - /// Pending winnings expiry is not configured (value is 0). - ExpiryNotConfigured = 78, -} +// SPDX-License-Identifier: MIT +//! Contract error types for the XLM Price Prediction Market. + +use soroban_sdk::contracterror; + +/// Contract error types +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ContractError { + AlreadyInitialized = 1, + AdminNotSet = 2, + OracleNotSet = 3, + InvalidBetAmount = 6, + NoActiveRound = 7, + RoundEnded = 8, + InsufficientBalance = 9, + AlreadyBet = 10, + Overflow = 11, + InvalidPrice = 12, + InvalidDuration = 13, + InvalidMode = 14, + WrongModeForPrediction = 15, + RoundNotEnded = 16, + StaleOracleData = 18, + InvalidOracleRound = 19, + RoundAlreadyActive = 20, + ContractPaused = 22, + WindowOutOfRange = 23, + FutureOracleData = 24, + PayoutOverflow = 25, + RoundNotCancellable = 27, + StakeExceedsMax = 28, + ExposureCapExceeded = 29, + PendingWinningsCapExceeded = 30, + InvalidStartPrice = 31, + OracleNonceReused = 33, + InvalidMinParticipants = 35, + InvalidPrecisionCap = 38, + PrecisionCapExceeded = 39, + OracleDeviationExceeded = 41, + UnsupportedSchemaVersion = 42, + MigrationActiveRound = 44, + CommitmentNotFound = 45, + AlreadyRevealed = 46, + InvalidRevealWindow = 47, + HashMismatch = 48, + OracleNetworkMismatch = 49, + InvalidProtocolFeeBps = 51, + MintLimitExceeded = 53, + NoPendingRotation = 54, + InvalidArchiveRetention = 62, + InvalidCommitment = 63, + NoRoundTemplate = 65, + OracleNotLive = 66, + ProposalNotFound = 67, + ProposalExpired = 68, + GovInvalidState = 69, + GovUnauthorized = 70, + AccessDenied = 80, +} diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs index 07a4d26b..2fec6006 100644 --- a/contracts/src/governance.rs +++ b/contracts/src/governance.rs @@ -4,18 +4,18 @@ use crate::admin::{_require_supported_schema, _set_mode}; use crate::common::{_emit_action_rejected, _extend_persistent_ttl, DEFAULT_GOV_PROPOSAL_TTL_LEDGERS}; use crate::errors::ContractError; -use crate::types::{DataKey, GovAction, GovProposal, GovProposalStatus, RuntimeMode}; +use crate::types::{DataKeyCore, DataKeyScoped, GovAction, GovProposal, GovProposalStatus, RuntimeMode}; use soroban_sdk::{symbol_short, Address, Env}; /// Returns whether `user` is an authorized governance administrator or approver. pub fn _is_authorized_gov_user(env: &Env, user: &Address) -> bool { - let admin: Option
= env.storage().persistent().get(&DataKey::Admin); + let admin: Option
= env.storage().persistent().get(&DataKeyCore::Admin); if let Some(ref a) = admin { if a == user { return true; } } - let approver: Option
= env.storage().persistent().get(&DataKey::GovApprover); + let approver: Option
= env.storage().persistent().get(&DataKeyCore::GovApprover); if let Some(ref ap) = approver { if ap == user { return true; @@ -26,7 +26,7 @@ pub fn _is_authorized_gov_user(env: &Env, user: &Address) -> bool { /// Returns whether dual governance approval is currently active (a secondary approver is set). pub fn _is_gov_approver_set(env: &Env) -> bool { - let key = DataKey::GovApprover; + let key = DataKeyCore::GovApprover; if env.storage().persistent().has(&key) { _extend_persistent_ttl(env, &key); true @@ -41,11 +41,11 @@ pub fn set_gov_approver(env: Env, approver: Address) -> Result<(), ContractError let admin: Address = env .storage() .persistent() - .get(&DataKey::Admin) + .get(&DataKeyCore::Admin) .ok_or(ContractError::AdminNotSet)?; admin.require_auth(); - let key = DataKey::GovApprover; + let key = DataKeyCore::GovApprover; env.storage().persistent().set(&key, &approver); _extend_persistent_ttl(&env, &key); @@ -60,7 +60,7 @@ pub fn set_gov_approver(env: Env, approver: Address) -> Result<(), ContractError /// Returns the configured secondary governance approver address, if set. pub fn get_gov_approver(env: Env) -> Option
{ - let key = DataKey::GovApprover; + let key = DataKeyCore::GovApprover; _extend_persistent_ttl(&env, &key); env.storage().persistent().get(&key) } @@ -71,7 +71,7 @@ pub fn set_gov_proposal_ttl(env: Env, ttl_ledgers: u32) -> Result<(), ContractEr let admin: Address = env .storage() .persistent() - .get(&DataKey::Admin) + .get(&DataKeyCore::Admin) .ok_or(ContractError::AdminNotSet)?; admin.require_auth(); @@ -79,7 +79,7 @@ pub fn set_gov_proposal_ttl(env: Env, ttl_ledgers: u32) -> Result<(), ContractEr return Err(ContractError::WindowOutOfRange); } - let key = DataKey::GovProposalTtlLedgers; + let key = DataKeyCore::GovProposalTtlLedgers; env.storage().persistent().set(&key, &ttl_ledgers); _extend_persistent_ttl(&env, &key); Ok(()) @@ -87,7 +87,7 @@ pub fn set_gov_proposal_ttl(env: Env, ttl_ledgers: u32) -> Result<(), ContractEr /// Returns the configured default proposal TTL in ledgers. pub fn get_gov_proposal_ttl(env: Env) -> u32 { - let key = DataKey::GovProposalTtlLedgers; + let key = DataKeyCore::GovProposalTtlLedgers; _extend_persistent_ttl(&env, &key); env.storage() .persistent() @@ -128,7 +128,7 @@ pub fn propose( return Err(ContractError::GovUnauthorized); } - let id_key = DataKey::NextGovProposalId; + let id_key = DataKeyCore::NextGovProposalId; let proposal_id: u64 = env.storage().persistent().get(&id_key).unwrap_or(1); env.storage().persistent().set(&id_key, &(proposal_id + 1)); _extend_persistent_ttl(&env, &id_key); @@ -147,7 +147,7 @@ pub fn propose( status: GovProposalStatus::Pending, }; - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); env.storage().persistent().set(&p_key, &proposal); _extend_persistent_ttl(&env, &p_key); @@ -175,7 +175,7 @@ pub fn approve(env: Env, approver: Address, proposal_id: u64) -> Result<(), Cont return Err(ContractError::GovUnauthorized); } - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); let mut proposal: GovProposal = env .storage() .persistent() @@ -243,7 +243,7 @@ pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), Cont return Err(ContractError::GovUnauthorized); } - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); let mut proposal: GovProposal = env .storage() .persistent() @@ -282,7 +282,7 @@ pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), Cont } GovAction::SetProtocolFeeBps(bps) => { crate::config::_validate_protocol_fee_bps(bps.clone())?; - let key = DataKey::ProtocolFeeBps; + let key = DataKeyCore::ProtocolFeeBps; if let Some(ref v) = bps { env.storage().persistent().set(&key, v); _extend_persistent_ttl(&env, &key); @@ -294,16 +294,16 @@ pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), Cont _execute_withdraw_fee(&env, &recipient, *amount)?; } GovAction::SetTreasuryAddress(treasury) => { - env.storage().persistent().set(&DataKey::ProtocolFeeTreasury, &treasury); - _extend_persistent_ttl(&env, &DataKey::ProtocolFeeTreasury); + env.storage().persistent().set(&DataKeyCore::ProtocolFeeTreasury, &treasury); + _extend_persistent_ttl(&env, &DataKeyCore::ProtocolFeeTreasury); } GovAction::SetAdmin(new_admin) => { - env.storage().persistent().set(&DataKey::Admin, &new_admin); - _extend_persistent_ttl(&env, &DataKey::Admin); + env.storage().persistent().set(&DataKeyCore::Admin, &new_admin); + _extend_persistent_ttl(&env, &DataKeyCore::Admin); } GovAction::SetOracle(new_oracle) => { - env.storage().persistent().set(&DataKey::Oracle, &new_oracle); - _extend_persistent_ttl(&env, &DataKey::Oracle); + env.storage().persistent().set(&DataKeyCore::Oracle, &new_oracle); + _extend_persistent_ttl(&env, &DataKeyCore::Oracle); } } @@ -329,7 +329,7 @@ fn _execute_withdraw_fee( if amount <= 0 { return Err(ContractError::InvalidBetAmount); } - let treasury_key = DataKey::ProtocolFeeTreasury; + let treasury_key = DataKeyCore::ProtocolFeeTreasury; let current: i128 = env.storage().persistent().get(&treasury_key).unwrap_or(0); if amount > current { return Err(ContractError::InsufficientBalance); @@ -368,7 +368,7 @@ pub fn cancel(env: Env, canceller: Address, proposal_id: u64) -> Result<(), Cont return Err(ContractError::GovUnauthorized); } - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); let mut proposal: GovProposal = env .storage() .persistent() @@ -396,7 +396,7 @@ pub fn cancel(env: Env, canceller: Address, proposal_id: u64) -> Result<(), Cont /// Queries details for a governance proposal. pub fn get_gov_proposal(env: Env, proposal_id: u64) -> Option { - let p_key = DataKey::GovProposal(proposal_id); + let p_key = DataKeyScoped::GovProposal(proposal_id); _extend_persistent_ttl(&env, &p_key); let mut proposal: GovProposal = env.storage().persistent().get(&p_key)?; diff --git a/contracts/src/leaderboard.rs b/contracts/src/leaderboard.rs index 66040043..4adaa63f 100644 --- a/contracts/src/leaderboard.rs +++ b/contracts/src/leaderboard.rs @@ -29,7 +29,7 @@ use crate::common::{ }; use crate::errors::ContractError; use crate::types::{ - DataKeyCore, DataKeyExt, DataKeyScoped, LeaderboardEntry, SeasonArchive, + DataKeyCore, DataKeyScoped, LeaderboardEntry, SeasonArchive, SeasonLeaderboardEntry, UserStats, }; use soroban_sdk::{symbol_short, Address, Env, Vec}; @@ -147,7 +147,7 @@ fn without_user(env: &Env, list: &Vec
, user: &Address) -> Vec
/// **after** the lifetime `UserStats` write, so the freshly-updated totals /// are what gets ranked. pub fn _update_leaderboards(env: &Env, user: Address) { - let wins_key = DataKeyCore::Ext(DataKeyExt::LeaderboardWins); + let wins_key = DataKeyCore::LeaderboardWins; let wins_list: Vec
= env .storage() .persistent() @@ -158,7 +158,7 @@ pub fn _update_leaderboards(env: &Env, user: Address) { let sorted = reinsert_sorted_by_wins(env, candidates, |addr| lifetime_user_stats(env, addr)); upsert_bounded_index(env, &wins_key, sorted); - let streak_key = DataKeyCore::Ext(DataKeyExt::LeaderboardStreak); + let streak_key = DataKeyCore::LeaderboardStreak; let streak_list: Vec
= env .storage() .persistent() @@ -177,7 +177,7 @@ pub fn get_leaderboard_by_wins(env: Env, offset: u32, limit: u32) -> Vec = env .storage() @@ -208,7 +208,7 @@ pub fn get_leaderboard_by_streak(env: Env, offset: u32, limit: u32) -> Vec = env .storage() @@ -237,13 +237,13 @@ pub fn get_leaderboard_by_streak(env: Env, offset: u32, limit: u32) -> Vec u32 { env.storage() .persistent() - .get(&DataKeyCore::Ext(DataKeyExt::SeasonId)) + .get(&DataKeyCore::SeasonId) .unwrap_or(1) } /// Returns the id of the currently-active leaderboard season (default 1). pub fn get_current_season_id(env: Env) -> u32 { - let key = DataKeyCore::Ext(DataKeyExt::SeasonId); + let key = DataKeyCore::SeasonId; _extend_persistent_ttl(&env, &key); _current_season_id(&env) } @@ -263,7 +263,7 @@ pub fn get_season_user_stats(env: Env, season_id: u32, user: Address) -> UserSta } fn _update_season_leaderboards(env: &Env, season_id: u32, user: Address) { - let wins_key = DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardWins); + let wins_key = DataKeyCore::SeasonLeaderboardWins; let wins_list: Vec
= env .storage() .persistent() @@ -276,7 +276,7 @@ fn _update_season_leaderboards(env: &Env, season_id: u32, user: Address) { }); upsert_bounded_index(env, &wins_key, sorted); - let streak_key = DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardStreak); + let streak_key = DataKeyCore::SeasonLeaderboardStreak; let streak_list: Vec
= env .storage() .persistent() @@ -371,12 +371,12 @@ pub fn reset_leaderboard_season(env: Env) -> Result { let wins_list: Vec
= env .storage() .persistent() - .get(&DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardWins)) + .get(&DataKeyCore::SeasonLeaderboardWins) .unwrap_or(Vec::new(&env)); let streak_list: Vec
= env .storage() .persistent() - .get(&DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardStreak)) + .get(&DataKeyCore::SeasonLeaderboardStreak) .unwrap_or(Vec::new(&env)); let mut wins_entries: Vec = Vec::new(&env); @@ -432,16 +432,16 @@ pub fn reset_leaderboard_season(env: Env) -> Result { _extend_persistent_ttl(&env, &archive_key); let new_season_id = season_id.checked_add(1).ok_or(ContractError::Overflow)?; - let season_key = DataKeyCore::Ext(DataKeyExt::SeasonId); + let season_key = DataKeyCore::SeasonId; env.storage().persistent().set(&season_key, &new_season_id); _extend_persistent_ttl(&env, &season_key); env.storage() .persistent() - .remove(&DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardWins)); + .remove(&DataKeyCore::SeasonLeaderboardWins); env.storage() .persistent() - .remove(&DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardStreak)); + .remove(&DataKeyCore::SeasonLeaderboardStreak); #[allow(deprecated)] env.events().publish( @@ -476,7 +476,7 @@ pub fn get_season_leaderboard_by_wins( } if season_id == _current_season_id(&env) { - let key = DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardWins); + let key = DataKeyCore::SeasonLeaderboardWins; _extend_persistent_ttl(&env, &key); let list: Vec
= env .storage() @@ -522,7 +522,7 @@ pub fn get_season_leaderboard_by_streak( } if season_id == _current_season_id(&env) { - let key = DataKeyCore::Ext(DataKeyExt::SeasonLeaderboardStreak); + let key = DataKeyCore::SeasonLeaderboardStreak; _extend_persistent_ttl(&env, &key); let list: Vec
= env .storage() diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 9bfb5de0..dd6386c3 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -11,18 +11,19 @@ //! - Comprehensive error handling #![no_std] -extern crate alloc; #[cfg(test)] extern crate std; +pub mod access_control; mod admin; mod betting; pub mod common; mod config; mod contract; mod errors; +pub mod governance; mod leaderboard; mod queries; mod settlement; @@ -37,8 +38,8 @@ mod tests; pub use contract::VirtualTokenContract; pub use errors::ContractError; pub use types::{ - ArchivedRoundSummary, BetSide, ConfigChangeKind, ConfigChangePayload, DataKeyCore, DataKeyScoped, - LeaderboardEntry, OracleRotationProposal, PendingConfigChange, PrecisionCommitment, + AccessState, ArchivedRoundSummary, BetSide, ConfigChangeKind, ConfigChangePayload, DataKeyCore, + DataKeyScoped, LeaderboardEntry, OracleRotationProposal, PendingConfigChange, PrecisionCommitment, PrecisionPrediction, ProtocolHealthStatus, Round, RoundArchiveStatus, RoundTemplate, SeasonArchive, SeasonLeaderboardEntry, UserPosition, UserStats, }; diff --git a/contracts/src/queries.rs b/contracts/src/queries.rs index 419b2172..dd725095 100644 --- a/contracts/src/queries.rs +++ b/contracts/src/queries.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT use crate::common::{ - _derive_round_phase, _extend_persistent_ttl, _legacy_positions_key, payout_add, payout_mul, + _derive_round_phase, _extend_persistent_ttl, payout_add, payout_mul, sort_addresses, BPS_DENOMINATOR, DEFAULT_ARCHIVE_RETENTION, MAX_PAGE_SIZE, }; use crate::config::{ @@ -9,7 +9,7 @@ use crate::config::{ }; use crate::errors::ContractError; use crate::types::{ - ArchivedRoundSummary, BetSide, DataKey, DataKeyCore, DataKeyScoped, LeaderboardEntry, + ArchivedRoundSummary, BetSide, DataKeyCore, DataKeyScoped, LeaderboardEntry, PrecisionCommitment, PrecisionPrediction, PendingWinningsUpdatedAtKey, Round, RoundMode, RoundPhase, RoundPoolStats, RoundTemplate, SeasonArchive, SimulationResult, UserOutcomeType, UserPosition, UserRoundOutcome, UserStats, @@ -218,7 +218,7 @@ pub fn get_user_archive_history( let user_rounds: Vec = env .storage() .persistent() - .get(&DataKey::UserArchivedRoundIds(user)) + .get(&DataKeyScoped::UserArchivedRoundIds(user)) .unwrap_or(Vec::new(env_ref)); let total = user_rounds.len(); @@ -235,7 +235,7 @@ pub fn get_user_archive_history( if let Some(summary) = env .storage() .persistent() - .get(&DataKey::ArchivedRound(round_id)) + .get(&DataKeyScoped::ArchivedRound(round_id)) { result.push_back(summary); } diff --git a/contracts/src/settlement.rs b/contracts/src/settlement.rs index 712f6a00..14f39f5b 100644 --- a/contracts/src/settlement.rs +++ b/contracts/src/settlement.rs @@ -1,12 +1,11 @@ -// SPDX-License-Identifier: MIT +use crate::access_control::_enforce_access_control; 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, MAX_ORACLE_OBSERVATIONS, SECONDS_PER_LEDGER, TTL_BUMP_AMOUNT, TTL_BUMP_THRESHOLD, }; use crate::config::{ @@ -20,7 +19,7 @@ use crate::settlement_math::{ use crate::storage::clear_round_storage; use crate::types::{ ArchivedRoundSummary, BetSide, DataKeyCore, DataKeyScoped, DeviationReferenceMode, - HbGateConfig, LeaderboardEntry, MultiFeedPayload, OracleHeartbeatRecord, + HbGateConfig, LeaderboardEntry, MultiFeedPayload, OneSidedPolicy, OracleHeartbeatRecord, OraclePayload, OracleQuorumConfig, PrecisionCommitment, PrecisionPayoutPolicy, PrecisionPrediction, PriceSample, PendingWinningsUpdatedAtKey, Round, RoundArchiveStatus, RoundMode, TwapSamplesKey, UserOutcomeType, UserPosition, UserRoundOutcome, UserStats, @@ -222,6 +221,7 @@ pub fn claim_winnings(env: Env, user: Address) -> Result { _require_supported_schema(&env)?; user.require_auth(); _ensure_not_paused(&env)?; // rejects FullyPaused; allows Normal & ClaimsOnly + _enforce_access_control(&env, &user)?; let key = DataKeyScoped::PendingWinnings(user.clone()); let pending: i128 = env.storage().persistent().get(&key).unwrap_or(0); @@ -271,11 +271,6 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr _emit_action_rejected(&env, &oracle, symbol_short!("resolve"), e); })?; - // Heartbeat health enforcement (Issue #264) — must come before any - // state mutation (nonce consumption) so a stale oracle cannot race - // the admin override. - _enforce_heartbeat_health(&env, &oracle)?; - let round: Round = env .storage() .persistent() @@ -384,9 +379,9 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr &env, &oracle, symbol_short!("resolve"), - ContractError::OracleTimestampOutsideWindow, + ContractError::WindowOutOfRange, ); - return Err(ContractError::OracleTimestampOutsideWindow); + return Err(ContractError::WindowOutOfRange); } // Oracle deviation guardrails (Issue #266: reference price is either the @@ -585,10 +580,10 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co // ── Basic payload validation ────────────────────────────────────────── if payload.prices.is_empty() || payload.sources.is_empty() { - return Err(ContractError::TooFewObservations); + return Err(ContractError::InvalidPrice); } if payload.prices.len() != payload.sources.len() { - return Err(ContractError::TooFewObservations); + return Err(ContractError::InvalidPrice); } // All prices must be non-zero @@ -712,9 +707,9 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co &env, &oracle, symbol_short!("resolve"), - ContractError::TooFewObservations, + ContractError::InvalidMinParticipants, ); - return Err(ContractError::TooFewObservations); + return Err(ContractError::InvalidMinParticipants); } // Reject excessive observations to prevent gas abuse from O(N²) sort if n > MAX_ORACLE_OBSERVATIONS { @@ -722,9 +717,9 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co &env, &oracle, symbol_short!("resolve"), - ContractError::TooFewObservations, + ContractError::InvalidMinParticipants, ); - return Err(ContractError::TooFewObservations); + return Err(ContractError::InvalidMinParticipants); } // ── Check for duplicate source identifiers ──────────────────────────── @@ -737,9 +732,9 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co &env, &oracle, symbol_short!("resolve"), - ContractError::DuplicateOracleSource, + ContractError::OracleNonceReused, ); - return Err(ContractError::DuplicateOracleSource); + return Err(ContractError::OracleNonceReused); } } } @@ -893,7 +888,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co quorum_cfg.quorum_threshold, ), ); - return Err(ContractError::InsufficientOracleQuorum); + return Err(ContractError::OracleDeviationExceeded); } // ── Emit multi-feed summary event ───────────────────────────────────── @@ -947,8 +942,10 @@ fn _settle_round_with_price( env, round, RoundArchiveStatus::FallbackRefund, - payload.price, + final_price, &threshold_participants, + 0, + confidence, ); _refund_under_threshold(env, round, &threshold_participants)?; #[allow(deprecated)] @@ -981,25 +978,22 @@ fn _settle_round_with_price( .persistent() .get(&DataKeyScoped::RoundParticipants(round_id)) .unwrap_or(Vec::new(env)); - let participant_count = participants.len(); _archive_round( env, round, RoundArchiveStatus::Resolved, - payload.price, - &participants, final_price, - participant_count, + &participants, fee_amount, - payload.confidence, + confidence, ); // Mode-scoped position cleanup (eliminates redundant storage delete lookups) match round.mode { RoundMode::UpDown => { - for i in 0..raw_participants.len() { - if let Some(user) = raw_participants.get(i) { + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { env.storage() .persistent() .remove(&DataKeyScoped::Position(round_id, user)); @@ -1007,8 +1001,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())); @@ -1051,13 +1045,75 @@ fn _settle_round_with_price( // ─── Internal helpers ──────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] +/// 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) +} + pub fn _resolve_updown_mode( env: &Env, round: &Round, final_price: u128, skip_payout: bool, ) -> Result<(bool, i128), ContractError> { - let participants = sort_addresses(raw_participants.clone()); + let raw_participants: Vec
= env + .storage() + .persistent() + .get(&DataKeyScoped::RoundParticipants(round.round_id)) + .unwrap_or(Vec::new(env)); + let participants = sort_addresses(raw_participants); // Pure price-direction classification and one-sided check delegated to // settlement_math for auditability and golden-vector coverage. @@ -1066,10 +1122,7 @@ pub fn _resolve_updown_mode( let price_went_up = direction == PriceDirection::Up; let price_went_down = direction == PriceDirection::Down; - // One-sided: exactly one pool is empty (XOR). Regardless of which way - // price moved, if the winning-side pool is 0 there are no winners to pay, - // and if the losing-side pool is 0 there is nothing to distribute — in - // both cases every participant gets a full refund. + // One-sided: exactly one pool is empty (XOR). let is_one_sided = is_one_sided_pool(round.pool_up, round.pool_down); let mut fee_amount = 0; @@ -1079,7 +1132,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 +1383,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() @@ -1354,9 +1407,9 @@ pub fn _resolve_precision_mode( let mut winners: Vec = Vec::new(env); let mut total_pot: i128 = 0; let p_len = participants.len() as usize; - let mut participant_amounts: StdVec = StdVec::with_capacity(p_len); - let mut participant_prices: StdVec = StdVec::with_capacity(p_len); - let mut is_winner_mask: StdVec = StdVec::with_capacity(p_len); + let mut participant_amounts: Vec = Vec::new(env); + let mut participant_prices: Vec = Vec::new(env); + let mut is_winner_mask: Vec = Vec::new(env); for i in 0..participants.len() { if let Some(user) = participants.get(i) { @@ -1388,9 +1441,9 @@ pub fn _resolve_precision_mode( total_pot = total_pot .checked_add(amount) .ok_or(ContractError::Overflow)?; - participant_amounts.push(amount); - participant_prices.push(cached_price); - is_winner_mask.push(false); + participant_amounts.push_back(amount); + participant_prices.push_back(cached_price); + is_winner_mask.push_back(false); if let Some(pred) = pred_opt { let diff = if pred.predicted_price >= final_price { @@ -1403,12 +1456,12 @@ pub fn _resolve_precision_mode( .ok_or(ContractError::Overflow)? }; - let idx = i as usize; + let idx = i; match min_diff { None => { min_diff = Some(diff); winners.push_back(pred.clone()); - is_winner_mask[idx] = true; + is_winner_mask.set(idx, true); } Some(current_min) => { if diff < current_min { @@ -1416,12 +1469,12 @@ pub fn _resolve_precision_mode( winners = Vec::new(env); winners.push_back(pred.clone()); for j in 0..idx { - is_winner_mask[j] = false; + is_winner_mask.set(j, false); } - is_winner_mask[idx] = true; + is_winner_mask.set(idx, true); } else if diff == current_min { winners.push_back(pred.clone()); - is_winner_mask[idx] = true; + is_winner_mask.set(idx, true); } } } @@ -1470,11 +1523,10 @@ pub fn _resolve_precision_mode( for i in 0..participants.len() { if let Some(user) = participants.get(i) { - let idx = i as usize; - let was_winner = is_winner_mask.get(idx).copied().unwrap_or(false); + let was_winner = is_winner_mask.get(i).unwrap_or(false); if !was_winner { - let stake = participant_amounts[idx]; - let predicted_price = participant_prices[idx]; + let stake = participant_amounts.get(i).unwrap_or(0); + let predicted_price = participant_prices.get(i).unwrap_or(0); #[allow(deprecated)] env.events().publish( @@ -1505,8 +1557,7 @@ pub fn _resolve_precision_mode( // sum_refunds == total_pot, fee == 0, no stats mutation). for i in 0..participants.len() { if let Some(user) = participants.get(i) { - let idx = i as usize; - let stake = participant_amounts.get(idx).copied().unwrap_or(0); + let stake = participant_amounts.get(i).unwrap_or(0); if stake > 0 { _accumulate_pending(env, user.clone(), stake)?; _persist_user_outcome( @@ -1774,7 +1825,7 @@ pub fn _archive_round( round: &Round, status: RoundArchiveStatus, final_price: u128, - participants: &[Address], + participants: &Vec
, fee_amount: i128, confidence: Option, ) { @@ -1796,7 +1847,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() @@ -1867,21 +1918,13 @@ pub fn _archive_round( 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, ), ); diff --git a/contracts/src/settlement_math.rs b/contracts/src/settlement_math.rs index d4e35254..ba3eec4b 100644 --- a/contracts/src/settlement_math.rs +++ b/contracts/src/settlement_math.rs @@ -9,6 +9,9 @@ //! `settlement.rs` remain responsible for storage reads/writes, events, //! and authorization — this module is the *engine*, not the *orchestrator*. +#[cfg(any(test, not(target_arch = "wasm32")))] +extern crate alloc; +#[cfg(any(test, not(target_arch = "wasm32")))] use alloc::vec::Vec; use crate::math_common::{payout_add, payout_mul, BPS_DENOMINATOR}; @@ -166,6 +169,7 @@ pub struct PrecisionEntry { } /// Result of the precision winner-determination algorithm. +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq)] pub struct PrecisionWinnersResult { /// Indices (into the original `entries` slice) of the winning participants. @@ -177,6 +181,7 @@ pub struct PrecisionWinnersResult { } /// Finds the closest prediction(s) to `final_price` under default absolute distance scoring. +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn find_precision_winners( entries: &[PrecisionEntry], final_price: u128, @@ -192,6 +197,7 @@ pub fn find_precision_winners( } /// Finds precision winners given a explicit `PrecisionScoringPolicy` (Absolute vs Relative distance, optional confidence band). +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn find_precision_winners_with_policy( entries: &[PrecisionEntry], final_price: u128, @@ -271,6 +277,7 @@ pub fn find_precision_winners_with_policy( /// /// The remainder (distributable % winner_count) is assigned to the first /// winner. Every winner receives at least `distributable / winner_count`. +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn split_pot_among_winners( distributable: i128, winner_count: usize, @@ -299,6 +306,7 @@ pub fn split_pot_among_winners( /// Splits `distributable` proportionally according to winner stakes. /// /// Integer remainder is allocated to the first winner for exact conservation. +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn split_pot_stake_weighted( distributable: i128, winner_stakes: &[i128], @@ -354,6 +362,7 @@ pub struct UpDownPosition { } /// Computed payout for one UpDown participant. +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq)] pub struct UpDownPayoutEntry { pub index: usize, @@ -367,6 +376,7 @@ pub struct UpDownPayoutEntry { /// /// Inputs are the round-level parameters and the list of participant /// positions. Returns one `UpDownPayoutEntry` per participant. +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn compute_updown_payouts( positions: &[UpDownPosition], start_price: u128, @@ -441,6 +451,7 @@ pub fn compute_updown_payouts( // ─── Composite: compute full Precision payout vector ───────────────────────── /// Computed payout for one Precision participant. +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq)] pub struct PrecisionPayoutEntry { pub index: usize, @@ -451,6 +462,7 @@ pub struct PrecisionPayoutEntry { pub is_refund: bool, } +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn compute_precision_payouts( entries: &[PrecisionEntry], final_price: u128, @@ -469,6 +481,7 @@ pub fn compute_precision_payouts( } /// Computes the full payout vector for a Precision round using explicit scoring and payout policies. +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn compute_precision_payouts_with_policy( entries: &[PrecisionEntry], final_price: u128, diff --git a/contracts/src/storage.rs b/contracts/src/storage.rs index 4120fd8f..ac92ce7a 100644 --- a/contracts/src/storage.rs +++ b/contracts/src/storage.rs @@ -11,7 +11,7 @@ //! (`Position` + `PrecisionPosition` + `PrecisionCommitment`) should route //! through `clear_user_positions` or `clear_round_storage`. -use crate::types::DataKey; +use crate::types::{DataKeyCore, DataKeyScoped}; use soroban_sdk::{Address, Env, Vec}; /// Removes **all** position storage keys for a single participant, @@ -28,13 +28,13 @@ use soroban_sdk::{Address, Env, Vec}; pub fn clear_user_positions(env: &Env, round_id: u64, user: &Address) { env.storage() .persistent() - .remove(&DataKey::Position(round_id, user.clone())); + .remove(&DataKeyScoped::Position(round_id, user.clone())); env.storage() .persistent() - .remove(&DataKey::PrecisionPosition(round_id, user.clone())); + .remove(&DataKeyScoped::PrecisionPosition(round_id, user.clone())); env.storage() .persistent() - .remove(&DataKey::PrecisionCommitment(round_id, user.clone())); + .remove(&DataKeyScoped::PrecisionCommitment(round_id, user.clone())); } /// Removes all position storage keys for every participant in a round, @@ -64,15 +64,15 @@ pub fn clear_round_storage(env: &Env, round_id: u64, participants: &Vec
// Clear shared keys env.storage() .persistent() - .remove(&DataKey::RoundParticipants(round_id)); - env.storage().persistent().remove(&DataKey::ActiveRound); + .remove(&DataKeyScoped::RoundParticipants(round_id)); + env.storage().persistent().remove(&DataKeyCore::ActiveRound); // Legacy keys — safe no-op when absent - env.storage().persistent().remove(&DataKey::Positions); + env.storage().persistent().remove(&DataKeyCore::Positions); env.storage() .persistent() - .remove(&DataKey::UpDownPositions); + .remove(&DataKeyCore::UpDownPositions); env.storage() .persistent() - .remove(&DataKey::PrecisionPositions); + .remove(&DataKeyCore::PrecisionPositions); } diff --git a/contracts/src/tests/access_control.rs b/contracts/src/tests/access_control.rs index d3c9f60b..2410d951 100644 --- a/contracts/src/tests/access_control.rs +++ b/contracts/src/tests/access_control.rs @@ -289,4 +289,144 @@ fn test_protocol_health_reports_access_mode() { client.set_access_control_enabled(&true); let after = client.get_protocol_health(); assert_eq!(after.status_code, 6, "allowlist mode should surface ACCESS_RESTRICTED"); +} + +/// `test_allowlist_gates_claim_winnings` — a non-allowlisted user cannot +/// claim pending winnings when allowlist mode is enabled. +#[test] +fn test_allowlist_gates_claim_winnings() { + let (env, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + + client.set_access_control_enabled(&true); + client.add_allowlisted(&alice); + client.mint_initial(&alice); + + client.create_round(&1_0000000, &None); + client.place_bet(&alice, &100_0000000, &BetSide::Up); + + // Cancel the round so Alice has pending winnings refunded. + client.cancel_round(&0); + + // Remove Alice from allowlist → claim is denied. + client.remove_allowlisted(&alice); + let claim_denied = client.try_claim_winnings(&alice); + assert_eq!(claim_denied, Err(Ok(ContractError::AccessDenied))); + + // Re-allowlist Alice → claim succeeds. + client.add_allowlisted(&alice); + let claimed = client.claim_winnings(&alice); + assert_eq!(claimed, 100_0000000); +} + +/// `test_denylist_blocks_claim_winnings` — a denylisted user cannot +/// claim pending winnings regardless of allowlist mode. +#[test] +fn test_denylist_blocks_claim_winnings() { + let (env, client, _admin, _oracle) = setup(); + let user = Address::generate(&env); + + client.mint_initial(&user); + client.create_round(&1_0000000, &None); + client.place_bet(&user, &100_0000000, &BetSide::Up); + + // Cancel the round so user has pending winnings. + client.cancel_round(&0); + + // Denylist the user. + client.add_denylisted(&user); + let claim_denied = client.try_claim_winnings(&user); + assert_eq!(claim_denied, Err(Ok(ContractError::AccessDenied))); + + // Un-denylist the user → claim succeeds. + client.remove_denylisted(&user); + let claimed = client.claim_winnings(&user); + assert_eq!(claimed, 100_0000000); +} + +/// `test_allowlist_gates_reveal` — a non-allowlisted user cannot reveal commitments. +#[test] +fn test_allowlist_gates_reveal() { + let (env, client, _admin, _oracle) = setup(); + let alice = Address::generate(&env); + + client.set_access_control_enabled(&true); + client.add_allowlisted(&alice); + client.mint_initial(&alice); + + client.create_round(&1_0000000, &Some(1)); + + let salt = [42u8; 32]; + let salt_bytes = soroban_sdk::BytesN::from_array(&env, &salt); + let mut preimage = soroban_sdk::Bytes::new(&env); + preimage.append(&1_1000000u128.to_xdr(&env)); + preimage.append(&salt_bytes.to_xdr(&env)); + let hash: soroban_sdk::BytesN<32> = env.crypto().sha256(&preimage).into(); + + client.commit_prediction(&alice, &hash, &100_0000000); + + // Advance to reveal phase (bet_end_ledger = 6). + env.ledger().with_mut(|li| { li.sequence_number = 7; }); + + // Admin removes Alice from allowlist before reveal. + client.remove_allowlisted(&alice); + let reveal_err = client.try_reveal_prediction(&alice, &1_1000000, &salt_bytes); + assert_eq!(reveal_err, Err(Ok(ContractError::AccessDenied))); + + // Admin re-allowlists Alice. + client.add_allowlisted(&alice); + let reveal_ok = client.try_reveal_prediction(&alice, &1_1000000, &salt_bytes); + assert!(reveal_ok.is_ok()); +} + +/// `test_denylist_blocks_reveal` — a denylisted user cannot reveal commitments. +#[test] +fn test_denylist_blocks_reveal() { + let (env, client, _admin, _oracle) = setup(); + let user = Address::generate(&env); + + client.mint_initial(&user); + client.create_round(&1_0000000, &Some(1)); + + let salt = [42u8; 32]; + let salt_bytes = soroban_sdk::BytesN::from_array(&env, &salt); + let mut preimage = soroban_sdk::Bytes::new(&env); + preimage.append(&1_1000000u128.to_xdr(&env)); + preimage.append(&salt_bytes.to_xdr(&env)); + let hash: soroban_sdk::BytesN<32> = env.crypto().sha256(&preimage).into(); + + client.commit_prediction(&user, &hash, &100_0000000); + + // Advance to reveal phase. + env.ledger().with_mut(|li| { li.sequence_number = 7; }); + + client.add_denylisted(&user); + let reveal_err = client.try_reveal_prediction(&user, &1_1000000, &salt_bytes); + assert_eq!(reveal_err, Err(Ok(ContractError::AccessDenied))); +} + +/// `test_get_access_policy_query` — tests querying composite policy tuple. +#[test] +fn test_get_access_policy_query() { + let (env, client, _admin, _oracle) = setup(); + let user = Address::generate(&env); + + let (enabled, state) = client.get_access_policy(&user); + assert!(!enabled); + assert_eq!(state, AccessState::Open); + + client.set_access_control_enabled(&true); + let (enabled, state) = client.get_access_policy(&user); + assert!(enabled); + assert_eq!(state, AccessState::Open); + + client.add_allowlisted(&user); + let (enabled, state) = client.get_access_policy(&user); + assert!(enabled); + assert_eq!(state, AccessState::Allowlisted); + + client.add_denylisted(&user); + let (enabled, state) = client.get_access_policy(&user); + assert!(enabled); + assert_eq!(state, AccessState::Denylisted); } \ No newline at end of file diff --git a/contracts/src/types.rs b/contracts/src/types.rs index d2ec9386..fb3216c1 100644 --- a/contracts/src/types.rs +++ b/contracts/src/types.rs @@ -1,2615 +1,625 @@ -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Env, IntoVal, Symbol, Val, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Payout policy for Precision mode -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum PrecisionPayoutPolicy { - Equal = 0, // Split payout pool equally among winners (default) - StakeWeighted = 1, // Split payout pool proportionally to winner stakes -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -/// -/// ## Indexed position keys (variants 13–15) -/// -/// `Position(round_id, address)` and `PrecisionPosition(round_id, address)` store -/// a single user's record under a composite key, enabling O(1) read/write per user -/// instead of deserializing the full participant map on every bet. -/// -/// `RoundParticipants(round_id)` holds the ordered `Vec
` used for -/// iteration at resolution time. Appending one address is cheaper than -/// re-serialising an N-entry `Map` for every bet placed. -/// -/// Legacy single-key maps (`UpDownPositions`, `PrecisionPositions`) are kept for -/// backward-compatible reads during a migration window; they are no longer written. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - /// On-chain storage schema version for migration safety. - /// If missing, the contract treats it as legacy schema version 1. - SchemaVersion, - ActiveRound, - Positions, // Legacy key — read-only migration compat - UpDownPositions, // Legacy key — read-only migration compat - PrecisionPositions, // Legacy key — read-only migration compat - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - /// Per-user UpDown position: (round_id, address) → UserPosition - Position(u64, Address), - /// Per-user Precision prediction: (round_id, address) → PrecisionPrediction - PrecisionPosition(u64, Address), - /// Per-user Precision commitment: (round_id, address) → PrecisionCommitment - PrecisionCommitment(u64, Address), - /// Ordered participant list for a round: round_id → Vec
- RoundParticipants(u64), - /// Maximum stake allowed per individual bet (None = unlimited) - MaxStake, - /// Maximum cumulative exposure per user per round (None = unlimited) - MaxUserRoundExposure, - /// Maximum pending winnings allowed per account (None = unlimited) - MaxPendingWinnings, - /// Marker for a cancelled round: round_id → true - CancelledRound(u64), - /// Per-round consumed oracle nonce: (round_id, nonce) → true. - /// Used to reject duplicate oracle payload submissions for the same round. - ConsumedOracleNonce(u64, u64), - /// Minimum participant count for competitive settlement; unset = no minimum enforced - MinParticipants, - /// Oracle heartbeat: last recorded timestamp and status - OracleHeartbeat, - /// Stale-heartbeat threshold in seconds (admin-configurable); unset = 3600 s default - OracleStaleThreshold, - /// Maximum participants accepted in a Precision round; unset = protocol default - MaxPrecisionParticipants, - /// Oracle max deviation threshold in basis points (1 bp = 0.01%). - /// If unset, deviation guardrails are disabled. - OracleMaxDeviationBps, - /// One-shot admin override allowing the next settlement to bypass deviation checks. - /// Automatically cleared after use. - OracleDeviationOverrideArmed, - /// Minimum oracle confidence threshold in basis points (0–10000). - /// If unset, confidence guardrails are disabled. - OracleMinConfidenceBps, - /// When true, payloads with missing confidence are rejected in strict mode. - OracleStrictMode, - /// Compact post-settlement summary keyed by round id for historical queries. - ArchivedRound(u64), - /// Ordered round ids for archive retention (oldest at index 0). - RecentArchivedRoundIds, - /// Per-user outcome record for a specific archived round (round_id, user). - /// Persisted at settlement for user history queries without event replay. - UserRoundOutcome(u64, Address), - /// Marker written by migrate_schema_v2_to_v3 to prove the migration ran. - MigratedToV3, - /// Timelocked pending critical config change keyed by change kind. - PendingConfigChange(ConfigChangeKind), - /// Optional protocol settlement fee in basis points (1 bp = 0.01%). - /// `None` (key absent) means fee disabled — no behaviour change. - /// Hard cap on fee is enforced at the contract layer, not by storage shape. - ProtocolFeeBps, - /// On-chain accumulated protocol fee balance in stroops (i128). - /// Admin withdraws via the dedicated withdrawal method; does NOT mix - /// into the per-user balance ledger. - ProtocolFeeTreasury, - /// Per-ledger mint counter: wraps the explicit ledger sequence number. - LedgerMintCounter(u32), - /// Mint limit configuration: maximum number of mints allowed per ledger. - MintLimitConfig, - /// Pending two-step oracle rotation proposal with expiry. - OracleRotationProposal, - /// Configurable archive retention limit: maximum number of ArchivedRound entries - /// retained on-chain before the oldest are pruned (FIFO). If unset, the protocol - /// default is used. - ArchiveRetention, - /// Admin-configured blueprint used by `create_next_from_template` to spin - /// up the next round without re-specifying `start_price` / `mode` each - /// time. Absent means no template is configured. - RoundTemplate, - /// Bounded index of user addresses sorted by lifetime total wins - /// descending (all-time leaderboard, independent of seasons). - LeaderboardWins, - /// Bounded index of user addresses sorted by lifetime best streak - /// descending (all-time leaderboard, independent of seasons). - LeaderboardStreak, - /// Monotonically increasing id of the currently-active leaderboard - /// season. Absent is treated as season 1. - SeasonId, - /// Per-season, per-user win/loss/streak stats: (season_id, address) → - /// UserStats, scoped independently of the lifetime `UserStats` totals so - /// a season reset never touches lifetime history. - SeasonUserStats(u32, Address), - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped total wins descending. - SeasonLeaderboardWins, - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped best streak descending. - SeasonLeaderboardStreak, - /// Frozen snapshot of a season's final rankings, written when the season - /// is reset. Seasons are never deleted — this is a permanent archive. - SeasonArchive(u32), - /// Admin-configured multi-feed oracle quorum parameters. - /// When set, `resolve_round_multi` is enabled. - OracleQuorum, - /// Announced next schema version for migration preview (v-next template). - /// When set, operators can inspect this value before executing a real migration. - /// Absent means no next migration has been announced. - NextSchemaVersion, - /// Minimum bet amount (dust protection). Unset = no minimum. - MinBet, - /// Epoch mint budget: total mints allowed per epoch. - EpochMintBudget, - /// Early cash-out penalty in basis points. Unset = early cash-out disabled. - EarlyCashoutBps, - /// Fee incidence model: FeeOnPot (default) or FeeOnWinnings. - FeeModel, - /// Dispute window length in ledgers. 0 = no dispute window. - DisputeLedgers, -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, - OracleTimestampSkew = 12, - EpochMintBudget = 12, - PendingWinningsExpiry = 13, - PrecisionPayoutPolicy = 14, - MinBet = 15, - DisputeLedgers = 16, - FeeModel = 17, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), - OracleTimestampSkew(u64), - EpochMintBudget(i128), - PendingWinningsExpiry(u32), - PrecisionPayoutPolicy(u32), - MinBet(Option), - DisputeLedgers(u32), - FeeModel(FeeModel), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - pub confidence: Option, - /// Optional ed25519 signature over the attestation domain-separated message. - pub attestation: Option>, -} - -/// Multi-feed oracle resolution payload (N observations, quorum + median). -/// -/// Unlike the legacy single-oracle `OraclePayload`, this carries N independent -/// feed observations as parallel arrays. The contract computes the median, -/// rejects outliers, and requires a configurable quorum of feeds to agree -/// within the outlier threshold before settlement proceeds. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct MultiFeedPayload { - /// Prices from each feed, scaled to 4 decimal places (e.g. 2297 = $0.2297). - pub prices: Vec, - /// Feed source identifiers (0-based index, max N-1). Must be unique. - pub sources: Vec, - /// Round identifier that must match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Unix epoch seconds when the observations were collected. - pub timestamp: u64, -} - -/// Admin-configurable quorum and outlier rejection parameters for multi-feed -/// oracle settlement. Stored under `DataKey::OracleQuorum`. -/// -/// When set, `resolve_round_multi` becomes the preferred settlement path. -/// The legacy single-oracle `resolve_round` path remains available -/// independently of this configuration. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleQuorumConfig { - /// Minimum number of unique feed observations required in a multi-feed payload. - pub min_observations: u32, - /// Minimum number of observations that must survive outlier rejection to - /// form a valid quorum and proceed to settlement. - pub quorum_threshold: u32, - /// Maximum deviation from the median (in basis points, 1 bp = 0.01%) - /// before an observation is rejected as an outlier. - pub outlier_threshold_bps: u32, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -/// Heartbeat health gate configuration (Issue #264). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct HbGateConfig { - pub strict_mode: bool, - pub override_armed: bool, - pub grace_seconds: u64, -} - -/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). -#[contracttype] -#[derive(Clone)] -pub enum HbGateKey { - Config, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) - pub start_timestamp: u64, // Ledger timestamp when round was created -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, - /// Dispute window ended via void; all participants refunded their stake. - Voided = 3, -} - -/// Composite protocol health status returned by `get_protocol_health`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - pub paused: bool, - pub oracle_live: bool, - pub oracle_status: u32, - pub has_active_round: bool, - pub active_round_phase: u32, - pub schema_version: u32, - pub ledger_sequence: u32, - pub ledger_timestamp: u64, - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - Active = 0, - Paused = 1, - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - Unknown = 0, - Betting = 1, - Running = 2, - AwaitingResolve = 3, - Resolved = 4, - Cancelled = 5, - FallbackRefund = 6, - Voided = 7, -} - -/// Terminal outcome persisted per user per archived round. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Void = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, - pub fee_model: u32, -} - -/// Admin-configured blueprint for `create_next_from_template`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} - -/// Configurable pending-winnings expiry in ledgers. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsExpiryKey(pub ()); - -pub const PENDING_WINNINGS_EXPIRY_KEY: PendingWinningsExpiryKey = PendingWinningsExpiryKey(()); - -/// Ledger sequence when a user's pending winnings entry was last modified. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsUpdatedAtKey(pub Address); - -/// Fee incidence model for protocol fees (Issue #268). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum FeeModel { - FeeOnPot = 0, // Fee charged on total pot (default) - FeeOnWinnings = 1, // Fee charged only on net winnings/profit -} - -/// TWAP sample ring entry (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PriceSample { - pub price: u128, - pub timestamp: u64, -} - -/// Storage key for TWAP samples ring (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum TwapSamplesKey { - Samples, -} - -/// Dev Reference Mode (Issue #266). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum DeviationReferenceMode { - StartPrice = 0, // Use round.price_start (default) - Twap = 1, // Use trailing-sample TWAP average -} - -/// Deviation guardrail config (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeviationConfig { - pub reference_mode: DeviationReferenceMode, - pub window_samples: u32, -} - -/// Storage key for deviation config (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum DeviationConfigKey { - Config, -} - -/// Oracle attestation config (Issue #263). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct AttestationConfig { - pub key: Option>, // ed25519 public key; None = attestation disabled -} - -/// Storage key for attestation config (separate from DataKey to stay within variant limits, Issue #263). -#[contracttype] -#[derive(Clone)] -pub enum AttestationConfigKey { - Config, -} - -impl IntoVal for DataKey { - fn into_val(&self, env: &Env) -> Val { - use Symbol as S; - match self { - DataKey::Balance(a) => (S::new(env, "Balance"), a.clone()).into_val(env), - DataKey::Admin => S::new(env, "Admin").into_val(env), - DataKey::Oracle => S::new(env, "Oracle").into_val(env), - DataKey::SchemaVersion => S::new(env, "SchemaVersion").into_val(env), - DataKey::ActiveRound => S::new(env, "ActiveRound").into_val(env), - DataKey::Positions => S::new(env, "Positions").into_val(env), - DataKey::UpDownPositions => S::new(env, "UpDownPositions").into_val(env), - DataKey::PrecisionPositions => S::new(env, "PrecisionPositions").into_val(env), - DataKey::PendingWinnings(a) => { - (S::new(env, "PendingWinnings"), a.clone()).into_val(env) - } - DataKey::UserStats(a) => (S::new(env, "UserStats"), a.clone()).into_val(env), - DataKey::Paused => S::new(env, "Paused").into_val(env), - DataKey::BetWindowLedgers => S::new(env, "BetWindowLedgers").into_val(env), - DataKey::RunWindowLedgers => S::new(env, "RunWindowLedgers").into_val(env), - DataKey::CloseBufferLedgers => S::new(env, "CloseBufferLedgers").into_val(env), - DataKey::LastRoundId => S::new(env, "LastRoundId").into_val(env), - DataKey::Position(id, a) => { - (S::new(env, "Position"), id, a.clone()).into_val(env) - } - DataKey::PrecisionPosition(id, a) => { - (S::new(env, "PrecisionPosition"), id, a.clone()).into_val(env) - } - DataKey::PrecisionCommitment(id, a) => { - (S::new(env, "PrecisionCommitment"), id, a.clone()).into_val(env) - } - DataKey::RoundParticipants(id) => { - (S::new(env, "RoundParticipants"), id).into_val(env) - } - DataKey::MaxStake => S::new(env, "MaxStake").into_val(env), - DataKey::MaxUserRoundExposure => S::new(env, "MaxUserRoundExposure").into_val(env), - DataKey::MaxPendingWinnings => S::new(env, "MaxPendingWinnings").into_val(env), - DataKey::CancelledRound(id) => (S::new(env, "CancelledRound"), id).into_val(env), - DataKey::ConsumedOracleNonce(id, nonce) => { - (S::new(env, "ConsumedOracleNonce"), id, nonce).into_val(env) - } - DataKey::MinParticipants => S::new(env, "MinParticipants").into_val(env), - DataKey::OracleHeartbeat => S::new(env, "OracleHeartbeat").into_val(env), - DataKey::OracleStaleThreshold => S::new(env, "OracleStaleThreshold").into_val(env), - DataKey::MaxPrecisionParticipants => { - S::new(env, "MaxPrecisionParticipants").into_val(env) - } - DataKey::OracleMaxDeviationBps => S::new(env, "OracleMaxDeviationBps").into_val(env), - DataKey::OracleDeviationOverrideArmed => { - S::new(env, "OracleDeviationOverrideArmed").into_val(env) - } - DataKey::OracleMinConfidenceBps => { - S::new(env, "OracleMinConfidenceBps").into_val(env) - } - DataKey::OracleStrictMode => S::new(env, "OracleStrictMode").into_val(env), - DataKey::ArchivedRound(id) => (S::new(env, "ArchivedRound"), id).into_val(env), - DataKey::RecentArchivedRoundIds => { - S::new(env, "RecentArchivedRoundIds").into_val(env) - } - DataKey::UserRoundOutcome(id, a) => { - (S::new(env, "UserRoundOutcome"), id, a.clone()).into_val(env) - } - DataKey::MigratedToV3 => S::new(env, "MigratedToV3").into_val(env), - DataKey::PendingConfigChange(k) => { - (S::new(env, "PendingConfigChange"), k.clone()).into_val(env) - } - DataKey::ProtocolFeeBps => S::new(env, "ProtocolFeeBps").into_val(env), - DataKey::ProtocolFeeTreasury => S::new(env, "ProtocolFeeTreasury").into_val(env), - DataKey::LedgerMintCounter(id) => { - (S::new(env, "LedgerMintCounter"), id).into_val(env) - } - DataKey::MintLimitConfig => S::new(env, "MintLimitConfig").into_val(env), - DataKey::OracleRotationProposal => S::new(env, "OracleRotationProposal").into_val(env), - DataKey::ArchiveRetention => S::new(env, "ArchiveRetention").into_val(env), - DataKey::RoundTemplate => S::new(env, "RoundTemplate").into_val(env), - DataKey::LeaderboardWins => S::new(env, "LeaderboardWins").into_val(env), - DataKey::LeaderboardStreak => S::new(env, "LeaderboardStreak").into_val(env), - DataKey::SeasonId => S::new(env, "SeasonId").into_val(env), - DataKey::SeasonUserStats(sid, a) => { - (S::new(env, "SeasonUserStats"), sid, a.clone()).into_val(env) - } - DataKey::SeasonLeaderboardWins => S::new(env, "SeasonLeaderboardWins").into_val(env), - DataKey::SeasonLeaderboardStreak => { - S::new(env, "SeasonLeaderboardStreak").into_val(env) - } - DataKey::SeasonArchive(id) => (S::new(env, "SeasonArchive"), id).into_val(env), - DataKey::OracleQuorum => S::new(env, "OracleQuorum").into_val(env), - DataKey::NextSchemaVersion => S::new(env, "NextSchemaVersion").into_val(env), - DataKey::MinBet => S::new(env, "MinBet").into_val(env), - DataKey::EpochMintBudget => S::new(env, "EpochMintBudget").into_val(env), - DataKey::EarlyCashoutBps => S::new(env, "EarlyCashoutBps").into_val(env), - DataKey::FeeModel => S::new(env, "FeeModel").into_val(env), - DataKey::DisputeLedgers => S::new(env, "DisputeLedgers").into_val(env), - } - } -} -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -/// -/// ## Indexed position keys (variants 13–15) -/// -/// `Position(round_id, address)` and `PrecisionPosition(round_id, address)` store -/// a single user's record under a composite key, enabling O(1) read/write per user -/// instead of deserializing the full participant map on every bet. -/// -/// `RoundParticipants(round_id)` holds the ordered `Vec
` used for -/// iteration at resolution time. Appending one address is cheaper than -/// re-serialising an N-entry `Map` for every bet placed. -/// -/// Legacy single-key maps (`UpDownPositions`, `PrecisionPositions`) are kept for -/// backward-compatible reads during a migration window; they are no longer written. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - /// On-chain storage schema version for migration safety. - /// If missing, the contract treats it as legacy schema version 1. - SchemaVersion, - ActiveRound, - Positions, // Legacy key — read-only migration compat - UpDownPositions, // Legacy key — read-only migration compat - PrecisionPositions, // Legacy key — read-only migration compat - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - /// Per-user UpDown position: (round_id, address) → UserPosition - Position(u64, Address), - /// Per-user Precision prediction: (round_id, address) → PrecisionPrediction - PrecisionPosition(u64, Address), - /// Per-user Precision commitment: (round_id, address) → PrecisionCommitment - PrecisionCommitment(u64, Address), - /// Ordered participant list for a round: round_id → Vec
- RoundParticipants(u64), - /// Maximum stake allowed per individual bet (None = unlimited) - MaxStake, - /// Maximum cumulative exposure per user per round (None = unlimited) - MaxUserRoundExposure, - /// Maximum pending winnings allowed per account (None = unlimited) - MaxPendingWinnings, - /// Marker for a cancelled round: round_id → true - CancelledRound(u64), - /// Per-round consumed oracle nonce: (round_id, nonce) → true. - /// Used to reject duplicate oracle payload submissions for the same round. - ConsumedOracleNonce(u64, u64), - /// Minimum participant count for competitive settlement; unset = no minimum enforced - MinParticipants, - /// Oracle heartbeat: last recorded timestamp and status - OracleHeartbeat, - /// Stale-heartbeat threshold in seconds (admin-configurable); unset = 3600 s default - OracleStaleThreshold, - /// Maximum participants accepted in a Precision round; unset = protocol default - MaxPrecisionParticipants, - /// Oracle max deviation threshold in basis points (1 bp = 0.01%). - /// If unset, deviation guardrails are disabled. - OracleMaxDeviationBps, - /// One-shot admin override allowing the next settlement to bypass deviation checks. - /// Automatically cleared after use. - OracleDeviationOverrideArmed, - /// Minimum oracle confidence threshold in basis points (0–10000). - /// If unset, confidence guardrails are disabled. - OracleMinConfidenceBps, - /// When true, payloads with missing confidence are rejected in strict mode. - OracleStrictMode, - /// Compact post-settlement summary keyed by round id for historical queries. - ArchivedRound(u64), - /// Ordered round ids for archive retention (oldest at index 0). - RecentArchivedRoundIds, - /// Per-user outcome record for a specific archived round (round_id, user). - /// Persisted at settlement for user history queries without event replay. - UserRoundOutcome(u64, Address), - /// Per-user index of archived round IDs the user participated in. - /// Written during archiving; read for paginated history queries. - /// Not pruned when archived rounds are evicted — stale entries are - /// filtered at query time by checking ArchivedRound existence. - UserArchivedRoundIds(Address), - /// Marker written by migrate_schema_v2_to_v3 to prove the migration ran. - MigratedToV3, - /// Timelocked pending critical config change keyed by change kind. - PendingConfigChange(ConfigChangeKind), - /// Optional protocol settlement fee in basis points (1 bp = 0.01%). - /// `None` (key absent) means fee disabled — no behaviour change. - /// Hard cap on fee is enforced at the contract layer, not by storage shape. - ProtocolFeeBps, - /// On-chain accumulated protocol fee balance in stroops (i128). - /// Admin withdraws via the dedicated withdrawal method; does NOT mix - /// into the per-user balance ledger. - ProtocolFeeTreasury, - /// Per-ledger mint counter: wraps the explicit ledger sequence number. - LedgerMintCounter(u32), - /// Mint limit configuration: maximum number of mints allowed per ledger. - MintLimitConfig, - /// Pending two-step oracle rotation proposal with expiry. - OracleRotationProposal, - /// Configurable archive retention limit: maximum number of ArchivedRound entries - /// retained on-chain before the oldest are pruned (FIFO). If unset, the protocol - /// default is used. - ArchiveRetention, - /// Admin-configured blueprint used by `create_next_from_template` to spin - /// up the next round without re-specifying `start_price` / `mode` each - /// time. Absent means no template is configured. - RoundTemplate, - /// Bounded index of user addresses sorted by lifetime total wins - /// descending (all-time leaderboard, independent of seasons). - LeaderboardWins, - /// Bounded index of user addresses sorted by lifetime best streak - /// descending (all-time leaderboard, independent of seasons). - LeaderboardStreak, - /// Monotonically increasing id of the currently-active leaderboard - /// season. Absent is treated as season 1. - SeasonId, - /// Per-season, per-user win/loss/streak stats: (season_id, address) → - /// UserStats, scoped independently of the lifetime `UserStats` totals so - /// a season reset never touches lifetime history. - SeasonUserStats(u32, Address), - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped total wins descending. - SeasonLeaderboardWins, - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped best streak descending. - SeasonLeaderboardStreak, - /// Frozen snapshot of a season's final rankings, written when the season - /// is reset. Seasons are never deleted — this is a permanent archive. - SeasonArchive(u32), -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - /// Optional protocol settlement fee in bps (Issue #162). - /// `None` disables the fee entirely, restoring pre-fee behaviour. - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - /// - /// The oracle service must generate a unique value per submission for a - /// given round (e.g. a monotonic counter or random 64-bit value). The - /// contract records each consumed nonce under - /// `DataKey::ConsumedOracleNonce(round_id, nonce)` and rejects any reuse, - /// making resolution idempotent against accidental duplicate submissions. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - /// Validated against `env.ledger().network_id()` to prevent cross-network replay. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - /// Validated against `env.current_contract_address()` to prevent cross-contract replay. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - /// When `None`, the payload is treated as a legacy submission. - /// When strict mode is enabled, `None` is rejected. - pub confidence: Option, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, -} - -/// Composite protocol health status returned by `get_protocol_health`. -/// -/// Designed for operators to poll a single endpoint instead of stitching -/// together multiple read-only calls. -/// -/// ## Status code → alert severity mapping -/// -/// | code | label | severity | meaning | -/// |------|-----------------|----------|-------------------------------------------| -/// | 0 | HEALTHY | none | All subsystems nominal | -/// | 1 | PAUSED | critical | Contract is emergency-paused | -/// | 2 | ORACLE_STALE | warning | Oracle heartbeat is stale or offline | -/// | 3 | ROUND_STALE | warning | Round is past its end ledger but unresolved| -/// | 4 | NO_ACTIVE_ROUND | info | No round currently active (idle protocol) | -/// | 5 | MULTIPLE_ISSUES | critical | Two or more issues detected simultaneously| -/// -/// ## Phase codes (`active_round_phase`) -/// -/// | phase | meaning | -/// |-------|---------------------------------------------------| -/// | 0 | No active round | -/// | 1 | Betting open (`ledger < bet_end_ledger`) | -/// | 2 | Running / reveal window (`bet_end_ledger ≤ ledger < end_ledger`) | -/// | 3 | Resolvable (`ledger ≥ end_ledger`) | -/// -/// ## Oracle status codes (`oracle_status`) -/// -/// | code | meaning | -/// |------|----------------------------------------| -/// | 0 | Active (healthy heartbeat) | -/// | 1 | Degraded (heartbeat marked degraded) | -/// | 2 | Offline (heartbeat marked offline) | -/// | 3 | Unknown (no heartbeat record stored) | -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - /// Whether the contract is emergency-paused (`Paused == true`) - pub paused: bool, - /// Whether the oracle heartbeat is non-stale and not offline - pub oracle_live: bool, - /// Raw oracle heartbeat status (0=active, 1=degraded, 2=offline, 3=unknown) - pub oracle_status: u32, - /// Whether a round is currently active - pub has_active_round: bool, - /// Current round phase (0=no_round, 1=betting, 2=running, 3=resolvable) - pub active_round_phase: u32, - /// On-chain storage schema version - pub schema_version: u32, - /// Ledger sequence at which this health snapshot was taken - pub ledger_sequence: u32, - /// Ledger timestamp at which this health snapshot was taken - pub ledger_timestamp: u64, - /// Composite status code (see mapping table above) - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -/// -/// Designed for explorer/analytics queries without replaying events. -/// `price_final` is `0` for admin cancellations (no oracle settlement price). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -/// -/// The admin proposes a new oracle address with a timestamp-based expiry window. -/// After `expires_at` (ledger timestamp) the proposal is stale and acceptance -/// is rejected until the admin submits a fresh proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -/// -/// Designed for frontend state machines that need a single, stable code -/// instead of combining multiple boolean flags. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|--------------|-------------------------------------------------------------------------| -/// | 0 | `Active` | Not paused; a round is currently active (bets open or running). | -/// | 1 | `Paused` | Emergency-paused by the admin; no mutations accepted except unpause. | -/// | 2 | `ClaimsOnly` | Not paused; no active round. Only `claim_winnings` is meaningful. | -/// -/// ## Transition rules -/// -/// - `ClaimsOnly` → `Active` when `create_round()` succeeds. -/// - `Active` → `ClaimsOnly` when `resolve_round()` or `cancel_round()` completes. -/// - Any state → `Paused` when `pause_contract()` is called. -/// - `Paused` → `Active` when `unpause_contract()` is called *and* an active round still exists. -/// - `Paused` → `ClaimsOnly` when `unpause_contract()` is called *and* no active round exists. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - /// The contract is not paused and has a currently active round. - Active = 0, - /// The contract is emergency-paused by the admin. - Paused = 1, - /// The contract is not paused, but no round is active. - /// Mutating actions are limited to claiming pending winnings. - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -/// -/// Queries a round by its monotonic `round_id`. Covers all lifecycle -/// stages from creation through terminal settlement. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|------------------|-----------------------------------------------------------------------------------| -/// | 0 | `Unknown` | Round does not exist or has been pruned from the on-chain archive. | -/// | 1 | `Betting` | Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). | -/// | 2 | `Running` | Betting closed; reveal window open (`bet_end_ledger ≤ ledger < end_ledger`). | -/// | 3 | `AwaitingResolve`| Round ended; awaiting oracle settlement (`ledger ≥ end_ledger`). | -/// | 4 | `Resolved` | Oracle settled the round; pot distributed to winners. | -/// | 5 | `Cancelled` | Admin cancelled the round; all stakes refunded. | -/// | 6 | `FallbackRefund` | Insufficient participants at settlement; all stakes refunded. | -/// -/// ## Transition rules -/// -/// - `Unknown` → `Betting` when `create_round()` succeeds. -/// - `Betting` → `Running` when `ledger ≥ bet_end_ledger` (derived; no on-chain write). -/// - `Running` → `AwaitingResolve` when `ledger ≥ end_ledger` (derived; no on-chain write). -/// - `{Betting | Running | AwaitingResolve}` → `Cancelled` when `cancel_round()` is called. -/// - `AwaitingResolve` → `Resolved` when `resolve_round()` settles with enough participants. -/// - `AwaitingResolve` → `FallbackRefund` when `resolve_round()` finds fewer than `min_participants`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - /// Round does not exist or has been pruned from the on-chain archive. - Unknown = 0, - /// Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). - Betting = 1, - /// Betting is closed; reveal window is open (`bet_end_ledger ≤ ledger < end_ledger`). - Running = 2, - /// Round has ended and is waiting for oracle settlement (`ledger ≥ end_ledger`). - AwaitingResolve = 3, - /// Oracle settled the round normally; pot distributed to winners. - Resolved = 4, - /// Admin cancelled the round; all stakes refunded. - Cancelled = 5, - /// Settlement triggered but insufficient participants; all stakes refunded. - FallbackRefund = 6, -} - -/// Terminal outcome persisted per user per archived round. -/// -/// Allows `get_user_archived_participation` to answer profile/history -/// queries without replaying the full event stream. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Cancel = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, -} - -/// Admin-configured blueprint for `create_next_from_template`. -/// -/// Mirrors the arguments accepted by `create_round` (`start_price`, `mode`) -/// so a keeper can spin up the next round after a settle/cancel without an -/// operator re-specifying parameters each time. Validated with the exact -/// same rules `create_round` applies at creation time. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings, written by -/// `reset_leaderboard_season`. `participant_count` is the number of distinct -/// addresses that appeared in either bounded index at reset time (a lower -/// bound on total season participants beyond the tracked top -/// `LEADERBOARD_LIMIT`, mirroring the same bound the live indexes enforce). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} -a -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - SchemaVersion, - ActiveRound, - Positions, - UpDownPositions, - PrecisionPositions, - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - Position(u64, Address), - PrecisionPosition(u64, Address), - PrecisionCommitment(u64, Address), - RoundParticipants(u64), - MaxStake, - MaxUserRoundExposure, - MaxPendingWinnings, - CancelledRound(u64), - ConsumedOracleNonce(u64, u64), - MinParticipants, - OracleHeartbeat, - OracleStaleThreshold, - MaxPrecisionParticipants, - OracleMaxDeviationBps, - OracleDeviationOverrideArmed, - OracleMinConfidenceBps, - OracleStrictMode, - ArchivedRound(u64), - RecentArchivedRoundIds, - UserRoundOutcome(u64, Address), - MigratedToV3, - PendingConfigChange(ConfigChangeKind), - ProtocolFeeBps, - ProtocolFeeTreasury, - LedgerMintCounter(u32), - MintLimitConfig, - OracleRotationProposal, - ArchiveRetention, - RoundTemplate, - Ext(DataKeyExt), -} - -#[contracttype] -#[derive(Clone)] -pub enum DataKeyExt { - LeaderboardWins, - LeaderboardStreak, - SeasonId, - SeasonUserStats(u32, Address), - SeasonLeaderboardWins, - SeasonLeaderboardStreak, - SeasonArchive(u32), -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - /// Optional protocol settlement fee in bps (Issue #162). - /// `None` disables the fee entirely, restoring pre-fee behaviour. - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - /// - /// The oracle service must generate a unique value per submission for a - /// given round (e.g. a monotonic counter or random 64-bit value). The - /// contract records each consumed nonce under - /// `DataKey::ConsumedOracleNonce(round_id, nonce)` and rejects any reuse, - /// making resolution idempotent against accidental duplicate submissions. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - /// Validated against `env.ledger().network_id()` to prevent cross-network replay. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - /// Validated against `env.current_contract_address()` to prevent cross-contract replay. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - /// When `None`, the payload is treated as a legacy submission. - /// When strict mode is enabled, `None` is rejected. - pub confidence: Option, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -/// Heartbeat health gate configuration (Issue #264). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct HbGateConfig { - pub strict_mode: bool, - pub override_armed: bool, - pub grace_seconds: u64, -} - -/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). -#[contracttype] -#[derive(Clone)] -pub enum HbGateKey { - Config, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, -} - -/// Composite protocol health status returned by `get_protocol_health`. -/// -/// Designed for operators to poll a single endpoint instead of stitching -/// together multiple read-only calls. -/// -/// ## Status code → alert severity mapping -/// -/// | code | label | severity | meaning | -/// |------|-----------------|----------|-------------------------------------------| -/// | 0 | HEALTHY | none | All subsystems nominal | -/// | 1 | PAUSED | critical | Contract is emergency-paused | -/// | 2 | ORACLE_STALE | warning | Oracle heartbeat is stale or offline | -/// | 3 | ROUND_STALE | warning | Round is past its end ledger but unresolved| -/// | 4 | NO_ACTIVE_ROUND | info | No round currently active (idle protocol) | -/// | 5 | MULTIPLE_ISSUES | critical | Two or more issues detected simultaneously| -/// -/// ## Phase codes (`active_round_phase`) -/// -/// | phase | meaning | -/// |-------|---------------------------------------------------| -/// | 0 | No active round | -/// | 1 | Betting open (`ledger < bet_end_ledger`) | -/// | 2 | Running / reveal window (`bet_end_ledger ≤ ledger < end_ledger`) | -/// | 3 | Resolvable (`ledger ≥ end_ledger`) | -/// -/// ## Oracle status codes (`oracle_status`) -/// -/// | code | meaning | -/// |------|----------------------------------------| -/// | 0 | Active (healthy heartbeat) | -/// | 1 | Degraded (heartbeat marked degraded) | -/// | 2 | Offline (heartbeat marked offline) | -/// | 3 | Unknown (no heartbeat record stored) | -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - /// Whether the contract is emergency-paused (`Paused == true`) - pub paused: bool, - /// Whether the oracle heartbeat is non-stale and not offline - pub oracle_live: bool, - /// Raw oracle heartbeat status (0=active, 1=degraded, 2=offline, 3=unknown) - pub oracle_status: u32, - /// Whether a round is currently active - pub has_active_round: bool, - /// Current round phase (0=no_round, 1=betting, 2=running, 3=resolvable) - pub active_round_phase: u32, - /// On-chain storage schema version - pub schema_version: u32, - /// Ledger sequence at which this health snapshot was taken - pub ledger_sequence: u32, - /// Ledger timestamp at which this health snapshot was taken - pub ledger_timestamp: u64, - /// Composite status code (see mapping table above) - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -/// -/// Designed for explorer/analytics queries without replaying events. -/// `price_final` is `0` for admin cancellations (no oracle settlement price). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -/// -/// The admin proposes a new oracle address with a timestamp-based expiry window. -/// After `expires_at` (ledger timestamp) the proposal is stale and acceptance -/// is rejected until the admin submits a fresh proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -/// -/// Designed for frontend state machines that need a single, stable code -/// instead of combining multiple boolean flags. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|--------------|-------------------------------------------------------------------------| -/// | 0 | `Active` | Not paused; a round is currently active (bets open or running). | -/// | 1 | `Paused` | Emergency-paused by the admin; no mutations accepted except unpause. | -/// | 2 | `ClaimsOnly` | Not paused; no active round. Only `claim_winnings` is meaningful. | -/// -/// ## Transition rules -/// -/// - `ClaimsOnly` → `Active` when `create_round()` succeeds. -/// - `Active` → `ClaimsOnly` when `resolve_round()` or `cancel_round()` completes. -/// - Any state → `Paused` when `pause_contract()` is called. -/// - `Paused` → `Active` when `unpause_contract()` is called *and* an active round still exists. -/// - `Paused` → `ClaimsOnly` when `unpause_contract()` is called *and* no active round exists. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - /// The contract is not paused and has a currently active round. - Active = 0, - /// The contract is emergency-paused by the admin. - Paused = 1, - /// The contract is not paused, but no round is active. - /// Mutating actions are limited to claiming pending winnings. - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -/// -/// Queries a round by its monotonic `round_id`. Covers all lifecycle -/// stages from creation through terminal settlement. -/// -/// ## Status codes -/// -/// | value | variant | description | -/// |-------|------------------|-----------------------------------------------------------------------------------| -/// | 0 | `Unknown` | Round does not exist or has been pruned from the on-chain archive. | -/// | 1 | `Betting` | Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). | -/// | 2 | `Running` | Betting closed; reveal window open (`bet_end_ledger ≤ ledger < end_ledger`). | -/// | 3 | `AwaitingResolve`| Round ended; awaiting oracle settlement (`ledger ≥ end_ledger`). | -/// | 4 | `Resolved` | Oracle settled the round; pot distributed to winners. | -/// | 5 | `Cancelled` | Admin cancelled the round; all stakes refunded. | -/// | 6 | `FallbackRefund` | Insufficient participants at settlement; all stakes refunded. | -/// -/// ## Transition rules -/// -/// - `Unknown` → `Betting` when `create_round()` succeeds. -/// - `Betting` → `Running` when `ledger ≥ bet_end_ledger` (derived; no on-chain write). -/// - `Running` → `AwaitingResolve` when `ledger ≥ end_ledger` (derived; no on-chain write). -/// - `{Betting | Running | AwaitingResolve}` → `Cancelled` when `cancel_round()` is called. -/// - `AwaitingResolve` → `Resolved` when `resolve_round()` settles with enough participants. -/// - `AwaitingResolve` → `FallbackRefund` when `resolve_round()` finds fewer than `min_participants`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - /// Round does not exist or has been pruned from the on-chain archive. - Unknown = 0, - /// Round is active; bets and predictions accepted (`ledger < bet_end_ledger`). - Betting = 1, - /// Betting is closed; reveal window is open (`bet_end_ledger ≤ ledger < end_ledger`). - Running = 2, - /// Round has ended and is waiting for oracle settlement (`ledger ≥ end_ledger`). - AwaitingResolve = 3, - /// Oracle settled the round normally; pot distributed to winners. - Resolved = 4, - /// Admin cancelled the round; all stakes refunded. - Cancelled = 5, - /// Settlement triggered but insufficient participants; all stakes refunded. - FallbackRefund = 6, -} - -/// Terminal outcome persisted per user per archived round. -/// -/// Allows `get_user_archived_participation` to answer profile/history -/// queries without replaying the full event stream. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Cancel = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, -} - -/// Admin-configured blueprint for `create_next_from_template`. -/// -/// Mirrors the arguments accepted by `create_round` (`start_price`, `mode`) -/// so a keeper can spin up the next round after a settle/cancel without an -/// operator re-specifying parameters each time. Validated with the exact -/// same rules `create_round` applies at creation time. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings, written by -/// `reset_leaderboard_season`. `participant_count` is the number of distinct -/// addresses that appeared in either bounded index at reset time (a lower -/// bound on total season participants beyond the tracked top -/// `LEADERBOARD_LIMIT`, mirroring the same bound the live indexes enforce). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} -// SPDX-License-Identifier: MIT -//! Type definitions for the XLM Price Prediction Market. - -use soroban_sdk::{contracttype, Address, BytesN, Env, IntoVal, Symbol, Val, Vec}; - -/// Round mode for prediction type -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundMode { - UpDown = 0, // Simple up/down predictions - Precision = 1, // Exact price predictions (Legends mode) -} - -/// Payout policy for Precision mode -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum PrecisionPayoutPolicy { - Equal = 0, // Split payout pool equally among winners (default) - StakeWeighted = 1, // Split payout pool proportionally to winner stakes -} - -/// Runtime mode for the contract lifecycle -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum RuntimeMode { - Normal = 0, - ClaimsOnly = 1, - FullyPaused = 2, -} - -/// Lifecycle phase of an active round, derived from ledger windows. -/// -/// Semantics (given `start_ledger`, `bet_end_ledger`, `end_ledger`): -/// - `Betting`: `ledger < bet_end_ledger` — bets and precision predictions accepted -/// - `Running`: `bet_end_ledger ≤ ledger < end_ledger` — reveal window (precision) -/// - `Resolvable`: `ledger ≥ end_ledger` — round may be settled via oracle payload -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundPhase { - Betting = 1, - Running = 2, - Resolvable = 3, -} - -/// Storage keys for contract data -/// -/// ## Indexed position keys (variants 13–15) -/// -/// `Position(round_id, address)` and `PrecisionPosition(round_id, address)` store -/// a single user's record under a composite key, enabling O(1) read/write per user -/// instead of deserializing the full participant map on every bet. -/// -/// `RoundParticipants(round_id)` holds the ordered `Vec
` used for -/// iteration at resolution time. Appending one address is cheaper than -/// re-serialising an N-entry `Map` for every bet placed. -/// -/// Legacy single-key maps (`UpDownPositions`, `PrecisionPositions`) are kept for -/// backward-compatible reads during a migration window; they are no longer written. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - Balance(Address), - Admin, - Oracle, - /// On-chain storage schema version for migration safety. - /// If missing, the contract treats it as legacy schema version 1. - SchemaVersion, - ActiveRound, - Positions, // Legacy key — read-only migration compat - UpDownPositions, // Legacy key — read-only migration compat - PrecisionPositions, // Legacy key — read-only migration compat - PendingWinnings(Address), - UserStats(Address), - Paused, - BetWindowLedgers, - RunWindowLedgers, - CloseBufferLedgers, - LastRoundId, - /// Per-user UpDown position: (round_id, address) → UserPosition - Position(u64, Address), - /// Per-user Precision prediction: (round_id, address) → PrecisionPrediction - PrecisionPosition(u64, Address), - /// Per-user Precision commitment: (round_id, address) → PrecisionCommitment - PrecisionCommitment(u64, Address), - /// Ordered participant list for a round: round_id → Vec
- RoundParticipants(u64), - /// Maximum stake allowed per individual bet (None = unlimited) - MaxStake, - /// Maximum cumulative exposure per user per round (None = unlimited) - MaxUserRoundExposure, - /// Maximum pending winnings allowed per account (None = unlimited) - MaxPendingWinnings, - /// Marker for a cancelled round: round_id → true - CancelledRound(u64), - /// Per-round consumed oracle nonce: (round_id, nonce) → true. - /// Used to reject duplicate oracle payload submissions for the same round. - ConsumedOracleNonce(u64, u64), - /// Minimum participant count for competitive settlement; unset = no minimum enforced - MinParticipants, - /// Oracle heartbeat: last recorded timestamp and status - OracleHeartbeat, - /// Stale-heartbeat threshold in seconds (admin-configurable); unset = 3600 s default - OracleStaleThreshold, - /// Maximum participants accepted in a Precision round; unset = protocol default - MaxPrecisionParticipants, - /// Oracle max deviation threshold in basis points (1 bp = 0.01%). - /// If unset, deviation guardrails are disabled. - OracleMaxDeviationBps, - /// One-shot admin override allowing the next settlement to bypass deviation checks. - /// Automatically cleared after use. - OracleDeviationOverrideArmed, - /// Minimum oracle confidence threshold in basis points (0–10000). - /// If unset, confidence guardrails are disabled. - OracleMinConfidenceBps, - /// When true, payloads with missing confidence are rejected in strict mode. - OracleStrictMode, - /// Compact post-settlement summary keyed by round id for historical queries. - ArchivedRound(u64), - /// Ordered round ids for archive retention (oldest at index 0). - RecentArchivedRoundIds, - /// Per-user outcome record for a specific archived round (round_id, user). - /// Persisted at settlement for user history queries without event replay. - UserRoundOutcome(u64, Address), - /// Marker written by migrate_schema_v2_to_v3 to prove the migration ran. - MigratedToV3, - /// Timelocked pending critical config change keyed by change kind. - PendingConfigChange(ConfigChangeKind), - /// Optional protocol settlement fee in basis points (1 bp = 0.01%). - /// `None` (key absent) means fee disabled — no behaviour change. - /// Hard cap on fee is enforced at the contract layer, not by storage shape. - ProtocolFeeBps, - /// On-chain accumulated protocol fee balance in stroops (i128). - /// Admin withdraws via the dedicated withdrawal method; does NOT mix - /// into the per-user balance ledger. - ProtocolFeeTreasury, - /// Per-ledger mint counter: wraps the explicit ledger sequence number. - LedgerMintCounter(u32), - /// Mint limit configuration: maximum number of mints allowed per ledger. - MintLimitConfig, - /// Pending two-step oracle rotation proposal with expiry. - OracleRotationProposal, - /// Configurable archive retention limit: maximum number of ArchivedRound entries - /// retained on-chain before the oldest are pruned (FIFO). If unset, the protocol - /// default is used. - ArchiveRetention, - /// Admin-configured blueprint used by `create_next_from_template` to spin - /// up the next round without re-specifying `start_price` / `mode` each - /// time. Absent means no template is configured. - RoundTemplate, - /// Bounded index of user addresses sorted by lifetime total wins - /// descending (all-time leaderboard, independent of seasons). - LeaderboardWins, - /// Bounded index of user addresses sorted by lifetime best streak - /// descending (all-time leaderboard, independent of seasons). - LeaderboardStreak, - /// Monotonically increasing id of the currently-active leaderboard - /// season. Absent is treated as season 1. - SeasonId, - /// Per-season, per-user win/loss/streak stats: (season_id, address) → - /// UserStats, scoped independently of the lifetime `UserStats` totals so - /// a season reset never touches lifetime history. - SeasonUserStats(u32, Address), - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped total wins descending. - SeasonLeaderboardWins, - /// Bounded index of user addresses in the *active* season sorted by - /// season-scoped best streak descending. - SeasonLeaderboardStreak, - /// Frozen snapshot of a season's final rankings, written when the season - /// is reset. Seasons are never deleted — this is a permanent archive. - SeasonArchive(u32), - /// Admin-configured multi-feed oracle quorum parameters. - /// When set, `resolve_round_multi` is enabled. - OracleQuorum, - /// Announced next schema version for migration preview (v-next template). - /// When set, operators can inspect this value before executing a real migration. - /// Absent means no next migration has been announced. - NextSchemaVersion, - /// Minimum bet amount (dust protection). Unset = no minimum. - MinBet, - /// Epoch mint budget: total mints allowed per epoch. - EpochMintBudget, - /// Early cash-out penalty in basis points. Unset = early cash-out disabled. - EarlyCashoutBps, - /// Fee incidence model: FeeOnPot (default) or FeeOnWinnings. - FeeModel, - /// Dispute window length in ledgers. 0 = no dispute window. - DisputeLedgers, -} - -/// Identifies which critical risk setting is pending timelocked activation. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ConfigChangeKind { - Windows = 0, - MaxStake = 1, - MaxUserRoundExposure = 2, - MaxPendingWinnings = 3, - OracleStaleThreshold = 4, - OracleMaxDeviationBps = 5, - ProtocolFeeBps = 6, - MinParticipants = 7, - MaxPrecisionParticipants = 8, - MintLimit = 9, - ArchiveRetention = 10, - CloseBufferLedgers = 11, - EpochMintBudget = 12, - PendingWinningsExpiry = 13, - PrecisionPayoutPolicy = 14, - MinBet = 15, - DisputeLedgers = 16, - FeeModel = 17, -} - -/// Payload for a scheduled critical config change. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ConfigChangePayload { - Windows(u32, u32), - MaxStake(Option), - MaxUserRoundExposure(Option), - MaxPendingWinnings(Option), - OracleStaleThreshold(u64), - OracleMaxDeviationBps(Option), - ProtocolFeeBps(Option), - MinParticipants(Option), - MaxPrecisionParticipants(u32), - MintLimit(u32), - ArchiveRetention(u32), - CloseBufferLedgers(u32), - EpochMintBudget(i128), - PendingWinningsExpiry(u32), - PrecisionPayoutPolicy(u32), - MinBet(Option), - DisputeLedgers(u32), - FeeModel(FeeModel), -} - -/// Pending timelocked config change with activation ledger for on-chain observability. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingConfigChange { - pub payload: ConfigChangePayload, - pub activation_ledger: u32, - pub scheduled_at_ledger: u32, -} - -/// Represents which side a user bet on -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum BetSide { - Up, - Down, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserPosition { - pub amount: i128, - pub side: BetSide, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserStats { - pub total_wins: u32, - pub total_losses: u32, - pub current_streak: u32, - pub best_streak: u32, -} - -/// Precision prediction entry (user address + predicted price) -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionPrediction { - pub user: Address, - pub predicted_price: u128, // Price scaled to 4 decimals (e.g., 0.2297 → 2297) - pub amount: i128, // Bet amount -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PrecisionCommitment { - pub hash: BytesN<32>, - pub amount: i128, - pub revealed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OraclePayload { - pub price: u128, - pub timestamp: u64, - /// Round identifier that should match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Optional confidence score from the price feed (0–10000 bps, where 10000 = 100%). - pub confidence: Option, - /// Optional ed25519 signature over the attestation domain-separated message. - pub attestation: Option>, -} - -/// Multi-feed oracle resolution payload (N observations, quorum + median). -/// -/// Unlike the legacy single-oracle `OraclePayload`, this carries N independent -/// feed observations as parallel arrays. The contract computes the median, -/// rejects outliers, and requires a configurable quorum of feeds to agree -/// within the outlier threshold before settlement proceeds. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct MultiFeedPayload { - /// Prices from each feed, scaled to 4 decimal places (e.g. 2297 = $0.2297). - pub prices: Vec, - /// Feed source identifiers (0-based index, max N-1). Must be unique. - pub sources: Vec, - /// Round identifier that must match `Round.start_ledger` - pub round_id: u32, - /// Per-round replay-protection nonce. - pub nonce: u64, - /// SHA-256 hash of the network passphrase this payload targets. - pub network_id: BytesN<32>, - /// Contract address this payload is intended for. - pub contract_addr: Address, - /// Unix epoch seconds when the observations were collected. - pub timestamp: u64, -} - -/// Admin-configurable quorum and outlier rejection parameters for multi-feed -/// oracle settlement. Stored under `DataKey::OracleQuorum`. -/// -/// When set, `resolve_round_multi` becomes the preferred settlement path. -/// The legacy single-oracle `resolve_round` path remains available -/// independently of this configuration. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleQuorumConfig { - /// Minimum number of unique feed observations required in a multi-feed payload. - pub min_observations: u32, - /// Minimum number of observations that must survive outlier rejection to - /// form a valid quorum and proceed to settlement. - pub quorum_threshold: u32, - /// Maximum deviation from the median (in basis points, 1 bp = 0.01%) - /// before an observation is rejected as an outlier. - pub outlier_threshold_bps: u32, -} - -/// Oracle liveness record, updated by the oracle service on each heartbeat call. -/// `status`: 0 = active, 1 = degraded, 2 = offline. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleHeartbeatRecord { - pub timestamp: u64, - pub status: u32, -} - -/// Heartbeat health gate configuration (Issue #264). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct HbGateConfig { - pub strict_mode: bool, - pub override_armed: bool, - pub grace_seconds: u64, -} - -/// Storage key for heartbeat gate config (separate from DataKey to stay within variant limits, Issue #264). -#[contracttype] -#[derive(Clone)] -pub enum HbGateKey { - Config, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Round { - pub round_id: u64, // Unique monotonically increasing round identifier - pub price_start: u128, // Starting XLM price in stroops - pub start_ledger: u32, // Ledger when round was created - pub bet_end_ledger: u32, // Ledger when betting closes - pub end_ledger: u32, // Ledger when round ends (~5s per ledger) - pub pool_up: i128, // Total vXLM bet on UP - pub pool_down: i128, // Total vXLM bet on DOWN - pub mode: RoundMode, // Round mode: UpDown (0) or Precision (1) -} - -/// Aggregated active-round pool composition for frontend transparency. -/// -/// Up/Down rounds populate the up/down pools, counts, and stake ratios. -/// Precision rounds populate the precision totals and participant counters while -/// leaving side-specific Up/Down fields at zero. Ratios are basis points of -/// the mode's total visible stake (10_000 = 100%). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundPoolStats { - pub round_id: u64, - pub mode: RoundMode, - pub total_up_stake: i128, - pub total_down_stake: i128, - pub up_participant_count: u32, - pub down_participant_count: u32, - pub up_stake_ratio_bps: u32, - pub down_stake_ratio_bps: u32, - pub precision_total_stake: i128, - pub precision_participant_count: u32, - pub precision_prediction_count: u32, - pub precision_commitment_count: u32, - pub precision_revealed_count: u32, -} - -/// Terminal outcome recorded when a round leaves the active state. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundArchiveStatus { - /// Oracle settlement completed (normal resolution path). - Resolved = 0, - /// Admin cancelled the round and refunded participants. - Cancelled = 1, - /// Settlement aborted due to insufficient participants; stakes refunded. - FallbackRefund = 2, - /// Dispute window ended via void; all participants refunded their stake. - Voided = 3, -} - -/// Composite protocol health status returned by `get_protocol_health`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ProtocolHealthStatus { - pub paused: bool, - pub oracle_live: bool, - pub oracle_status: u32, - pub has_active_round: bool, - pub active_round_phase: u32, - pub schema_version: u32, - pub ledger_sequence: u32, - pub ledger_timestamp: u64, - pub status_code: u32, -} - -/// Compact historical round summary persisted after resolve or cancel. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ArchivedRoundSummary { - pub round_id: u64, - pub price_start: u128, - pub price_final: u128, - pub mode: RoundMode, - pub status: RoundArchiveStatus, - pub pool_up: i128, - pub pool_down: i128, - pub participant_count: u32, - pub settled_at_ledger: u32, -} - -/// Pending two-step oracle rotation proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OracleRotationProposal { - pub new_oracle: Address, - pub proposed_at: u64, - pub expires_at: u64, -} - -/// Global status of the protocol, returned by `get_protocol_status`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum ProtocolStatus { - Active = 0, - Paused = 1, - ClaimsOnly = 2, -} - -/// Status of a specific round, returned by `get_round_status(round_id)`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum RoundStatus { - Unknown = 0, - Betting = 1, - Running = 2, - AwaitingResolve = 3, - Resolved = 4, - Cancelled = 5, - FallbackRefund = 6, - Voided = 7, -} - -/// Terminal outcome persisted per user per archived round. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum UserOutcomeType { - Win = 0, - Loss = 1, - Refund = 2, - Void = 3, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UserRoundOutcome { - pub user: Address, - pub round_mode: u32, - pub prediction_side: u32, - pub predicted_price: u128, - pub stake: i128, - pub payout: i128, - pub outcome: UserOutcomeType, -} - -/// Simulated payout result for a specific hypothetical final price. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SimulationResult { - pub mode: RoundMode, - pub pool_up: i128, - pub pool_down: i128, - pub precision_total_stake: i128, - pub fee_amount: i128, - pub outcomes: Vec, - pub fee_model: u32, -} - -/// Admin-configured blueprint for `create_next_from_template`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RoundTemplate { - pub start_price: u128, - pub mode: Option, -} - -/// A single entry in the lifetime (all-time) leaderboard. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LeaderboardEntry { - pub user: Address, - pub stats: UserStats, -} - -/// A single entry in a season-scoped leaderboard, live or archived. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonLeaderboardEntry { - pub user: Address, - pub wins: u32, - pub best_streak: u32, -} - -/// Frozen snapshot of a season's final bounded rankings. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SeasonArchive { - pub season_id: u32, - pub ended_at_ledger: u32, - pub wins: Vec, - pub streak: Vec, - pub participant_count: u32, -} - -/// Configurable pending-winnings expiry in ledgers. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsExpiryKey(pub ()); - -pub const PENDING_WINNINGS_EXPIRY_KEY: PendingWinningsExpiryKey = PendingWinningsExpiryKey(()); - -/// Ledger sequence when a user's pending winnings entry was last modified. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PendingWinningsUpdatedAtKey(pub Address); - -/// Fee incidence model for protocol fees (Issue #268). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum FeeModel { - FeeOnPot = 0, // Fee charged on total pot (default) - FeeOnWinnings = 1, // Fee charged only on net winnings/profit -} - -/// TWAP sample ring entry (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PriceSample { - pub price: u128, - pub timestamp: u64, -} - -/// Storage key for TWAP samples ring (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum TwapSamplesKey { - Samples, -} - -/// Dev Reference Mode (Issue #266). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(u32)] -pub enum DeviationReferenceMode { - StartPrice = 0, // Use round.price_start (default) - Twap = 1, // Use trailing-sample TWAP average -} - -/// Deviation guardrail config (Issue #266). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct DeviationConfig { - pub reference_mode: DeviationReferenceMode, - pub window_samples: u32, -} - -/// Storage key for deviation config (separate from DataKey to stay within variant limits, Issue #266). -#[contracttype] -#[derive(Clone)] -pub enum DeviationConfigKey { - Config, -} - -/// Oracle attestation config (Issue #263). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct AttestationConfig { - pub key: Option>, // ed25519 public key; None = attestation disabled -} - -/// Storage key for attestation config (separate from DataKey to stay within variant limits, Issue #263). -#[contracttype] -#[derive(Clone)] -pub enum AttestationConfigKey { - Config, -} - -impl IntoVal for DataKey { - fn into_val(&self, env: &Env) -> Val { - use Symbol as S; - match self { - DataKey::Balance(a) => (S::new(env, "Balance"), a.clone()).into_val(env), - DataKey::Admin => S::new(env, "Admin").into_val(env), - DataKey::Oracle => S::new(env, "Oracle").into_val(env), - DataKey::SchemaVersion => S::new(env, "SchemaVersion").into_val(env), - DataKey::ActiveRound => S::new(env, "ActiveRound").into_val(env), - DataKey::Positions => S::new(env, "Positions").into_val(env), - DataKey::UpDownPositions => S::new(env, "UpDownPositions").into_val(env), - DataKey::PrecisionPositions => S::new(env, "PrecisionPositions").into_val(env), - DataKey::PendingWinnings(a) => { - (S::new(env, "PendingWinnings"), a.clone()).into_val(env) - } - DataKey::UserStats(a) => (S::new(env, "UserStats"), a.clone()).into_val(env), - DataKey::Paused => S::new(env, "Paused").into_val(env), - DataKey::BetWindowLedgers => S::new(env, "BetWindowLedgers").into_val(env), - DataKey::RunWindowLedgers => S::new(env, "RunWindowLedgers").into_val(env), - DataKey::CloseBufferLedgers => S::new(env, "CloseBufferLedgers").into_val(env), - DataKey::LastRoundId => S::new(env, "LastRoundId").into_val(env), - DataKey::Position(id, a) => { - (S::new(env, "Position"), id, a.clone()).into_val(env) - } - DataKey::PrecisionPosition(id, a) => { - (S::new(env, "PrecisionPosition"), id, a.clone()).into_val(env) - } - DataKey::PrecisionCommitment(id, a) => { - (S::new(env, "PrecisionCommitment"), id, a.clone()).into_val(env) - } - DataKey::RoundParticipants(id) => { - (S::new(env, "RoundParticipants"), id).into_val(env) - } - DataKey::MaxStake => S::new(env, "MaxStake").into_val(env), - DataKey::MaxUserRoundExposure => S::new(env, "MaxUserRoundExposure").into_val(env), - DataKey::MaxPendingWinnings => S::new(env, "MaxPendingWinnings").into_val(env), - DataKey::CancelledRound(id) => (S::new(env, "CancelledRound"), id).into_val(env), - DataKey::ConsumedOracleNonce(id, nonce) => { - (S::new(env, "ConsumedOracleNonce"), id, nonce).into_val(env) - } - DataKey::MinParticipants => S::new(env, "MinParticipants").into_val(env), - DataKey::OracleHeartbeat => S::new(env, "OracleHeartbeat").into_val(env), - DataKey::OracleStaleThreshold => S::new(env, "OracleStaleThreshold").into_val(env), - DataKey::MaxPrecisionParticipants => { - S::new(env, "MaxPrecisionParticipants").into_val(env) - } - DataKey::OracleMaxDeviationBps => S::new(env, "OracleMaxDeviationBps").into_val(env), - DataKey::OracleDeviationOverrideArmed => { - S::new(env, "OracleDeviationOverrideArmed").into_val(env) - } - DataKey::OracleMinConfidenceBps => { - S::new(env, "OracleMinConfidenceBps").into_val(env) - } - DataKey::OracleStrictMode => S::new(env, "OracleStrictMode").into_val(env), - DataKey::ArchivedRound(id) => (S::new(env, "ArchivedRound"), id).into_val(env), - DataKey::RecentArchivedRoundIds => { - S::new(env, "RecentArchivedRoundIds").into_val(env) - } - DataKey::UserRoundOutcome(id, a) => { - (S::new(env, "UserRoundOutcome"), id, a.clone()).into_val(env) - } - DataKey::MigratedToV3 => S::new(env, "MigratedToV3").into_val(env), - DataKey::PendingConfigChange(k) => { - (S::new(env, "PendingConfigChange"), k.clone()).into_val(env) - } - DataKey::ProtocolFeeBps => S::new(env, "ProtocolFeeBps").into_val(env), - DataKey::ProtocolFeeTreasury => S::new(env, "ProtocolFeeTreasury").into_val(env), - DataKey::LedgerMintCounter(id) => { - (S::new(env, "LedgerMintCounter"), id).into_val(env) - } - DataKey::MintLimitConfig => S::new(env, "MintLimitConfig").into_val(env), - DataKey::OracleRotationProposal => S::new(env, "OracleRotationProposal").into_val(env), - DataKey::ArchiveRetention => S::new(env, "ArchiveRetention").into_val(env), - DataKey::RoundTemplate => S::new(env, "RoundTemplate").into_val(env), - DataKey::LeaderboardWins => S::new(env, "LeaderboardWins").into_val(env), - DataKey::LeaderboardStreak => S::new(env, "LeaderboardStreak").into_val(env), - DataKey::SeasonId => S::new(env, "SeasonId").into_val(env), - DataKey::SeasonUserStats(sid, a) => { - (S::new(env, "SeasonUserStats"), sid, a.clone()).into_val(env) - } - DataKey::SeasonLeaderboardWins => S::new(env, "SeasonLeaderboardWins").into_val(env), - DataKey::SeasonLeaderboardStreak => { - S::new(env, "SeasonLeaderboardStreak").into_val(env) - } - DataKey::SeasonArchive(id) => (S::new(env, "SeasonArchive"), id).into_val(env), - DataKey::OracleQuorum => S::new(env, "OracleQuorum").into_val(env), - DataKey::NextSchemaVersion => S::new(env, "NextSchemaVersion").into_val(env), - DataKey::MinBet => S::new(env, "MinBet").into_val(env), - DataKey::EpochMintBudget => S::new(env, "EpochMintBudget").into_val(env), - DataKey::EarlyCashoutBps => S::new(env, "EarlyCashoutBps").into_val(env), - DataKey::FeeModel => S::new(env, "FeeModel").into_val(env), - DataKey::DisputeLedgers => S::new(env, "DisputeLedgers").into_val(env), - } - } -} +// SPDX-License-Identifier: MIT +//! Type definitions for the XLM Price Prediction Market. + +use soroban_sdk::{contracttype, Address, BytesN, Vec}; + +/// Round mode for prediction type +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum RoundMode { + UpDown = 0, // Simple up/down predictions + Precision = 1, // Exact price predictions (Legends mode) +} + +/// Payout policy for Precision mode +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum PrecisionPayoutPolicy { + Equal = 0, // Split payout pool equally among winners (default) + StakeWeighted = 1, // Split payout pool proportionally to winner stakes +} + +/// Runtime mode for the contract lifecycle +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum RuntimeMode { + Normal = 0, + ClaimsOnly = 1, + FullyPaused = 2, +} + +/// Lifecycle phase of an active round, derived from ledger windows. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum RoundPhase { + Betting = 1, + Running = 2, + Resolvable = 3, +} + +/// Deterministic settlement policy governing degenerate (one-sided) market rounds. +#[contracttype] +#[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 type Policy = OneSidedPolicy; + +/// Resolved participant access state for allowlist/denylist gating. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum AccessState { + Open = 0, + Allowlisted = 1, + Denylisted = 2, +} + +/// Parameterless system, config, and metadata storage keys. +#[contracttype] +#[derive(Clone)] +pub enum DataKeyCore { + Admin, + Oracle, + /// On-chain storage schema version for migration safety. + SchemaVersion, + ActiveRound, + Positions, // Legacy key — read-only migration compat + UpDownPositions, // Legacy key — read-only migration compat + PrecisionPositions, // Legacy key — read-only migration compat + Paused, + BetWindowLedgers, + RunWindowLedgers, + CloseBufferLedgers, + LastRoundId, + MaxStake, + MaxUserRoundExposure, + MaxPendingWinnings, + MinParticipants, + OracleHeartbeat, + OracleStaleThreshold, + MaxPrecisionParticipants, + OracleMaxDeviationBps, + OracleDeviationOverrideArmed, + OracleMinConfidenceBps, + OracleStrictMode, + RecentArchivedRoundIds, + MigratedToV3, + ProtocolFeeBps, + ProtocolFeeTreasury, + MintLimitConfig, + OracleRotationProposal, + ArchiveRetention, + RoundTemplate, + LeaderboardWins, + LeaderboardStreak, + SeasonId, + SeasonLeaderboardWins, + SeasonLeaderboardStreak, + OracleQuorum, + NextSchemaVersion, + MinBet, + EpochMintBudget, + EarlyCashoutBps, + FeeModel, + DisputeLedgers, + PrecisionPayoutPolicy, + AccessControlEnabled, + NextGovProposalId, + GovApprover, + GovProposalTtlLedgers, +} + +/// Parameterised and round-scoped storage keys. +#[contracttype] +#[derive(Clone)] +pub enum DataKeyScoped { + Balance(Address), + PendingWinnings(Address), + UserStats(Address), + Position(u64, Address), + PrecisionPosition(u64, Address), + PrecisionCommitment(u64, Address), + RoundParticipants(u64), + CancelledRound(u64), + ConsumedOracleNonce(u64, u64), + UserRoundOutcome(u64, Address), + UserArchivedRoundIds(Address), + PendingConfigChange(ConfigChangeKind), + LedgerMintCounter(u32), + ArchivedRound(u64), + SeasonUserStats(u32, Address), + SeasonArchive(u32), + Allowlisted(Address), + Denylisted(Address), + GovProposal(u64), +} + +pub type DataKey = DataKeyCore; + +/// 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, + PrecisionPayoutPolicy = 12, + MinBet = 13, + EpochMintBudget = 14, + EarlyCashoutBps = 15, + FeeModel = 16, + DisputeLedgers = 17, + OracleTimestampSkew = 18, + PendingWinningsExpiry = 19, +} + +/// Payload for a scheduled critical config change. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ConfigChangePayload { + Windows(u32, u32), + MaxStake(Option), + MaxUserRoundExposure(Option), + MaxPendingWinnings(Option), + OracleStaleThreshold(u64), + OracleMaxDeviationBps(Option), + ProtocolFeeBps(Option), + MinParticipants(Option), + MaxPrecisionParticipants(u32), + MintLimit(u32), + ArchiveRetention(u32), + CloseBufferLedgers(u32), + PrecisionPayoutPolicy(u32), + MinBet(Option), + EpochMintBudget(i128), + EarlyCashoutBps(Option), + FeeModel(FeeModel), + DisputeLedgers(u32), + OracleTimestampSkew(u64), + PendingWinningsExpiry(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, +} + +/// Actions protected by dual-approval governance (Issue #272) +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum GovAction { + PauseProtocol, + UnpauseProtocol, + SetProtocolFeeBps(Option), + WithdrawProtocolFee(Address, i128), + SetTreasuryAddress(Address), + SetAdmin(Address), + SetOracle(Address), +} + +/// Lifecycle status of a dual-approval governance proposal +#[contracttype] +#[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 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, +} + +/// Policy action kind for governance audit logs +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum PolicyAction { + RoundMutation = 0, + Claim = 1, + AdminConfig = 2, + Settlement = 3, + AllowlistAdd = 4, + AllowlistRemove = 5, + DenylistAdd = 6, + DenylistRemove = 7, + ToggleAccessControl = 8, +} + +/// 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, + pub amount: i128, +} + +#[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, + pub round_id: u32, + pub nonce: u64, + pub network_id: BytesN<32>, + pub contract_addr: Address, + pub confidence: Option, + pub attestation: Option>, +} + +/// Multi-feed oracle payload containing aggregated reports. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MultiFeedPayload { + pub price: u128, + pub timestamp: u64, + pub round_id: u32, + pub nonce: u64, + pub network_id: BytesN<32>, + pub contract_addr: Address, + pub confidence: Option, + pub reports: Vec, + pub prices: Vec, + pub sources: Vec, +} + +/// Oracle quorum configuration. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OracleQuorumConfig { + pub min_observations: u32, + pub quorum_threshold: u32, + pub outlier_threshold_bps: u32, + pub min_reports: u32, + pub max_skew_seconds: u64, +} + +/// Oracle liveness record, updated by the oracle service on each heartbeat call. +#[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, + 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. +#[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 { + Resolved = 0, + Cancelled = 1, + FallbackRefund = 2, + Voided = 3, +} + +/// Health-check gate config for oracle heartbeat strictness and override state. +#[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. +#[contracttype] +#[derive(Clone)] +pub enum HbGateKey { + Config, +} + +/// 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, + Cancel = 3, + Void = 4, +} + +#[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, +}