Skip to main content

streak_api/
pyth.rs

1//! Pyth price-feed helpers (`PriceUpdateV2` accounts — push **or** pull).
2//!
3//! ## Preferred flow (push feed accounts)
4//! The Pyth Data Association maintains continuously-updated price feed accounts at fixed PDAs
5//! (heartbeat ~400 ms). Pass [`crate::consts::PYTH_BTC_USD_PRICE_FEED_ACCOUNT`] directly to
6//! `AdminInstantSettlement` — no Hermes fetch or `post_update_atomic` required.
7//!
8//! ## Settlement price chain
9//! `AdminInstantSettlement` reads the close price from the Pyth push feed and writes it back to
10//! `Treasury::last_close_*`. The **next** period's settlement reads that stored value as its open
11//! reference. No anchor transaction at `open_ts` is needed.
12
13#![allow(dead_code)]
14
15use borsh::BorshDeserialize;
16use solana_program::{
17    account_info::AccountInfo,
18    entrypoint::ProgramResult,
19    program_error::ProgramError,
20};
21
22use crate::consts::{PYTH_MAX_PRICE_AGE_SECS, PYTH_PUSH_ORACLE_PROGRAM, PYTH_RECEIVER_PROGRAM};
23use crate::error::StreakError;
24
25// ---------------------------------------------------------------------------
26// Price type
27// ---------------------------------------------------------------------------
28
29#[derive(Clone, Copy, Debug)]
30pub struct Price {
31    pub price: i64,
32    pub conf: u64,
33    pub expo: i32,
34    pub publish_time: i64,
35}
36
37impl Price {
38    /// Rescale to a common exponent for comparison. Returns `None` on overflow.
39    pub fn scale_to_exponent(&self, target_expo: i32) -> Option<Price> {
40        let diff = self.expo - target_expo;
41        if diff == 0 {
42            return Some(*self);
43        }
44        if diff > 0 {
45            let factor = 10i64.checked_pow(diff as u32)?;
46            Some(Price {
47                price: self.price.checked_div(factor)?,
48                conf: self.conf / (factor as u64),
49                expo: target_expo,
50                publish_time: self.publish_time,
51            })
52        } else {
53            let factor = 10i64.checked_pow((-diff) as u32)?;
54            Some(Price {
55                price: self.price.checked_mul(factor)?,
56                conf: self.conf.checked_mul(factor as u64)?,
57                expo: target_expo,
58                publish_time: self.publish_time,
59            })
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// PriceUpdateV2 borsh layout
66// ---------------------------------------------------------------------------
67
68#[derive(BorshDeserialize, Clone, Debug)]
69#[allow(dead_code)]
70enum VerificationLevel {
71    Partial { num_signatures: u8 },
72    Full,
73}
74
75#[derive(BorshDeserialize, Clone, Debug)]
76struct PriceFeedMessage {
77    pub feed_id: [u8; 32],
78    pub price: i64,
79    pub conf: u64,
80    pub exponent: i32,
81    pub publish_time: i64,
82    pub prev_publish_time: i64,
83    pub ema_price: i64,
84    pub ema_conf: u64,
85}
86
87#[derive(BorshDeserialize, Clone, Debug)]
88struct RawPriceUpdateV2 {
89    pub write_authority: [u8; 32],
90    pub verification_level: VerificationLevel,
91    pub price_message: PriceFeedMessage,
92    pub posted_slot: u64,
93}
94
95// ---------------------------------------------------------------------------
96// Validation helpers
97// ---------------------------------------------------------------------------
98
99/// Assert that `info` is owned by either the Pyth Receiver program (pull feeds) or the
100/// Pyth Push Oracle program (continuously-maintained push feed accounts).
101pub fn assert_pyth_receiver_owner(info: &AccountInfo) -> ProgramResult {
102    if *info.key == solana_program::pubkey::Pubkey::default() {
103        return Err(StreakError::PythInvalidAccount.into());
104    }
105    if info.owner != &PYTH_RECEIVER_PROGRAM && info.owner != &PYTH_PUSH_ORACLE_PROGRAM {
106        return Err(StreakError::PythBadOwner.into());
107    }
108    Ok(())
109}
110
111/// Parse a `PriceUpdateV2` account into our internal `Price` type.
112///
113/// Validates:
114/// - Account is owned by `PYTH_RECEIVER_PROGRAM` or `PYTH_PUSH_ORACLE_PROGRAM`
115/// - `price_message.feed_id` matches `expected_feed_id`
116/// - `publish_time` is within `PYTH_MAX_PRICE_AGE_SECS` of `unix_timestamp`
117pub fn load_fresh_price(
118    info: &AccountInfo,
119    expected_feed_id: &[u8; 32],
120    unix_timestamp: i64,
121) -> Result<Price, ProgramError> {
122    assert_pyth_receiver_owner(info)?;
123
124    let data = info.try_borrow_data()?;
125    if data.len() < 8 {
126        return Err(StreakError::PythInvalidAccount.into());
127    }
128    let raw = RawPriceUpdateV2::deserialize(&mut &data[8..])
129        .map_err(|_| ProgramError::from(StreakError::PythInvalidAccount))?;
130
131    if &raw.price_message.feed_id != expected_feed_id {
132        return Err(StreakError::PythInvalidAccount.into());
133    }
134
135    let age = unix_timestamp.saturating_sub(raw.price_message.publish_time);
136    if age < 0 || age as u64 > PYTH_MAX_PRICE_AGE_SECS {
137        return Err(StreakError::OraclePriceStale.into());
138    }
139
140    Ok(Price {
141        price: raw.price_message.price,
142        conf: raw.price_message.conf,
143        expo: raw.price_message.exponent,
144        publish_time: raw.price_message.publish_time,
145    })
146}
147
148// ---------------------------------------------------------------------------
149// Settlement outcome
150// ---------------------------------------------------------------------------
151
152/// Determine UP or DOWN from open vs close prices at a common exponent.
153///
154/// Tie (close == open at normalized precision) resolves as DOWN. There is no
155/// void threshold — every candle resolves deterministically.
156pub fn outcome_from_open_close(open: Price, close: Price) -> Result<u8, ProgramError> {
157    const EXP: i32 = -8;
158    let o = open
159        .scale_to_exponent(EXP)
160        .ok_or(StreakError::OracleNormalize)?;
161    let c = close
162        .scale_to_exponent(EXP)
163        .ok_or(StreakError::OracleNormalize)?;
164
165    if c.price > o.price {
166        Ok(0u8) // SIDE_UP
167    } else {
168        Ok(1u8) // SIDE_DOWN
169    }
170}