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    /// Activates the Layer 2 guardrails (liquidity threshold + thin sampling).
335    ///
336    /// When `false` (the default), `lastprice` runs **only** Layer 1
337    /// (deviation, staleness, cross-source) plus the optional circuit breaker,
338    /// and the `liquidity_registry` is **never** queried — not even for
339    /// `Asset::Stellar`. This is the trustless mode: every check is pure
340    /// on-chain Reflector math with no dependency on an off-chain attester
341    /// pipeline.
342    ///
343    /// When `true`, `Asset::Stellar` assets additionally require a fresh,
344    /// attested `LiquidityRegistry` snapshot (see `get_validated_snapshot`);
345    /// `Asset::Other` assets skip Layer 2 regardless, as before.
346    ///
347    /// # Why opt-in
348    ///
349    /// Layer 2 introduces a second trust vector — the off-chain attesters that
350    /// sign `LiquidityRegistry` snapshots. Integrators who cannot run (or do
351    /// not yet trust) an attester pipeline get the full Layer 1 defense without
352    /// being forced to stand up that infrastructure. Layer 2 is the
353    /// defense-in-depth upgrade for the sophisticated sub-threshold attack
354    /// (see `e2e_attack_scenarios::scenario_3`), enabled deliberately.
355    ///
356    /// **Default:** `false`.
357    pub layer2_enabled: bool,
358}
359
360impl Default for SafeOracleConfig {
361    fn default() -> Self {
362        Self {
363            max_deviation_bps: 2000,
364            max_staleness_seconds: 300,
365            // Phase 7.2: 3× max_staleness_seconds. The previous price is
366            // typically ~5min behind the current price, so 15min absorbs one
367            // resolution window of attestation lag without classifying it as
368            // a data gap.
369            previous_max_staleness_seconds: 900,
370            max_cross_source_bps: 500,
371            max_snapshot_age_seconds: 300,
372            min_liquidity_usd: 100_000_000_000,
373            min_trade_count_1h: 5,
374            secondary_oracle: None,
375            circuit_breaker_enabled: false,
376            circuit_breaker_halt_ledgers: 720,
377            // Trustless default: Layer 1 only. Layer 2 (attester-dependent)
378            // is opt-in — see the field doc for the trust-model rationale.
379            layer2_enabled: false,
380        }
381    }
382}
383
384/// Errors returned by [`SafeOracleConfig::validate`] when a config field
385/// has an out-of-range value that would silently disable a guardrail or
386/// produce nonsensical behavior at runtime.
387///
388/// # Spec
389///
390/// See spec §4 — Config Struct. Validation prevents silent guardrail
391/// disabling caused by misconfiguration (e.g., `max_deviation_bps = 0`
392/// allows infinite deviation, effectively disabling the deviation check
393/// without any visible signal).
394///
395/// # Audit notes
396///
397/// - Validation is **opt-in** — callers must invoke `config.validate()`.
398///   The library does not enforce validation in `lastprice()` to avoid
399///   per-call gas cost. Production integrators should validate at init
400///   time (recommended pattern: `MockLending::initialize` calls validate).
401///
402/// - All errors are recoverable at init time; runtime config changes are
403///   not supported (config is immutable after deploy per spec §4).
404// Note: `enum_variant_names` lint suppression for the shared `Invalid`
405// prefix is at crate level (see #![allow] at the top of this file) —
406// the lint fires from inside `#[contracttype]`'s macro expansion and
407// per-enum attributes do not suppress it.
408#[contracttype]
409#[derive(Clone, Debug, Eq, PartialEq)]
410pub enum ConfigError {
411    /// `max_deviation_bps` is 0 (allows infinite deviation, disabling the
412    /// check) or > 10_000 (100% — values above this are nonsensical for a
413    /// relative-deviation threshold).
414    InvalidDeviationBps,
415
416    /// `max_staleness_seconds` is 0 (rejects every recorded price as
417    /// stale) or > 86_400 (24h — stale data older than a day is unsafe
418    /// regardless of how lenient the integrator wants to be).
419    InvalidStalenessSeconds,
420
421    /// `min_liquidity_usd` is `<= 0` — negative values are semantically
422    /// nonsense (liquidity is non-negative by definition), and `0` silently
423    /// disables the Layer 2 liquidity check (every snapshot's
424    /// `volume_30m_usd > 0` trivially passes the threshold). Same defensive
425    /// principle as `InvalidTradeCountThreshold`: a zero guardrail is no
426    /// guardrail.
427    ///
428    /// # AR.H M1 closure
429    ///
430    /// This variant's runtime rule was tightened from `< 0` to `<= 0` after
431    /// AR.H surfaced the silent-disable case as the single residual asymmetry
432    /// from Hardening Closure (Debt #22).
433    InvalidLiquidityThreshold,
434
435    /// `max_cross_source_bps` is `0` (requires impossible primary/secondary
436    /// price equality) or `> 10_000` (semantically nonsensical, > 100% deviation)
437    /// when `secondary_oracle` is configured. Validation skipped if secondary
438    /// is `None` (field is dormant).
439    ///
440    /// # AR.H L2 closure
441    ///
442    /// Validation rule was tightened from `> 10_000` only to `== 0 || > 10_000`
443    /// after AR.H surfaced the silent-footgun case where a zero threshold
444    /// produces always-fires CrossSourceMismatch on every borrow.
445    InvalidCrossSourceBps,
446
447    /// `circuit_breaker_halt_ledgers` is `0` (degenerate halt window — the
448    /// breaker would fire and immediately auto-recover, providing no actual
449    /// halt) or `> MAX_CIRCUIT_BREAKER_HALT_LEDGERS` (~1 week, beyond the
450    /// reasonable auto-recovery window) when `circuit_breaker_enabled` is
451    /// `true`. Validation skipped if breaker is disabled (field is dormant).
452    ///
453    /// # AR.H L1 closure
454    ///
455    /// Upper bound added after AR.H surfaced that `u32::MAX` (~6.8 years
456    /// at Stellar's ledger cadence) makes a misconfigured deploy
457    /// effectively-permanently halted without governance intervention.
458    InvalidHaltLedgers,
459
460    /// `min_trade_count_1h` is 0 — disables thin-sampling check entirely
461    /// (every snapshot's `unique_trades_1h >= 0` always true). Same defensive
462    /// principle as `InvalidDeviationBps`: a "guardrail of zero" is no
463    /// guardrail at all. Integrators wanting to disable Layer 2 thin-sampling
464    /// should leave the guardrail's threshold meaningful and route around it
465    /// via Layer 1 / circuit breaker controls.
466    ///
467    /// # Hardening 3A follow-up (Debt #22)
468    ///
469    /// This variant closes the gap intentionally left open in Hardening 3A,
470    /// where the prompt's "5 variant" boundary kept the pattern consistent
471    /// with the other guardrails but left two silent-disable cases
472    /// undetected. Hardening Closure brings parity.
473    InvalidTradeCountThreshold,
474
475    /// `max_snapshot_age_seconds` is 0 (rejects all snapshots) or > 86_400
476    /// (24h — staler than this is unsafe regardless of integrator intent).
477    /// Mirrors `InvalidStalenessSeconds` boundary logic for the Layer 2 path.
478    ///
479    /// # Hardening 3A follow-up (Debt #22)
480    ///
481    /// Same closure rationale as `InvalidTradeCountThreshold`. The Hardening
482    /// 3A boundary kept the new-variant count at 5; the Layer 2 snapshot age
483    /// validation remained an audit-trail gap until this patch.
484    InvalidSnapshotAge,
485
486    /// `previous_max_staleness_seconds == 0` silently disables the
487    /// previous-price freshness check (every previous price would be
488    /// classified `StaleData`, blocking every borrow), or `> 86_400` (24h)
489    /// accepts unsafe staleness for the deviation reference. Mirrors
490    /// `InvalidStalenessSeconds` boundary logic.
491    ///
492    /// Phase 7.2 addition — pairs with the new
493    /// `previous_max_staleness_seconds` field on [`SafeOracleConfig`].
494    InvalidPreviousStalenessSeconds,
495}
496
497impl SafeOracleConfig {
498    /// Validates the config and returns an error if any field has an
499    /// out-of-range value. Recommended call site: at integrator
500    /// initialization, before storing the config in instance storage.
501    ///
502    /// # Spec
503    ///
504    /// See spec §4 — Config Struct. Validation is opt-in (the library
505    /// does not enforce it on every `lastprice` call) so integrators pay
506    /// the check exactly once per config change.
507    ///
508    /// # Errors
509    ///
510    /// - [`ConfigError::InvalidDeviationBps`] — `max_deviation_bps == 0`
511    ///   or `> 10_000`.
512    /// - [`ConfigError::InvalidStalenessSeconds`] — `max_staleness_seconds
513    ///   == 0` or `> 86_400`.
514    /// - [`ConfigError::InvalidLiquidityThreshold`] — `min_liquidity_usd
515    ///   <= 0` (AR.H M1).
516    /// - [`ConfigError::InvalidCrossSourceBps`] — secondary configured
517    ///   and `max_cross_source_bps == 0` or `> 10_000` (AR.H L2).
518    /// - [`ConfigError::InvalidHaltLedgers`] — `circuit_breaker_enabled`
519    ///   and `circuit_breaker_halt_ledgers == 0` or
520    ///   `> MAX_CIRCUIT_BREAKER_HALT_LEDGERS` (AR.H L1).
521    /// - [`ConfigError::InvalidTradeCountThreshold`] — `min_trade_count_1h
522    ///   == 0` (Hardening Closure / Debt #22).
523    /// - [`ConfigError::InvalidSnapshotAge`] — `max_snapshot_age_seconds
524    ///   == 0` or `> 86_400` (Hardening Closure / Debt #22).
525    ///
526    /// # Examples
527    ///
528    /// ```rust,ignore
529    /// let config = SafeOracleConfig::default();
530    /// config.validate().expect("default config is valid by construction");
531    /// ```
532    pub fn validate(&self) -> Result<(), ConfigError> {
533        if self.max_deviation_bps == 0 || self.max_deviation_bps > 10_000 {
534            return Err(ConfigError::InvalidDeviationBps);
535        }
536
537        if self.max_staleness_seconds == 0 || self.max_staleness_seconds > 86_400 {
538            return Err(ConfigError::InvalidStalenessSeconds);
539        }
540
541        // AR.H M1 fix: also reject == 0 to prevent silent-disable.
542        // With min_liquidity_usd == 0, the runtime check
543        // `snapshot.volume_30m_usd < 0` is unreachable because write_snapshot
544        // rejects volume_30m_usd <= 0 — every attestation passes the threshold,
545        // silently disabling the Layer 2 liquidity guardrail (the YieldBlox
546        // vector). Mirrors the silent-disable defenses Hardening 3A established
547        // for the deviation/staleness/halt-ledgers fields and Hardening Closure
548        // (Debt #22) extended to min_trade_count_1h and max_snapshot_age_seconds.
549        // Gated on `layer2_enabled`: when Layer 2 is off the field is dormant
550        // (the registry is never queried), mirroring the secondary_oracle /
551        // circuit_breaker conditional-validation pattern below.
552        if self.layer2_enabled && self.min_liquidity_usd <= 0 {
553            return Err(ConfigError::InvalidLiquidityThreshold);
554        }
555
556        // AR.H L2 fix: also reject == 0 when secondary is configured. A zero
557        // cross-source threshold requires perfect primary/secondary price
558        // equality, which is operationally impossible — every borrow would
559        // fire CrossSourceMismatch. Same silent-footgun shape as M1
560        // (min_liquidity_usd == 0) and Hardening Closure / Debt #22.
561        if self.secondary_oracle.is_some()
562            && (self.max_cross_source_bps == 0 || self.max_cross_source_bps > 10_000)
563        {
564            return Err(ConfigError::InvalidCrossSourceBps);
565        }
566
567        // AR.H L1 fix: cap halt_ledgers at MAX_CIRCUIT_BREAKER_HALT_LEDGERS to
568        // prevent misconfigured deploys from creating an effectively-permanent
569        // halt that only governance intervention can clear. u32::MAX is ~6.8
570        // years at Stellar's ledger cadence; the cap (~1 week) is the longest
571        // reasonable auto-recovery window.
572        if self.circuit_breaker_enabled
573            && (self.circuit_breaker_halt_ledgers == 0
574                || self.circuit_breaker_halt_ledgers > MAX_CIRCUIT_BREAKER_HALT_LEDGERS)
575        {
576            return Err(ConfigError::InvalidHaltLedgers);
577        }
578
579        // Hardening Closure (Debt #22): Layer 2 thin-sampling guard.
580        // min_trade_count_1h == 0 silently disables the check.
581        if self.layer2_enabled && self.min_trade_count_1h == 0 {
582            return Err(ConfigError::InvalidTradeCountThreshold);
583        }
584
585        // Hardening Closure (Debt #22): Layer 2 snapshot age guard.
586        // 0 rejects all snapshots; > 86_400 (24h) accepts unsafe staleness.
587        if self.layer2_enabled
588            && (self.max_snapshot_age_seconds == 0 || self.max_snapshot_age_seconds > 86_400)
589        {
590            return Err(ConfigError::InvalidSnapshotAge);
591        }
592
593        // Phase 7.2: previous-price staleness gate. Same boundary logic as
594        // `max_staleness_seconds` (silent-disable defense + 24h upper).
595        if self.previous_max_staleness_seconds == 0 || self.previous_max_staleness_seconds > 86_400
596        {
597            return Err(ConfigError::InvalidPreviousStalenessSeconds);
598        }
599
600        Ok(())
601    }
602}
603
604/// Validates oracle output against five layered guardrails before returning a
605/// price, wrapped by the circuit breaker (Phase 5.2 v2).
606///
607/// Public entry point of the `safe_oracle` library. Lending protocols call
608/// this instead of `reflector.lastprice()` directly.
609///
610/// # Spec
611///
612/// See spec §4 — `safe_oracle` Library API. This is the canonical entry
613/// point defined in "Function Signature and Stub Contract"; the integration
614/// example in §4 shows the one-line migration from `reflector.lastprice(asset)`
615/// to this call.
616///
617/// # Why `PriceResult` instead of `Result`?
618///
619/// Soroban contract methods that return `Result::Err` roll back all storage
620/// writes in the same invocation. The original Phase 5.2 design (commit
621/// `6ef65b7`, reverted in `e98ed48`) hit this and could not commit
622/// `open_circuit_breaker()` writes — auto-halt never persisted. Wrapping
623/// violations in `PriceResult::Err` (returned through the `Ok` boundary at
624/// the Soroban level) lets the breaker write commit cleanly.
625///
626/// See `PriceResult` for full migration guidance and `into_result()` shim.
627///
628/// # Guardrails
629/// - Layer 1 (Reflector-only): deviation, staleness, cross-source
630/// - Layer 2 (LiquidityRegistry-required): liquidity threshold, thin sampling
631/// - Wrapper: circuit breaker (Phase 5)
632///
633/// # Circuit breaker integration
634///
635/// 1. Pre-flight: `check_circuit_breaker(env, asset)` runs first. If the
636///    breaker is `Open` and the halt window has not expired, returns
637///    `PriceResult::Err(CircuitBreakerOpen)` immediately — no Reflector or
638///    LiquidityRegistry calls are made, so a halted asset costs near-zero
639///    gas to reject. Auto-recovery on expiry is handled inside
640///    `check_circuit_breaker`.
641///
642/// 2. Auto-halt: if `config.circuit_breaker_enabled == true` (default
643///    `false`) and any guardrail violates, the breaker is opened for
644///    `config.circuit_breaker_halt_ledgers` ledgers (default 720, ~1 hour
645///    at 5-second close time). The violation is then returned as
646///    `PriceResult::Err(<violation>)`.
647///
648/// The breaker is opt-in. With the default config, this function preserves
649/// the exact Phase 1-4 contract: guardrail violations propagate as
650/// `PriceResult::Err` without persisting any breaker state.
651pub fn lastprice(
652    env: &Env,
653    asset: &Asset,
654    reflector: &Address,
655    liquidity_registry: &Address,
656    config: &SafeOracleConfig,
657) -> PriceResult {
658    // Pre-flight breaker check. Open + not yet expired → short-circuit
659    // before any cross-contract call. Auto-recovery (state transition
660    // Open → Closed when ledger advanced past halt window) is handled
661    // inside check_circuit_breaker.
662    if let Err(e) = circuit_breaker::check_circuit_breaker(env, asset) {
663        return PriceResult::Err(e as u32);
664    }
665
666    let result = lastprice_inner(env, asset, reflector, liquidity_registry, config);
667
668    // Auto-halt on guardrail violation. Only trips when the integrator
669    // opted in — default `circuit_breaker_enabled = false` keeps Phase 1-4
670    // behavior (no breaker side effects).
671    //
672    // CRITICAL: this write commits because the contract method returns Ok
673    // at the Soroban boundary (PriceResult::Err is wrapped in Ok). Phase
674    // 5.2 v1 used Result::Err here and the write rolled back; that is the
675    // bug this version exists to fix. Empirical evidence in the Pre-5.2.C
676    // discovery diagnostic (no-commit transient state).
677    if result.is_err() && config.circuit_breaker_enabled {
678        circuit_breaker::open_circuit_breaker(env, asset, config.circuit_breaker_halt_ledgers);
679    }
680
681    PriceResult::from(result)
682}
683
684/// Internal: full 5-guardrail chain without circuit breaker concerns.
685///
686/// Split from `lastprice` so the breaker stays a pure wrapper concern
687/// (pre-flight check + post-failure halt) and the guardrail chain itself
688/// remains the unchanged Phase 4.2 implementation. Returns `Result` rather
689/// than `PriceResult` because the wrapper composes the two with a single
690/// `PriceResult::from(result)` at the boundary — the `?` operator on
691/// `Result` keeps the inner code idiomatic.
692fn lastprice_inner(
693    env: &Env,
694    asset: &Asset,
695    reflector: &Address,
696    liquidity_registry: &Address,
697    config: &SafeOracleConfig,
698) -> Result<PriceData, OracleSafetyViolation> {
699    // 1. Fetch newest + previous prices in a single cross-contract call.
700    //
701    // Hardening Phase debt #14: pre-6A this path issued two reads —
702    // `records=1` here for `current`, then `records=2` again inside
703    // `check_deviation` for the previous price. The records=2 fetch
704    // already returns both, so the records=1 call was redundant; folding
705    // it eliminates one Reflector round-trip per Layer 1 evaluation.
706    // Actual gas savings will be measured under sustained production load
707    // (debt #13, deferred to Phase 9 — mainnet measurement).
708    //
709    // `fetch_reflector_prices` enforces `prices.len() >= records`, so
710    // missing-history scenarios (0 or 1 stored price) surface as
711    // `StaleData` from the helper itself — `prices.get(1)` here is
712    // always populated when this path executes.
713    let prices = fetch_reflector_prices(env, reflector, asset, 2)?;
714    let p0 = prices.get(0).ok_or(OracleSafetyViolation::StaleData)?;
715    let p1 = prices.get(1).ok_or(OracleSafetyViolation::StaleData)?;
716
717    // Newest/oldest by `timestamp`, not vec index — the mock currently
718    // returns newest-first, but production code does not depend on that
719    // ordering convention.
720    let (current, previous) = if p0.timestamp >= p1.timestamp {
721        (p0, p1)
722    } else {
723        (p1, p0)
724    };
725
726    // 2. Phase 7.2: validate primary Reflector decimals before any further
727    // computation. If the primary publishes a precision other than the
728    // expected `REFLECTOR_DECIMALS_EXPECTED`, the library's BPS / staleness
729    // calculations would silently produce scaled-wrong results — fail
730    // explicitly with `UnexpectedDecimals` so the misconfiguration surfaces.
731    let primary_decimals = check_primary_decimals(env, reflector)?;
732
733    // 3. Phase 7.2: gate the previous price's freshness BEFORE the deviation
734    // calculation. An ancient `previous` (post-gap recovery) makes the
735    // BPS deviation meaningless — surface that as `StaleData` rather than
736    // a misclassified `ExcessiveDeviation`.
737    check_previous_staleness(env, &previous, config)?;
738
739    // 4. Layer 1 guardrails (Reflector-only data).
740    // `check_deviation_from_pair` is pure validation — both prices are
741    // already in hand, no further cross-contract calls.
742    check_deviation_from_pair(&current, &previous, config)?;
743    check_staleness(env, &current, config)?;
744    check_cross_source(env, reflector, asset, &current, config, primary_decimals)?;
745
746    // 3. Layer 2 guardrails (require LiquidityRegistry).
747    // Single cross-contract call shared by both threshold checks; helper
748    // returns None for Asset::Other so both guardrails skip together.
749    if let Some(snapshot) = get_validated_snapshot(env, liquidity_registry, asset, config)? {
750        check_liquidity(&snapshot, config)?;
751        check_thin_sampling(&snapshot, config)?;
752    }
753
754    Ok(current)
755}
756
757/// Fetches the most recent `records` prices from Reflector via cross-contract call.
758///
759/// Returns prices ordered newest-first. Single source of truth for every
760/// Reflector read: `lastprice_inner` calls this once with `records=2`
761/// (Hardening 6A debt #14 collapsed the previous two-call pattern into
762/// one). Reflector returns `None` when the asset has no recorded prices,
763/// and a shorter `Vec` when history is thinner than `records`; both cases
764/// map to `Err(StaleData)` here — fail-safe default that downstream
765/// guardrails can rely on.
766fn fetch_reflector_prices(
767    env: &Env,
768    reflector: &Address,
769    asset: &Asset,
770    records: u32,
771) -> Result<Vec<PriceData>, OracleSafetyViolation> {
772    let client = ReflectorClient::new(env, reflector);
773    // Hardening Phase debt #4: graceful handling of Reflector contract
774    // trap. `try_prices` wraps the cross-contract invocation so a
775    // Reflector panic (upgrade incompatibility, storage corruption, host
776    // trap) lands in the `Err(Ok(_))` arm rather than propagating to the
777    // caller. Without this guard a primary-feed crash would prevent the
778    // auto-halt write from committing — same root-cause family as the
779    // Phase 5.2 v1 revert.
780    //
781    // Empirical PoC (pre-3C): a panicking contract method invoked through
782    // `try_<method>` lands in `Err(Ok(_))` with a framework
783    // representation of the trap. The wildcard arm below catches that
784    // plus all other non-success shapes (XDR conversion, host error).
785    let prices = match client.try_prices(asset, &records) {
786        Ok(Ok(Some(p))) => p,
787        Ok(Ok(None)) => return Err(OracleSafetyViolation::StaleData),
788        _ => return Err(OracleSafetyViolation::ExternalContractFailure),
789    };
790
791    if prices.len() < records {
792        return Err(OracleSafetyViolation::StaleData);
793    }
794
795    Ok(prices)
796}
797
798/// Layer 1, Guardrail 1 — Maximum Deviation (pure validation).
799///
800/// Compares the newest price against its predecessor recorded by Reflector
801/// (one resolution-window earlier — typically ~5 min) and rejects updates
802/// whose BPS deviation exceeds `config.max_deviation_bps`. This is the
803/// primary defense against YieldBlox-class SDEX manipulation: an attacker
804/// who shifts the spot price by buying/selling on a thin market produces a
805/// delta that this guardrail flags as `ExcessiveDeviation`.
806///
807/// # Hardening Phase debt #14
808///
809/// Pre-6A this lived as `check_deviation`, which made its own
810/// `fetch_reflector_prices(records=2)` call independent of the records=1
811/// fetch in `lastprice_inner` — two cross-contract reads on every Layer 1
812/// evaluation. The records=2 fetch is now done once at the entry point
813/// and both prices passed in here as references; this helper became pure
814/// validation (no env/reflector/asset parameters).
815///
816/// The pre-6A sanity check `current.timestamp != newest.timestamp` was a
817/// defense against the (impossible-in-single-tx) scenario where storage
818/// mutated between the two cross-contract reads. With one read, that
819/// scenario cannot arise; the check is removed as dead code.
820///
821/// # Defensive logic
822/// - `current.price <= 0` or `previous.price <= 0` → `ExcessiveDeviation`.
823///   Reflector should never return a non-positive price, but a corrupted
824///   or malicious feed is the threat model.
825/// - `checked_mul(10_000)` catches the rare overflow where
826///   `abs_diff * 10_000` would exceed `i128::MAX`; treating overflow as
827///   deviation is the safe default.
828///
829/// # Previous-price staleness (Phase 7.2 closure of AR.H M3)
830///
831/// The pre-7.2 design only freshness-checked `current.timestamp` and left
832/// `previous` unbounded — during a real-world data gap (RPC outage,
833/// oracle downtime, asset just listed), `previous` could be days/weeks
834/// old, so legitimate post-gap drift produced false-positive
835/// `ExcessiveDeviation` halts.
836///
837/// **Phase 7.2 fix:** [`check_previous_staleness`] gates the previous
838/// price against the new `config.previous_max_staleness_seconds` field
839/// (default 900s = 3× current threshold) **before** deviation runs.
840/// Excessively-stale previous price now surfaces as `StaleData` rather
841/// than misclassified `ExcessiveDeviation`, so callers and the circuit
842/// breaker see "no fresh deviation reference" instead of "violent move."
843///
844/// Integrators choose the gap policy via the config field: tighter values
845/// halt sooner; looser values accept stale references and fall back to
846/// the deviation calculation against ancient denominators.
847fn check_deviation_from_pair(
848    current: &PriceData,
849    previous: &PriceData,
850    config: &SafeOracleConfig,
851) -> Result<(), OracleSafetyViolation> {
852    if current.price <= 0 || previous.price <= 0 {
853        return Err(OracleSafetyViolation::ExcessiveDeviation);
854    }
855
856    let abs_diff = (current.price - previous.price).abs();
857    let scaled = abs_diff
858        .checked_mul(10_000)
859        .ok_or(OracleSafetyViolation::ExcessiveDeviation)?;
860    let deviation_bps = scaled / previous.price;
861
862    if deviation_bps > config.max_deviation_bps as i128 {
863        return Err(OracleSafetyViolation::ExcessiveDeviation);
864    }
865
866    Ok(())
867}
868
869/// Layer 1, Guardrail 3 — Staleness Check.
870///
871/// Compares the Reflector price's `timestamp` against the current ledger time
872/// (`env.ledger().timestamp()` — both Unix seconds, no conversion). Rejects
873/// prices older than `config.max_staleness_seconds`. This blocks the
874/// stale-feed attack class: an oracle that has not refreshed (because the
875/// off-chain feed is down or paused) cannot be used to value collateral.
876///
877/// # Defensive logic
878/// - `current.timestamp > now` → `StaleData`. A future-dated price implies
879///   clock skew or feed manipulation; treat as untrusted.
880/// - `elapsed > max_staleness_seconds` → `StaleData`. Hard cutoff; `>` is
881///   used (not `>=`) so the boundary value is accepted — consistent with
882///   `check_deviation`'s threshold semantics.
883/// - `now - current.timestamp` cannot underflow: the future-check above
884///   guarantees `current.timestamp <= now`.
885fn check_staleness(
886    env: &Env,
887    current: &PriceData,
888    config: &SafeOracleConfig,
889) -> Result<(), OracleSafetyViolation> {
890    let now = env.ledger().timestamp();
891
892    if current.timestamp > now {
893        return Err(OracleSafetyViolation::StaleData);
894    }
895
896    let elapsed = now - current.timestamp;
897    if elapsed > config.max_staleness_seconds as u64 {
898        return Err(OracleSafetyViolation::StaleData);
899    }
900
901    Ok(())
902}
903
904/// Layer 1, Guardrail 4 — Multi-Source Cross-Check.
905///
906/// When `config.secondary_oracle` is `Some(addr)`, fetches the secondary
907/// oracle's price for the same asset and rejects the trade if the two sources
908/// disagree by more than `config.max_cross_source_bps`. Reflector CEX feeds
909/// can be cross-checked against DEX feeds (or DIA) so that an attack that
910/// shifts only one feed is caught by the other. Opt-in: `None` skips entirely.
911///
912/// # Skip vs. fail semantics
913/// - `secondary_oracle = None` → `Ok(())`. Single-source operation is allowed.
914/// - Secondary returns `None` (no recorded price) → `Ok(())`. "No evidence" is
915///   not the same as "evidence of mismatch"; we don't penalize an asset just
916///   because the secondary feed has not seen it yet.
917/// - Secondary returns a non-positive price → `CrossSourceMismatch`. A live
918///   feed reporting zero/negative is a manipulation signal, not a data gap.
919/// - Secondary returns a stale price (older than `config.max_staleness_seconds`,
920///   the same threshold the primary's freshness check uses) → `Ok(())`. A
921///   stale value is "no fresh evidence"; comparing primary against an old
922///   secondary would generate false-positive halts whenever the secondary
923///   updates lag behind primary. Hardening 3B debt #3 added this skip;
924///   pre-3B behavior collapsed stale secondary into the BPS comparison
925///   below.
926/// - BPS deviation beyond threshold → `CrossSourceMismatch`.
927///
928/// Primary is the BPS reference (`|primary - secondary| * 10_000 / primary`)
929/// because primary is the value the lending contract actually consumes.
930///
931/// # Decimals reconciliation (Phase 7.2 closure of AR.H M2)
932///
933/// **The library now enforces precision agreement explicitly.** Pre-7.2,
934/// `current.price` and `secondary_price.price` were compared as raw `i128`
935/// values without decimals reconciliation, leaving the always-fires-on-
936/// mismatch footgun documented as integrator responsibility.
937///
938/// Phase 7.2 closure: this function fetches `decimals()` from both oracles
939/// before the BPS comparison and returns
940/// [`OracleSafetyViolation::DecimalsMismatch`] on disagreement. Cost is
941/// two extra cross-contract calls per cross-source-enabled `lastprice`,
942/// paid once at the cross-source step (Reflector cost is amortized; both
943/// reads of `lastprice`/`prices` already dominate the gas budget).
944///
945/// Mismatched-precision pairs surface as a distinct, recoverable error
946/// (operator removes the secondary or upgrades library) rather than the
947/// pre-7.2 always-fires `CrossSourceMismatch`.
948fn check_cross_source(
949    env: &Env,
950    primary: &Address,
951    asset: &Asset,
952    current: &PriceData,
953    config: &SafeOracleConfig,
954    primary_decimals: u32,
955) -> Result<(), OracleSafetyViolation> {
956    let secondary = match &config.secondary_oracle {
957        Some(addr) => addr,
958        None => return Ok(()),
959    };
960
961    if current.price <= 0 {
962        return Err(OracleSafetyViolation::CrossSourceMismatch);
963    }
964
965    let client = ReflectorClient::new(env, secondary);
966    // Hardening Phase debt #4: graceful handling of secondary Reflector
967    // trap. Secondary failure short-circuits to `Ok(())` (silent skip) —
968    // same semantics as `secondary_oracle = None` and "secondary returned
969    // `None`". The cross-source check is opt-in defense-in-depth; a
970    // broken secondary feed must not freeze borrowing on an
971    // otherwise-healthy primary. Primary failure is handled separately in
972    // `fetch_reflector_prices` and surfaces as `ExternalContractFailure`.
973    let secondary_price = match client.try_lastprice(asset) {
974        Ok(Ok(Some(p))) => p,
975        _ => return Ok(()),
976    };
977
978    if secondary_price.price <= 0 {
979        return Err(OracleSafetyViolation::CrossSourceMismatch);
980    }
981
982    // Phase 7.2: decimals reconciliation. Fetch the secondary's decimals and
983    // compare against the primary's already-validated value. A secondary
984    // `try_decimals` trap is silent-skip (same semantics as a secondary
985    // `try_lastprice` trap above); a successful but mismatched value is a
986    // hard `DecimalsMismatch` error so misconfigured pairs surface cleanly
987    // rather than producing always-fires `CrossSourceMismatch` halts.
988    //
989    // `primary` is unused for the decimals fetch (primary value already
990    // determined upstream in `lastprice_inner` and passed in as
991    // `primary_decimals`) but retained in the signature for future
992    // primary-side cross-checks; bind to `_` to silence the unused warning.
993    let _ = primary;
994    let secondary_decimals = match client.try_decimals() {
995        Ok(Ok(d)) => d,
996        _ => return Ok(()),
997    };
998    if secondary_decimals != primary_decimals {
999        return Err(OracleSafetyViolation::DecimalsMismatch);
1000    }
1001
1002    // Hardening Phase debt #3: skip when the secondary feed is stale. A
1003    // stale value is not fresh evidence of disagreement; treating it as a
1004    // mismatch would generate false-positive halts whenever the secondary
1005    // updates lag behind primary. Uses the same `max_staleness_seconds`
1006    // threshold as primary's `check_staleness` — the integrator's freshness
1007    // expectation is uniform across both feeds.
1008    //
1009    // `saturating_sub` handles future-dated secondary timestamps (clock
1010    // skew) without panicking: future values yield `secondary_age = 0`,
1011    // which falls through to the BPS comparison rather than hitting the
1012    // skip path. The BPS check itself is the safety net for that anomaly.
1013    let now = env.ledger().timestamp();
1014    let secondary_age = now.saturating_sub(secondary_price.timestamp);
1015    if secondary_age > config.max_staleness_seconds as u64 {
1016        return Ok(());
1017    }
1018
1019    let abs_diff = (current.price - secondary_price.price).abs();
1020    let scaled = abs_diff
1021        .checked_mul(10_000)
1022        .ok_or(OracleSafetyViolation::CrossSourceMismatch)?;
1023    let deviation_bps = scaled / current.price;
1024
1025    if deviation_bps > config.max_cross_source_bps as i128 {
1026        return Err(OracleSafetyViolation::CrossSourceMismatch);
1027    }
1028
1029    Ok(())
1030}
1031
1032/// Phase 7.2: fetch + validate the primary Reflector's `decimals()` value.
1033///
1034/// Returns the live decimals value on success. Two failure modes:
1035/// - Cross-contract call traps → [`OracleSafetyViolation::ExternalContractFailure`]
1036///   (same as primary `lastprice` trap — uniform handling for primary feed
1037///   failure modes).
1038/// - Live value disagrees with [`REFLECTOR_DECIMALS_EXPECTED`] →
1039///   [`OracleSafetyViolation::UnexpectedDecimals`] (Phase 7.2 closure of
1040///   the lib.rs:820 plan; prevents silent scaling errors when Reflector
1041///   contract upgrades change precision).
1042///
1043/// Cost: one extra cross-contract call per `lastprice` invocation. Reflector
1044/// `decimals()` reads instance storage (cheaper than the persistent reads
1045/// done by `lastprice`/`prices`), so the marginal cost is small relative
1046/// to the existing call budget.
1047fn check_primary_decimals(env: &Env, primary: &Address) -> Result<u32, OracleSafetyViolation> {
1048    let client = ReflectorClient::new(env, primary);
1049    let decimals = match client.try_decimals() {
1050        Ok(Ok(d)) => d,
1051        _ => return Err(OracleSafetyViolation::ExternalContractFailure),
1052    };
1053    if decimals != REFLECTOR_DECIMALS_EXPECTED {
1054        return Err(OracleSafetyViolation::UnexpectedDecimals);
1055    }
1056    Ok(decimals)
1057}
1058
1059/// Phase 7.2: gate the previous price's freshness BEFORE deviation runs.
1060///
1061/// The `previous` price is intentionally older than `current` (one Reflector
1062/// resolution window earlier — typically ~5 min), but during a real-world
1063/// data gap (RPC outage, oracle downtime, asset just listed) it can be
1064/// arbitrarily old. Without this gate, post-gap recovery computes deviation
1065/// against ancient denominators and produces false-positive
1066/// `ExcessiveDeviation` halts.
1067///
1068/// Returns `StaleData` (not `ExcessiveDeviation`) when `previous` exceeds
1069/// `config.previous_max_staleness_seconds` so callers and the circuit
1070/// breaker observe the correct semantic — "no fresh deviation reference"
1071/// rather than "violent move."
1072///
1073/// `saturating_sub` handles future-dated `previous.timestamp` (clock skew)
1074/// without panicking — future values yield `age = 0` and fall through.
1075fn check_previous_staleness(
1076    env: &Env,
1077    previous: &PriceData,
1078    config: &SafeOracleConfig,
1079) -> Result<(), OracleSafetyViolation> {
1080    let now = env.ledger().timestamp();
1081    let age = now.saturating_sub(previous.timestamp);
1082    if age > config.previous_max_staleness_seconds as u64 {
1083        return Err(OracleSafetyViolation::StaleData);
1084    }
1085    Ok(())
1086}
1087
1088/// Fetch and validate a `LiquiditySnapshot` for a given asset.
1089///
1090/// Encapsulates the snapshot fetch + freshness check shared by
1091/// `check_liquidity` and `check_thin_sampling`. Called once per `lastprice`
1092/// invocation so that both Layer 2 guardrails are served by a single
1093/// cross-contract call to `LiquidityRegistry::get_snapshot` — the round-trip
1094/// dominates Layer 2 cost, and Phase 4.1's per-guardrail fetch was paying it
1095/// twice.
1096///
1097/// # Returns
1098/// - `Ok(Some(snapshot))` — `Asset::Stellar` with a fresh, attested snapshot.
1099/// - `Ok(None)` — `Asset::Other` (off-chain asset). Cross-source (Layer 1)
1100///   is the relevant defense for these; both Layer 2 guardrails skip when
1101///   the helper returns `None`.
1102/// - `Err(InsufficientLiquidity)` — `Asset::Stellar` with no snapshot in the
1103///   registry. Fail-safe: "no evidence of liquidity" is treated as evidence
1104///   of absence so a forgotten attester pipeline cannot silently bypass the
1105///   guardrail (spec §3, Layer 2).
1106/// - `Err(StaleSnapshot)` — snapshot older than `config.max_snapshot_age_seconds`.
1107///   Freshness is enforced consumer-side (here) rather than in the registry,
1108///   keeping the registry policy-agnostic so different integrators can use
1109///   different thresholds against one shared attestation feed.
1110///
1111/// # Future-dated snapshots
1112/// If `snapshot.timestamp > now` (possible from clock drift between attesters),
1113/// the snapshot is accepted as fresh — `now - snapshot.timestamp` is gated on
1114/// `now > snapshot.timestamp` so the subtraction can never underflow.
1115fn get_validated_snapshot(
1116    env: &Env,
1117    liquidity_registry: &Address,
1118    asset: &Asset,
1119    config: &SafeOracleConfig,
1120) -> Result<Option<LiquiditySnapshot>, OracleSafetyViolation> {
1121    // Layer 2 is opt-in (default off). When disabled, the registry is never
1122    // queried — `lastprice` is pure Layer 1 + breaker, with no dependency on
1123    // the off-chain attester pipeline. See `SafeOracleConfig::layer2_enabled`.
1124    if !config.layer2_enabled {
1125        return Ok(None);
1126    }
1127
1128    let asset_address = match asset {
1129        Asset::Stellar(addr) => addr.clone(),
1130        Asset::Other(_) => return Ok(None),
1131    };
1132
1133    let registry_client = LiquidityRegistryClient::new(env, liquidity_registry);
1134    // Hardening Phase debt #4: graceful handling of `LiquidityRegistry`
1135    // contract trap. A registry failure (upgrade incompatibility, storage
1136    // corruption) becomes `ExternalContractFailure` rather than
1137    // propagating; integrators with `circuit_breaker_enabled = true` then
1138    // auto-halt on the failure, treating it as "no fresh evidence" the
1139    // same way Reflector failures are treated.
1140    let snapshot = match registry_client.try_get_snapshot(&asset_address) {
1141        Ok(Ok(Some(s))) => s,
1142        Ok(Ok(None)) => return Err(OracleSafetyViolation::InsufficientLiquidity),
1143        _ => return Err(OracleSafetyViolation::ExternalContractFailure),
1144    };
1145
1146    let now = env.ledger().timestamp();
1147    if now > snapshot.timestamp {
1148        let age = now - snapshot.timestamp;
1149        if age > config.max_snapshot_age_seconds {
1150            return Err(OracleSafetyViolation::StaleSnapshot);
1151        }
1152    }
1153
1154    Ok(Some(snapshot))
1155}
1156
1157/// Layer 2, Guardrail 4 — Minimum SDEX Liquidity (Phase 4.1).
1158///
1159/// Threshold check on a snapshot already fetched + freshness-validated by
1160/// `get_validated_snapshot`. Rejects when the asset's 30-minute SDEX volume
1161/// is below `config.min_liquidity_usd`.
1162///
1163/// Structural defense against YieldBlox-class attacks: an attacker who can
1164/// move price with a $5 trade has — by definition — drained the order book
1165/// to near-zero, and this check blocks borrowing against such an unstable
1166/// feed even when Reflector reports a clean-looking price.
1167///
1168/// **Precision:** `volume_30m_usd` and `min_liquidity_usd` both use 7-decimal
1169/// USD (Stellar stroop convention) — direct `<` comparison without scaling.
1170/// See `LiquiditySnapshot` doc for the full precision convention. See
1171/// `get_validated_snapshot` for the skip and fail-safe semantics that produce
1172/// the snapshot reaching this function.
1173fn check_liquidity(
1174    snapshot: &LiquiditySnapshot,
1175    config: &SafeOracleConfig,
1176) -> Result<(), OracleSafetyViolation> {
1177    if snapshot.volume_30m_usd < config.min_liquidity_usd {
1178        return Err(OracleSafetyViolation::InsufficientLiquidity);
1179    }
1180    Ok(())
1181}
1182
1183/// Layer 2, Guardrail 5 — Thin Sampling Detection (Phase 4.2).
1184///
1185/// Threshold check on a snapshot already fetched + freshness-validated by
1186/// `get_validated_snapshot`. Rejects when fewer than `config.min_trade_count_1h`
1187/// unique trades occurred in the past hour.
1188///
1189/// Defense against price manipulation in markets where trade frequency is
1190/// too low for VWAP/TWAP feeds to produce trustworthy prices. Even when
1191/// 30-minute volume passes `check_liquidity`, a market with only 1–2 trades
1192/// per hour is structurally vulnerable to single-trade manipulation — the
1193/// YieldBlox attacker had effectively one trade in the relevant pricing
1194/// window, and this guardrail catches that shape independently of the
1195/// volume threshold.
1196///
1197/// `unique_trades_1h` semantics (one trade per `source_account` per ledger,
1198/// $10 minimum sybil floor) are defined by `oracle-watch`; see spec §5
1199/// "Trade Counting Definition". See `get_validated_snapshot` for the skip and
1200/// fail-safe semantics that produce the snapshot reaching this function.
1201fn check_thin_sampling(
1202    snapshot: &LiquiditySnapshot,
1203    config: &SafeOracleConfig,
1204) -> Result<(), OracleSafetyViolation> {
1205    if snapshot.unique_trades_1h < config.min_trade_count_1h {
1206        return Err(OracleSafetyViolation::ThinSampling);
1207    }
1208    Ok(())
1209}
1210
1211#[cfg(test)]
1212mod test {
1213    use super::*;
1214
1215    #[test]
1216    fn test_default_config_values() {
1217        let cfg = SafeOracleConfig::default();
1218        assert_eq!(cfg.max_deviation_bps, 2000);
1219        assert_eq!(cfg.max_staleness_seconds, 300);
1220        // Phase 7.2: previous-price staleness default = 3× current threshold.
1221        assert_eq!(cfg.previous_max_staleness_seconds, 900);
1222        assert_eq!(cfg.max_cross_source_bps, 500);
1223        assert_eq!(cfg.max_snapshot_age_seconds, 300);
1224        assert_eq!(cfg.min_liquidity_usd, 100_000_000_000);
1225        assert_eq!(cfg.min_trade_count_1h, 5);
1226        assert!(cfg.secondary_oracle.is_none());
1227        assert!(!cfg.circuit_breaker_enabled);
1228        assert_eq!(cfg.circuit_breaker_halt_ledgers, 720);
1229        // Trustless default: Layer 2 off until the integrator opts in.
1230        assert!(!cfg.layer2_enabled);
1231    }
1232
1233    #[test]
1234    fn test_error_variants_have_correct_discriminants() {
1235        assert_eq!(OracleSafetyViolation::ExcessiveDeviation as u32, 1);
1236        assert_eq!(OracleSafetyViolation::StaleData as u32, 2);
1237        assert_eq!(OracleSafetyViolation::CrossSourceMismatch as u32, 3);
1238        assert_eq!(OracleSafetyViolation::InsufficientLiquidity as u32, 4);
1239        assert_eq!(OracleSafetyViolation::ThinSampling as u32, 5);
1240        assert_eq!(OracleSafetyViolation::CircuitBreakerOpen as u32, 6);
1241        assert_eq!(OracleSafetyViolation::StaleSnapshot as u32, 7);
1242        // AR.H L5 fix: ExternalContractFailure = 8 (Hardening 3C) regression guard.
1243        // The discriminant is correctly used in PriceResult::into_result and the
1244        // mock-lending mirror; the gap was solely in this regression test.
1245        assert_eq!(OracleSafetyViolation::ExternalContractFailure as u32, 8);
1246        // Phase 7.2 additions — discriminants must stay aligned with the
1247        // mock-lending mirror and `PriceResult::into_result` re-hydration.
1248        assert_eq!(OracleSafetyViolation::DecimalsMismatch as u32, 9);
1249        assert_eq!(OracleSafetyViolation::UnexpectedDecimals as u32, 10);
1250    }
1251}