streak-api 0.1.5

API for interacting with the STREAK directional markets protocol on Solana
Documentation
//! Pyth **Hermes pull-feed** helpers (`PriceUpdateV2` accounts).
//!
//! Replaces the legacy `pyth-sdk-solana` push-oracle dependency. Price update accounts are
//! posted on-chain by the executor crank just before each oracle-gated instruction by calling
//! the Pyth Receiver program (`PYTH_RECEIVER_PROGRAM`). This module reads them directly
//! via borsh deserialization (no `anchor-lang` dependency required).
//!
//! ## Flow
//! 1. Executor fetches signed price update VAA from Hermes REST API.
//! 2. Executor posts it via `PYTH_RECEIVER_PROGRAM::post_update` → `PriceUpdateV2` account.
//! 3. Executor calls our instruction with that account as `pyth_price_feed_info`.
//! 4. Program validates owner, feed ID, staleness, then reads price.

#![allow(dead_code)]

use borsh::BorshDeserialize;
use solana_program::{
    account_info::AccountInfo,
    entrypoint::ProgramResult,
    program_error::ProgramError,
};

use crate::consts::{BTC_VOID_THRESHOLD_BPS, PYTH_MAX_PRICE_AGE_SECS, PYTH_RECEIVER_PROGRAM};
use crate::error::StreakError;
use crate::state::Market;

// ---------------------------------------------------------------------------
// Price type (mirrors pyth-sdk-solana::Price; kept internal to streak-api)
// ---------------------------------------------------------------------------

#[derive(Clone, Copy, Debug)]
pub struct Price {
    pub price: i64,
    pub conf: u64,
    pub expo: i32,
    pub publish_time: i64,
}

impl Price {
    /// Rescale to a common exponent for comparison. Returns `None` on overflow.
    pub fn scale_to_exponent(&self, target_expo: i32) -> Option<Price> {
        let diff = self.expo - target_expo;
        if diff == 0 {
            return Some(*self);
        }
        if diff > 0 {
            // target is less precise — shift right (may lose precision but won't overflow)
            let factor = 10i64.checked_pow(diff as u32)?;
            Some(Price {
                price: self.price.checked_div(factor)?,
                conf: self.conf / (factor as u64),
                expo: target_expo,
                publish_time: self.publish_time,
            })
        } else {
            // target is more precise — shift left (may overflow)
            let factor = 10i64.checked_pow((-diff) as u32)?;
            Some(Price {
                price: self.price.checked_mul(factor)?,
                conf: self.conf.checked_mul(factor as u64)?,
                expo: target_expo,
                publish_time: self.publish_time,
            })
        }
    }
}

// ---------------------------------------------------------------------------
// PriceUpdateV2 borsh layout (mirrors pyth-solana-receiver-sdk, no anchor dep)
// ---------------------------------------------------------------------------

#[derive(BorshDeserialize, Clone, Debug)]
#[allow(dead_code)]
enum VerificationLevel {
    Partial { num_signatures: u8 },
    Full,
}

#[derive(BorshDeserialize, Clone, Debug)]
struct PriceFeedMessage {
    pub feed_id: [u8; 32],
    pub price: i64,
    pub conf: u64,
    pub exponent: i32,
    pub publish_time: i64,
    pub prev_publish_time: i64,
    pub ema_price: i64,
    pub ema_conf: u64,
}

#[derive(BorshDeserialize, Clone, Debug)]
struct RawPriceUpdateV2 {
    pub write_authority: [u8; 32],
    pub verification_level: VerificationLevel,
    pub price_message: PriceFeedMessage,
    pub posted_slot: u64,
}

// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------

pub fn assert_pyth_receiver_owner(info: &AccountInfo) -> ProgramResult {
    if *info.key == solana_program::pubkey::Pubkey::default() {
        return Err(StreakError::PythInvalidAccount.into());
    }
    if info.owner != &PYTH_RECEIVER_PROGRAM {
        return Err(StreakError::PythBadOwner.into());
    }
    Ok(())
}

/// Parse a `PriceUpdateV2` account into our internal `Price` type.
///
/// Validates:
/// - Account is owned by `PYTH_RECEIVER_PROGRAM`
/// - `price_message.feed_id` matches `expected_feed_id`
/// - `publish_time` is within `PYTH_MAX_PRICE_AGE_SECS` of `unix_timestamp`
pub fn load_fresh_price_hermes(
    info: &AccountInfo,
    expected_feed_id: &[u8; 32],
    unix_timestamp: i64,
) -> Result<Price, ProgramError> {
    assert_pyth_receiver_owner(info)?;

    let data = info.try_borrow_data()?;
    // Skip 8-byte Anchor discriminator
    if data.len() < 8 {
        return Err(StreakError::PythInvalidAccount.into());
    }
    let raw = RawPriceUpdateV2::deserialize(&mut &data[8..])
        .map_err(|_| ProgramError::from(StreakError::PythInvalidAccount))?;

    if &raw.price_message.feed_id != expected_feed_id {
        return Err(StreakError::PythInvalidAccount.into());
    }

    let age = unix_timestamp.saturating_sub(raw.price_message.publish_time);
    if age < 0 || age as u64 > PYTH_MAX_PRICE_AGE_SECS {
        return Err(StreakError::OraclePriceStale.into());
    }

    Ok(Price {
        price: raw.price_message.price,
        conf: raw.price_message.conf,
        expo: raw.price_message.exponent,
        publish_time: raw.price_message.publish_time,
    })
}

// ---------------------------------------------------------------------------
// Open-snapshot anchor
// ---------------------------------------------------------------------------

/// Writes `open_*` fields from a Hermes `PriceUpdateV2` account.
///
/// `market.pyth_price_feed` is now the **Pyth feed ID** (32 bytes), not an account address.
pub fn anchor_open_snapshot(
    market: &mut Market,
    pyth_price_feed_info: &AccountInfo,
    unix_timestamp: i64,
) -> Result<(), ProgramError> {
    let feed_id: [u8; 32] = market.pyth_price_feed.to_bytes();
    let px = load_fresh_price_hermes(pyth_price_feed_info, &feed_id, unix_timestamp)?;
    market.open_ref_price = px.price;
    market.open_ref_expo = px.expo;
    market.open_ref_publish_time = px.publish_time;
    Ok(())
}

// ---------------------------------------------------------------------------
// Settlement outcome
// ---------------------------------------------------------------------------

/// UP/DOWN from open vs close prices at a common exponent (tie → DOWN).
///
/// Returns `Ok(None)` if the absolute move is below `BTC_VOID_THRESHOLD_BPS` (0.01%) —
/// the executor should call `AdminVoidMarket` instead of `AdminInstantSettlement`.
pub fn outcome_from_open_close(open: Price, close: Price) -> Result<Option<u8>, ProgramError> {
    const EXP: i32 = -8;
    let o = open
        .scale_to_exponent(EXP)
        .ok_or(StreakError::OracleNormalize)?;
    let c = close
        .scale_to_exponent(EXP)
        .ok_or(StreakError::OracleNormalize)?;

    // Absolute move in basis points: |Δ| * 10_000 / open
    // Use u128 to avoid overflow on large prices
    if o.price > 0 {
        let delta = (c.price - o.price).unsigned_abs() as u128;
        let open_abs = o.price.unsigned_abs() as u128;
        let move_bps = delta.saturating_mul(10_000) / open_abs;
        if move_bps < BTC_VOID_THRESHOLD_BPS as u128 {
            return Ok(None); // void candle
        }
    }

    if c.price > o.price {
        Ok(Some(Market::SIDE_UP))
    } else {
        Ok(Some(Market::SIDE_DOWN))
    }
}