use anchor_lang::prelude::{Pubkey, Result};
use anchor_lang::solana_program::pubkey;
use fix::prelude::*;
use fix::typenum::{Integer, Z0};
use pyth_solana_receiver_sdk::price_update::{
FeedId, PriceUpdateV2, VerificationLevel,
};
use crate::error::CoreError::{
PythOracleConfidence, PythOracleExponent, PythOracleNegativePrice,
PythOracleNegativeTime, PythOracleOutdated, PythOraclePriceRange,
PythOracleSlotInvalid, PythOracleVerificationLevel,
};
use crate::solana_clock::SolanaClock;
pub const SOL_USD: FeedId = [
239, 13, 139, 111, 218, 44, 235, 164, 29, 161, 93, 64, 149, 209, 218, 57, 42,
13, 47, 142, 208, 198, 199, 188, 15, 76, 250, 200, 194, 128, 181, 109,
];
pub const SOL_USD_PYTH_FEED: Pubkey =
pubkey!("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
#[derive(Copy, Clone)]
pub struct OracleConfig<Exp> {
pub interval_secs: u64,
pub conf_tolerance: UFix64<Exp>,
}
impl<Exp> OracleConfig<Exp> {
#[must_use]
pub fn new(
interval_secs: u64,
conf_tolerance: UFix64<Exp>,
) -> OracleConfig<Exp> {
OracleConfig {
interval_secs,
conf_tolerance,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PriceRange<Exp: Integer> {
pub lower: UFix64<Exp>,
pub upper: UFix64<Exp>,
}
impl<Exp: Integer> PriceRange<Exp> {
pub fn from_conf(
price: UFix64<Exp>,
conf: UFix64<Exp>,
) -> Result<PriceRange<Exp>> {
let (lower, upper) = price
.checked_sub(&conf)
.zip(price.checked_add(&conf))
.ok_or(PythOraclePriceRange)?;
Ok(Self::new(lower, upper))
}
#[must_use]
pub fn one(price: UFix64<Exp>) -> PriceRange<Exp> {
Self::new(price, price)
}
#[must_use]
pub fn new(lower: UFix64<Exp>, upper: UFix64<Exp>) -> PriceRange<Exp> {
PriceRange { lower, upper }
}
}
fn validate_conf<Exp>(
price: UFix64<Exp>,
conf: UFix64<Exp>,
tolerance: UFix64<Exp>,
) -> Result<UFix64<Exp>>
where
UFix64<Exp>: FixExt,
{
conf
.mul_div_floor(UFix64::one(), price)
.filter(|diff| diff.le(&tolerance))
.map(|_| conf)
.ok_or(PythOracleConfidence.into())
}
fn validate_publish_time(
publish_time: i64,
oracle_interval: u64,
clock_time: i64,
) -> Result<()> {
let (publish_time, clock_time) =
if publish_time.is_positive() && clock_time.is_positive() {
Ok((publish_time.unsigned_abs(), clock_time.unsigned_abs()))
} else {
Err(PythOracleNegativeTime)
}?;
if publish_time.saturating_add(oracle_interval) >= clock_time {
Ok(())
} else {
Err(PythOracleOutdated.into())
}
}
fn slot_interval(oracle_interval_secs: u64) -> Option<u64> {
let time: UFix64<N2> = UFix64::<Z0>::new(oracle_interval_secs).convert();
let slot_time = UFix64::<N2>::new(40); time.checked_div(&slot_time).map(|i| i.bits)
}
fn validate_posted_slot(
posted_slot: u64,
oracle_interval_secs: u64,
current_slot: u64,
) -> Result<()> {
current_slot
.checked_sub(posted_slot)
.zip(slot_interval(oracle_interval_secs))
.filter(|(delta, slot_interval)| *delta <= *slot_interval)
.ok_or(PythOracleSlotInvalid.into())
.map(|_| ())
}
fn validate_price<Exp: Integer>(price: i64, exp: i32) -> Result<UFix64<Exp>> {
if Exp::to_i32() != exp {
Err(PythOracleExponent.into())
} else if price <= 0 {
Err(PythOracleNegativePrice.into())
} else {
Ok(UFix64::new(price.unsigned_abs()))
}
}
fn validate_verification_level(level: VerificationLevel) -> Result<()> {
if level == VerificationLevel::Full {
Ok(())
} else {
Err(PythOracleVerificationLevel.into())
}
}
pub fn query_pyth_price<Exp: Integer, C: SolanaClock>(
clock: &C,
oracle: &PriceUpdateV2,
OracleConfig {
interval_secs,
conf_tolerance,
}: OracleConfig<Exp>,
) -> Result<PriceRange<Exp>>
where
UFix64<Exp>: FixExt,
{
validate_verification_level(oracle.verification_level)?;
validate_publish_time(
oracle.price_message.publish_time,
interval_secs,
clock.unix_timestamp(),
)?;
validate_posted_slot(oracle.posted_slot, interval_secs, clock.slot())?;
let spot_price =
validate_price(oracle.price_message.price, oracle.price_message.exponent)?;
let spot_conf = validate_conf(
spot_price,
UFix64::new(oracle.price_message.conf),
conf_tolerance,
)?;
PriceRange::from_conf(spot_price, spot_conf)
}
#[cfg(test)]
mod tests {
use fix::typenum::N8;
use proptest::prelude::*;
use super::*;
const INTERVAL_SECS: u64 = 60;
proptest! {
#[test]
fn validate_price_pos(price in i64::arbitrary()) {
prop_assume!(price > 0);
let out = validate_price::<N8>(price, -8)?;
prop_assert_eq!(out, UFix64::new(price.unsigned_abs()));
}
#[test]
fn validate_price_neg(price in i64::arbitrary(), exp in i32::arbitrary()) {
prop_assume!(price < 0 || exp != -8);
let out = validate_price::<N8>(price, exp);
prop_assert!(out.is_err());
}
#[test]
fn validate_publish_time_neg(
publish_time in i64::arbitrary(),
time in i64::arbitrary()
) {
let out = validate_publish_time(publish_time, INTERVAL_SECS, time);
if publish_time.is_negative() || time.is_negative() {
prop_assert_eq!(out, Err(PythOracleNegativeTime.into()));
} else if publish_time.unsigned_abs() + INTERVAL_SECS < time.unsigned_abs() {
prop_assert_eq!(out, Err(PythOracleOutdated.into()));
} else {
prop_assert!(out.is_ok());
}
}
#[allow(clippy::cast_possible_wrap)]
#[test]
fn validate_publish_time_pos(
publish_time in i64::arbitrary(),
offset in 0..INTERVAL_SECS as i64,
) {
prop_assume!(publish_time.is_positive());
let out = validate_publish_time(publish_time, INTERVAL_SECS, publish_time + offset);
prop_assert!(out.is_ok());
}
}
#[test]
fn slot_interval_precise() {
let out = slot_interval(60);
assert_eq!(out, Some(150));
}
#[test]
fn slot_interval_lossy() {
let out = slot_interval(1);
assert_eq!(out, Some(2));
}
#[test]
fn validate_confidence_pos() {
let price = UFix64::<N8>::new(14_640_110_937);
let conf = UFix64::<N8>::new(9_463_582);
let tolerance = UFix64::<N8>::new(200_000);
let out = validate_conf(price, conf, tolerance);
assert!(out.is_ok());
}
}