Skip to main content

streak_api/
pyth.rs

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