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