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
21pub 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
48pub 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
57pub 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#[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 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 #[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
103fn 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
117pub 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
137fn 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); time.checked_div(&slot_time).map(|i| i.bits)
142}
143
144fn 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
158fn 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
170fn 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
190fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub struct OraclePrice {
204 pub spot: UFix64<N9>,
205 pub conf: UFix64<N9>,
206}
207
208impl OraclePrice {
209 pub fn price_range(&self) -> Result<PriceRange<N9>, CoreError> {
214 PriceRange::from_conf(self.spot, self.conf)
215 }
216}
217
218pub 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
245pub 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 fn pyth_price_max(exp: i32) -> u64 {
268 u64::MAX / 10u64.pow(9 - exp.unsigned_abs())
269 }
270
271 fn pyth_exponent() -> BoxedStrategy<i32> {
273 (-9i32..=-2).boxed()
274 }
275
276 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}