streak-api 0.1.7

API for interacting with the STREAK directional markets protocol on Solana
Documentation
//! Pyth price-feed helpers (`PriceUpdateV2` accounts — push **or** pull).
//!
//! ## Preferred flow (push feed accounts)
//! The Pyth Data Association maintains continuously-updated price feed accounts at fixed PDAs
//! (heartbeat ~400 ms). Pass [`crate::consts::PYTH_BTC_USD_PRICE_FEED_ACCOUNT`] directly to
//! `AdminInstantSettlement` — no Hermes fetch or `post_update_atomic` required.
//!
//! ## Settlement price chain
//! `AdminInstantSettlement` reads the close price from the Pyth push feed and writes it back to
//! `Treasury::last_close_*`. The **next** period's settlement reads that stored value as its open
//! reference. No anchor transaction at `open_ts` is needed.

#![allow(dead_code)]

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

use crate::consts::{PYTH_MAX_PRICE_AGE_SECS, PYTH_PUSH_ORACLE_PROGRAM, PYTH_RECEIVER_PROGRAM};
use crate::error::StreakError;

// ---------------------------------------------------------------------------
// Price type
// ---------------------------------------------------------------------------

#[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 {
            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 {
            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
// ---------------------------------------------------------------------------

#[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
// ---------------------------------------------------------------------------

/// Assert that `info` is owned by either the Pyth Receiver program (pull feeds) or the
/// Pyth Push Oracle program (continuously-maintained push feed accounts).
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 && info.owner != &PYTH_PUSH_ORACLE_PROGRAM {
        return Err(StreakError::PythBadOwner.into());
    }
    Ok(())
}

/// Parse a `PriceUpdateV2` account into our internal `Price` type.
///
/// Validates:
/// - Account is owned by `PYTH_RECEIVER_PROGRAM` or `PYTH_PUSH_ORACLE_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(
    info: &AccountInfo,
    expected_feed_id: &[u8; 32],
    unix_timestamp: i64,
) -> Result<Price, ProgramError> {
    assert_pyth_receiver_owner(info)?;

    let data = info.try_borrow_data()?;
    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,
    })
}

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

/// Determine UP or DOWN from open vs close prices at a common exponent.
///
/// Tie (close == open at normalized precision) resolves as DOWN. There is no
/// void threshold — every candle resolves deterministically.
pub fn outcome_from_open_close(open: Price, close: Price) -> Result<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)?;

    if c.price > o.price {
        Ok(0u8) // SIDE_UP
    } else {
        Ok(1u8) // SIDE_DOWN
    }
}