Skip to main content

safe_oracle/
lib.rs

1#![no_std]
2// CI fix: clippy::enum_variant_names fires from inside the
3// #[contracttype] macro expansion for `ConfigError` (all 8 variants
4// share the `Invalid` prefix by design — every variant represents a
5// config field rejected by `validate()`, and the prefix improves
6// readability for integrators destructuring errors). The lint
7// originates in the macro's emitted code, so a per-enum #[allow]
8// attribute on the original item does not suppress it (verified on
9// rustc 1.95 — both `#[allow]` above and below `#[contracttype]`
10// failed to silence the error). Crate-level inner attribute is the
11// working suppression. Renaming the variants would be a public API
12// breaking change (290 tests reference these); the lint is stylistic,
13// not semantic — keeping the names.
14#![allow(clippy::enum_variant_names)]
15
16use soroban_sdk::{contracterror, contracttype, Address, Env, Symbol, Vec};
17
18pub mod circuit_breaker;
19mod reflector_client;
20mod registry_client;
21pub use reflector_client::ReflectorClient;
22pub use registry_client::{LiquidityRegistryClient, LiquiditySnapshot};
23
24/// Maximum allowed circuit breaker halt duration in ledgers.
25///
26/// Equals approximately 1 week at Stellar's ~5-second ledger close cadence
27/// (604_800 seconds / 5 ≈ 120_960 ledgers). Beyond this duration, governance
28/// should manually open or close the breaker rather than rely on auto-recovery
29/// — week-long halts are an operational decision, not a config default.
30///
31/// # AR.H L1 closure
32///
33/// Added after AR.H surfaced that an unbounded `circuit_breaker_halt_ledgers`
34/// (u32::MAX ≈ 6.8 years) makes a misconfigured deploy unrecoverable without
35/// governance intervention. `validate()` rejects values above this bound.
36pub const MAX_CIRCUIT_BREAKER_HALT_LEDGERS: u32 = 120_960;
37
38/// Reasons a guardrail has rejected a price; the `Err` payload of every
39/// safe_oracle public API.
40///
41/// Discriminants are stable u32 values (1..=10) so they can be carried as the
42/// `u32` inside [`PriceResult::Err`] and re-hydrated through
43/// [`PriceResult::into_result`]. Integrators surfacing oracle violations to
44/// their own callers typically mirror these discriminants 1:1 in their own
45/// error enum (see `mock_lending::MockLendingError` for the canonical
46/// reference) so audit logs preserve which guardrail tripped.
47///
48/// # Spec
49///
50/// See spec §4 — Error Enum. The seven variants here implement the spec's
51/// required violation taxonomy. Phases 1–5 wired the variants in order:
52/// 1–3 (Layer 1) in Phase 2, 4–5 (Layer 2) in Phase 4, 6 (circuit breaker)
53/// in Phase 5, and 7 (stale snapshot) introduced alongside the freshness
54/// check in Phase 4.
55#[contracterror]
56#[derive(Copy, Clone, Debug, Eq, PartialEq)]
57pub enum OracleSafetyViolation {
58    ExcessiveDeviation = 1,
59    StaleData = 2,
60    CrossSourceMismatch = 3,
61    InsufficientLiquidity = 4,
62    ThinSampling = 5,
63    CircuitBreakerOpen = 6,
64    StaleSnapshot = 7,
65    /// An external contract (Reflector primary feed or `LiquidityRegistry`)
66    /// failed unexpectedly — host-level trap, contract upgrade
67    /// incompatibility, storage corruption, or any other invocation error
68    /// surfaced through Soroban's `try_*` client variants. Hardening Phase
69    /// debt #4 added this variant so cross-contract failures arrive as
70    /// regular guardrail violations rather than propagating to the caller
71    /// (which would prevent auto-halt from committing — same Phase 5.2 v1
72    /// root cause).
73    ///
74    /// Secondary-feed failures intentionally do NOT surface as this variant
75    /// — `check_cross_source` skips silently on secondary trap, consistent
76    /// with `None` and "secondary returned `None`" semantics.
77    ExternalContractFailure = 8,
78    /// Cross-source check rejected because primary and secondary oracles
79    /// report different `decimals()` values. Comparing prices across
80    /// different scales would produce false signals; fail explicitly so
81    /// integrators see a misconfigured pair rather than always-fires
82    /// `CrossSourceMismatch`.
83    ///
84    /// **Recovery:** verify both oracles target the same precision (Reflector
85    /// mainnet = 14). Phase 7.2 closure of the lib.rs:262 reconciliation plan
86    /// — what was previously documented as integrator responsibility is now
87    /// enforced at library level.
88    DecimalsMismatch = 9,
89    /// Primary Reflector reported a `decimals()` value different from
90    /// `REFLECTOR_DECIMALS_EXPECTED` (14). The library's BPS arithmetic and
91    /// staleness calculations are calibrated for 14-decimal precision; a
92    /// different value indicates a misconfigured oracle address or a
93    /// Reflector contract upgrade that has changed the precision contract.
94    ///
95    /// **Recovery:** verify oracle address matches Reflector's published
96    /// mainnet/testnet address. If Reflector intentionally changed decimals,
97    /// safe-oracle library version bump is required. Phase 7.2 closure of
98    /// the lib.rs:820 plan.
99    UnexpectedDecimals = 10,
100}
101
102/// Expected `decimals()` value for the primary Reflector oracle contract.
103///
104/// Reflector publishes 14-decimal precision per mainnet convention. The
105/// library's BPS arithmetic and staleness comparisons assume this value;
106/// deviation from 14 returns [`OracleSafetyViolation::UnexpectedDecimals`]
107/// rather than silently producing scaled-wrong results.
108///
109/// Phase 7.2 closure of the lib.rs:820 plan — runtime validation replaces
110/// the previous "Phase 7 will add a one-time `decimals()` call" doc-only
111/// commitment.
112pub const REFLECTOR_DECIMALS_EXPECTED: u32 = 14;
113
114#[contracttype]
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub enum Asset {
117    Stellar(Address),
118    Other(Symbol),
119}
120
121#[contracttype]
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct PriceData {
124    pub price: i128,
125    pub timestamp: u64,
126}
127
128/// Result type for `lastprice()` that allows auto-halt to commit even on
129/// guardrail violations.
130///
131/// # Why a custom enum instead of `Result<PriceData, OracleSafetyViolation>`?
132///
133/// Soroban contract methods that return `Result::Err` cause **all storage
134/// writes in the same invocation to roll back**, including writes inside
135/// `open_circuit_breaker()`. The original Phase 5.2 design hit this and was
136/// reverted (commit `e98ed48`). By returning `Ok(PriceResult::Err(...))`
137/// from the contract method, the breaker write commits while still
138/// conveying the violation to the caller.
139///
140/// # Why `Err(u32)` and not `Err(OracleSafetyViolation)`?
141///
142/// `OracleSafetyViolation` is a `#[contracterror]` type. soroban-sdk 25.x
143/// has two distinct constraints that block embedding it inside the
144/// `#[contracttype]` enum below — both empirically verified, the second
145/// not surfaced until Hardening 6C's PoC:
146///
147/// 1. **`SorobanArbitrary` bound (Pre-5.4 finding).** Under the test
148///    feature `soroban-sdk` derives an `Arbitrary` prototype for every
149///    `#[contracttype]`. The derive recursively requires every variant's
150///    payload to implement `SorobanArbitrary`, which `#[contracterror]`
151///    types do not — build fails with "trait bound
152///    `OracleSafetyViolation: SorobanArbitrary` is not satisfied."
153///    Manual `SorobanArbitrary` impl on the error type is conceptually
154///    possible (the trait is `pub`, three trait bounds to satisfy).
155///
156/// 2. **`ScVec: TryFrom<(ScSymbol, &OracleSafetyViolation)>` bound
157///    (Hardening 6C finding, deferred).** Independent of the
158///    `Arbitrary` derive, the `#[contracttype]` macro's XDR encoding
159///    expects each variant payload to be convertible into the tuple
160///    shape `(ScSymbol, &T)` ⟶ `ScVec`. `#[contracterror]` types
161///    implement `IntoVal<Env, Val>` but not this specific tuple-to-XDR
162///    path. A manual impl is blocked by Rust's orphan rule — both
163///    `ScVec` and `(ScSymbol, &T)` are foreign, so neither side of the
164///    `TryFrom` can host the impl from this crate. Closing this would
165///    require either (a) a `soroban-sdk` change exposing the conversion
166///    or (b) reshaping `OracleSafetyViolation` away from
167///    `#[contracterror]` (which would lose the stable u32 discriminants
168///    that integrators consume).
169///
170/// Carrying the violation as its `u32` discriminant sidesteps both
171/// constraints. The values here MUST stay aligned with
172/// `OracleSafetyViolation = 1..=10`. The `into_result()` shim re-hydrates
173/// the typed variant for callers that want it. Hardening Phase debt #17
174/// remains deferred for future SDK releases that resolve constraint (2).
175///
176/// # Migration from the Phase 1-4 `Result<PriceData, OracleSafetyViolation>`
177///
178/// Callers that used `?` continue to do so via the `into_result()` shim:
179///
180/// ```ignore
181/// // Before (Phase 1-4):
182/// let price = safe_oracle::lastprice(&env, &asset, ...)?;
183///
184/// // After (Phase 5.2 v2):
185/// let price = safe_oracle::lastprice(&env, &asset, ...).into_result()?;
186/// ```
187///
188/// `From<Result<PriceData, OracleSafetyViolation>>` is also implemented so
189/// internal helpers that produce `Result` (e.g., `lastprice_inner`) convert
190/// at the API boundary without per-callsite match plumbing.
191///
192/// # Audit notes
193///
194/// - `PriceResult::Err(d)` is semantically identical to a guardrail
195///   failure. A lending protocol MUST NOT proceed with `PriceResult::Err`
196///   the same way it would not proceed with `Err` in Phase 1-4.
197/// - The `Ok` wrapping at the Soroban boundary is a storage-commit
198///   mechanism only; the public-facing semantics ("violation = no price")
199///   are unchanged.
200/// - Tuple variants (not named-field) match the soroban-sdk 25.x
201///   `#[contracttype]` enum constraint observed in Phase 5.1.
202///
203/// # Spec
204///
205/// See spec §4 — Function Signature and Stub Contract. `PriceResult`
206/// preserves the spec's `lastprice → Ok(price) | Err(violation)` semantic
207/// at the public API level (via [`PriceResult::into_result`]) while letting
208/// auto-halt writes inside `lastprice` commit at the Soroban boundary.
209#[contracttype]
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub enum PriceResult {
212    /// Validated price data, all guardrails passed.
213    Ok(PriceData),
214
215    /// Guardrail violation; price MUST NOT be used. The `u32` is the
216    /// `OracleSafetyViolation` discriminant (1..=10); see `into_result()`
217    /// for the typed re-hydration.
218    Err(u32),
219}
220
221impl PriceResult {
222    /// Returns `true` if the result is `Ok`.
223    pub fn is_ok(&self) -> bool {
224        matches!(self, PriceResult::Ok(_))
225    }
226
227    /// Returns `true` if the result is `Err`.
228    pub fn is_err(&self) -> bool {
229        matches!(self, PriceResult::Err(_))
230    }
231
232    /// Convert to standard Rust `Result` for ergonomic `?` operator usage.
233    ///
234    /// Recommended migration path for Phase 1-4 callers: replace
235    /// `lastprice(...)?` with `lastprice(...).into_result()?`.
236    ///
237    /// Re-hydrates the `u32` discriminant into the typed
238    /// `OracleSafetyViolation`. Unknown discriminants panic — they cannot
239    /// occur on a result produced by `lastprice()`, which only emits
240    /// values from the canonical `1..=10` range, but the explicit panic
241    /// guards against forged values reaching the shim.
242    pub fn into_result(self) -> Result<PriceData, OracleSafetyViolation> {
243        match self {
244            PriceResult::Ok(p) => Ok(p),
245            PriceResult::Err(1) => Err(OracleSafetyViolation::ExcessiveDeviation),
246            PriceResult::Err(2) => Err(OracleSafetyViolation::StaleData),
247            PriceResult::Err(3) => Err(OracleSafetyViolation::CrossSourceMismatch),
248            PriceResult::Err(4) => Err(OracleSafetyViolation::InsufficientLiquidity),
249            PriceResult::Err(5) => Err(OracleSafetyViolation::ThinSampling),
250            PriceResult::Err(6) => Err(OracleSafetyViolation::CircuitBreakerOpen),
251            PriceResult::Err(7) => Err(OracleSafetyViolation::StaleSnapshot),
252            PriceResult::Err(8) => Err(OracleSafetyViolation::ExternalContractFailure),
253            PriceResult::Err(9) => Err(OracleSafetyViolation::DecimalsMismatch),
254            PriceResult::Err(10) => Err(OracleSafetyViolation::UnexpectedDecimals),
255            PriceResult::Err(d) => panic!(
256                "PriceResult::Err discriminant {} is outside the OracleSafetyViolation range (1..=10)",
257                d
258            ),
259        }
260    }
261}
262
263impl From<Result<PriceData, OracleSafetyViolation>> for PriceResult {
264    fn from(r: Result<PriceData, OracleSafetyViolation>) -> Self {
265        match r {
266            Ok(p) => PriceResult::Ok(p),
267            Err(e) => PriceResult::Err(e as u32),
268        }
269    }
270}
271
272/// Configuration for the safe_oracle library — the per-pool tuning surface.
273///
274/// Holds the thresholds and toggles consumed by [`lastprice`] and the
275/// [`circuit_breaker`] module. Integrators construct it once at init time
276/// and pass it to every `lastprice` call. The library owns no storage of
277/// its own — circuit-breaker halt state lives in the *caller's* instance
278/// storage — so the integrator owns where this config lives.
279///
280/// # Spec
281///
282/// See spec §4 — Config Struct. The defaults returned by
283/// [`SafeOracleConfig::default`] match the spec's recommended values
284/// (`max_deviation_bps=2000`, `max_staleness_seconds=300`,
285/// `max_cross_source_bps=500`, `min_liquidity_usd=$10,000` at 7-decimal
286/// precision, `min_trade_count_1h=5`); integrators requiring tighter or
287/// looser thresholds override per-field.
288#[contracttype]
289#[derive(Clone, Debug)]
290pub struct SafeOracleConfig {
291    pub max_deviation_bps: u32,
292    pub max_staleness_seconds: u32,
293    /// Maximum staleness (in seconds) for the **previous** price reference
294    /// used in deviation comparison.
295    ///
296    /// Distinct from `max_staleness_seconds`, which gates the *current*
297    /// price. The previous price is intentionally older (one Reflector
298    /// resolution window earlier — typically ~5 min) and is allowed to be
299    /// further from "now" than the current price, but excessively-stale
300    /// references make deviation comparison meaningless: a years-old
301    /// previous price compared to a fresh current produces false-positive
302    /// `ExcessiveDeviation` halts.
303    ///
304    /// **Default:** `900` (15 minutes) — three times the default
305    /// `max_staleness_seconds = 300`. Recommend 2-3× current threshold.
306    /// `0` is rejected by `validate()` as a silent-disable.
307    ///
308    /// Phase 7.2 closure of the lib.rs:713 plan — replaces the previous
309    /// "Phase 7 will add a configurable previous_max_staleness_seconds"
310    /// doc-only commitment.
311    pub previous_max_staleness_seconds: u32,
312    pub max_cross_source_bps: u32,
313    /// Maximum age (in seconds) of a `LiquidityRegistry` snapshot still
314    /// considered fresh. Phase 4's `check_liquidity` rejects snapshots older
315    /// than this against `env.ledger().timestamp()`; the field is wired here
316    /// in Phase 3.6 so config-construction sites do not need to change again
317    /// when the Layer 2 logic lands.
318    pub max_snapshot_age_seconds: u64,
319    pub min_liquidity_usd: i128,
320    pub min_trade_count_1h: u32,
321    /// Optional secondary oracle for cross-source price verification.
322    /// `None` skips the cross-source guardrail entirely (single-source mode);
323    /// `Some(addr)` activates `check_cross_source` against the configured
324    /// `max_cross_source_bps` threshold.
325    ///
326    /// **Decimals reconciliation (Phase 7.2 closure):** when this is
327    /// `Some(addr)`, both primary and secondary `decimals()` are fetched at
328    /// cross-source check time and must agree, otherwise the call returns
329    /// [`OracleSafetyViolation::DecimalsMismatch`]. The pre-7.2 integrator
330    /// warning ("verify same precision") is now enforced at library level.
331    pub secondary_oracle: Option<Address>,
332    pub circuit_breaker_enabled: bool,
333    pub circuit_breaker_halt_ledgers: u32,
334}
335
336impl Default for SafeOracleConfig {
337    fn default() -> Self {
338        Self {
339            max_deviation_bps: 2000,
340            max_staleness_seconds: 300,
341            // Phase 7.2: 3× max_staleness_seconds. The previous price is
342            // typically ~5min behind the current price, so 15min absorbs one
343            // resolution window of attestation lag without classifying it as
344            // a data gap.
345            previous_max_staleness_seconds: 900,
346            max_cross_source_bps: 500,
347            max_snapshot_age_seconds: 300,
348            min_liquidity_usd: 100_000_000_000,
349            min_trade_count_1h: 5,
350            secondary_oracle: None,
351            circuit_breaker_enabled: false,
352            circuit_breaker_halt_ledgers: 720,
353        }
354    }
355}
356
357/// Errors returned by [`SafeOracleConfig::validate`] when a config field
358/// has an out-of-range value that would silently disable a guardrail or
359/// produce nonsensical behavior at runtime.
360///
361/// # Spec
362///
363/// See spec §4 — Config Struct. Validation prevents silent guardrail
364/// disabling caused by misconfiguration (e.g., `max_deviation_bps = 0`
365/// allows infinite deviation, effectively disabling the deviation check
366/// without any visible signal).
367///
368/// # Audit notes
369///
370/// - Validation is **opt-in** — callers must invoke `config.validate()`.
371///   The library does not enforce validation in `lastprice()` to avoid
372///   per-call gas cost. Production integrators should validate at init
373///   time (recommended pattern: `MockLending::initialize` calls validate).
374///
375/// - All errors are recoverable at init time; runtime config changes are
376///   not supported (config is immutable after deploy per spec §4).
377// Note: `enum_variant_names` lint suppression for the shared `Invalid`
378// prefix is at crate level (see #![allow] at the top of this file) —
379// the lint fires from inside `#[contracttype]`'s macro expansion and
380// per-enum attributes do not suppress it.
381#[contracttype]
382#[derive(Clone, Debug, Eq, PartialEq)]
383pub enum ConfigError {
384    /// `max_deviation_bps` is 0 (allows infinite deviation, disabling the
385    /// check) or > 10_000 (100% — values above this are nonsensical for a
386    /// relative-deviation threshold).
387    InvalidDeviationBps,
388
389    /// `max_staleness_seconds` is 0 (rejects every recorded price as
390    /// stale) or > 86_400 (24h — stale data older than a day is unsafe
391    /// regardless of how lenient the integrator wants to be).
392    InvalidStalenessSeconds,
393
394    /// `min_liquidity_usd` is `<= 0` — negative values are semantically
395    /// nonsense (liquidity is non-negative by definition), and `0` silently
396    /// disables the Layer 2 liquidity check (every snapshot's
397    /// `volume_30m_usd > 0` trivially passes the threshold). Same defensive
398    /// principle as `InvalidTradeCountThreshold`: a zero guardrail is no
399    /// guardrail.
400    ///
401    /// # AR.H M1 closure
402    ///
403    /// This variant's runtime rule was tightened from `< 0` to `<= 0` after
404    /// AR.H surfaced the silent-disable case as the single residual asymmetry
405    /// from Hardening Closure (Debt #22).
406    InvalidLiquidityThreshold,
407
408    /// `max_cross_source_bps` is `0` (requires impossible primary/secondary
409    /// price equality) or `> 10_000` (semantically nonsensical, > 100% deviation)
410    /// when `secondary_oracle` is configured. Validation skipped if secondary
411    /// is `None` (field is dormant).
412    ///
413    /// # AR.H L2 closure
414    ///
415    /// Validation rule was tightened from `> 10_000` only to `== 0 || > 10_000`
416    /// after AR.H surfaced the silent-footgun case where a zero threshold
417    /// produces always-fires CrossSourceMismatch on every borrow.
418    InvalidCrossSourceBps,
419
420    /// `circuit_breaker_halt_ledgers` is `0` (degenerate halt window — the
421    /// breaker would fire and immediately auto-recover, providing no actual
422    /// halt) or `> MAX_CIRCUIT_BREAKER_HALT_LEDGERS` (~1 week, beyond the
423    /// reasonable auto-recovery window) when `circuit_breaker_enabled` is
424    /// `true`. Validation skipped if breaker is disabled (field is dormant).
425    ///
426    /// # AR.H L1 closure
427    ///
428    /// Upper bound added after AR.H surfaced that `u32::MAX` (~6.8 years
429    /// at Stellar's ledger cadence) makes a misconfigured deploy
430    /// effectively-permanently halted without governance intervention.
431    InvalidHaltLedgers,
432
433    /// `min_trade_count_1h` is 0 — disables thin-sampling check entirely
434    /// (every snapshot's `unique_trades_1h >= 0` always true). Same defensive
435    /// principle as `InvalidDeviationBps`: a "guardrail of zero" is no
436    /// guardrail at all. Integrators wanting to disable Layer 2 thin-sampling
437    /// should leave the guardrail's threshold meaningful and route around it
438    /// via Layer 1 / circuit breaker controls.
439    ///
440    /// # Hardening 3A follow-up (Debt #22)
441    ///
442    /// This variant closes the gap intentionally left open in Hardening 3A,
443    /// where the prompt's "5 variant" boundary kept the pattern consistent
444    /// with the other guardrails but left two silent-disable cases
445    /// undetected. Hardening Closure brings parity.
446    InvalidTradeCountThreshold,
447
448    /// `max_snapshot_age_seconds` is 0 (rejects all snapshots) or > 86_400
449    /// (24h — staler than this is unsafe regardless of integrator intent).
450    /// Mirrors `InvalidStalenessSeconds` boundary logic for the Layer 2 path.
451    ///
452    /// # Hardening 3A follow-up (Debt #22)
453    ///
454    /// Same closure rationale as `InvalidTradeCountThreshold`. The Hardening
455    /// 3A boundary kept the new-variant count at 5; the Layer 2 snapshot age
456    /// validation remained an audit-trail gap until this patch.
457    InvalidSnapshotAge,
458
459    /// `previous_max_staleness_seconds == 0` silently disables the
460    /// previous-price freshness check (every previous price would be
461    /// classified `StaleData`, blocking every borrow), or `> 86_400` (24h)
462    /// accepts unsafe staleness for the deviation reference. Mirrors
463    /// `InvalidStalenessSeconds` boundary logic.
464    ///
465    /// Phase 7.2 addition — pairs with the new
466    /// `previous_max_staleness_seconds` field on [`SafeOracleConfig`].
467    InvalidPreviousStalenessSeconds,
468}
469
470impl SafeOracleConfig {
471    /// Validates the config and returns an error if any field has an
472    /// out-of-range value. Recommended call site: at integrator
473    /// initialization, before storing the config in instance storage.
474    ///
475    /// # Spec
476    ///
477    /// See spec §4 — Config Struct. Validation is opt-in (the library
478    /// does not enforce it on every `lastprice` call) so integrators pay
479    /// the check exactly once per config change.
480    ///
481    /// # Errors
482    ///
483    /// - [`ConfigError::InvalidDeviationBps`] — `max_deviation_bps == 0`
484    ///   or `> 10_000`.
485    /// - [`ConfigError::InvalidStalenessSeconds`] — `max_staleness_seconds
486    ///   == 0` or `> 86_400`.
487    /// - [`ConfigError::InvalidLiquidityThreshold`] — `min_liquidity_usd
488    ///   <= 0` (AR.H M1).
489    /// - [`ConfigError::InvalidCrossSourceBps`] — secondary configured
490    ///   and `max_cross_source_bps == 0` or `> 10_000` (AR.H L2).
491    /// - [`ConfigError::InvalidHaltLedgers`] — `circuit_breaker_enabled`
492    ///   and `circuit_breaker_halt_ledgers == 0` or
493    ///   `> MAX_CIRCUIT_BREAKER_HALT_LEDGERS` (AR.H L1).
494    /// - [`ConfigError::InvalidTradeCountThreshold`] — `min_trade_count_1h
495    ///   == 0` (Hardening Closure / Debt #22).
496    /// - [`ConfigError::InvalidSnapshotAge`] — `max_snapshot_age_seconds
497    ///   == 0` or `> 86_400` (Hardening Closure / Debt #22).
498    ///
499    /// # Examples
500    ///
501    /// ```rust,ignore
502    /// let config = SafeOracleConfig::default();
503    /// config.validate().expect("default config is valid by construction");
504    /// ```
505    pub fn validate(&self) -> Result<(), ConfigError> {
506        if self.max_deviation_bps == 0 || self.max_deviation_bps > 10_000 {
507            return Err(ConfigError::InvalidDeviationBps);
508        }
509
510        if self.max_staleness_seconds == 0 || self.max_staleness_seconds > 86_400 {
511            return Err(ConfigError::InvalidStalenessSeconds);
512        }
513
514        // AR.H M1 fix: also reject == 0 to prevent silent-disable.
515        // With min_liquidity_usd == 0, the runtime check
516        // `snapshot.volume_30m_usd < 0` is unreachable because write_snapshot
517        // rejects volume_30m_usd <= 0 — every attestation passes the threshold,
518        // silently disabling the Layer 2 liquidity guardrail (the YieldBlox
519        // vector). Mirrors the silent-disable defenses Hardening 3A established
520        // for the deviation/staleness/halt-ledgers fields and Hardening Closure
521        // (Debt #22) extended to min_trade_count_1h and max_snapshot_age_seconds.
522        if self.min_liquidity_usd <= 0 {
523            return Err(ConfigError::InvalidLiquidityThreshold);
524        }
525
526        // AR.H L2 fix: also reject == 0 when secondary is configured. A zero
527        // cross-source threshold requires perfect primary/secondary price
528        // equality, which is operationally impossible — every borrow would
529        // fire CrossSourceMismatch. Same silent-footgun shape as M1
530        // (min_liquidity_usd == 0) and Hardening Closure / Debt #22.
531        if self.secondary_oracle.is_some()
532            && (self.max_cross_source_bps == 0 || self.max_cross_source_bps > 10_000)
533        {
534            return Err(ConfigError::InvalidCrossSourceBps);
535        }
536
537        // AR.H L1 fix: cap halt_ledgers at MAX_CIRCUIT_BREAKER_HALT_LEDGERS to
538        // prevent misconfigured deploys from creating an effectively-permanent
539        // halt that only governance intervention can clear. u32::MAX is ~6.8
540        // years at Stellar's ledger cadence; the cap (~1 week) is the longest
541        // reasonable auto-recovery window.
542        if self.circuit_breaker_enabled
543            && (self.circuit_breaker_halt_ledgers == 0
544                || self.circuit_breaker_halt_ledgers > MAX_CIRCUIT_BREAKER_HALT_LEDGERS)
545        {
546            return Err(ConfigError::InvalidHaltLedgers);
547        }
548
549        // Hardening Closure (Debt #22): Layer 2 thin-sampling guard.
550        // min_trade_count_1h == 0 silently disables the check.
551        if self.min_trade_count_1h == 0 {
552            return Err(ConfigError::InvalidTradeCountThreshold);
553        }
554
555        // Hardening Closure (Debt #22): Layer 2 snapshot age guard.
556        // 0 rejects all snapshots; > 86_400 (24h) accepts unsafe staleness.
557        if self.max_snapshot_age_seconds == 0 || self.max_snapshot_age_seconds > 86_400 {
558            return Err(ConfigError::InvalidSnapshotAge);
559        }
560
561        // Phase 7.2: previous-price staleness gate. Same boundary logic as
562        // `max_staleness_seconds` (silent-disable defense + 24h upper).
563        if self.previous_max_staleness_seconds == 0 || self.previous_max_staleness_seconds > 86_400
564        {
565            return Err(ConfigError::InvalidPreviousStalenessSeconds);
566        }
567
568        Ok(())
569    }
570}
571
572/// Validates oracle output against five layered guardrails before returning a
573/// price, wrapped by the circuit breaker (Phase 5.2 v2).
574///
575/// Public entry point of the `safe_oracle` library. Lending protocols call
576/// this instead of `reflector.lastprice()` directly.
577///
578/// # Spec
579///
580/// See spec §4 — `safe_oracle` Library API. This is the canonical entry
581/// point defined in "Function Signature and Stub Contract"; the integration
582/// example in §4 shows the one-line migration from `reflector.lastprice(asset)`
583/// to this call.
584///
585/// # Why `PriceResult` instead of `Result`?
586///
587/// Soroban contract methods that return `Result::Err` roll back all storage
588/// writes in the same invocation. The original Phase 5.2 design (commit
589/// `6ef65b7`, reverted in `e98ed48`) hit this and could not commit
590/// `open_circuit_breaker()` writes — auto-halt never persisted. Wrapping
591/// violations in `PriceResult::Err` (returned through the `Ok` boundary at
592/// the Soroban level) lets the breaker write commit cleanly.
593///
594/// See `PriceResult` for full migration guidance and `into_result()` shim.
595///
596/// # Guardrails
597/// - Layer 1 (Reflector-only): deviation, staleness, cross-source
598/// - Layer 2 (LiquidityRegistry-required): liquidity threshold, thin sampling
599/// - Wrapper: circuit breaker (Phase 5)
600///
601/// # Circuit breaker integration
602///
603/// 1. Pre-flight: `check_circuit_breaker(env, asset)` runs first. If the
604///    breaker is `Open` and the halt window has not expired, returns
605///    `PriceResult::Err(CircuitBreakerOpen)` immediately — no Reflector or
606///    LiquidityRegistry calls are made, so a halted asset costs near-zero
607///    gas to reject. Auto-recovery on expiry is handled inside
608///    `check_circuit_breaker`.
609///
610/// 2. Auto-halt: if `config.circuit_breaker_enabled == true` (default
611///    `false`) and any guardrail violates, the breaker is opened for
612///    `config.circuit_breaker_halt_ledgers` ledgers (default 720, ~1 hour
613///    at 5-second close time). The violation is then returned as
614///    `PriceResult::Err(<violation>)`.
615///
616/// The breaker is opt-in. With the default config, this function preserves
617/// the exact Phase 1-4 contract: guardrail violations propagate as
618/// `PriceResult::Err` without persisting any breaker state.
619pub fn lastprice(
620    env: &Env,
621    asset: &Asset,
622    reflector: &Address,
623    liquidity_registry: &Address,
624    config: &SafeOracleConfig,
625) -> PriceResult {
626    // Pre-flight breaker check. Open + not yet expired → short-circuit
627    // before any cross-contract call. Auto-recovery (state transition
628    // Open → Closed when ledger advanced past halt window) is handled
629    // inside check_circuit_breaker.
630    if let Err(e) = circuit_breaker::check_circuit_breaker(env, asset) {
631        return PriceResult::Err(e as u32);
632    }
633
634    let result = lastprice_inner(env, asset, reflector, liquidity_registry, config);
635
636    // Auto-halt on guardrail violation. Only trips when the integrator
637    // opted in — default `circuit_breaker_enabled = false` keeps Phase 1-4
638    // behavior (no breaker side effects).
639    //
640    // CRITICAL: this write commits because the contract method returns Ok
641    // at the Soroban boundary (PriceResult::Err is wrapped in Ok). Phase
642    // 5.2 v1 used Result::Err here and the write rolled back; that is the
643    // bug this version exists to fix. Empirical evidence in the Pre-5.2.C
644    // discovery diagnostic (no-commit transient state).
645    if result.is_err() && config.circuit_breaker_enabled {
646        circuit_breaker::open_circuit_breaker(env, asset, config.circuit_breaker_halt_ledgers);
647    }
648
649    PriceResult::from(result)
650}
651
652/// Internal: full 5-guardrail chain without circuit breaker concerns.
653///
654/// Split from `lastprice` so the breaker stays a pure wrapper concern
655/// (pre-flight check + post-failure halt) and the guardrail chain itself
656/// remains the unchanged Phase 4.2 implementation. Returns `Result` rather
657/// than `PriceResult` because the wrapper composes the two with a single
658/// `PriceResult::from(result)` at the boundary — the `?` operator on
659/// `Result` keeps the inner code idiomatic.
660fn lastprice_inner(
661    env: &Env,
662    asset: &Asset,
663    reflector: &Address,
664    liquidity_registry: &Address,
665    config: &SafeOracleConfig,
666) -> Result<PriceData, OracleSafetyViolation> {
667    // 1. Fetch newest + previous prices in a single cross-contract call.
668    //
669    // Hardening Phase debt #14: pre-6A this path issued two reads —
670    // `records=1` here for `current`, then `records=2` again inside
671    // `check_deviation` for the previous price. The records=2 fetch
672    // already returns both, so the records=1 call was redundant; folding
673    // it eliminates one Reflector round-trip per Layer 1 evaluation.
674    // Actual gas savings will be measured under sustained production load
675    // (debt #13, deferred to Phase 9 — mainnet measurement).
676    //
677    // `fetch_reflector_prices` enforces `prices.len() >= records`, so
678    // missing-history scenarios (0 or 1 stored price) surface as
679    // `StaleData` from the helper itself — `prices.get(1)` here is
680    // always populated when this path executes.
681    let prices = fetch_reflector_prices(env, reflector, asset, 2)?;
682    let p0 = prices.get(0).ok_or(OracleSafetyViolation::StaleData)?;
683    let p1 = prices.get(1).ok_or(OracleSafetyViolation::StaleData)?;
684
685    // Newest/oldest by `timestamp`, not vec index — the mock currently
686    // returns newest-first, but production code does not depend on that
687    // ordering convention.
688    let (current, previous) = if p0.timestamp >= p1.timestamp {
689        (p0, p1)
690    } else {
691        (p1, p0)
692    };
693
694    // 2. Phase 7.2: validate primary Reflector decimals before any further
695    // computation. If the primary publishes a precision other than the
696    // expected `REFLECTOR_DECIMALS_EXPECTED`, the library's BPS / staleness
697    // calculations would silently produce scaled-wrong results — fail
698    // explicitly with `UnexpectedDecimals` so the misconfiguration surfaces.
699    let primary_decimals = check_primary_decimals(env, reflector)?;
700
701    // 3. Phase 7.2: gate the previous price's freshness BEFORE the deviation
702    // calculation. An ancient `previous` (post-gap recovery) makes the
703    // BPS deviation meaningless — surface that as `StaleData` rather than
704    // a misclassified `ExcessiveDeviation`.
705    check_previous_staleness(env, &previous, config)?;
706
707    // 4. Layer 1 guardrails (Reflector-only data).
708    // `check_deviation_from_pair` is pure validation — both prices are
709    // already in hand, no further cross-contract calls.
710    check_deviation_from_pair(&current, &previous, config)?;
711    check_staleness(env, &current, config)?;
712    check_cross_source(env, reflector, asset, &current, config, primary_decimals)?;
713
714    // 3. Layer 2 guardrails (require LiquidityRegistry).
715    // Single cross-contract call shared by both threshold checks; helper
716    // returns None for Asset::Other so both guardrails skip together.
717    if let Some(snapshot) = get_validated_snapshot(env, liquidity_registry, asset, config)? {
718        check_liquidity(&snapshot, config)?;
719        check_thin_sampling(&snapshot, config)?;
720    }
721
722    Ok(current)
723}
724
725/// Fetches the most recent `records` prices from Reflector via cross-contract call.
726///
727/// Returns prices ordered newest-first. Single source of truth for every
728/// Reflector read: `lastprice_inner` calls this once with `records=2`
729/// (Hardening 6A debt #14 collapsed the previous two-call pattern into
730/// one). Reflector returns `None` when the asset has no recorded prices,
731/// and a shorter `Vec` when history is thinner than `records`; both cases
732/// map to `Err(StaleData)` here — fail-safe default that downstream
733/// guardrails can rely on.
734fn fetch_reflector_prices(
735    env: &Env,
736    reflector: &Address,
737    asset: &Asset,
738    records: u32,
739) -> Result<Vec<PriceData>, OracleSafetyViolation> {
740    let client = ReflectorClient::new(env, reflector);
741    // Hardening Phase debt #4: graceful handling of Reflector contract
742    // trap. `try_lastprices` wraps the cross-contract invocation so a
743    // Reflector panic (upgrade incompatibility, storage corruption, host
744    // trap) lands in the `Err(Ok(_))` arm rather than propagating to the
745    // caller. Without this guard a primary-feed crash would prevent the
746    // auto-halt write from committing — same root-cause family as the
747    // Phase 5.2 v1 revert.
748    //
749    // Empirical PoC (pre-3C): a panicking contract method invoked through
750    // `try_<method>` lands in `Err(Ok(_))` with a framework
751    // representation of the trap. The wildcard arm below catches that
752    // plus all other non-success shapes (XDR conversion, host error).
753    let prices = match client.try_lastprices(asset, &records) {
754        Ok(Ok(Some(p))) => p,
755        Ok(Ok(None)) => return Err(OracleSafetyViolation::StaleData),
756        _ => return Err(OracleSafetyViolation::ExternalContractFailure),
757    };
758
759    if prices.len() < records {
760        return Err(OracleSafetyViolation::StaleData);
761    }
762
763    Ok(prices)
764}
765
766/// Layer 1, Guardrail 1 — Maximum Deviation (pure validation).
767///
768/// Compares the newest price against its predecessor recorded by Reflector
769/// (one resolution-window earlier — typically ~5 min) and rejects updates
770/// whose BPS deviation exceeds `config.max_deviation_bps`. This is the
771/// primary defense against YieldBlox-class SDEX manipulation: an attacker
772/// who shifts the spot price by buying/selling on a thin market produces a
773/// delta that this guardrail flags as `ExcessiveDeviation`.
774///
775/// # Hardening Phase debt #14
776///
777/// Pre-6A this lived as `check_deviation`, which made its own
778/// `fetch_reflector_prices(records=2)` call independent of the records=1
779/// fetch in `lastprice_inner` — two cross-contract reads on every Layer 1
780/// evaluation. The records=2 fetch is now done once at the entry point
781/// and both prices passed in here as references; this helper became pure
782/// validation (no env/reflector/asset parameters).
783///
784/// The pre-6A sanity check `current.timestamp != newest.timestamp` was a
785/// defense against the (impossible-in-single-tx) scenario where storage
786/// mutated between the two cross-contract reads. With one read, that
787/// scenario cannot arise; the check is removed as dead code.
788///
789/// # Defensive logic
790/// - `current.price <= 0` or `previous.price <= 0` → `ExcessiveDeviation`.
791///   Reflector should never return a non-positive price, but a corrupted
792///   or malicious feed is the threat model.
793/// - `checked_mul(10_000)` catches the rare overflow where
794///   `abs_diff * 10_000` would exceed `i128::MAX`; treating overflow as
795///   deviation is the safe default.
796///
797/// # Previous-price staleness (Phase 7.2 closure of AR.H M3)
798///
799/// The pre-7.2 design only freshness-checked `current.timestamp` and left
800/// `previous` unbounded — during a real-world data gap (RPC outage,
801/// oracle downtime, asset just listed), `previous` could be days/weeks
802/// old, so legitimate post-gap drift produced false-positive
803/// `ExcessiveDeviation` halts.
804///
805/// **Phase 7.2 fix:** [`check_previous_staleness`] gates the previous
806/// price against the new `config.previous_max_staleness_seconds` field
807/// (default 900s = 3× current threshold) **before** deviation runs.
808/// Excessively-stale previous price now surfaces as `StaleData` rather
809/// than misclassified `ExcessiveDeviation`, so callers and the circuit
810/// breaker see "no fresh deviation reference" instead of "violent move."
811///
812/// Integrators choose the gap policy via the config field: tighter values
813/// halt sooner; looser values accept stale references and fall back to
814/// the deviation calculation against ancient denominators.
815fn check_deviation_from_pair(
816    current: &PriceData,
817    previous: &PriceData,
818    config: &SafeOracleConfig,
819) -> Result<(), OracleSafetyViolation> {
820    if current.price <= 0 || previous.price <= 0 {
821        return Err(OracleSafetyViolation::ExcessiveDeviation);
822    }
823
824    let abs_diff = (current.price - previous.price).abs();
825    let scaled = abs_diff
826        .checked_mul(10_000)
827        .ok_or(OracleSafetyViolation::ExcessiveDeviation)?;
828    let deviation_bps = scaled / previous.price;
829
830    if deviation_bps > config.max_deviation_bps as i128 {
831        return Err(OracleSafetyViolation::ExcessiveDeviation);
832    }
833
834    Ok(())
835}
836
837/// Layer 1, Guardrail 3 — Staleness Check.
838///
839/// Compares the Reflector price's `timestamp` against the current ledger time
840/// (`env.ledger().timestamp()` — both Unix seconds, no conversion). Rejects
841/// prices older than `config.max_staleness_seconds`. This blocks the
842/// stale-feed attack class: an oracle that has not refreshed (because the
843/// off-chain feed is down or paused) cannot be used to value collateral.
844///
845/// # Defensive logic
846/// - `current.timestamp > now` → `StaleData`. A future-dated price implies
847///   clock skew or feed manipulation; treat as untrusted.
848/// - `elapsed > max_staleness_seconds` → `StaleData`. Hard cutoff; `>` is
849///   used (not `>=`) so the boundary value is accepted — consistent with
850///   `check_deviation`'s threshold semantics.
851/// - `now - current.timestamp` cannot underflow: the future-check above
852///   guarantees `current.timestamp <= now`.
853fn check_staleness(
854    env: &Env,
855    current: &PriceData,
856    config: &SafeOracleConfig,
857) -> Result<(), OracleSafetyViolation> {
858    let now = env.ledger().timestamp();
859
860    if current.timestamp > now {
861        return Err(OracleSafetyViolation::StaleData);
862    }
863
864    let elapsed = now - current.timestamp;
865    if elapsed > config.max_staleness_seconds as u64 {
866        return Err(OracleSafetyViolation::StaleData);
867    }
868
869    Ok(())
870}
871
872/// Layer 1, Guardrail 4 — Multi-Source Cross-Check.
873///
874/// When `config.secondary_oracle` is `Some(addr)`, fetches the secondary
875/// oracle's price for the same asset and rejects the trade if the two sources
876/// disagree by more than `config.max_cross_source_bps`. Reflector CEX feeds
877/// can be cross-checked against DEX feeds (or DIA) so that an attack that
878/// shifts only one feed is caught by the other. Opt-in: `None` skips entirely.
879///
880/// # Skip vs. fail semantics
881/// - `secondary_oracle = None` → `Ok(())`. Single-source operation is allowed.
882/// - Secondary returns `None` (no recorded price) → `Ok(())`. "No evidence" is
883///   not the same as "evidence of mismatch"; we don't penalize an asset just
884///   because the secondary feed has not seen it yet.
885/// - Secondary returns a non-positive price → `CrossSourceMismatch`. A live
886///   feed reporting zero/negative is a manipulation signal, not a data gap.
887/// - Secondary returns a stale price (older than `config.max_staleness_seconds`,
888///   the same threshold the primary's freshness check uses) → `Ok(())`. A
889///   stale value is "no fresh evidence"; comparing primary against an old
890///   secondary would generate false-positive halts whenever the secondary
891///   updates lag behind primary. Hardening 3B debt #3 added this skip;
892///   pre-3B behavior collapsed stale secondary into the BPS comparison
893///   below.
894/// - BPS deviation beyond threshold → `CrossSourceMismatch`.
895///
896/// Primary is the BPS reference (`|primary - secondary| * 10_000 / primary`)
897/// because primary is the value the lending contract actually consumes.
898///
899/// # Decimals reconciliation (Phase 7.2 closure of AR.H M2)
900///
901/// **The library now enforces precision agreement explicitly.** Pre-7.2,
902/// `current.price` and `secondary_price.price` were compared as raw `i128`
903/// values without decimals reconciliation, leaving the always-fires-on-
904/// mismatch footgun documented as integrator responsibility.
905///
906/// Phase 7.2 closure: this function fetches `decimals()` from both oracles
907/// before the BPS comparison and returns
908/// [`OracleSafetyViolation::DecimalsMismatch`] on disagreement. Cost is
909/// two extra cross-contract calls per cross-source-enabled `lastprice`,
910/// paid once at the cross-source step (Reflector cost is amortized; both
911/// reads of `lastprice`/`lastprices` already dominate the gas budget).
912///
913/// Mismatched-precision pairs surface as a distinct, recoverable error
914/// (operator removes the secondary or upgrades library) rather than the
915/// pre-7.2 always-fires `CrossSourceMismatch`.
916fn check_cross_source(
917    env: &Env,
918    primary: &Address,
919    asset: &Asset,
920    current: &PriceData,
921    config: &SafeOracleConfig,
922    primary_decimals: u32,
923) -> Result<(), OracleSafetyViolation> {
924    let secondary = match &config.secondary_oracle {
925        Some(addr) => addr,
926        None => return Ok(()),
927    };
928
929    if current.price <= 0 {
930        return Err(OracleSafetyViolation::CrossSourceMismatch);
931    }
932
933    let client = ReflectorClient::new(env, secondary);
934    // Hardening Phase debt #4: graceful handling of secondary Reflector
935    // trap. Secondary failure short-circuits to `Ok(())` (silent skip) —
936    // same semantics as `secondary_oracle = None` and "secondary returned
937    // `None`". The cross-source check is opt-in defense-in-depth; a
938    // broken secondary feed must not freeze borrowing on an
939    // otherwise-healthy primary. Primary failure is handled separately in
940    // `fetch_reflector_prices` and surfaces as `ExternalContractFailure`.
941    let secondary_price = match client.try_lastprice(asset) {
942        Ok(Ok(Some(p))) => p,
943        _ => return Ok(()),
944    };
945
946    if secondary_price.price <= 0 {
947        return Err(OracleSafetyViolation::CrossSourceMismatch);
948    }
949
950    // Phase 7.2: decimals reconciliation. Fetch the secondary's decimals and
951    // compare against the primary's already-validated value. A secondary
952    // `try_decimals` trap is silent-skip (same semantics as a secondary
953    // `try_lastprice` trap above); a successful but mismatched value is a
954    // hard `DecimalsMismatch` error so misconfigured pairs surface cleanly
955    // rather than producing always-fires `CrossSourceMismatch` halts.
956    //
957    // `primary` is unused for the decimals fetch (primary value already
958    // determined upstream in `lastprice_inner` and passed in as
959    // `primary_decimals`) but retained in the signature for future
960    // primary-side cross-checks; bind to `_` to silence the unused warning.
961    let _ = primary;
962    let secondary_decimals = match client.try_decimals() {
963        Ok(Ok(d)) => d,
964        _ => return Ok(()),
965    };
966    if secondary_decimals != primary_decimals {
967        return Err(OracleSafetyViolation::DecimalsMismatch);
968    }
969
970    // Hardening Phase debt #3: skip when the secondary feed is stale. A
971    // stale value is not fresh evidence of disagreement; treating it as a
972    // mismatch would generate false-positive halts whenever the secondary
973    // updates lag behind primary. Uses the same `max_staleness_seconds`
974    // threshold as primary's `check_staleness` — the integrator's freshness
975    // expectation is uniform across both feeds.
976    //
977    // `saturating_sub` handles future-dated secondary timestamps (clock
978    // skew) without panicking: future values yield `secondary_age = 0`,
979    // which falls through to the BPS comparison rather than hitting the
980    // skip path. The BPS check itself is the safety net for that anomaly.
981    let now = env.ledger().timestamp();
982    let secondary_age = now.saturating_sub(secondary_price.timestamp);
983    if secondary_age > config.max_staleness_seconds as u64 {
984        return Ok(());
985    }
986
987    let abs_diff = (current.price - secondary_price.price).abs();
988    let scaled = abs_diff
989        .checked_mul(10_000)
990        .ok_or(OracleSafetyViolation::CrossSourceMismatch)?;
991    let deviation_bps = scaled / current.price;
992
993    if deviation_bps > config.max_cross_source_bps as i128 {
994        return Err(OracleSafetyViolation::CrossSourceMismatch);
995    }
996
997    Ok(())
998}
999
1000/// Phase 7.2: fetch + validate the primary Reflector's `decimals()` value.
1001///
1002/// Returns the live decimals value on success. Two failure modes:
1003/// - Cross-contract call traps → [`OracleSafetyViolation::ExternalContractFailure`]
1004///   (same as primary `lastprice` trap — uniform handling for primary feed
1005///   failure modes).
1006/// - Live value disagrees with [`REFLECTOR_DECIMALS_EXPECTED`] →
1007///   [`OracleSafetyViolation::UnexpectedDecimals`] (Phase 7.2 closure of
1008///   the lib.rs:820 plan; prevents silent scaling errors when Reflector
1009///   contract upgrades change precision).
1010///
1011/// Cost: one extra cross-contract call per `lastprice` invocation. Reflector
1012/// `decimals()` reads instance storage (cheaper than the persistent reads
1013/// done by `lastprice`/`lastprices`), so the marginal cost is small relative
1014/// to the existing call budget.
1015fn check_primary_decimals(env: &Env, primary: &Address) -> Result<u32, OracleSafetyViolation> {
1016    let client = ReflectorClient::new(env, primary);
1017    let decimals = match client.try_decimals() {
1018        Ok(Ok(d)) => d,
1019        _ => return Err(OracleSafetyViolation::ExternalContractFailure),
1020    };
1021    if decimals != REFLECTOR_DECIMALS_EXPECTED {
1022        return Err(OracleSafetyViolation::UnexpectedDecimals);
1023    }
1024    Ok(decimals)
1025}
1026
1027/// Phase 7.2: gate the previous price's freshness BEFORE deviation runs.
1028///
1029/// The `previous` price is intentionally older than `current` (one Reflector
1030/// resolution window earlier — typically ~5 min), but during a real-world
1031/// data gap (RPC outage, oracle downtime, asset just listed) it can be
1032/// arbitrarily old. Without this gate, post-gap recovery computes deviation
1033/// against ancient denominators and produces false-positive
1034/// `ExcessiveDeviation` halts.
1035///
1036/// Returns `StaleData` (not `ExcessiveDeviation`) when `previous` exceeds
1037/// `config.previous_max_staleness_seconds` so callers and the circuit
1038/// breaker observe the correct semantic — "no fresh deviation reference"
1039/// rather than "violent move."
1040///
1041/// `saturating_sub` handles future-dated `previous.timestamp` (clock skew)
1042/// without panicking — future values yield `age = 0` and fall through.
1043fn check_previous_staleness(
1044    env: &Env,
1045    previous: &PriceData,
1046    config: &SafeOracleConfig,
1047) -> Result<(), OracleSafetyViolation> {
1048    let now = env.ledger().timestamp();
1049    let age = now.saturating_sub(previous.timestamp);
1050    if age > config.previous_max_staleness_seconds as u64 {
1051        return Err(OracleSafetyViolation::StaleData);
1052    }
1053    Ok(())
1054}
1055
1056/// Fetch and validate a `LiquiditySnapshot` for a given asset.
1057///
1058/// Encapsulates the snapshot fetch + freshness check shared by
1059/// `check_liquidity` and `check_thin_sampling`. Called once per `lastprice`
1060/// invocation so that both Layer 2 guardrails are served by a single
1061/// cross-contract call to `LiquidityRegistry::get_snapshot` — the round-trip
1062/// dominates Layer 2 cost, and Phase 4.1's per-guardrail fetch was paying it
1063/// twice.
1064///
1065/// # Returns
1066/// - `Ok(Some(snapshot))` — `Asset::Stellar` with a fresh, attested snapshot.
1067/// - `Ok(None)` — `Asset::Other` (off-chain asset). Cross-source (Layer 1)
1068///   is the relevant defense for these; both Layer 2 guardrails skip when
1069///   the helper returns `None`.
1070/// - `Err(InsufficientLiquidity)` — `Asset::Stellar` with no snapshot in the
1071///   registry. Fail-safe: "no evidence of liquidity" is treated as evidence
1072///   of absence so a forgotten attester pipeline cannot silently bypass the
1073///   guardrail (spec §3, Layer 2).
1074/// - `Err(StaleSnapshot)` — snapshot older than `config.max_snapshot_age_seconds`.
1075///   Freshness is enforced consumer-side (here) rather than in the registry,
1076///   keeping the registry policy-agnostic so different integrators can use
1077///   different thresholds against one shared attestation feed.
1078///
1079/// # Future-dated snapshots
1080/// If `snapshot.timestamp > now` (possible from clock drift between attesters),
1081/// the snapshot is accepted as fresh — `now - snapshot.timestamp` is gated on
1082/// `now > snapshot.timestamp` so the subtraction can never underflow.
1083fn get_validated_snapshot(
1084    env: &Env,
1085    liquidity_registry: &Address,
1086    asset: &Asset,
1087    config: &SafeOracleConfig,
1088) -> Result<Option<LiquiditySnapshot>, OracleSafetyViolation> {
1089    let asset_address = match asset {
1090        Asset::Stellar(addr) => addr.clone(),
1091        Asset::Other(_) => return Ok(None),
1092    };
1093
1094    let registry_client = LiquidityRegistryClient::new(env, liquidity_registry);
1095    // Hardening Phase debt #4: graceful handling of `LiquidityRegistry`
1096    // contract trap. A registry failure (upgrade incompatibility, storage
1097    // corruption) becomes `ExternalContractFailure` rather than
1098    // propagating; integrators with `circuit_breaker_enabled = true` then
1099    // auto-halt on the failure, treating it as "no fresh evidence" the
1100    // same way Reflector failures are treated.
1101    let snapshot = match registry_client.try_get_snapshot(&asset_address) {
1102        Ok(Ok(Some(s))) => s,
1103        Ok(Ok(None)) => return Err(OracleSafetyViolation::InsufficientLiquidity),
1104        _ => return Err(OracleSafetyViolation::ExternalContractFailure),
1105    };
1106
1107    let now = env.ledger().timestamp();
1108    if now > snapshot.timestamp {
1109        let age = now - snapshot.timestamp;
1110        if age > config.max_snapshot_age_seconds {
1111            return Err(OracleSafetyViolation::StaleSnapshot);
1112        }
1113    }
1114
1115    Ok(Some(snapshot))
1116}
1117
1118/// Layer 2, Guardrail 4 — Minimum SDEX Liquidity (Phase 4.1).
1119///
1120/// Threshold check on a snapshot already fetched + freshness-validated by
1121/// `get_validated_snapshot`. Rejects when the asset's 30-minute SDEX volume
1122/// is below `config.min_liquidity_usd`.
1123///
1124/// Structural defense against YieldBlox-class attacks: an attacker who can
1125/// move price with a $5 trade has — by definition — drained the order book
1126/// to near-zero, and this check blocks borrowing against such an unstable
1127/// feed even when Reflector reports a clean-looking price.
1128///
1129/// **Precision:** `volume_30m_usd` and `min_liquidity_usd` both use 7-decimal
1130/// USD (Stellar stroop convention) — direct `<` comparison without scaling.
1131/// See `LiquiditySnapshot` doc for the full precision convention. See
1132/// `get_validated_snapshot` for the skip and fail-safe semantics that produce
1133/// the snapshot reaching this function.
1134fn check_liquidity(
1135    snapshot: &LiquiditySnapshot,
1136    config: &SafeOracleConfig,
1137) -> Result<(), OracleSafetyViolation> {
1138    if snapshot.volume_30m_usd < config.min_liquidity_usd {
1139        return Err(OracleSafetyViolation::InsufficientLiquidity);
1140    }
1141    Ok(())
1142}
1143
1144/// Layer 2, Guardrail 5 — Thin Sampling Detection (Phase 4.2).
1145///
1146/// Threshold check on a snapshot already fetched + freshness-validated by
1147/// `get_validated_snapshot`. Rejects when fewer than `config.min_trade_count_1h`
1148/// unique trades occurred in the past hour.
1149///
1150/// Defense against price manipulation in markets where trade frequency is
1151/// too low for VWAP/TWAP feeds to produce trustworthy prices. Even when
1152/// 30-minute volume passes `check_liquidity`, a market with only 1–2 trades
1153/// per hour is structurally vulnerable to single-trade manipulation — the
1154/// YieldBlox attacker had effectively one trade in the relevant pricing
1155/// window, and this guardrail catches that shape independently of the
1156/// volume threshold.
1157///
1158/// `unique_trades_1h` semantics (one trade per `source_account` per ledger,
1159/// $10 minimum sybil floor) are defined by `oracle-watch`; see spec §5
1160/// "Trade Counting Definition". See `get_validated_snapshot` for the skip and
1161/// fail-safe semantics that produce the snapshot reaching this function.
1162fn check_thin_sampling(
1163    snapshot: &LiquiditySnapshot,
1164    config: &SafeOracleConfig,
1165) -> Result<(), OracleSafetyViolation> {
1166    if snapshot.unique_trades_1h < config.min_trade_count_1h {
1167        return Err(OracleSafetyViolation::ThinSampling);
1168    }
1169    Ok(())
1170}
1171
1172#[cfg(test)]
1173mod test {
1174    use super::*;
1175
1176    #[test]
1177    fn test_default_config_values() {
1178        let cfg = SafeOracleConfig::default();
1179        assert_eq!(cfg.max_deviation_bps, 2000);
1180        assert_eq!(cfg.max_staleness_seconds, 300);
1181        // Phase 7.2: previous-price staleness default = 3× current threshold.
1182        assert_eq!(cfg.previous_max_staleness_seconds, 900);
1183        assert_eq!(cfg.max_cross_source_bps, 500);
1184        assert_eq!(cfg.max_snapshot_age_seconds, 300);
1185        assert_eq!(cfg.min_liquidity_usd, 100_000_000_000);
1186        assert_eq!(cfg.min_trade_count_1h, 5);
1187        assert!(cfg.secondary_oracle.is_none());
1188        assert!(!cfg.circuit_breaker_enabled);
1189        assert_eq!(cfg.circuit_breaker_halt_ledgers, 720);
1190    }
1191
1192    #[test]
1193    fn test_error_variants_have_correct_discriminants() {
1194        assert_eq!(OracleSafetyViolation::ExcessiveDeviation as u32, 1);
1195        assert_eq!(OracleSafetyViolation::StaleData as u32, 2);
1196        assert_eq!(OracleSafetyViolation::CrossSourceMismatch as u32, 3);
1197        assert_eq!(OracleSafetyViolation::InsufficientLiquidity as u32, 4);
1198        assert_eq!(OracleSafetyViolation::ThinSampling as u32, 5);
1199        assert_eq!(OracleSafetyViolation::CircuitBreakerOpen as u32, 6);
1200        assert_eq!(OracleSafetyViolation::StaleSnapshot as u32, 7);
1201        // AR.H L5 fix: ExternalContractFailure = 8 (Hardening 3C) regression guard.
1202        // The discriminant is correctly used in PriceResult::into_result and the
1203        // mock-lending mirror; the gap was solely in this regression test.
1204        assert_eq!(OracleSafetyViolation::ExternalContractFailure as u32, 8);
1205        // Phase 7.2 additions — discriminants must stay aligned with the
1206        // mock-lending mirror and `PriceResult::into_result` re-hydration.
1207        assert_eq!(OracleSafetyViolation::DecimalsMismatch as u32, 9);
1208        assert_eq!(OracleSafetyViolation::UnexpectedDecimals as u32, 10);
1209    }
1210}