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//! oracle-gated instructions — no Hermes fetch or `post_update_atomic` required.
7//!
8//! ## Fallback flow (pull / Hermes)
9//! 1. Caller fetches a signed price-update VAA from the Hermes REST API.
10//! 2. Caller posts it via `PYTH_RECEIVER_PROGRAM::post_update_atomic` → ephemeral `PriceUpdateV2`.
11//! 3. Caller passes that account as `pyth_price_feed_info`.
12//!
13//! Both flows use the same `PriceUpdateV2` borsh layout. The only difference is the account
14//! owner (`PYTH_PUSH_ORACLE_PROGRAM` for push feeds, `PYTH_RECEIVER_PROGRAM` for pull). This
15//! module accepts either.
16
17#![allow(dead_code)]
18
19use borsh::BorshDeserialize;
20use solana_program::{
21    account_info::AccountInfo,
22    entrypoint::ProgramResult,
23    program_error::ProgramError,
24};
25
26use crate::consts::{
27    BTC_VOID_THRESHOLD_BPS, PYTH_MAX_PRICE_AGE_SECS, PYTH_PUSH_ORACLE_PROGRAM,
28    PYTH_RECEIVER_PROGRAM,
29};
30use crate::error::StreakError;
31use crate::state::Market;
32
33// ---------------------------------------------------------------------------
34// Price type (mirrors pyth-sdk-solana::Price; kept internal to streak-api)
35// ---------------------------------------------------------------------------
36
37#[derive(Clone, Copy, Debug)]
38pub struct Price {
39    pub price: i64,
40    pub conf: u64,
41    pub expo: i32,
42    pub publish_time: i64,
43}
44
45impl Price {
46    /// Rescale to a common exponent for comparison. Returns `None` on overflow.
47    pub fn scale_to_exponent(&self, target_expo: i32) -> Option<Price> {
48        let diff = self.expo - target_expo;
49        if diff == 0 {
50            return Some(*self);
51        }
52        if diff > 0 {
53            // target is less precise — shift right (may lose precision but won't overflow)
54            let factor = 10i64.checked_pow(diff as u32)?;
55            Some(Price {
56                price: self.price.checked_div(factor)?,
57                conf: self.conf / (factor as u64),
58                expo: target_expo,
59                publish_time: self.publish_time,
60            })
61        } else {
62            // target is more precise — shift left (may overflow)
63            let factor = 10i64.checked_pow((-diff) as u32)?;
64            Some(Price {
65                price: self.price.checked_mul(factor)?,
66                conf: self.conf.checked_mul(factor as u64)?,
67                expo: target_expo,
68                publish_time: self.publish_time,
69            })
70        }
71    }
72}
73
74// ---------------------------------------------------------------------------
75// PriceUpdateV2 borsh layout (mirrors pyth-solana-receiver-sdk, no anchor dep)
76// ---------------------------------------------------------------------------
77
78#[derive(BorshDeserialize, Clone, Debug)]
79#[allow(dead_code)]
80enum VerificationLevel {
81    Partial { num_signatures: u8 },
82    Full,
83}
84
85#[derive(BorshDeserialize, Clone, Debug)]
86struct PriceFeedMessage {
87    pub feed_id: [u8; 32],
88    pub price: i64,
89    pub conf: u64,
90    pub exponent: i32,
91    pub publish_time: i64,
92    pub prev_publish_time: i64,
93    pub ema_price: i64,
94    pub ema_conf: u64,
95}
96
97#[derive(BorshDeserialize, Clone, Debug)]
98struct RawPriceUpdateV2 {
99    pub write_authority: [u8; 32],
100    pub verification_level: VerificationLevel,
101    pub price_message: PriceFeedMessage,
102    pub posted_slot: u64,
103}
104
105// ---------------------------------------------------------------------------
106// Validation helpers
107// ---------------------------------------------------------------------------
108
109/// Assert that `info` is owned by either the Pyth Receiver program (pull feeds / `post_update_atomic`)
110/// or the Pyth Push Oracle program (continuously-maintained push feed accounts).
111///
112/// Both program types store price data using the identical `PriceUpdateV2` borsh layout.
113pub fn assert_pyth_receiver_owner(info: &AccountInfo) -> ProgramResult {
114    if *info.key == solana_program::pubkey::Pubkey::default() {
115        return Err(StreakError::PythInvalidAccount.into());
116    }
117    if info.owner != &PYTH_RECEIVER_PROGRAM && info.owner != &PYTH_PUSH_ORACLE_PROGRAM {
118        return Err(StreakError::PythBadOwner.into());
119    }
120    Ok(())
121}
122
123/// Parse a `PriceUpdateV2` account into our internal `Price` type.
124///
125/// Validates:
126/// - Account is owned by `PYTH_RECEIVER_PROGRAM`
127/// - `price_message.feed_id` matches `expected_feed_id`
128/// - `publish_time` is within `PYTH_MAX_PRICE_AGE_SECS` of `unix_timestamp`
129pub fn load_fresh_price_hermes(
130    info: &AccountInfo,
131    expected_feed_id: &[u8; 32],
132    unix_timestamp: i64,
133) -> Result<Price, ProgramError> {
134    assert_pyth_receiver_owner(info)?;
135
136    let data = info.try_borrow_data()?;
137    // Skip 8-byte Anchor discriminator
138    if data.len() < 8 {
139        return Err(StreakError::PythInvalidAccount.into());
140    }
141    let raw = RawPriceUpdateV2::deserialize(&mut &data[8..])
142        .map_err(|_| ProgramError::from(StreakError::PythInvalidAccount))?;
143
144    if &raw.price_message.feed_id != expected_feed_id {
145        return Err(StreakError::PythInvalidAccount.into());
146    }
147
148    let age = unix_timestamp.saturating_sub(raw.price_message.publish_time);
149    if age < 0 || age as u64 > PYTH_MAX_PRICE_AGE_SECS {
150        return Err(StreakError::OraclePriceStale.into());
151    }
152
153    Ok(Price {
154        price: raw.price_message.price,
155        conf: raw.price_message.conf,
156        expo: raw.price_message.exponent,
157        publish_time: raw.price_message.publish_time,
158    })
159}
160
161// ---------------------------------------------------------------------------
162// Open-snapshot anchor
163// ---------------------------------------------------------------------------
164
165/// Writes `open_*` fields from a Hermes `PriceUpdateV2` account.
166///
167/// `market.pyth_price_feed` is now the **Pyth feed ID** (32 bytes), not an account address.
168pub fn anchor_open_snapshot(
169    market: &mut Market,
170    pyth_price_feed_info: &AccountInfo,
171    unix_timestamp: i64,
172) -> Result<(), ProgramError> {
173    let feed_id: [u8; 32] = market.pyth_price_feed.to_bytes();
174    let px = load_fresh_price_hermes(pyth_price_feed_info, &feed_id, unix_timestamp)?;
175    market.open_ref_price = px.price;
176    market.open_ref_expo = px.expo;
177    market.open_ref_publish_time = px.publish_time;
178    Ok(())
179}
180
181// ---------------------------------------------------------------------------
182// Settlement outcome
183// ---------------------------------------------------------------------------
184
185/// UP/DOWN from open vs close prices at a common exponent (tie → DOWN).
186///
187/// Returns `Ok(None)` if the absolute move is below `BTC_VOID_THRESHOLD_BPS` (0.01%) —
188/// the executor should call `AdminVoidMarket` instead of `AdminInstantSettlement`.
189pub fn outcome_from_open_close(open: Price, close: Price) -> Result<Option<u8>, ProgramError> {
190    const EXP: i32 = -8;
191    let o = open
192        .scale_to_exponent(EXP)
193        .ok_or(StreakError::OracleNormalize)?;
194    let c = close
195        .scale_to_exponent(EXP)
196        .ok_or(StreakError::OracleNormalize)?;
197
198    // Absolute move in basis points: |Δ| * 10_000 / open
199    // Use u128 to avoid overflow on large prices
200    if o.price > 0 {
201        let delta = (c.price - o.price).unsigned_abs() as u128;
202        let open_abs = o.price.unsigned_abs() as u128;
203        let move_bps = delta.saturating_mul(10_000) / open_abs;
204        if move_bps < BTC_VOID_THRESHOLD_BPS as u128 {
205            return Ok(None); // void candle
206        }
207    }
208
209    if c.price > o.price {
210        Ok(Some(Market::SIDE_UP))
211    } else {
212        Ok(Some(Market::SIDE_DOWN))
213    }
214}