|
| 1 | +# Requirements Document |
| 2 | + |
| 3 | +## Introduction |
| 4 | + |
| 5 | +The `predictify-hybrid` contract resolves prediction markets by consulting a primary oracle and, |
| 6 | +when configured, a fallback oracle. Currently, when the two oracle sources return data with |
| 7 | +conflicting staleness characteristics — for example, the primary data is fresh while the fallback |
| 8 | +data is stale, or both sources are stale — the contract does not emit a dedicated on-chain event |
| 9 | +to signal this cross-oracle staleness condition. Downstream consumers (indexers, monitoring |
| 10 | +dashboards, dispute tools) therefore have no structured, filterable signal indicating that a |
| 11 | +multi-source staleness anomaly occurred during a resolution attempt. |
| 12 | + |
| 13 | +This feature adds a new `CrossOracleStalenessEvent` Soroban contract event that is emitted |
| 14 | +precisely when a cross-oracle staleness mismatch is detected in `OracleResolutionManager::fetch_oracle_result` |
| 15 | +and in the median-resolution path (`resolve_with_median`). It also adds a corresponding emitter |
| 16 | +method to `EventEmitter`, registers the event in `EventSchemaRegistry`, documents the new event in |
| 17 | +`docs/EVENT_SCHEMA.md`, and provides focused tests covering the happy-path and all staleness |
| 18 | +mismatch scenarios. |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## Glossary |
| 23 | + |
| 24 | +- **The Contract**: The `predictify-hybrid` Soroban smart contract. |
| 25 | +- **Primary_Oracle**: The oracle source identified by `market.oracle_config`. |
| 26 | +- **Fallback_Oracle**: The optional secondary oracle source identified by |
| 27 | + `market.fallback_oracle_config`, present when `market.has_fallback == true`. |
| 28 | +- **Cross-Oracle Staleness**: The condition where at least one oracle source's price data exceeds |
| 29 | + the effective `max_staleness_secs` threshold _and_ a second oracle source is present, so the |
| 30 | + staleness state differs across the two sources (one fresh, one stale; or both stale). |
| 31 | +- **OraclePriceData**: The struct returned by `OracleInterface::get_price_data`, containing |
| 32 | + `price`, `confidence`, and `publish_time` fields. |
| 33 | +- **Staleness Age**: `env.ledger().timestamp().saturating_sub(publish_time)` for a given |
| 34 | + `OraclePriceData` value. |
| 35 | +- **Effective Config**: The per-event `EventOracleValidationConfig` if set; otherwise the global |
| 36 | + `GlobalOracleValidationConfig`. Resolved by `OracleValidationConfigManager::get_effective_config`. |
| 37 | +- **EventEmitter**: The `EventEmitter` struct in `src/events.rs` that centralises all on-chain |
| 38 | + event emission. |
| 39 | +- **EventSchemaRegistry**: The `EventSchemaRegistry` struct in `src/events.rs` that maps event |
| 40 | + names to topic symbols and schema versions. |
| 41 | +- **topic symbol**: The ≤ 9-character `Symbol` used as the first element of the Soroban event |
| 42 | + topic tuple, created via `symbol_short!`. |
| 43 | +- **nonce**: The per-topic monotonically-increasing replay-protection counter managed by |
| 44 | + `EventEmitter::get_and_increment_nonce`. |
| 45 | +- **OracleResultEvent**: The existing `#[contracttype]` struct emitted after a successful oracle |
| 46 | + fetch, under topic `"oracle_rs"`. |
| 47 | +- **OracleValidationFailedEvent**: The existing `#[contracttype]` struct emitted when a single |
| 48 | + oracle source fails staleness or confidence validation, under topic `"orc_val"`. |
| 49 | +- **CrossOracleStalenessEvent**: The new `#[contracttype]` struct introduced by this feature, |
| 50 | + emitted under topic `"orc_xstl"` when a cross-oracle staleness condition is detected. |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +## Requirements |
| 55 | + |
| 56 | +### Requirement 1: CrossOracleStalenessEvent struct |
| 57 | + |
| 58 | +**User Story:** As a market indexer operator, I want a named, versioned on-chain event struct |
| 59 | +whenever two oracle sources have mismatched staleness, so that I can filter and alert on this |
| 60 | +condition without parsing untyped raw data. |
| 61 | + |
| 62 | +#### Acceptance Criteria |
| 63 | + |
| 64 | +1. THE Contract SHALL define a `CrossOracleStalenessEvent` struct annotated with `#[contracttype]` |
| 65 | + and `#[derive(Clone, Debug, Eq, PartialEq)]` in `src/events.rs`. |
| 66 | + |
| 67 | +2. THE `CrossOracleStalenessEvent` SHALL contain the following fields exactly: |
| 68 | + - `market_id: Symbol` — identifier of the market being resolved |
| 69 | + - `primary_provider: String` — display name of the Primary_Oracle provider |
| 70 | + - `primary_feed_id: String` — feed ID used by the Primary_Oracle |
| 71 | + - `primary_age_secs: u64` — Staleness Age of the Primary_Oracle's data at detection time |
| 72 | + - `fallback_provider: String` — display name of the Fallback_Oracle provider |
| 73 | + - `fallback_feed_id: String` — feed ID used by the Fallback_Oracle |
| 74 | + - `fallback_age_secs: u64` — Staleness Age of the Fallback_Oracle's data at detection time |
| 75 | + - `max_age_secs: u64` — the `max_staleness_secs` from the Effective Config at detection time |
| 76 | + - `nonce: u64` — replay-protection nonce |
| 77 | + - `timestamp: u64` — `env.ledger().timestamp()` at emission time |
| 78 | + |
| 79 | +3. IF any field listed in criterion 2 is absent or has a different type, THEN THE Contract SHALL |
| 80 | + fail to compile, preventing deployment of a malformed event schema. |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +### Requirement 2: EventEmitter emission method |
| 85 | + |
| 86 | +**User Story:** As a contract developer integrating new oracle resolution paths, I want a single |
| 87 | +`EventEmitter::emit_cross_oracle_staleness` function, so that all call sites emit the event |
| 88 | +consistently without duplicating struct construction logic. |
| 89 | + |
| 90 | +#### Acceptance Criteria |
| 91 | + |
| 92 | +1. THE `EventEmitter` SHALL expose a public method with the signature: |
| 93 | + ``` |
| 94 | + pub fn emit_cross_oracle_staleness( |
| 95 | + env: &Env, |
| 96 | + market_id: &Symbol, |
| 97 | + primary_provider: &String, |
| 98 | + primary_feed_id: &String, |
| 99 | + primary_age_secs: u64, |
| 100 | + fallback_provider: &String, |
| 101 | + fallback_feed_id: &String, |
| 102 | + fallback_age_secs: u64, |
| 103 | + max_age_secs: u64, |
| 104 | + ) |
| 105 | + ``` |
| 106 | + |
| 107 | +2. WHEN `emit_cross_oracle_staleness` is called, THE Contract SHALL build a |
| 108 | + `CrossOracleStalenessEvent` struct populated with all provided parameters, with `nonce` set |
| 109 | + by `EventEmitter::get_and_increment_nonce` using the topic symbol for `"orc_xstl"`, and |
| 110 | + `timestamp` set to `env.ledger().timestamp()`. |
| 111 | + |
| 112 | +3. WHEN `emit_cross_oracle_staleness` is called, THE Contract SHALL persist the event via |
| 113 | + `Self::store_event(env, &symbol_short!("orc_xstl"), &event)`. |
| 114 | + |
| 115 | +4. WHEN `emit_cross_oracle_staleness` is called, THE Contract SHALL publish the event to the |
| 116 | + Soroban ledger stream via |
| 117 | + `env.events().publish((symbol_short!("orc_xstl"), market_id.clone()), event)`. |
| 118 | + |
| 119 | +5. THE topic symbol for `CrossOracleStalenessEvent` SHALL be `symbol_short!("orc_xstl")`. |
| 120 | + |
| 121 | +--- |
| 122 | + |
| 123 | +### Requirement 3: Detection in the dual-oracle resolution path |
| 124 | + |
| 125 | +**User Story:** As a market participant, I want the contract to emit `CrossOracleStalenessEvent` |
| 126 | +whenever the dual-oracle path detects a staleness mismatch across sources, so that I can be |
| 127 | +alerted that resolution data quality may be degraded. |
| 128 | + |
| 129 | +#### Acceptance Criteria |
| 130 | + |
| 131 | +1. WHEN `OracleResolutionManager::fetch_oracle_result` fetches price data from both the |
| 132 | + Primary_Oracle and the Fallback_Oracle and the Staleness Age of either source exceeds |
| 133 | + `max_staleness_secs` from the Effective Config, THEN THE Contract SHALL call |
| 134 | + `EventEmitter::emit_cross_oracle_staleness` before returning from the function. |
| 135 | + |
| 136 | +2. WHEN only one oracle source has a Staleness Age that exceeds `max_staleness_secs` (i.e., a |
| 137 | + partial staleness mismatch), THEN THE Contract SHALL still emit `CrossOracleStalenessEvent`. |
| 138 | + |
| 139 | +3. WHEN both oracle sources have a Staleness Age that exceeds `max_staleness_secs`, THEN THE |
| 140 | + Contract SHALL emit `CrossOracleStalenessEvent` exactly once for the resolution call. |
| 141 | + |
| 142 | +4. WHEN only the primary oracle is available (i.e., `market.has_fallback == false`), THEN THE |
| 143 | + Contract SHALL NOT emit `CrossOracleStalenessEvent`, because no cross-source comparison is |
| 144 | + possible. |
| 145 | + |
| 146 | +5. WHEN `fetch_oracle_result` is called and the Primary_Oracle fetch itself returns an error |
| 147 | + before price data is available, THEN THE Contract SHALL NOT emit `CrossOracleStalenessEvent` |
| 148 | + for the primary source. |
| 149 | + |
| 150 | +6. THE emission of `CrossOracleStalenessEvent` SHALL NOT alter the existing return value or |
| 151 | + error behaviour of `fetch_oracle_result`; cross-oracle staleness is an observability signal, |
| 152 | + not a resolution blocker. |
| 153 | + |
| 154 | +--- |
| 155 | + |
| 156 | +### Requirement 4: Detection in the median-resolution path |
| 157 | + |
| 158 | +**User Story:** As an on-chain analytics consumer, I want `CrossOracleStalenessEvent` to also be |
| 159 | +emitted when the multi-source median resolution path detects cross-oracle staleness, so that |
| 160 | +monitoring is consistent across both resolution strategies. |
| 161 | + |
| 162 | +#### Acceptance Criteria |
| 163 | + |
| 164 | +1. WHEN `OracleResolutionManager::resolve_with_median` collects price quotes from multiple oracle |
| 165 | + sources and the Staleness Age of any included quote exceeds `max_staleness_secs` from the |
| 166 | + Effective Config while at least one other quote is within the staleness limit, THEN THE |
| 167 | + Contract SHALL call `EventEmitter::emit_cross_oracle_staleness` once per resolution call. |
| 168 | + |
| 169 | +2. WHEN all included quotes in `resolve_with_median` exceed `max_staleness_secs`, THEN THE |
| 170 | + Contract SHALL emit `CrossOracleStalenessEvent` exactly once, using the first two quotes as |
| 171 | + representative primary and fallback sources. |
| 172 | + |
| 173 | +3. THE emission of `CrossOracleStalenessEvent` in `resolve_with_median` SHALL NOT alter the |
| 174 | + existing return value or error behaviour of that function. |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +### Requirement 5: EventSchemaRegistry registration |
| 179 | + |
| 180 | +**User Story:** As a contract integrator using `EventSchemaRegistry::get_schema`, I want the new |
| 181 | +event to be registered in the registry, so that I can discover its canonical topic symbol and |
| 182 | +schema version programmatically. |
| 183 | + |
| 184 | +#### Acceptance Criteria |
| 185 | + |
| 186 | +1. THE `EventSchemaRegistry::get_schema` function SHALL return a valid `EventSchemaEntry` when |
| 187 | + called with the name `"cross_oracle_staleness"`. |
| 188 | + |
| 189 | +2. THE returned `EventSchemaEntry` SHALL have `topic` equal to `symbol_short!("orc_xstl")` and |
| 190 | + `schema_version` equal to `1`. |
| 191 | + |
| 192 | +3. IF `"cross_oracle_staleness"` is not registered and `get_schema` falls back to the default |
| 193 | + branch, THEN THE Contract SHALL still return an `EventSchemaEntry` consistent with |
| 194 | + `topic = symbol_short!("orc_xstl")` and `schema_version = 1`. |
| 195 | + |
| 196 | +--- |
| 197 | + |
| 198 | +### Requirement 6: Documentation update |
| 199 | + |
| 200 | +**User Story:** As a downstream consumer building an indexer, I want `docs/EVENT_SCHEMA.md` to |
| 201 | +document `CrossOracleStalenessEvent`, so that I know its topic, all fields, and stability |
| 202 | +guarantee without reading source code. |
| 203 | + |
| 204 | +#### Acceptance Criteria |
| 205 | + |
| 206 | +1. THE file `contracts/predictify-hybrid/docs/EVENT_SCHEMA.md` SHALL include a row for |
| 207 | + `CrossOracleStalenessEvent` in the Oracle Events table with topic `"orc_xstl"` and stability |
| 208 | + badge 🟢 Stable. |
| 209 | + |
| 210 | +2. THE file SHALL include a `### CrossOracleStalenessEvent` subsection that lists every field |
| 211 | + from the struct defined in Requirement 1 criterion 2, including field name, type, and a |
| 212 | + one-line description. |
| 213 | + |
| 214 | +3. THE documentation SHALL state that the event is emitted in both `fetch_oracle_result` and |
| 215 | + `resolve_with_median` when a cross-oracle staleness mismatch is detected. |
| 216 | + |
| 217 | +4. THE documentation SHALL state that emitting this event does not block or alter market |
| 218 | + resolution. |
| 219 | + |
| 220 | +--- |
| 221 | + |
| 222 | +### Requirement 7: Tests |
| 223 | + |
| 224 | +**User Story:** As a code reviewer, I want focused tests that prove each staleness scenario |
| 225 | +triggers (or suppresses) the event correctly, so that I can approve the PR with confidence. |
| 226 | + |
| 227 | +#### Acceptance Criteria |
| 228 | + |
| 229 | +1. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_emitted_when_primary_stale` |
| 230 | + that verifies `CrossOracleStalenessEvent` is emitted when the Primary_Oracle data is stale |
| 231 | + and the Fallback_Oracle data is fresh. |
| 232 | + |
| 233 | +2. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_emitted_when_fallback_stale` |
| 234 | + that verifies `CrossOracleStalenessEvent` is emitted when the Fallback_Oracle data is stale |
| 235 | + and the Primary_Oracle data is fresh. |
| 236 | + |
| 237 | +3. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_emitted_when_both_stale` |
| 238 | + that verifies `CrossOracleStalenessEvent` is emitted exactly once when both oracle sources |
| 239 | + exceed `max_staleness_secs`. |
| 240 | + |
| 241 | +4. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_not_emitted_single_oracle` |
| 242 | + that verifies `CrossOracleStalenessEvent` is NOT emitted when only a single oracle source is |
| 243 | + present (`has_fallback == false`). |
| 244 | + |
| 245 | +5. THE codebase SHALL include a test named `test_cross_oracle_staleness_event_not_emitted_both_fresh` |
| 246 | + that verifies `CrossOracleStalenessEvent` is NOT emitted when both oracle sources return data |
| 247 | + within `max_staleness_secs`. |
| 248 | + |
| 249 | +6. WHEN any test listed in criteria 1–5 asserts that the event IS emitted, THE test SHALL verify |
| 250 | + the following fields carry correct values: `market_id`, `primary_age_secs`, |
| 251 | + `fallback_age_secs`, `max_age_secs`, and `timestamp`. |
| 252 | + |
| 253 | +7. THE tests SHALL use the Soroban SDK's `env.events().all()` or equivalent introspection API to |
| 254 | + assert event emission without relying on side effects in persistent storage. |
| 255 | + |
| 256 | +8. WHEN adding the new tests, THE codebase SHALL continue to compile and all existing tests SHALL |
| 257 | + pass without modification. |
| 258 | + |
| 259 | +--- |
| 260 | + |
| 261 | +### Requirement 8: Code style and lint compliance |
| 262 | + |
| 263 | +**User Story:** As a code reviewer, I want the new code to be indistinguishable in style from the |
| 264 | +existing `events.rs` and `resolution.rs` modules, so that the PR diff is easy to read and |
| 265 | +review. |
| 266 | + |
| 267 | +#### Acceptance Criteria |
| 268 | + |
| 269 | +1. THE new `CrossOracleStalenessEvent` struct SHALL follow the same rustdoc comment style used |
| 270 | + by neighbouring event structs in `src/events.rs` (doc comment on the struct, per-field doc |
| 271 | + comments). |
| 272 | + |
| 273 | +2. THE `emit_cross_oracle_staleness` method SHALL be placed in the `EventEmitter` impl block |
| 274 | + in alphabetical or logical order relative to neighbouring oracle-related emit methods. |
| 275 | + |
| 276 | +3. WHEN `cargo clippy` is run on the workspace, THE new code SHALL produce zero new warnings |
| 277 | + or errors. |
| 278 | + |
| 279 | +4. WHEN `cargo fmt --check` is run on the workspace, THE new code SHALL produce no formatting |
| 280 | + violations. |
0 commit comments