/** * On-chain Pyth Price Reader * * Reads price data directly from Pyth's on-chain price feed accounts. * This is used to ensure the price we calculate outcomes from matches * the price that will be verified on-chain in mark_extreme. * * @see https://github.com/pyth-network/pyth-crosschain/blob/main/target_chains/solana/pyth_solana_receiver_sdk/src/price_update.rs */ import { Connection, PublicKey } from "@solana/web3.js"; import { SOL_USD_PRICE_FEED_ACCOUNT } from "../constants.js"; import { PythPrice } from "./types.js"; /** * Safely convert a BigInt to a JavaScript number. * Throws an error if the value exceeds JavaScript's safe integer limit (2^53 - 1). * * @param value - The bigint value to convert * @param context - Optional description for error messages * @returns The number value * @throws Error if the value exceeds MAX_SAFE_INTEGER */ function safeBigIntToNumber(value: bigint, context?: string): number { const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); const absValue = value < BigInt(0) ? -value : value; if (absValue > MAX_SAFE) { throw new Error( `Value exceeds MAX_SAFE_INTEGER (2^53-1)${ context ? ` in ${context}` : "" }: ${value.toString()}` ); } return Number(value); } /** * PriceUpdateV2 account layout constants * * Layout (verified against actual devnet data): * - 8 bytes: discriminator (Anchor) * - 32 bytes: write_authority (Pubkey) * - 1 byte: verification_level (enum, NOT 2 bytes as documented) * - PriceFeedMessage: * - 32 bytes: feed_id * - 8 bytes: price (i64) * - 8 bytes: conf (u64) * - 4 bytes: exponent (i32) * - 8 bytes: publish_time (i64) * - 8 bytes: prev_publish_time (i64) * - 8 bytes: ema_price (i64) * - 8 bytes: ema_conf (u64) * - 8 bytes: posted_slot (u64) */ const OFFSET_PRICE_MESSAGE = 41; // 8 + 32 + 1 (verification_level is 1 byte) const OFFSET_PRICE = OFFSET_PRICE_MESSAGE + 32; // 73 const OFFSET_CONF = OFFSET_PRICE + 8; // 81 const OFFSET_EXPONENT = OFFSET_CONF + 8; // 89 const OFFSET_PUBLISH_TIME = OFFSET_EXPONENT + 4; // 93 /** * Read and parse a Pyth PriceUpdateV2 account * * @param connection - Solana connection * @param priceAccount - Price feed account pubkey * @returns Parsed price data or null if account doesn't exist */ export async function readPythPriceAccount( connection: Connection, priceAccount: PublicKey = SOL_USD_PRICE_FEED_ACCOUNT ): Promise { const accountInfo = await connection.getAccountInfo(priceAccount); if (!accountInfo) { return null; } const data = accountInfo.data; // Minimum size check (at least up to publish_time) if (data.length < OFFSET_PUBLISH_TIME + 8) { console.error(`PriceUpdateV2 account too small: ${data.length} bytes`); return null; } // Read price (i64, little-endian) const priceView = new DataView(data.buffer, data.byteOffset + OFFSET_PRICE, 8); const price = priceView.getBigInt64(0, true); // Read conf (u64, little-endian) const confView = new DataView(data.buffer, data.byteOffset + OFFSET_CONF, 8); const conf = confView.getBigUint64(0, true); // Read exponent (i32, little-endian) const expoView = new DataView(data.buffer, data.byteOffset + OFFSET_EXPONENT, 4); const exponent = expoView.getInt32(0, true); // Read publish_time (i64, little-endian) const publishTimeView = new DataView(data.buffer, data.byteOffset + OFFSET_PUBLISH_TIME, 8); const publishTime = publishTimeView.getBigInt64(0, true); // Convert to our price format using safe conversion // Pyth prices are typically in range of 10^8 with -8 exponent, so raw values are safe const priceNum = safeBigIntToNumber(price, "pyth:price"); const confNum = safeBigIntToNumber(conf, "pyth:conf"); const publishTimeNum = safeBigIntToNumber(publishTime, "pyth:publishTime"); const priceUsd = priceNum * Math.pow(10, exponent); const confUsd = confNum * Math.pow(10, exponent); const priceInCents = Math.round(priceUsd * 100); return { price: priceUsd, priceInCents, confidence: confUsd, timestamp: publishTimeNum, expo: exponent, }; } /** * Check if the on-chain price supports a given outcome * * @param priceInCents - Price in cents * @param strikes - Strike prices in cents [s0, s1, s2] * @param isHighPool - true for High pool, false for Low pool * @param outcome - The outcome to check (1, 2, or 3) * @returns true if the current price supports this outcome */ export function priceSupportsOutcome( priceInCents: number, strikes: [number, number, number], isHighPool: boolean, outcome: number ): boolean { if (outcome < 1 || outcome > 3) { return false; } if (isHighPool) { // High pool: price must be >= required strike const requiredStrike = strikes[outcome - 1]; return priceInCents >= requiredStrike; } else { // Low pool: price must be < required strike // outcome 1 -> must be < strikes[2] (highest) // outcome 2 -> must be < strikes[1] (middle) // outcome 3 -> must be < strikes[0] (lowest) const requiredStrike = strikes[3 - outcome]; return priceInCents < requiredStrike; } }