Skip to main content

hylo_core/pyth/
oracle.rs

1use fix::prelude::*;
2use fix::typenum::{Integer, Z0};
3use pyth_solana_receiver_sdk::price_update::{
4  PriceUpdateV2, VerificationLevel,
5};
6
7use crate::error::CoreError;
8use crate::error::CoreError::{
9  OracleConfToleranceInvalid, OracleIntervalSecsInvalid, PythOracleConfidence,
10  PythOracleExponent, PythOracleNegativePrice, PythOracleNegativeTime,
11  PythOracleOutdated, PythOraclePriceRange, PythOracleSlotInvalid,
12  PythOracleVerificationLevel,
13};
14use crate::solana_clock::SolanaClock;
15
16const MIN_INTERVAL_SECS: u64 = 1;
17const MAX_INTERVAL_SECS: u64 = 60;
18const MIN_CONF_TOLERANCE: UFix64<N9> = UFix64::constant(0);
19const MAX_CONF_TOLERANCE: UFix64<N9> = UFix64::constant(50_000_000);
20
21/// Divides oracle secs to a tighter tolerance.
22pub const ORACLE_DIVISOR: u64 = 4;
23
24#[derive(Clone, Copy)]
25pub struct OracleConfig {
26  pub interval_secs: u64,
27  pub conf_tolerance: UFix64<N9>,
28}
29
30impl OracleConfig {
31  #[must_use]
32  pub fn new(interval_secs: u64, conf_tolerance: UFix64<N9>) -> OracleConfig {
33    OracleConfig {
34      interval_secs,
35      conf_tolerance,
36    }
37  }
38
39  #[must_use]
40  pub fn for_stablecoin(self) -> OracleConfig {
41    OracleConfig {
42      interval_secs: self.interval_secs.div_ceil(ORACLE_DIVISOR),
43      ..self
44    }
45  }
46}
47
48/// Oracle interval must be in `[MIN, MAX]`.
49pub fn validate_interval_secs(secs: u64) -> Result<u64, CoreError> {
50  if (MIN_INTERVAL_SECS..=MAX_INTERVAL_SECS).contains(&secs) {
51    Ok(secs)
52  } else {
53    Err(OracleIntervalSecsInvalid)
54  }
55}
56
57/// Confidence tolerance must be in `[MIN, MAX]`.
58pub fn validate_conf_tolerance(
59  tolerance: UFixValue64,
60) -> Result<UFixValue64, CoreError> {
61  let pct: UFix64<N9> = tolerance.try_into()?;
62  if (MIN_CONF_TOLERANCE..=MAX_CONF_TOLERANCE).contains(&pct) {
63    Ok(tolerance)
64  } else {
65    Err(OracleConfToleranceInvalid)
66  }
67}
68
69/// Spread of an asset price, with a lower and upper quote.
70/// Use lower in minting, higher in redeeming.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct PriceRange<Exp: Integer> {
73  pub lower: UFix64<Exp>,
74  pub upper: UFix64<Exp>,
75}
76
77impl<Exp: Integer> PriceRange<Exp> {
78  /// Lower or upper bound of the Pyth confidence interval `(μ-σ, μ+σ)`
79  /// around the base price.
80  pub fn from_conf(
81    price: UFix64<Exp>,
82    conf: UFix64<Exp>,
83  ) -> Result<PriceRange<Exp>, CoreError> {
84    let (lower, upper) = price
85      .checked_sub(&conf)
86      .zip(price.checked_add(&conf))
87      .ok_or(PythOraclePriceRange)?;
88    Ok(Self::new(lower, upper))
89  }
90
91  /// Makes a range of one price, useful in test scenarios.
92  #[must_use]
93  pub fn one(price: UFix64<Exp>) -> PriceRange<Exp> {
94    Self::new(price, price)
95  }
96
97  #[must_use]
98  pub(crate) fn new(lower: UFix64<Exp>, upper: UFix64<Exp>) -> PriceRange<Exp> {
99    PriceRange { lower, upper }
100  }
101}
102
103/// Checks the ratio of `conf / price` against given tolerance.
104/// Guards against unusually large spreads in the oracle price.
105fn validate_conf(
106  price: UFix64<N9>,
107  conf: UFix64<N9>,
108  tolerance: UFix64<N9>,
109) -> Result<UFix64<N9>, CoreError> {
110  conf
111    .mul_div_floor(UFix64::one(), price)
112    .filter(|diff| diff.le(&tolerance))
113    .map(|_| conf)
114    .ok_or(PythOracleConfidence)
115}
116
117/// Ensures the oracle's publish time is within the inclusive range:
118///   `[clock_time - oracle_interval, clock_time]`
119pub fn validate_publish_time(
120  publish_time: i64,
121  oracle_interval: u64,
122  clock_time: i64,
123) -> Result<(), CoreError> {
124  let (publish_time, clock_time) =
125    if publish_time.is_positive() && clock_time.is_positive() {
126      Ok((publish_time.unsigned_abs(), clock_time.unsigned_abs()))
127    } else {
128      Err(PythOracleNegativeTime)
129    }?;
130  if publish_time.saturating_add(oracle_interval) >= clock_time {
131    Ok(())
132  } else {
133    Err(PythOracleOutdated)
134  }
135}
136
137/// Number of Solana slots in configured oracle interval time.
138fn slot_interval(oracle_interval_secs: u64) -> Option<u64> {
139  let time: UFix64<N2> = UFix64::<Z0>::new(oracle_interval_secs).convert();
140  let slot_time = UFix64::<N2>::new(20); // 200ms slot time
141  time.checked_div(&slot_time).map(|i| i.bits)
142}
143
144/// Checks the posted slot of a price against the configured oracle interval.
145fn validate_posted_slot(
146  posted_slot: u64,
147  oracle_interval_secs: u64,
148  current_slot: u64,
149) -> Result<(), CoreError> {
150  current_slot
151    .checked_sub(posted_slot)
152    .zip(slot_interval(oracle_interval_secs))
153    .filter(|(delta, slot_interval)| *delta <= *slot_interval)
154    .map(|_| ())
155    .ok_or(PythOracleSlotInvalid)
156}
157
158/// Validates a Pyth price is positive and normalizes to `N9`.
159///
160/// # Errors
161/// * Negative price or unsupported exponent
162fn validate_price(price: i64, exp: i32) -> Result<UFix64<N9>, CoreError> {
163  if price <= 0 {
164    Err(PythOracleNegativePrice)
165  } else {
166    normalize_pyth_price(price.unsigned_abs(), exp)
167  }
168}
169
170/// Normalizes a raw Pyth price to canonical `N9` precision.
171/// Accepts Pyth exponents from `-2` through `-9`.
172///
173/// # Errors
174/// * Unsupported exponent or conversion overflow
175fn normalize_pyth_price(price: u64, exp: i32) -> Result<UFix64<N9>, CoreError> {
176  match exp {
177    -2 => UFix64::<N2>::new(price).checked_convert(),
178    -3 => UFix64::<N3>::new(price).checked_convert(),
179    -4 => UFix64::<N4>::new(price).checked_convert(),
180    -5 => UFix64::<N5>::new(price).checked_convert(),
181    -6 => UFix64::<N6>::new(price).checked_convert(),
182    -7 => UFix64::<N7>::new(price).checked_convert(),
183    -8 => UFix64::<N8>::new(price).checked_convert(),
184    -9 => Some(UFix64::<N9>::new(price)),
185    _ => None,
186  }
187  .ok_or(PythOracleExponent)
188}
189
190/// Checks Pythnet verification level for the price update.
191fn validate_verification_level(
192  level: VerificationLevel,
193) -> Result<(), CoreError> {
194  if level == VerificationLevel::Full {
195    Ok(())
196  } else {
197    Err(PythOracleVerificationLevel)
198  }
199}
200
201/// Validated oracle spot price and confidence interval.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub struct OraclePrice {
204  pub spot: UFix64<N9>,
205  pub conf: UFix64<N9>,
206}
207
208impl OraclePrice {
209  /// Builds a price range from validated spot and confidence.
210  ///
211  /// # Errors
212  /// * Arithmetic overflow from `PriceRange::from_conf`
213  pub fn price_range(&self) -> Result<PriceRange<N9>, CoreError> {
214    PriceRange::from_conf(self.spot, self.conf)
215  }
216}
217
218/// Fetches validated price and confidence from Pyth.
219///
220/// # Errors
221/// * Validation
222pub fn query_pyth_oracle<C: SolanaClock>(
223  clock: &C,
224  oracle: &PriceUpdateV2,
225  OracleConfig {
226    interval_secs,
227    conf_tolerance,
228  }: OracleConfig,
229) -> Result<OraclePrice, CoreError> {
230  validate_verification_level(oracle.verification_level)?;
231  validate_publish_time(
232    oracle.price_message.publish_time,
233    interval_secs,
234    clock.unix_timestamp(),
235  )?;
236  validate_posted_slot(oracle.posted_slot, interval_secs, clock.slot())?;
237
238  let exp = oracle.price_message.exponent;
239  let spot = validate_price(oracle.price_message.price, exp)?;
240  let conf = normalize_pyth_price(oracle.price_message.conf, exp)?;
241  validate_conf(spot, conf, conf_tolerance)?;
242  Ok(OraclePrice { spot, conf })
243}
244
245/// Builds price range from Pyth oracle.
246///
247/// # Errors
248/// * Validation
249pub fn query_pyth_price<C: SolanaClock>(
250  clock: &C,
251  oracle: &PriceUpdateV2,
252  config: OracleConfig,
253) -> Result<PriceRange<N9>, CoreError> {
254  let oracle_price = query_pyth_oracle(clock, oracle, config)?;
255  PriceRange::from_conf(oracle_price.spot, oracle_price.conf)
256}
257
258#[cfg(test)]
259mod tests {
260  use fix::prelude::*;
261  use proptest::prelude::*;
262
263  use super::*;
264
265  /// Largest raw price bits for an exponent before N9 overflow.
266  /// `u64::MAX / 10^(9 - |exp|)`
267  fn pyth_price_max(exp: i32) -> u64 {
268    u64::MAX / 10u64.pow(9 - exp.unsigned_abs())
269  }
270
271  /// Supported Pyth exponent (-9 through -2).
272  fn pyth_exponent() -> BoxedStrategy<i32> {
273    (-9i32..=-2).boxed()
274  }
275
276  /// Raw Pyth price and exponent pair that converts to N9 without overflow.
277  fn pyth_price() -> BoxedStrategy<(u64, i32)> {
278    pyth_exponent()
279      .prop_flat_map(|exp| (1u64..=pyth_price_max(exp), Just(exp)))
280      .boxed()
281  }
282
283  proptest! {
284    #[test]
285    fn normalize_safe_price_succeeds(
286      (price, exp) in pyth_price(),
287    ) {
288      prop_assert!(normalize_pyth_price(price, exp).is_ok());
289    }
290
291    #[test]
292    fn normalize_unsupported_exp_fails(
293      price in 0u64..,
294      exp in prop_oneof![-100i32..=-10, -1i32..=100],
295    ) {
296      prop_assert!(normalize_pyth_price(price, exp).is_err());
297    }
298
299    #[test]
300    fn normalize_n9_identity(price: u64) {
301      let result = normalize_pyth_price(price, -9)?;
302      prop_assert_eq!(result.bits, price);
303    }
304
305    #[test]
306    fn normalize_overflow_fails(
307      exp in -8i32..=-2,
308    ) {
309      let over = pyth_price_max(exp) + 1;
310      prop_assert!(normalize_pyth_price(over, exp).is_err());
311    }
312  }
313
314  #[test]
315  fn normalize_n8_known_value() -> Result<(), CoreError> {
316    let result = normalize_pyth_price(14_640_110_937, -8)?;
317    assert_eq!(result, UFix64::<N9>::new(146_401_109_370));
318    Ok(())
319  }
320
321  #[test]
322  fn normalize_n9_passthrough() -> Result<(), CoreError> {
323    let result = normalize_pyth_price(123_456_789, -9)?;
324    assert_eq!(result, UFix64::<N9>::new(123_456_789));
325    Ok(())
326  }
327
328  #[test]
329  fn normalize_n9_max() -> Result<(), CoreError> {
330    let result = normalize_pyth_price(u64::MAX, -9)?;
331    assert_eq!(result, UFix64::<N9>::new(u64::MAX));
332    Ok(())
333  }
334
335  #[test]
336  fn normalize_n2_small() -> Result<(), CoreError> {
337    let result = normalize_pyth_price(14_640, -2)?;
338    assert_eq!(result, UFix64::<N9>::new(146_400_000_000));
339    Ok(())
340  }
341
342  #[test]
343  fn normalize_n2_overflow() {
344    let over = pyth_price_max(-2) + 1;
345    assert!(normalize_pyth_price(over, -2).is_err());
346  }
347
348  #[test]
349  fn normalize_n8_overflow() {
350    let over = pyth_price_max(-8) + 1;
351    assert!(normalize_pyth_price(over, -8).is_err());
352  }
353
354  #[test]
355  fn normalize_unsupported_exponents() {
356    assert!(normalize_pyth_price(100, -1).is_err());
357    assert!(normalize_pyth_price(100, -10).is_err());
358    assert!(normalize_pyth_price(100, -11).is_err());
359    assert!(normalize_pyth_price(100, 0).is_err());
360    assert!(normalize_pyth_price(100, 5).is_err());
361  }
362
363  #[test]
364  fn normalize_zero_price() -> Result<(), CoreError> {
365    let result = normalize_pyth_price(0, -8)?;
366    assert_eq!(result, UFix64::<N9>::zero());
367    Ok(())
368  }
369
370  #[test]
371  fn validate_conf_within_tolerance() -> Result<(), CoreError> {
372    let price = UFix64::<N9>::new(146_401_109_370);
373    let conf = UFix64::<N9>::new(80_000_000);
374    let tolerance = UFix64::<N9>::new(1_000_000);
375    let result = validate_conf(price, conf, tolerance)?;
376    assert_eq!(result, conf);
377    Ok(())
378  }
379
380  #[test]
381  fn validate_conf_exceeds_tolerance() {
382    let price = UFix64::<N9>::new(146_401_109_370);
383    let conf = UFix64::<N9>::new(2_000_000_000);
384    let tolerance = UFix64::<N9>::new(1_000_000);
385    assert!(validate_conf(price, conf, tolerance).is_err());
386  }
387
388  #[test]
389  fn validate_conf_exact_boundary() -> Result<(), CoreError> {
390    let price = UFix64::<N9>::new(1_000_000_000);
391    let conf = UFix64::<N9>::new(10_000_000);
392    let tolerance = UFix64::<N9>::new(10_000_000);
393    let result = validate_conf(price, conf, tolerance)?;
394    assert_eq!(result, conf);
395    Ok(())
396  }
397
398  #[test]
399  fn validate_conf_zero_passes() -> Result<(), CoreError> {
400    let price = UFix64::<N9>::new(100_000_000_000);
401    let conf = UFix64::<N9>::zero();
402    let tolerance = UFix64::<N9>::new(1_000_000);
403    let result = validate_conf(price, conf, tolerance)?;
404    assert_eq!(result, conf);
405    Ok(())
406  }
407
408  #[test]
409  fn publish_time_exact_boundary() {
410    assert!(validate_publish_time(100, 60, 160).is_ok());
411  }
412
413  #[test]
414  fn publish_time_just_expired() {
415    assert!(validate_publish_time(100, 60, 161).is_err());
416  }
417
418  #[test]
419  fn publish_time_large_interval() {
420    assert!(validate_publish_time(1000, 3600, 4500).is_ok());
421  }
422
423  #[test]
424  fn publish_time_zero_interval() {
425    assert!(validate_publish_time(100, 0, 100).is_ok());
426    assert!(validate_publish_time(100, 0, 101).is_err());
427  }
428
429  #[test]
430  fn publish_time_negative_publish() {
431    assert!(validate_publish_time(-1, 120, 100).is_err());
432  }
433
434  #[test]
435  fn publish_time_negative_clock() {
436    assert!(validate_publish_time(100, 30, -1).is_err());
437  }
438
439  #[test]
440  fn slot_interval_precise() {
441    assert_eq!(slot_interval(60), Some(300));
442  }
443
444  #[test]
445  fn slot_interval_one_sec() {
446    assert_eq!(slot_interval(1), Some(5));
447  }
448
449  #[test]
450  fn slot_interval_zero() {
451    assert_eq!(slot_interval(0), Some(0));
452  }
453
454  #[test]
455  fn slot_interval_large() {
456    assert_eq!(slot_interval(3600), Some(18_000));
457  }
458
459  #[test]
460  fn posted_slot_within_interval() {
461    assert!(validate_posted_slot(1000, 60, 1100).is_ok());
462  }
463
464  #[test]
465  fn posted_slot_exact_boundary() {
466    assert!(validate_posted_slot(1000, 60, 1300).is_ok());
467  }
468
469  #[test]
470  fn posted_slot_one_over() {
471    assert!(validate_posted_slot(1000, 60, 1301).is_err());
472  }
473
474  #[test]
475  fn posted_slot_future_fails() {
476    assert!(validate_posted_slot(2000, 60, 1000).is_err());
477  }
478
479  #[test]
480  fn posted_slot_same() {
481    assert!(validate_posted_slot(500, 60, 500).is_ok());
482  }
483}