hylo_core/
pyth.rs

1use anchor_lang::prelude::{Pubkey, Result};
2use anchor_lang::solana_program::pubkey;
3use fix::prelude::*;
4use fix::typenum::{Integer, Z0};
5use pyth_solana_receiver_sdk::price_update::{
6  FeedId, PriceUpdateV2, VerificationLevel,
7};
8
9use crate::error::CoreError::{
10  PythOracleConfidence, PythOracleExponent, PythOracleNegativePrice,
11  PythOracleNegativeTime, PythOracleOutdated, PythOraclePriceRange,
12  PythOracleSlotInvalid, PythOracleVerificationLevel,
13};
14use crate::solana_clock::SolanaClock;
15
16pub const SOL_USD: FeedId = [
17  239, 13, 139, 111, 218, 44, 235, 164, 29, 161, 93, 64, 149, 209, 218, 57, 42,
18  13, 47, 142, 208, 198, 199, 188, 15, 76, 250, 200, 194, 128, 181, 109,
19];
20
21pub const SOL_USD_PYTH_FEED: Pubkey =
22  pubkey!("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
23
24#[derive(Copy, Clone)]
25pub struct OracleConfig<Exp> {
26  pub interval_secs: u64,
27  pub conf_tolerance: UFix64<Exp>,
28}
29
30impl<Exp> OracleConfig<Exp> {
31  #[must_use]
32  pub fn new(
33    interval_secs: u64,
34    conf_tolerance: UFix64<Exp>,
35  ) -> OracleConfig<Exp> {
36    OracleConfig {
37      interval_secs,
38      conf_tolerance,
39    }
40  }
41}
42
43/// Spread of an asset price, with a lower and upper quote.
44/// Use lower in minting, higher in redeeming.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct PriceRange<Exp: Integer> {
47  pub lower: UFix64<Exp>,
48  pub upper: UFix64<Exp>,
49}
50
51impl<Exp: Integer> PriceRange<Exp> {
52  /// Pyth does not publish a "true" price but a range of values defined by a
53  /// base price and a confidence interval `(μ-σ, μ+σ)`.
54  /// This data type either returns the lower or upper bound of that range.
55  /// See [Pyth documentation](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals)
56  pub fn from_conf(
57    price: UFix64<Exp>,
58    conf: UFix64<Exp>,
59  ) -> Result<PriceRange<Exp>> {
60    let (lower, upper) = price
61      .checked_sub(&conf)
62      .zip(price.checked_add(&conf))
63      .ok_or(PythOraclePriceRange)?;
64    Ok(Self::new(lower, upper))
65  }
66
67  /// Makes a range of one price, useful in test scenarios.
68  #[must_use]
69  pub fn one(price: UFix64<Exp>) -> PriceRange<Exp> {
70    Self::new(price, price)
71  }
72
73  /// Raw construction of range from lower and upper bounds.
74  #[must_use]
75  pub fn new(lower: UFix64<Exp>, upper: UFix64<Exp>) -> PriceRange<Exp> {
76    PriceRange { lower, upper }
77  }
78}
79
80/// Checks the ratio of `conf / price` against given tolerance.
81/// Guards against unusually large spreads in the oracle price.
82fn validate_conf<Exp>(
83  price: UFix64<Exp>,
84  conf: UFix64<Exp>,
85  tolerance: UFix64<Exp>,
86) -> Result<UFix64<Exp>>
87where
88  UFix64<Exp>: FixExt,
89{
90  conf
91    .mul_div_floor(UFix64::one(), price)
92    .filter(|diff| diff.le(&tolerance))
93    .map(|_| conf)
94    .ok_or(PythOracleConfidence.into())
95}
96
97/// Ensures the oracle's publish time is within the inclusive range:
98///   `[clock_time - oracle_interval, clock_time]`
99fn validate_publish_time(
100  publish_time: i64,
101  oracle_interval: u64,
102  clock_time: i64,
103) -> Result<()> {
104  let (publish_time, clock_time) =
105    if publish_time.is_positive() && clock_time.is_positive() {
106      Ok((publish_time.unsigned_abs(), clock_time.unsigned_abs()))
107    } else {
108      Err(PythOracleNegativeTime)
109    }?;
110  if publish_time.saturating_add(oracle_interval) >= clock_time {
111    Ok(())
112  } else {
113    Err(PythOracleOutdated.into())
114  }
115}
116
117/// Number of Solana slots in configured oracle interval time.
118fn slot_interval(oracle_interval_secs: u64) -> Option<u64> {
119  let time: UFix64<N2> = UFix64::<Z0>::new(oracle_interval_secs).convert();
120  let slot_time = UFix64::<N2>::new(40); // 400ms slot time
121  time.checked_div(&slot_time).map(|i| i.bits)
122}
123
124/// Checks the posted slot of a price against the configured oracle interval.
125fn validate_posted_slot(
126  posted_slot: u64,
127  oracle_interval_secs: u64,
128  current_slot: u64,
129) -> Result<()> {
130  current_slot
131    .checked_sub(posted_slot)
132    .zip(slot_interval(oracle_interval_secs))
133    .filter(|(delta, slot_interval)| *delta <= *slot_interval)
134    .ok_or(PythOracleSlotInvalid.into())
135    .map(|_| ())
136}
137
138/// Ensures the `exp` given by Pyth matches the target exponent type.
139/// Also checks if the quoted price is negative.
140fn validate_price<Exp: Integer>(price: i64, exp: i32) -> Result<UFix64<Exp>> {
141  if Exp::to_i32() != exp {
142    Err(PythOracleExponent.into())
143  } else if price <= 0 {
144    Err(PythOracleNegativePrice.into())
145  } else {
146    Ok(UFix64::new(price.unsigned_abs()))
147  }
148}
149
150/// Checks Pythnet verification level for the price update.
151fn validate_verification_level(level: VerificationLevel) -> Result<()> {
152  if level == VerificationLevel::Full {
153    Ok(())
154  } else {
155    Err(PythOracleVerificationLevel.into())
156  }
157}
158
159/// Fetches price range from a Pyth oracle with a number of validations.
160pub fn query_pyth_price<Exp: Integer, C: SolanaClock>(
161  clock: &C,
162  oracle: &PriceUpdateV2,
163  OracleConfig {
164    interval_secs,
165    conf_tolerance,
166  }: OracleConfig<Exp>,
167) -> Result<PriceRange<Exp>>
168where
169  UFix64<Exp>: FixExt,
170{
171  // Price update validations
172  validate_verification_level(oracle.verification_level)?;
173  validate_publish_time(
174    oracle.price_message.publish_time,
175    interval_secs,
176    clock.unix_timestamp(),
177  )?;
178  validate_posted_slot(oracle.posted_slot, interval_secs, clock.slot())?;
179
180  // Build spot range
181  let spot_price =
182    validate_price(oracle.price_message.price, oracle.price_message.exponent)?;
183  let spot_conf = validate_conf(
184    spot_price,
185    UFix64::new(oracle.price_message.conf),
186    conf_tolerance,
187  )?;
188  PriceRange::from_conf(spot_price, spot_conf)
189}
190
191#[cfg(test)]
192mod tests {
193  use fix::typenum::N8;
194  use proptest::prelude::*;
195
196  use super::*;
197
198  const INTERVAL_SECS: u64 = 60;
199
200  proptest! {
201    #[test]
202    fn validate_price_pos(price in i64::arbitrary()) {
203      prop_assume!(price > 0);
204      let out = validate_price::<N8>(price, -8)?;
205      prop_assert_eq!(out, UFix64::new(price.unsigned_abs()));
206    }
207
208    #[test]
209    fn validate_price_neg(price in i64::arbitrary(), exp in i32::arbitrary()) {
210      prop_assume!(price < 0 || exp != -8);
211      let out = validate_price::<N8>(price, exp);
212      prop_assert!(out.is_err());
213    }
214
215    #[test]
216    fn validate_publish_time_neg(
217      publish_time in i64::arbitrary(),
218      time in i64::arbitrary()
219    ) {
220      let out = validate_publish_time(publish_time, INTERVAL_SECS, time);
221      if publish_time.is_negative() || time.is_negative() {
222        prop_assert_eq!(out, Err(PythOracleNegativeTime.into()));
223      } else if publish_time.unsigned_abs() + INTERVAL_SECS < time.unsigned_abs() {
224        prop_assert_eq!(out, Err(PythOracleOutdated.into()));
225      } else {
226        prop_assert!(out.is_ok());
227      }
228    }
229
230    #[allow(clippy::cast_possible_wrap)]
231    #[test]
232    fn validate_publish_time_pos(
233      publish_time in i64::arbitrary(),
234      offset in 0..INTERVAL_SECS as i64,
235    ) {
236      prop_assume!(publish_time.is_positive());
237      let out = validate_publish_time(publish_time, INTERVAL_SECS, publish_time + offset);
238      prop_assert!(out.is_ok());
239    }
240  }
241
242  #[test]
243  fn slot_interval_precise() {
244    // 60 second interval should equate to 150 slots
245    let out = slot_interval(60);
246    assert_eq!(out, Some(150));
247  }
248
249  #[test]
250  fn slot_interval_lossy() {
251    // 1 second interval should lose half a slot, safer than rounding up
252    let out = slot_interval(1);
253    assert_eq!(out, Some(2));
254  }
255
256  #[test]
257  fn validate_confidence_pos() {
258    let price = UFix64::<N8>::new(14_640_110_937);
259    let conf = UFix64::<N8>::new(9_463_582);
260    let tolerance = UFix64::<N8>::new(200_000);
261    let out = validate_conf(price, conf, tolerance);
262    assert!(out.is_ok());
263  }
264}