diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 778e941c..a5b9c088 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -252,24 +252,109 @@ This document provides a complete API reference for the Predictify Hybrid smart **Purpose**: Read-only query interface for retrieving market, user, and contract state information. -### Primary Functions +**Verification method**: `grep -n "pub fn " contracts/predictify-hybrid/src/*.rs` + +### Status Key + +- **Implemented** — function exists and returns real data. +- **Stubbed** — function exists but returns a placeholder / zero value; linked to tracking issue. +- **Planned** — no implementation exists yet. + +--- + +### QueryManager — Implemented Functions + +All functions below live on `QueryManager` in `queries.rs` unless a different call path is noted. + +#### Admin & Multisig Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_admin_role` | `(env, admin: Address) → Result` | **Implemented** | +| `query_admin_roles` | `(env) → Result, Error>` | **Implemented** | +| `query_has_permission` | `(env, admin: Address, action: String) → Result` | **Implemented** | +| `query_multisig_config` | `(env) → Result` | **Implemented** | +| `query_requires_multisig` | `(env, action: String) → Result` | **Implemented** | + +`get_permissions_for_role` is **Implemented** but not surfaced via `QueryManager`; call `AdminRoleManager::get_permissions_for_role(env, role)` in `admin.rs` directly. + +#### Oracle Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_approved_oracles` | `(env) → Result, Error>` | **Implemented** | +| `query_oracle_metadata` | `(env, oracle: Address) → Result` | **Implemented** | + +`get_oracle_resolution` is **Stubbed** — `ResolutionManager::get_oracle_resolution(env, market_id)` in `resolution.rs` always returns `Ok(None)`; full persistence not yet implemented (see issue #595). + +`get_global_oracle_config` is **Planned** — no implementation exists in any module. + +#### Dispute Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_dispute_stats` | `(env, market_id: Symbol) → Result` | **Implemented** | +| `query_market_disputes` | `(env, market_id: Symbol) → Result, Error>` | **Implemented** | +| `query_dispute_votes` | `(env, dispute_id: Symbol) → Result, Error>` | **Implemented** | -**QueryManager** +`get_dispute_timeout_status` is **Implemented** but not surfaced via `QueryManager`; call `DisputeManager::get_dispute_timeout_status(env, dispute_id)` in `disputes.rs` directly. -**Market/Event Queries** -- `query_event_details(env, market_id)` - Get complete market information -- `query_event_status(env, market_id)` - Get market status and end time -- `get_all_markets(env)` - Get list of all market IDs +#### Governance Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_proposals` | `(env) → Result, Error>` | **Implemented** — delegates to `GovernanceContract::list_proposals` in `governance.rs` | +| `query_proposal_details` | `(env, proposal_id: Symbol) → Result` | **Implemented** — delegates to `GovernanceContract::get_proposal` in `governance.rs` | + +#### Market / Event Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_event_details` | `(env, market_id: Symbol) → Result` | **Implemented** — includes `created_at` | +| `query_event_status` | `(env, market_id: Symbol) → Result<(MarketStatus, u64), Error>` | **Implemented** | +| `get_all_markets` | `(env) → Result, Error>` | **Implemented** | +| `get_all_markets_paged` | `(env, cursor: u32, limit: u32) → Result` | **Implemented** | + +#### User Bet Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_user_bet` | `(env, user: Address, market_id: Symbol) → Result` | **Implemented** — includes `voted_at` | +| `query_user_bets` | `(env, user: Address) → Result` | **Implemented** | +| `query_user_bets_paged` | `(env, user: Address, cursor: u32, limit: u32) → Result` | **Implemented** | + +#### Balance & Pool Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_user_balance` | `(env, user: Address) → Result` | **Stubbed** — `available_balance` and `total_winnings` fields return `0`; token-contract integration pending (see issue #595) | +| `query_market_pool` | `(env, market_id: Symbol) → Result` | **Stubbed** — `platform_fees` field returns `0`; fees-module integration pending (see issue #595) | +| `query_total_pool_size` | `(env) → Result` | **Implemented** | + +#### Contract State Queries + +| Function | Signature | Status | +|----------|-----------|--------| +| `query_contract_state` | `(env) → Result` | **Stubbed** — core counters implemented; `unique_users` proxied via `DashboardStatisticsV1`; `total_fees_collected` from `StatisticsManager` (see issue #595) | +| `query_contract_state_paged` | `(env, cursor: u32, limit: u32) → Result<(ContractStateQuery, u32), Error>` | **Stubbed** — same caveats as above | + +--- + +### Bet Limit Getters (outside QueryManager) + +| Function | Call path | Status | +|----------|-----------|--------| +| `get_effective_bet_limits` | `bets::get_effective_bet_limits(env, market_id)` in `bets.rs`; also exposed as contract entry-point in `lib.rs` | **Implemented** | +| `get_global_bet_limits` | No standalone getter; limits are read internally by `get_effective_bet_limits` | **Planned** | + +--- -**User Bet Queries** -- `query_user_bet(env, user, market_id)` - Get user's participation details -- `query_user_bets(env, user)` - Get all user's bets across markets -- `query_user_balance(env, user)` - Get user balance for each asset -- `query_market_pool(env, market_id)` - Get market pool statistics +### Config Getters (outside QueryManager) -**Contract State Queries** -- `query_total_pool_size(env)` - Get total platform staking -- `query_contract_state(env)` - Get overall contract state and status +| Function | Call path | Status | +|----------|-----------|--------| +| `get_config` | `config::ConfigManager::get_config(env)` in `config.rs` | **Implemented** | +| `get_configuration_history` | `config::ConfigManager::get_configuration_history(env, limit)` in `config.rs` | **Implemented** | --- diff --git a/contracts/predictify-hybrid/src/queries.rs b/contracts/predictify-hybrid/src/queries.rs index 7b865d37..766afbc4 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -16,18 +16,67 @@ //! 3. **Contract State Queries** - Retrieve global contract state and statistics //! 4. **Analytics Queries** - Get aggregated market analytics and performance metrics //! -//! # Gap Analysis (2026-04-23) +//! # Gap Analysis — Reconciled (2026-06-18, supersedes 2026-04-23) //! -//! The following gaps were identified between the published API spec and current implementation: +//! Status of every getter originally flagged in the gap analysis. +//! Verification: `grep -n "pub fn " contracts/predictify-hybrid/src/**/*.rs` //! -//! - **Missing Admin Getters**: `get_admin_role`, `get_admin_roles`, `has_permission`, `get_permissions_for_role` -//! - **Missing Multisig Getters**: `get_multisig_config`, `requires_multisig` -//! - **Missing Bet Limit Getters**: `get_effective_bet_limits`, `get_global_bet_limits` -//! - **Missing Oracle Getters**: `get_oracle_resolution`, `get_approved_oracles`, `get_oracle_metadata`, `get_global_oracle_config` -//! - **Missing Dispute Getters**: `get_dispute_stats`, `get_market_disputes`, `get_dispute_votes`, `get_dispute_timeout_status` -//! - **Missing Governance Getters**: `list_proposals`, `get_proposal` -//! - **Missing Config Getters**: `get_config`, `get_configuration_history` -//! - **Inconsistencies**: `query_event_details` (missing `created_at`), `query_user_bet` (missing `voted_at`), `query_contract_state` (stubbed metrics) +//! ## Admin / Multisig Getters +//! +//! | Getter | Status | Call path | +//! |--------|--------|-----------| +//! | `get_admin_role` / `get_admin_roles` | **Implemented** | `QueryManager::query_admin_role` / `query_admin_roles` (this module) → `AdminManager` in `admin.rs` | +//! | `has_permission` | **Implemented** | `QueryManager::query_has_permission` (this module) → `AdminManager::validate_admin_permission` in `admin.rs` | +//! | `get_permissions_for_role` | **Implemented** | Not surfaced via `QueryManager`; call `AdminRoleManager::get_permissions_for_role` in `admin.rs` directly | +//! | `get_multisig_config` | **Implemented** | `QueryManager::query_multisig_config` (this module) | +//! | `requires_multisig` | **Implemented** | `QueryManager::query_requires_multisig` (this module) → `MultisigManager::requires_multisig` in `admin.rs` | +//! +//! ## Bet Limit Getters +//! +//! | Getter | Status | Call path | +//! |--------|--------|-----------| +//! | `get_effective_bet_limits` | **Implemented** | Not surfaced via `QueryManager`; call `get_effective_bet_limits(env, market_id)` in `bets.rs` directly, or via `lib.rs` contract entry-point | +//! | `get_global_bet_limits` | **Planned** | No standalone getter exists; global limits are read inside `get_effective_bet_limits` in `bets.rs` | +//! +//! ## Oracle Getters +//! +//! | Getter | Status | Call path | +//! |--------|--------|-----------| +//! | `get_oracle_resolution` | **Stubbed** | `ResolutionManager::get_oracle_resolution` in `resolution.rs` — always returns `Ok(None)`; full persistence not yet implemented | +//! | `get_approved_oracles` | **Implemented** | `QueryManager::query_approved_oracles` (this module) → `OracleWhitelist::get_approved_oracles` in `oracles.rs` | +//! | `get_oracle_metadata` | **Implemented** | `QueryManager::query_oracle_metadata` (this module) → persistent storage via `OracleWhitelistKey` in `oracles.rs` | +//! | `get_global_oracle_config` | **Planned** | No implementation exists in any module | +//! +//! ## Dispute Getters +//! +//! | Getter | Status | Call path | +//! |--------|--------|-----------| +//! | `get_dispute_stats` | **Implemented** | `QueryManager::query_dispute_stats` (this module) → `DisputeManager::get_dispute_stats` in `disputes.rs` | +//! | `get_market_disputes` | **Implemented** | `QueryManager::query_market_disputes` (this module) → `DisputeManager::get_market_disputes` in `disputes.rs` | +//! | `get_dispute_votes` | **Implemented** | `QueryManager::query_dispute_votes` (this module) → `DisputeManager::get_dispute_votes` in `disputes.rs` | +//! | `get_dispute_timeout_status` | **Implemented** | Not surfaced via `QueryManager`; call `DisputeManager::get_dispute_timeout_status` in `disputes.rs` directly | +//! +//! ## Governance Getters +//! +//! | Getter | Status | Call path | +//! |--------|--------|-----------| +//! | `list_proposals` | **Implemented** | `QueryManager::query_proposals` (this module) → `GovernanceContract::list_proposals` in `governance.rs` | +//! | `get_proposal` | **Implemented** | `QueryManager::query_proposal_details` (this module) → `GovernanceContract::get_proposal` in `governance.rs` | +//! +//! ## Config Getters +//! +//! | Getter | Status | Call path | +//! |--------|--------|-----------| +//! | `get_config` | **Implemented** | Not surfaced via `QueryManager`; call `ConfigManager::get_config` in `config.rs` directly | +//! | `get_configuration_history` | **Implemented** | Not surfaced via `QueryManager`; call `ConfigManager::get_configuration_history` in `config.rs` directly | +//! +//! ## Previously-Noted Inconsistencies +//! +//! | Issue | Status | +//! |-------|--------| +//! | `query_event_details` missing `created_at` | **Fixed** — field populated from `EventManager::get_event` | +//! | `query_user_bet` missing `voted_at` | **Fixed** — field populated from `BetManager::get_bet` timestamp | +//! | `query_contract_state` stubbed metrics (`unique_users`, `total_fees_collected`) | **Stubbed** — `unique_users` proxied via `DashboardStatisticsV1`; `total_fees_collected` sourced from `StatisticsManager`; token-balance fields still TODO (see issue #595) | use alloc::string::ToString; diff --git a/docs/README.md b/docs/README.md index 02c9b7d7..36c0a1a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ Complete API reference for Predictify Hybrid contract, including: Comprehensive security documentation and guidelines: +- **[Threat Model](./security/THREAT_MODEL.md)** - Oracle-resolution and dispute-attack threat model with code-grounded citations - **[Attack Vectors](./security/ATTACK-VECTORS.md)** - Known attack vectors and mitigation strategies - **[Audit Checklist](./security/AUDIT_CHECKLIST.md)** - Security audit requirements and checklist - **[Soroban SDK Workspace Audit](./security/SOROBAN_SDK_AUDIT.md)** - Workspace Soroban SDK target, verification steps, and audit notes for Protocol 25 alignment diff --git a/docs/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md new file mode 100644 index 00000000..e6b551f3 --- /dev/null +++ b/docs/security/THREAT_MODEL.md @@ -0,0 +1,203 @@ +# Oracle-Resolution and Dispute-Attack Threat Model + +This document is the single, code-grounded threat model for the oracle-resolution and dispute subsystems of the Predictify Hybrid contract. It enumerates each threat, maps it to the concrete defense in the source, and cites the relevant `Error` variant from `contracts/predictify-hybrid/src/err.rs`. + +For broader attack-surface context (reentrancy, access control, flash-loan), see [ATTACK-VECTORS.md](./ATTACK-VECTORS.md). +For system-wide security considerations see [SECURITY_CONSIDERATIONS.md](./SECURITY_CONSIDERATIONS.md). +For the Security Features and Oracle/Dispute Management API surface see [API_DOCUMENTATION.md](../api/API_DOCUMENTATION.md). + +--- + +## Scope and Assets + +| Asset | Description | +|---|---| +| Market outcome | The string stored as the canonical resolution of a prediction market | +| User funds | Bet stakes and dispute stakes locked in the contract | +| Oracle data | Price/confidence data consumed from Reflector or Pyth feeds | +| Dispute votes | Stake-weighted community votes that can overturn an oracle result | + +**Threat actors**: malicious oracle operators, bot-driven dispute spammers, economic attackers with large stake, colluding voters. + +--- + +## 1. Oracle Subsystem Threats + +### 1.1 Oracle Manipulation (Feed Poisoning) + +**Threat**: An attacker controls or bribes one oracle source and submits a fabricated price to force a false market outcome. + +**Defense — Oracle whitelist** +Only addresses registered in `OracleWhitelist` (`oracles.rs`, `OracleWhitelistKey`) are accepted. Unregistered callers are rejected by `OracleWhitelist::validate_oracle_contract`. +Relevant errors: `Error::OracleCallbackUnauthorized = 211`, `Error::OracleCallbackAuthFailed = 210`. + +**Defense — Multi-source consensus** +`OracleIntegrationManager` (`oracles.rs`, line 2594) fetches from all active sources and requires `DEFAULT_CONSENSUS_THRESHOLD = 66` (66 %, i.e. ≥ 2/3 majority) before accepting any outcome. A single compromised source cannot reach the threshold alone. +Relevant error: `Error::OracleNoConsensus = 203`. + +**Defense — Callback replay prevention** +Each oracle callback carries a nonce/timestamp checked against stored state; replays are rejected immediately. +Relevant error: `Error::OracleCallbackReplayDetected = 213`. + +**Residual risk**: An attacker who controls ≥ 2/3 of whitelisted oracle sources could still manipulate a result. Mitigation is operational: the whitelist should hold independently-operated, geographically-distributed providers. + +--- + +### 1.2 Stale-Price Exploitation + +**Threat**: An attacker triggers resolution using cached oracle data that is outdated, selecting a favorable historical price. + +**Defense — Staleness validation** +`OracleValidationConfigManager::validate_oracle_data` (`oracles.rs`, line 2428) computes `observed_age = now - data.publish_time` and rejects data exceeding the configured threshold. +Default: `DEFAULT_MAX_STALENESS_SECS = 60` seconds (global config, `oracles.rs`, line 2346). +Relevant error: `Error::OracleStale = 202`. + +**Defense — Per-market staleness override** +Admins may set tighter or looser windows per market via `EventOracleValidationConfig` (`types.rs`, `EventOracleValidationConfig::max_staleness_secs`), stored and resolved through `OracleValidationConfigManager::set_event_config` / `get_effective_config`. Per-market config takes precedence over the global default. + +**Residual risk**: If network latency is high and the staleness window is not tightened for fast-moving markets, there is a brief window in which slightly stale data may be accepted. Operators should reduce `max_staleness_secs` for volatile assets. + +--- + +### 1.3 Low-Confidence / Wide-Interval Manipulation + +**Threat**: An attacker submits or induces an oracle reading with a very wide confidence interval, making the price meaningless while still passing staleness checks. + +**Defense — Confidence-bound enforcement** +`validate_oracle_data` computes the confidence ratio in basis points and rejects readings where `conf_bps > max_confidence_bps`. +Default: `DEFAULT_MAX_CONFIDENCE_BPS = 500` bps (5 %). +Relevant error: `Error::OracleConfidenceTooWide = 208`. + +Per-market overrides are available through `EventOracleValidationConfig::max_confidence_bps`. + +**Residual risk**: Confidence intervals are only enforced for providers that supply them (e.g. Pyth). Providers without a confidence field bypass this check; they rely on whitelist and staleness controls alone. + +--- + +### 1.4 Oracle Unavailability / DoS + +**Threat**: An attacker takes down oracle infrastructure to prevent markets from resolving, holding funds hostage. + +**Defense — Fallback oracle** +When the primary oracle is unavailable the contract falls back to a secondary source. +Relevant error: `Error::FallbackOracleUnavailable = 206`. + +**Defense — Resolution timeout** +If neither primary nor fallback responds within the allowed window, `Error::ResolutionTimeoutReached = 207` is returned, enabling administrative recovery paths. + +--- + +## 2. Dispute Subsystem Threats + +### 2.1 Dispute Griefing (Spam) + +**Threat**: An attacker floods the contract with baseless disputes against many markets to raise gas costs, lock user funds, or delay payouts. + +**Defense — Minimum stake requirement** +`DisputeUtils` and `VotingUtils` enforce `MIN_DISPUTE_STAKE = 10_000_000` stroops (1 XLM) per dispute (`config.rs`, line 293; re-exported in `voting.rs`, line 20; enforced in `disputes.rs`, line 2175). +Relevant error: `Error::InsufficientStake = 107`. + +**Defense — One dispute per market** +`Error::AlreadyDisputed = 404` is returned if a dispute already exists for a market, preventing iterative griefing against the same market. + +**Known gap — No dispute rate-limiting across markets** +There is currently no cap on how many *different* markets a single address can dispute in a given period. A well-funded actor could still spam across many markets simultaneously. This is a tracked gap; see [issue #594](https://github.com/your-org/predictify-contracts/issues/594) for the rate-limiting work item. + +--- + +### 2.2 Dispute Stake-Manipulation / Sybil Attack + +**Threat**: An attacker creates many wallets, each staking just above `MIN_DISPUTE_STAKE`, to numerically dominate the voting tally while committing little total capital. + +**Defense — Stake-weighted tally** +Dispute outcomes are determined by `DisputeUtils::calculate_stake_weighted_outcome` (`disputes.rs`, line 1517). Raw vote *count* does not matter; each vote is weighted by its stake. A Sybil attacker spreading 10 XLM across 10 wallets has the same voting power as a single 10 XLM vote. + +**Residual risk**: A well-capitalised attacker can acquire a majority stake-weight. The economic cost of doing so scales linearly with honest-voter participation; market design (high TVL, broad community) is the primary mitigation. + +--- + +### 2.3 Tie Manipulation + +**Threat**: An attacker engineers an exact stake-weighted tie to cause an indeterminate outcome and exploit the resolution path. + +**Defense — Tie → oracle stands** +`DisputeUtils` implements the rule: exact tie ⇒ the original oracle result is upheld (`disputes.rs`, line 491, `OracleIntegrationManager` result is preserved). The attacker gains nothing from a tie. +Relevant error context: `Error::DisputeCondNotMet = 408` if resolution conditions are not satisfied. + +--- + +### 2.4 Double-Dispute / Duplicate Vote + +**Threat**: An attacker submits multiple dispute votes from the same address to inflate their stake weight. + +**Defense — Single-vote enforcement** +`VotingUtils::cast_vote` returns `Error::AlreadyVoted = 109` on a second vote from the same address; `Error::DisputeAlreadyVoted = 407` specifically guards the dispute-vote path. + +**Defense — Single-dispute-per-market** +The outer dispute creation check (`Error::AlreadyDisputed = 404`) prevents the same market being disputed twice, closing the "create a fresh dispute to revote" vector. + +--- + +### 2.5 Voting-Window Expiry Attack + +**Threat**: An attacker delays casting their vote until the window closes on the honest side, then votes just before expiry to prevent counter-votes. + +**Defense — Voting window enforcement** +`VotingUtils` enforces `DISPUTE_EXTENSION_HOURS = 24` hours (`config.rs`, line 308) as the dispute-vote deadline. Votes submitted after expiry are rejected. +Relevant error: `Error::DisputeVoteExpired = 405`. + +**Residual risk**: A 24-hour window may be insufficient for global coordination on high-value markets. Admins can extend via `dispute_extension_hours` in the voting config, but there is no automatic adaptive window. + +--- + +## 3. Error Code Quick Reference + +| Error | Code | Subsystem | Threat Mitigated | +|---|---|---|---| +| `OracleUnavailable` | 200 | Oracle | Unavailability / DoS | +| `InvalidOracleConfig` | 201 | Oracle | Misconfiguration | +| `OracleStale` | 202 | Oracle | Stale-price exploitation | +| `OracleNoConsensus` | 203 | Oracle | Feed poisoning (multi-source) | +| `MarketNotReady` | 205 | Oracle | Premature resolution | +| `FallbackOracleUnavailable` | 206 | Oracle | Unavailability / DoS | +| `ResolutionTimeoutReached` | 207 | Oracle | Unavailability / DoS | +| `OracleConfidenceTooWide` | 208 | Oracle | Low-confidence manipulation | +| `OracleCallbackAuthFailed` | 210 | Oracle | Feed poisoning (auth) | +| `OracleCallbackUnauthorized` | 211 | Oracle | Feed poisoning (whitelist) | +| `OracleCallbackInvalidSignature` | 212 | Oracle | Feed poisoning (signature) | +| `OracleCallbackReplayDetected` | 213 | Oracle | Feed poisoning (replay) | +| `OracleCallbackTimeout` | 214 | Oracle | Unavailability / DoS | +| `InsufficientStake` | 107 | Dispute | Griefing / spam | +| `AlreadyVoted` | 109 | Dispute | Double-vote | +| `AlreadyDisputed` | 404 | Dispute | Double-dispute / spam | +| `DisputeVoteExpired` | 405 | Dispute | Window expiry attack | +| `DisputeAlreadyVoted` | 407 | Dispute | Double-vote (dispute path) | +| `DisputeCondNotMet` | 408 | Dispute | Tie / condition manipulation | + +--- + +## 4. Known Gaps and Tracking + +| Gap | Description | Status | +|---|---|---| +| Dispute rate-limiting across markets | A single address can dispute arbitrarily many different markets simultaneously. No per-address, per-period dispute count cap exists. | Open — tracked in issue #594 | +| Confidence validation for non-Pyth providers | Confidence-bound checks apply only when the provider supplies a confidence field. Providers without one rely solely on whitelist and staleness. | Accepted risk — documented above | +| Adaptive voting window | The dispute window is fixed at 24 h (configurable by admin). There is no automatic extension triggered by late voting activity. | Open — future governance consideration | + +--- + +## 5. Key Module and Constant Index + +| Symbol | File | Purpose | +|---|---|---| +| `OracleIntegrationManager` | `oracles.rs:2592` | Multi-source fetch, consensus, result storage | +| `DEFAULT_CONSENSUS_THRESHOLD = 66` | `oracles.rs:2602` | 66 % majority required across sources | +| `OracleValidationConfigManager::validate_oracle_data` | `oracles.rs:2428` | Staleness and confidence validation | +| `DEFAULT_MAX_STALENESS_SECS = 60` | `oracles.rs:2346` | Global staleness window (seconds) | +| `DEFAULT_MAX_CONFIDENCE_BPS = 500` | `oracles.rs:2348` | Global confidence ceiling (basis points) | +| `EventOracleValidationConfig` | `types.rs:1864` | Per-market staleness/confidence override | +| `OracleWhitelist` | `oracles.rs:1797` | Permitted oracle contract registry | +| `MIN_DISPUTE_STAKE = 10_000_000` | `config.rs:293` | Minimum dispute stake (1 XLM in stroops) | +| `DISPUTE_EXTENSION_HOURS = 24` | `config.rs:308` | Dispute voting window duration | +| `DisputeUtils::calculate_stake_weighted_outcome` | `disputes.rs:1517` | Stake-weighted tally and tie-break rule | +| `Error` variants (all) | `err.rs` | Canonical error codes |