Skip to main content

percli_program/instructions/
crank.rs

1use anchor_lang::prelude::*;
2use pyth_sdk_solana::state::{load_price_account, PriceStatus};
3
4use crate::error::{from_risk_error, PercolatorError};
5use crate::instructions::events;
6use crate::state::{engine_from_account_data, header_from_account_data, MARKET_ACCOUNT_SIZE};
7
8/// Maximum age of a Pyth price update before it's considered stale (seconds).
9const MAX_PRICE_AGE_SECS: i64 = 60;
10
11/// Pyth v2 oracle program on mainnet/devnet.
12const PYTH_PROGRAM_ID: Pubkey = pubkey!("FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH");
13
14#[derive(Accounts)]
15pub struct Crank<'info> {
16    /// Permissionless — anyone can crank.
17    pub cranker: Signer<'info>,
18
19    /// CHECK: Validated via owner, discriminator, and size.
20    #[account(
21        mut,
22        owner = crate::ID @ PercolatorError::AccountNotFound,
23        constraint = market.data_len() >= MARKET_ACCOUNT_SIZE @ PercolatorError::AccountNotFound,
24    )]
25    pub market: UncheckedAccount<'info>,
26
27    /// CHECK: Pyth price feed account. Validated by owner check and pyth_sdk_solana deserialization.
28    #[account(
29        owner = PYTH_PROGRAM_ID @ PercolatorError::InvalidOraclePrice,
30    )]
31    pub oracle: UncheckedAccount<'info>,
32}
33
34pub fn handler(ctx: Context<Crank>, funding_rate: i64) -> Result<()> {
35    // Verify the oracle account matches the one stored in the market header
36    {
37        let data = ctx.accounts.market.try_borrow_data()?;
38        require!(&data[0..8] == b"percmrkt", PercolatorError::AccountNotFound);
39        let header = header_from_account_data(&data)?;
40        require!(
41            header.oracle == ctx.accounts.oracle.key(),
42            PercolatorError::InvalidOraclePrice
43        );
44    }
45
46    // Read oracle price from Pyth — deserialize directly from raw bytes
47    // to avoid solana-pubkey version mismatch between pyth-sdk-solana and anchor-lang 1.0.
48    let oracle_data = ctx.accounts.oracle.data.borrow();
49    let price_account = load_price_account::<32, ()>(&oracle_data)
50        .map_err(|_| error!(PercolatorError::InvalidOraclePrice))?;
51
52    // Require Trading status
53    require!(
54        price_account.agg.status == PriceStatus::Trading,
55        PercolatorError::InvalidOraclePrice
56    );
57
58    // Check staleness — reject future timestamps (would bypass the age check via saturating_sub)
59    let clock = Clock::get()?;
60    let current_timestamp = clock.unix_timestamp;
61    let price_age = current_timestamp
62        .checked_sub(price_account.timestamp)
63        .ok_or_else(|| error!(PercolatorError::StaleOracle))?;
64    require!(price_age <= MAX_PRICE_AGE_SECS, PercolatorError::StaleOracle);
65
66    let price = price_account.agg.price;
67    let expo = price_account.expo;
68
69    // Price must be positive
70    require!(price > 0, PercolatorError::InvalidOraclePriceValue);
71
72    // Bound exponent to reasonable range to prevent overflow/truncation-to-zero
73    require!(expo >= -18 && expo <= 18, PercolatorError::InvalidOraclePrice);
74
75    // Convert Pyth price to u64 oracle price for the engine.
76    // Pyth prices have an exponent (e.g. price=12345, expo=-2 means $123.45).
77    // The engine uses raw integer prices, so normalize to a consistent scale.
78    let oracle_price = if expo >= 0 {
79        (price as u64)
80            .checked_mul(10u64.pow(expo as u32))
81            .ok_or_else(|| error!(PercolatorError::InvalidOraclePriceValue))?
82    } else {
83        let divisor = 10u64.pow((-expo) as u32);
84        (price as u64)
85            .checked_div(divisor)
86            .ok_or_else(|| error!(PercolatorError::InvalidOraclePriceValue))?
87    };
88
89    require!(oracle_price > 0, PercolatorError::InvalidOraclePriceValue);
90
91    // Drop the oracle borrow before mutably borrowing market
92    drop(oracle_data);
93
94    // Update engine
95    let market = &ctx.accounts.market;
96    let mut data = market.try_borrow_mut_data()?;
97
98    require!(
99        &data[0..8] == b"percmrkt",
100        PercolatorError::AccountNotFound
101    );
102
103    let engine = engine_from_account_data(&mut data);
104
105    engine
106        .keeper_crank_not_atomic(clock.slot, oracle_price, &[], 0, funding_rate)
107        .map_err(from_risk_error)?;
108
109    emit!(events::Cranked {
110        cranker: ctx.accounts.cranker.key(),
111        oracle_price,
112        slot: clock.slot,
113    });
114
115    Ok(())
116}