diff --git a/Cargo.toml b/Cargo.toml index 17c6e6e..68a04f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ no-entrypoint = [] solana-program = "1.8.1" borsh = "0.9" borsh-derive = "0.9.0" +bytemuck = "1.7.2" +num-derive = "0.3" +num-traits = "0.2" +thiserror = "1.0" [dev-dependencies] solana-program-test = "1.8.1" diff --git a/README.md b/README.md index 50b3594..2277b70 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,27 @@ -# pyth-client-rs +# Pyth Client -A rust API for describing on-chain pyth account structures. A primer on pyth accounts can be found at https://github.com/pyth-network/pyth-client/blob/main/doc/aggregate_price.md +A Rust library for consuming price feeds from the [pyth.network](https://pyth.network/) oracle on the Solana network. +This package includes a library for on-chain programs and an example program for printing product reference data. -Contains a library for use in on-chain program development and an off-chain example program for loading and printing product reference data and aggregate prices from all devnet pyth accounts. +Key features of this library include: + +* Get the current price of over [50 products](https://pyth.network/markets/), including cryptocurrencies, + US equities, forex and more. +* Combine listed products to create new price feeds, e.g., for baskets of tokens or non-USD quote currencies. +* Consume prices in on-chain Solana programs or off-chain applications. + +Please see the [pyth.network documentation](https://docs.pyth.network/) for more information about pyth.network. + +## Usage + +Add a dependency to your Cargo.toml: + +```toml +[dependencies] +pyth-client="" +``` + +See [pyth-client on crates.io](https://crates.io/crates/pyth-client/) to get the latest version of the library. ### Running the Example @@ -38,9 +57,23 @@ product_account .. 6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8 twac ......... 2259870 ``` +## Development + +This library can be built for either your native platform or in BPF (used by Solana programs). +Use `cargo build` / `cargo test` to build and test natively. +Use `cargo build-bpf` / `cargo test-bpf` to build in BPF for Solana; these commands require you to have installed the [Solana CLI tools](https://docs.solana.com/cli/install-solana-cli-tools). + +The BPF tests will also run an instruction count program that logs the resource consumption +of various library functions. +This program can also be run on its own using `cargo test-bpf --test instruction_count`. + +### Releases -### Development +To release a new version of this package, perform the following steps: -Run `cargo test-bpf` to build in BPF and run the unit tests. -This command will also build an instruction count program that logs the resource consumption -of various functions. +1. Increment the version number in `Cargo.toml`. + You may use a version number with a `-beta.x` suffix such as `0.0.1-beta.0` to create opt-in test versions. +2. Merge your change into `main` on github. +3. Create and publish a new github release. + The name of the release should be the version number, and the tag should be the version number prefixed with `v`. + Publishing the release will trigger a github action that will automatically publish the [pyth-client](https://crates.io/crates/pyth-client) rust crate to `crates.io`. diff --git a/examples/get_accounts.rs b/examples/get_accounts.rs index 759cfa8..b0cc4cb 100644 --- a/examples/get_accounts.rs +++ b/examples/get_accounts.rs @@ -2,16 +2,12 @@ // bootstrap all product and pricing accounts from root mapping account use pyth_client::{ - AccountType, - Mapping, - Product, - Price, PriceType, PriceStatus, CorpAction, - cast, - MAGIC, - VERSION_2, + load_mapping, + load_product, + load_price, PROD_HDR_SIZE }; use solana_client::{ @@ -71,24 +67,14 @@ fn main() { loop { // get Mapping account from key let map_data = clnt.get_account_data( &akey ).unwrap(); - let map_acct = cast::( &map_data ); - assert_eq!( map_acct.magic, MAGIC, "not a valid pyth account" ); - assert_eq!( map_acct.atype, AccountType::Mapping as u32, - "not a valid pyth mapping account" ); - assert_eq!( map_acct.ver, VERSION_2, - "unexpected pyth mapping account version" ); + let map_acct = load_mapping( &map_data ).unwrap(); // iget and print each Product in Mapping directory let mut i = 0; for prod_akey in &map_acct.products { let prod_pkey = Pubkey::new( &prod_akey.val ); let prod_data = clnt.get_account_data( &prod_pkey ).unwrap(); - let prod_acct = cast::( &prod_data ); - assert_eq!( prod_acct.magic, MAGIC, "not a valid pyth account" ); - assert_eq!( prod_acct.atype, AccountType::Product as u32, - "not a valid pyth product account" ); - assert_eq!( prod_acct.ver, VERSION_2, - "unexpected pyth product account version" ); + let prod_acct = load_product( &prod_data ).unwrap(); // print key and reference data for this Product println!( "product_account .. {:?}", prod_pkey ); @@ -106,13 +92,8 @@ fn main() { let mut px_pkey = Pubkey::new( &prod_acct.px_acc.val ); loop { let pd = clnt.get_account_data( &px_pkey ).unwrap(); - let pa = cast::( &pd ); + let pa = load_price( &pd ).unwrap(); - assert_eq!( pa.magic, MAGIC, "not a valid pyth account" ); - assert_eq!( pa.atype, AccountType::Price as u32, - "not a valid pyth price account" ); - assert_eq!( pa.ver, VERSION_2, - "unexpected pyth price account version" ); println!( " price_account .. {:?}", px_pkey ); let maybe_price = pa.get_current_price(); diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..79d2460 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,25 @@ +use num_derive::FromPrimitive; +use solana_program::program_error::ProgramError; +use thiserror::Error; + +/// Errors that may be returned by Pyth. +#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] +pub enum PythError { + // 0 + /// Invalid account data -- either insufficient data, or incorrect magic number + #[error("Failed to convert account into a Pyth account")] + InvalidAccountData, + /// Wrong version number + #[error("Incorrect version number for Pyth account")] + BadVersionNumber, + /// Tried reading an account with the wrong type, e.g., tried to read + /// a price account as a product account. + #[error("Incorrect account type")] + WrongAccountType, +} + +impl From for ProgramError { + fn from(e: PythError) -> Self { + ProgramError::Custom(e as u32) + } +} diff --git a/src/instruction.rs b/src/instruction.rs index 8502c81..e6718bd 100644 --- a/src/instruction.rs +++ b/src/instruction.rs @@ -1,4 +1,4 @@ -//! Program instructions, used for end-to-end testing and instruction counts +//! Program instructions for end-to-end testing and instruction counts use { crate::id, diff --git a/src/lib.rs b/src/lib.rs index 9dbebb8..a674ae8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,38 @@ +//! A Rust library for consuming price feeds from the [pyth.network](https://pyth.network/) oracle on the Solana network. +//! +//! In order to use this library, you likely need to understand how Pyth's price feeds are stored in Solana accounts. +//! Please see [this document](https://docs.pyth.network/how-pyth-works/account-structure) for more information. +//! +//! # Quick Start +//! +//! Get the price from a Pyth price account: +//! +//! ```no_run +//! use pyth_client::{load_price, PriceConf}; +//! // solana account data as bytes, either passed to on-chain program or from RPC connection. +//! let account_data: Vec = vec![]; +//! let price_account = load_price( &account_data ).unwrap(); +//! // May be None if price is not currently available. +//! let price: PriceConf = price_account.get_current_price().unwrap(); +//! println!("price: ({} +- {}) x 10^{}", price.price, price.conf, price.expo); +//! ``` + pub use self::price_conf::PriceConf; +pub use self::error::PythError; mod entrypoint; +mod error; +mod price_conf; + pub mod processor; pub mod instruction; -mod price_conf; +use std::mem::size_of; +use bytemuck::{ + cast_slice, from_bytes, try_cast_slice, + Pod, PodCastError, Zeroable, +}; + solana_program::declare_id!("PythC11111111111111111111111111111111111111"); pub const MAGIC : u32 = 0xa1b2c3d4; @@ -15,7 +43,8 @@ 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; -// each account has its own type +/// The type of Pyth account determines what data it contains +#[derive(Copy, Clone)] #[repr(C)] pub enum AccountType { @@ -25,25 +54,32 @@ pub enum AccountType Price } -// aggregate and contributing prices are associated with a status -// only Trading status is valid +/// The current status of a price feed. +#[derive(Copy, Clone, PartialEq)] #[repr(C)] pub enum PriceStatus { + /// The price feed is not currently updating for an unknown reason. Unknown, + /// The price feed is updating as expected. Trading, + /// The price feed is not currently updating because trading in the product has been halted. Halted, + /// The price feed is not currently updating because an auction is setting the price. Auction } -// ongoing coporate action event - still undergoing dev +/// Status of any ongoing corporate actions. +/// (still undergoing dev) +#[derive(Copy, Clone, PartialEq)] #[repr(C)] pub enum CorpAction { NoCorpAct } -// different types of prices associated with a product +/// The type of prices associated with a product -- each product may have multiple price feeds of different types. +#[derive(Copy, Clone, PartialEq)] #[repr(C)] pub enum PriceType { @@ -51,100 +87,175 @@ pub enum PriceType Price } -// solana public key +/// Public key of a Solana account +#[derive(Copy, Clone)] #[repr(C)] pub struct AccKey { pub val: [u8;32] } -// Mapping account structure +/// Mapping accounts form a linked-list containing the listing of all products on Pyth. +#[derive(Copy, Clone)] #[repr(C)] pub struct Mapping { - pub magic : u32, // pyth magic number - pub ver : u32, // program version - pub atype : u32, // account type - pub size : u32, // account used size - pub num : u32, // number of product accounts + /// pyth magic number + pub magic : u32, + /// program version + pub ver : u32, + /// account type + pub atype : u32, + /// account used size + pub size : u32, + /// number of product accounts + pub num : u32, pub unused : u32, - pub next : AccKey, // next mapping account (if any) + /// next mapping account (if any) + pub next : AccKey, pub products : [AccKey;MAP_TABLE_SIZE] } -// Product account structure +#[cfg(target_endian = "little")] +unsafe impl Zeroable for Mapping {} + +#[cfg(target_endian = "little")] +unsafe impl Pod for Mapping {} + + +/// Product accounts contain metadata for a single product, such as its symbol ("Crypto.BTC/USD") +/// and its base/quote currencies. +#[derive(Copy, Clone)] #[repr(C)] pub struct Product { - pub magic : u32, // pyth magic number - pub ver : u32, // program version - pub atype : u32, // account type - pub size : u32, // price account size - pub px_acc : AccKey, // first price account in list - pub attr : [u8;PROD_ATTR_SIZE] // key/value pairs of reference attr. + /// pyth magic number + pub magic : u32, + /// program version + pub ver : u32, + /// account type + pub atype : u32, + /// price account size + pub size : u32, + /// first price account in list + pub px_acc : AccKey, + /// key/value pairs of reference attr. + pub attr : [u8;PROD_ATTR_SIZE] } -// contributing or aggregate price component +#[cfg(target_endian = "little")] +unsafe impl Zeroable for Product {} + +#[cfg(target_endian = "little")] +unsafe impl Pod for Product {} + +/// A price and confidence at a specific slot. This struct can represent either a +/// publisher's contribution or the outcome of price aggregation. +#[derive(Copy, Clone)] #[repr(C)] pub struct PriceInfo { - pub price : i64, // product price - pub conf : u64, // confidence interval of product price - pub status : PriceStatus,// status of price (Trading is valid) - pub corp_act : CorpAction, // notification of any corporate action + /// the current price + pub price : i64, + /// confidence interval around the price + pub conf : u64, + /// status of price (Trading is valid) + pub status : PriceStatus, + /// notification of any corporate action + pub corp_act : CorpAction, pub pub_slot : u64 } -// latest component price and price used in aggregate snapshot +/// The price and confidence contributed by a specific publisher. +#[derive(Copy, Clone)] #[repr(C)] pub struct PriceComp { - pub publisher : AccKey, // key of contributing quoter - pub agg : PriceInfo, // contributing price to last aggregate - pub latest : PriceInfo // latest contributing price (not in agg.) + /// key of contributing publisher + pub publisher : AccKey, + /// the price used to compute the current aggregate price + pub agg : PriceInfo, + /// The publisher's latest price. This price will be incorporated into the aggregate price + /// when price aggregation runs next. + pub latest : PriceInfo + } +/// An exponentially-weighted moving average. +#[derive(Copy, Clone)] #[repr(C)] pub struct Ema { - pub val : i64, // current value of ema - numer : i64, // numerator state for next update - denom : i64 // denominator state for next update + /// The current value of the EMA + pub val : i64, + /// numerator state for next update + numer : i64, + /// denominator state for next update + denom : i64 } -// Price account structure +/// Price accounts represent a continuously-updating price feed for a product. +#[derive(Copy, Clone)] #[repr(C)] pub struct Price { - pub magic : u32, // pyth magic number - pub ver : u32, // program version - pub atype : u32, // account type - pub size : u32, // price account size - pub ptype : PriceType, // price or calculation type - pub expo : i32, // price exponent - pub num : u32, // number of component prices - pub num_qt : u32, // number of quoters that make up aggregate - pub last_slot : u64, // slot of last valid (not unknown) aggregate price - pub valid_slot : u64, // valid slot-time of agg. price - pub twap : Ema, // time-weighted average price - pub twac : Ema, // time-weighted average confidence interval - pub drv1 : i64, // space for future derived values - pub drv2 : i64, // space for future derived values - pub prod : AccKey, // product account key - pub next : AccKey, // next Price account in linked list - pub prev_slot : u64, // valid slot of previous update - pub prev_price : i64, // aggregate price of previous update - pub prev_conf : u64, // confidence interval of previous update - pub drv3 : i64, // space for future derived values - pub agg : PriceInfo, // aggregate price info - pub comp : [PriceComp;32] // price components one per quoter + /// pyth magic number + pub magic : u32, + /// program version + pub ver : u32, + /// account type + pub atype : u32, + /// price account size + pub size : u32, + /// price or calculation type + pub ptype : PriceType, + /// price exponent + pub expo : i32, + /// number of component prices + pub num : u32, + /// number of quoters that make up aggregate + pub num_qt : u32, + /// slot of last valid (not unknown) aggregate price + pub last_slot : u64, + /// valid slot-time of agg. price + pub valid_slot : u64, + /// time-weighted average price + pub twap : Ema, + /// time-weighted average confidence interval + pub twac : Ema, + /// space for future derived values + pub drv1 : i64, + /// space for future derived values + pub drv2 : i64, + /// product account key + pub prod : AccKey, + /// next Price account in linked list + pub next : AccKey, + /// valid slot of previous update + pub prev_slot : u64, + /// aggregate price of previous update + pub prev_price : i64, + /// confidence interval of previous update + pub prev_conf : u64, + /// space for future derived values + pub drv3 : i64, + /// aggregate price info + pub agg : PriceInfo, + /// price components one per quoter + pub comp : [PriceComp;32] } +#[cfg(target_endian = "little")] +unsafe impl Zeroable for Price {} + +#[cfg(target_endian = "little")] +unsafe impl Pod for Price {} + impl Price { /** * 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. + * 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) { @@ -160,7 +271,7 @@ impl Price { /** * Get the time-weighted average price (TWAP) and a confidence interval on the result. - * Returns None if the twap is currently unavailable. + * Returns `None` if the twap is currently unavailable. * * At the moment, the confidence interval returned by this method is computed in * a somewhat questionable way, so we do not recommend using it for high-value applications. @@ -208,20 +319,86 @@ impl Price { } } +#[derive(Copy, Clone)] struct AccKeyU64 { pub val: [u64;4] } -pub fn cast( d: &[u8] ) -> &T { - let (_, pxa, _) = unsafe { d.align_to::() }; - &pxa[0] -} +#[cfg(target_endian = "little")] +unsafe impl Zeroable for AccKeyU64 {} + +#[cfg(target_endian = "little")] +unsafe impl Pod for AccKeyU64 {} impl AccKey { pub fn is_valid( &self ) -> bool { - let k8 = cast::( &self.val ); - return k8.val[0]!=0 || k8.val[1]!=0 || k8.val[2]!=0 || k8.val[3]!=0; + match load::( &self.val ) { + Ok(k8) => k8.val[0]!=0 || k8.val[1]!=0 || k8.val[2]!=0 || k8.val[3]!=0, + Err(_) => false, + } + } +} + +fn load(data: &[u8]) -> Result<&T, PodCastError> { + let size = size_of::(); + if data.len() >= size { + Ok(from_bytes(cast_slice::(try_cast_slice( + &data[0..size], + )?))) + } else { + Err(PodCastError::SizeMismatch) + } +} + +/** Get a `Mapping` account from the raw byte value of a Solana account. */ +pub fn load_mapping(data: &[u8]) -> Result<&Mapping, PythError> { + let pyth_mapping = load::(&data).map_err(|_| PythError::InvalidAccountData)?; + + if pyth_mapping.magic != MAGIC { + return Err(PythError::InvalidAccountData); + } + if pyth_mapping.ver != VERSION_2 { + return Err(PythError::BadVersionNumber); + } + if pyth_mapping.atype != AccountType::Mapping as u32 { + return Err(PythError::WrongAccountType); + } + + return Ok(pyth_mapping); +} + +/** Get a `Product` account from the raw byte value of a Solana account. */ +pub fn load_product(data: &[u8]) -> Result<&Product, PythError> { + let pyth_product = load::(&data).map_err(|_| PythError::InvalidAccountData)?; + + if pyth_product.magic != MAGIC { + return Err(PythError::InvalidAccountData); } + if pyth_product.ver != VERSION_2 { + return Err(PythError::BadVersionNumber); + } + if pyth_product.atype != AccountType::Product as u32 { + return Err(PythError::WrongAccountType); + } + + return Ok(pyth_product); +} + +/** Get a `Price` account from the raw byte value of a Solana account. */ +pub fn load_price(data: &[u8]) -> Result<&Price, PythError> { + let pyth_price = load::(&data).map_err(|_| PythError::InvalidAccountData)?; + + if pyth_price.magic != MAGIC { + return Err(PythError::InvalidAccountData); + } + if pyth_price.ver != VERSION_2 { + return Err(PythError::BadVersionNumber); + } + if pyth_price.atype != AccountType::Price as u32 { + return Err(PythError::WrongAccountType); + } + + return Ok(pyth_price); } diff --git a/src/processor.rs b/src/processor.rs index a135233..97e44e3 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -1,4 +1,4 @@ -//! Program instruction processor +//! Program instruction processor for end-to-end testing and instruction counts use borsh::BorshDeserialize; use solana_program::{