From 0398bbad89f2f355ee4fea964b9dbb861daaf947 Mon Sep 17 00:00:00 2001 From: Jayant Krishnamurthy Date: Wed, 29 Dec 2021 10:54:57 -0800 Subject: [PATCH 1/3] uh oh --- Cargo.toml | 3 +++ README.md | 38 +++++++++++++++++++++++++--- examples/get_accounts.rs | 31 +++++------------------ src/error.rs | 24 ++++++++++++++++++ src/instruction.rs | 2 +- src/lib.rs | 54 +++++++++++++++++++++++++++++++++++++++- src/processor.rs | 2 +- 7 files changed, 123 insertions(+), 31 deletions(-) create mode 100644 src/error.rs diff --git a/Cargo.toml b/Cargo.toml index 17c6e6e..f6d9a35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,9 @@ no-entrypoint = [] solana-program = "1.8.1" borsh = "0.9" borsh-derive = "0.9.0" +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 272606f..fd36d94 100644 --- a/README.md +++ b/README.md @@ -1,9 +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. +Key features of this crate include: -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. +* 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,3 +56,17 @@ product_account .. 6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8 twap ......... 7426390900 twac ......... 2259870 ``` + +### Development + + +### Releasing + +To release a new version of this package, perform the following steps: + +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..35c88b5 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,24 @@ +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 instruction data passed in. + #[error("Failed to unpack instruction data")] + InvalidAccountData, + /// Invalid instruction data passed in. + #[error("Failed to unpack instruction data")] + BadVersionNumber, + /// Invalid instruction data passed in. + #[error("Failed to unpack instruction data")] + 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 d04df30..9e8ee95 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..f7de922 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,10 @@ pub use self::price_conf::PriceConf; +pub use self::error::PythError; mod entrypoint; pub mod processor; pub mod instruction; - +mod error; mod price_conf; solana_program::declare_id!("PythC11111111111111111111111111111111111111"); @@ -213,6 +214,7 @@ struct AccKeyU64 pub val: [u64;4] } +// FIXME: this should fail gracefully pub fn cast( d: &[u8] ) -> &T { let (_, pxa, _) = unsafe { d.align_to::() }; &pxa[0] @@ -225,3 +227,53 @@ impl AccKey return k8.val[0]!=0 || k8.val[1]!=0 || k8.val[2]!=0 || k8.val[3]!=0; } } + +pub fn load_mapping(data: &[u8]) -> Result<&Mapping, PythError> { + // let pyth_product = cast::(&data).map_err(|_| PythError::InvalidAccountData)?; + let pyth_mapping = cast::(&data); + + 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); +} + +pub fn load_product(data: &[u8]) -> Result<&Product, PythError> { + // let pyth_product = cast::(&data).map_err(|_| PythError::InvalidAccountData)?; + let pyth_product = cast::(&data); + + 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); +} + +pub fn load_price(data: &[u8]) -> Result<&Price, PythError> { + let pyth_price = cast::(&data); + + 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 ad74d38..8e16874 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::{ From 7f09597fcad5e69eade06acc6ff57a2685040bd2 Mon Sep 17 00:00:00 2001 From: Jayant Krishnamurthy Date: Wed, 29 Dec 2021 11:59:47 -0800 Subject: [PATCH 2/3] docs --- src/lib.rs | 180 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 117 insertions(+), 63 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a0447b6..a674ae8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,8 @@ //! 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: @@ -11,13 +14,8 @@ //! 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); +//! println!("price: ({} +- {}) x 10^{}", price.price, price.conf, price.expo); //! ``` -//! -//! -//! -//! - pub use self::price_conf::PriceConf; pub use self::error::PythError; @@ -45,7 +43,7 @@ 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 @@ -56,19 +54,23 @@ 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 @@ -76,7 +78,7 @@ 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 @@ -85,7 +87,7 @@ pub enum PriceType Price } -// solana public key +/// Public key of a Solana account #[derive(Copy, Clone)] #[repr(C)] pub struct AccKey @@ -93,18 +95,24 @@ 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] } @@ -115,17 +123,24 @@ unsafe impl Zeroable for Mapping {} unsafe impl Pod for Mapping {} -// Product account structure +/// 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] } #[cfg(target_endian = "little")] @@ -134,64 +149,100 @@ unsafe impl Zeroable for Product {} #[cfg(target_endian = "little")] unsafe impl Pod for Product {} -// contributing or aggregate price component +/// 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")] @@ -204,7 +255,7 @@ 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) { @@ -220,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. @@ -301,6 +352,7 @@ fn load(data: &[u8]) -> Result<&T, PodCastError> { } } +/** 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)?; @@ -317,6 +369,7 @@ pub fn load_mapping(data: &[u8]) -> Result<&Mapping, PythError> { 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)?; @@ -333,6 +386,7 @@ pub fn load_product(data: &[u8]) -> Result<&Product, PythError> { 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)?; From 37291d3f289bb67d9fa28b948023d6fbb582aaca Mon Sep 17 00:00:00 2001 From: Jayant Krishnamurthy Date: Wed, 29 Dec 2021 12:03:56 -0800 Subject: [PATCH 3/3] fix error docs --- src/error.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/error.rs b/src/error.rs index 35c88b5..79d2460 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,14 +6,15 @@ use thiserror::Error; #[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] pub enum PythError { // 0 - /// Invalid instruction data passed in. - #[error("Failed to unpack instruction data")] + /// Invalid account data -- either insufficient data, or incorrect magic number + #[error("Failed to convert account into a Pyth account")] InvalidAccountData, - /// Invalid instruction data passed in. - #[error("Failed to unpack instruction data")] + /// Wrong version number + #[error("Incorrect version number for Pyth account")] BadVersionNumber, - /// Invalid instruction data passed in. - #[error("Failed to unpack instruction data")] + /// 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, }