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(¤t, &previous, config)?;
743 check_staleness(env, ¤t, config)?;
744 check_cross_source(env, reflector, asset, ¤t, 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}