diff --git a/README.md b/README.md index 74dabd6..294440b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ The price is returned along with a confidence interval that represents the degre Both values are represented as fixed-point numbers, `a * 10^e`. The method will return `None` if the price is not currently available. +The status of the price feed determines if the price is available. You can get the current status using: + +```rust +let price_status: PriceStatus = price_account.get_current_price_status(); +``` + ### Non-USD prices Most assets in Pyth are priced in USD. diff --git a/examples/get_accounts.rs b/examples/get_accounts.rs index e98d543..679db45 100644 --- a/examples/get_accounts.rs +++ b/examples/get_accounts.rs @@ -66,7 +66,9 @@ fn main() { // print key and reference data for this Product println!( "product_account .. {:?}", prod_pkey ); for (key, val) in prod_acct.iter() { - println!( " {:.<16} {}", key, val ); + if key.len() > 0 { + println!( " {:.<16} {}", key, val ); + } } // print all Prices that correspond to this Product @@ -92,7 +94,7 @@ fn main() { println!( " price_type ... {}", get_price_type(&pa.ptype)); println!( " exponent ..... {}", pa.expo ); - println!( " status ....... {}", get_status(&pa.agg.status)); + println!( " status ....... {}", get_status(&pa.get_current_price_status())); println!( " corp_act ..... {}", get_corp_act(&pa.agg.corp_act)); println!( " num_qt ....... {}", pa.num_qt ); diff --git a/src/instruction.rs b/src/instruction.rs index e6718bd..32a095f 100644 --- a/src/instruction.rs +++ b/src/instruction.rs @@ -1,5 +1,9 @@ //! Program instructions for end-to-end testing and instruction counts +use bytemuck::bytes_of; + +use crate::{PriceStatus, Price}; + use { crate::id, borsh::{BorshDeserialize, BorshSerialize}, @@ -34,6 +38,13 @@ pub enum PythClientInstruction { /// /// No accounts required for this instruction Noop, + + PriceStatusCheck { + // A Price serialized as a vector of bytes. This field is stored as a vector of bytes (instead of a Price) + // so that we do not have to add Borsh serialization to all structs, which is expensive. + price_account_data: Vec, + expected_price_status: PriceStatus + } } pub fn divide(numerator: PriceConf, denominator: PriceConf) -> Instruction { @@ -94,3 +105,14 @@ pub fn noop() -> Instruction { data: PythClientInstruction::Noop.try_to_vec().unwrap(), } } + +// Returns ok if price account status matches given expected price status. +pub fn price_status_check(price: &Price, expected_price_status: PriceStatus) -> Instruction { + Instruction { + program_id: id(), + accounts: vec![], + data: PythClientInstruction::PriceStatusCheck { price_account_data: bytes_of(price).to_vec(), expected_price_status } + .try_to_vec() + .unwrap(), + } +} diff --git a/src/lib.rs b/src/lib.rs index c2047bf..057f3ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,20 +13,25 @@ pub mod processor; pub mod instruction; use std::mem::size_of; +use borsh::{BorshSerialize, BorshDeserialize}; use bytemuck::{ cast_slice, from_bytes, try_cast_slice, Pod, PodCastError, Zeroable, }; +#[cfg(target_arch = "bpf")] +use solana_program::{clock::Clock, sysvar::Sysvar}; + solana_program::declare_id!("PythC11111111111111111111111111111111111111"); -pub const MAGIC : u32 = 0xa1b2c3d4; -pub const VERSION_2 : u32 = 2; -pub const VERSION : u32 = VERSION_2; -pub const MAP_TABLE_SIZE : usize = 640; -pub const PROD_ACCT_SIZE : usize = 512; -pub const PROD_HDR_SIZE : usize = 48; -pub const PROD_ATTR_SIZE : usize = PROD_ACCT_SIZE - PROD_HDR_SIZE; +pub const MAGIC : u32 = 0xa1b2c3d4; +pub const VERSION_2 : u32 = 2; +pub const VERSION : u32 = VERSION_2; +pub const MAP_TABLE_SIZE : usize = 640; +pub const PROD_ACCT_SIZE : usize = 512; +pub const PROD_HDR_SIZE : usize = 48; +pub const PROD_ATTR_SIZE : usize = PROD_ACCT_SIZE - PROD_HDR_SIZE; +pub const MAX_SLOT_DIFFERENCE : u64 = 25; /// The type of Pyth account determines what data it contains #[derive(Copy, Clone)] @@ -40,7 +45,7 @@ pub enum AccountType } /// The current status of a price feed. -#[derive(Copy, Clone, PartialEq)] +#[derive(Copy, Clone, PartialEq, BorshSerialize, BorshDeserialize, Debug)] #[repr(C)] pub enum PriceStatus { @@ -146,11 +151,15 @@ unsafe impl Pod for Product {} #[repr(C)] pub struct PriceInfo { - /// the current price + /// the current price. + /// For the aggregate price use price.get_current_price() whenever possible. It has more checks to make sure price is valid. pub price : i64, - /// confidence interval around the price + /// confidence interval around the price. + /// For the aggregate confidence use price.get_current_price() whenever possible. It has more checks to make sure price is valid. pub conf : u64, - /// status of price (Trading is valid) + /// status of price (Trading is valid). + /// For the aggregate status use price.get_current_status() whenever possible. + /// Price data can sometimes go stale and the function handles the status in such cases. pub status : PriceStatus, /// notification of any corporate action pub corp_act : CorpAction, @@ -180,9 +189,9 @@ pub struct Ema /// The current value of the EMA pub val : i64, /// numerator state for next update - numer : i64, + pub numer : i64, /// denominator state for next update - denom : i64 + pub denom : i64 } /// Price accounts represent a continuously-updating price feed for a product. @@ -243,13 +252,26 @@ unsafe impl Zeroable for Price {} unsafe impl Pod for Price {} impl Price { + /** + * Get the current status of the aggregate price. + * If this lib is used on-chain it will mark price status as unknown if price has not been updated for a while. + */ + pub fn get_current_price_status(&self) -> PriceStatus { + #[cfg(target_arch = "bpf")] + if matches!(self.agg.status, PriceStatus::Trading) && + Clock::get().unwrap().slot - self.agg.pub_slot > MAX_SLOT_DIFFERENCE { + return PriceStatus::Unknown; + } + self.agg.status + } + /** * Get the current price and confidence interval as fixed-point numbers of the form a * 10^e. * Returns a struct containing the current price, confidence interval, and the exponent for both * numbers. Returns `None` if price information is currently unavailable for any reason. */ pub fn get_current_price(&self) -> Option { - if !matches!(self.agg.status, PriceStatus::Trading) { + if !matches!(self.get_current_price_status(), PriceStatus::Trading) { None } else { Some(PriceConf { @@ -394,6 +416,7 @@ pub fn load_price(data: &[u8]) -> Result<&Price, PythError> { return Ok(pyth_price); } + pub struct AttributeIter<'a> { attrs: &'a [u8], } diff --git a/src/processor.rs b/src/processor.rs index 97e44e3..fd0f324 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -5,10 +5,11 @@ use solana_program::{ account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey, + program_error::ProgramError }; use crate::{ - instruction::PythClientInstruction, + instruction::PythClientInstruction, load_price, }; pub fn process_instruction( @@ -41,5 +42,14 @@ pub fn process_instruction( PythClientInstruction::Noop => { Ok(()) } + PythClientInstruction::PriceStatusCheck { price_account_data, expected_price_status } => { + let price = load_price(&price_account_data[..])?; + + if price.get_current_price_status() == expected_price_status { + Ok(()) + } else { + Err(ProgramError::Custom(0)) + } + } } } diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..6d98755 --- /dev/null +++ b/tests/common.rs @@ -0,0 +1,24 @@ +use { + pyth_client::id, + pyth_client::processor::process_instruction, + solana_program::instruction::Instruction, + solana_program_test::*, + solana_sdk::{signature::Signer, transaction::Transaction}, +}; + +// Panics if running instruction fails +pub async fn test_instr_exec_ok(instr: Instruction) { + let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + "pyth_client", + id(), + processor!(process_instruction), + ) + .start() + .await; + let mut transaction = Transaction::new_with_payer( + &[instr], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap() +} diff --git a/tests/instruction_count.rs b/tests/instruction_count.rs index 7082eac..5fe40fa 100644 --- a/tests/instruction_count.rs +++ b/tests/instruction_count.rs @@ -1,26 +1,10 @@ use { - pyth_client::{id, instruction, PriceConf}, - pyth_client::processor::process_instruction, - solana_program::instruction::Instruction, + pyth_client::{instruction, PriceConf}, solana_program_test::*, - solana_sdk::{signature::Signer, transaction::Transaction}, }; -async fn test_instr(instr: Instruction) { - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( - "pyth_client", - id(), - processor!(process_instruction), - ) - .start() - .await; - let mut transaction = Transaction::new_with_payer( - &[instr], - Some(&payer.pubkey()), - ); - transaction.sign(&[&payer], recent_blockhash); - banks_client.process_transaction(transaction).await.unwrap(); -} +mod common; +use common::test_instr_exec_ok; fn pc(price: i64, conf: u64, expo: i32) -> PriceConf { PriceConf { @@ -32,47 +16,47 @@ fn pc(price: i64, conf: u64, expo: i32) -> PriceConf { #[tokio::test] async fn test_noop() { - test_instr(instruction::noop()).await; + test_instr_exec_ok(instruction::noop()).await; } #[tokio::test] async fn test_scale_to_exponent_down() { - test_instr(instruction::scale_to_exponent(pc(1, u64::MAX, -1000), 1000)).await + test_instr_exec_ok(instruction::scale_to_exponent(pc(1, u64::MAX, -1000), 1000)).await } #[tokio::test] async fn test_scale_to_exponent_up() { - test_instr(instruction::scale_to_exponent(pc(1, u64::MAX, 1000), -1000)).await + test_instr_exec_ok(instruction::scale_to_exponent(pc(1, u64::MAX, 1000), -1000)).await } #[tokio::test] async fn test_scale_to_exponent_best_case() { - test_instr(instruction::scale_to_exponent(pc(1, u64::MAX, 10), 10)).await + test_instr_exec_ok(instruction::scale_to_exponent(pc(1, u64::MAX, 10), 10)).await } #[tokio::test] async fn test_normalize_max_conf() { - test_instr(instruction::normalize(pc(1, u64::MAX, 0))).await + test_instr_exec_ok(instruction::normalize(pc(1, u64::MAX, 0))).await } #[tokio::test] async fn test_normalize_max_price() { - test_instr(instruction::normalize(pc(i64::MAX, 1, 0))).await + test_instr_exec_ok(instruction::normalize(pc(i64::MAX, 1, 0))).await } #[tokio::test] async fn test_normalize_min_price() { - test_instr(instruction::normalize(pc(i64::MIN, 1, 0))).await + test_instr_exec_ok(instruction::normalize(pc(i64::MIN, 1, 0))).await } #[tokio::test] async fn test_normalize_best_case() { - test_instr(instruction::normalize(pc(1, 1, 0))).await + test_instr_exec_ok(instruction::normalize(pc(1, 1, 0))).await } #[tokio::test] async fn test_div_max_price() { - test_instr(instruction::divide( + test_instr_exec_ok(instruction::divide( pc(i64::MAX, 1, 0), pc(1, 1, 0) )).await; @@ -80,7 +64,7 @@ async fn test_div_max_price() { #[tokio::test] async fn test_div_max_price_2() { - test_instr(instruction::divide( + test_instr_exec_ok(instruction::divide( pc(i64::MAX, 1, 0), pc(i64::MAX, 1, 0) )).await; @@ -88,7 +72,7 @@ async fn test_div_max_price_2() { #[tokio::test] async fn test_mul_max_price() { - test_instr(instruction::multiply( + test_instr_exec_ok(instruction::multiply( pc(i64::MAX, 1, 2), pc(123, 1, 2), )).await; @@ -96,7 +80,7 @@ async fn test_mul_max_price() { #[tokio::test] async fn test_mul_max_price_2() { - test_instr(instruction::multiply( + test_instr_exec_ok(instruction::multiply( pc(i64::MAX, 1, 2), pc(i64::MAX, 1, 2), )).await; diff --git a/tests/stale_price.rs b/tests/stale_price.rs new file mode 100644 index 0000000..ee3ac63 --- /dev/null +++ b/tests/stale_price.rs @@ -0,0 +1,81 @@ +#![cfg(feature = "test-bpf")] // Only runs on bpf, where solana programs run + +use { + pyth_client::{MAGIC, VERSION_2, instruction, PriceType, Price, AccountType, AccKey, Ema, PriceComp, PriceInfo, CorpAction, PriceStatus}, + solana_program_test::*, +}; + + +mod common; +use common::test_instr_exec_ok; + +fn price_all_zero() -> Price { + let acc_key = AccKey { + val: [0; 32] + }; + + let ema = Ema { + val: 0, + numer: 0, + denom: 0 + }; + + let price_info = PriceInfo { + conf: 0, + corp_act: CorpAction::NoCorpAct, + price: 0, + pub_slot: 0, + status: PriceStatus::Unknown + }; + + let price_comp = PriceComp { + agg: price_info, + latest: price_info, + publisher: acc_key + }; + + Price { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Price as u32, + size: 0, + ptype: PriceType::Price, + expo: 0, + num: 0, + num_qt: 0, + last_slot: 0, + valid_slot: 0, + twap: ema, + twac: ema, + drv1: 0, + drv2: 0, + prod: acc_key, + next: acc_key, + prev_slot: 0, + prev_price: 0, + prev_conf: 0, + drv3: 0, + agg: price_info, + comp: [price_comp; 32] + } +} + + +#[tokio::test] +async fn test_price_not_stale() { + let mut price = price_all_zero(); + price.agg.status = PriceStatus::Trading; + test_instr_exec_ok(instruction::price_status_check(&price, PriceStatus::Trading)).await; +} + + +#[tokio::test] +async fn test_price_stale() { + let mut price = price_all_zero(); + price.agg.status = PriceStatus::Trading; + // Value 100 will cause an overflow because this is bigger than Solana slot in the test suite (its ~1-5). + // As the check will be 5u - 100u ~= 1e18 > MAX_SLOT_DIFFERENCE. It can only break when Solana slot in the test suite becomes + // between 100 and 100+MAX_SLOT_DIFFERENCE. + price.agg.pub_slot = 100; + test_instr_exec_ok(instruction::price_status_check(&price, PriceStatus::Unknown)).await; +}