diff --git a/.gitignore b/.gitignore index d9f7706..7e5781a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ debug target Cargo.lock + +# IntelliJ temp files +.idea +*.iml \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ceb7633..927d905 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyth-client" -version = "0.2.3-beta.0" +version = "0.3.0" authors = ["Richard Brooks"] edition = "2018" license = "Apache-2.0" @@ -10,10 +10,27 @@ description = "pyth price oracle data structures and example usage" keywords = [ "pyth", "solana", "oracle" ] readme = "README.md" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +test-bpf = [] +no-entrypoint = [] + +[dependencies] +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-client = "1.6.7" -solana-sdk = "1.6.7" -solana-program = "1.6.7" +solana-program-test = "1.8.1" +solana-client = "1.8.1" +solana-sdk = "1.8.1" + +[lib] +crate-type = ["cdylib", "lib"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/README.md b/README.md index 09df993..74dabd6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,110 @@ -# pyth-client-rs +# Pyth Client -A rust API for desribing 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 +This crate provides utilities for reading price feeds from the [pyth.network](https://pyth.network/) oracle on the Solana network. +The crate includes a library for on-chain programs and an off-chain example program. +Key features of this library 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. -### Running the Example +Please see the [pyth.network documentation](https://docs.pyth.network/) for more information about pyth.network. + +## Installation + +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. + +## Usage + +Pyth Network stores its price feeds in a collection of Solana accounts. +This crate provides utilities for interpreting and manipulating the content of these accounts. +Applications can obtain the content of these accounts in two different ways: +* On-chain programs should pass these accounts to the instructions that require price feeds. +* Off-chain programs can access these accounts using the Solana RPC client (as in the [example program](examples/get_accounts.rs)). + +In both cases, the content of the account will be provided to the application as a binary blob (`Vec`). +The examples below assume that the user has already obtained this account data. + +### Parse account data + +Pyth Network has several different types of accounts: +* Price accounts store the current price for a product +* Product accounts store metadata about a product, such as its symbol (e.g., "BTC/USD"). +* Mapping accounts store a listing of all Pyth accounts + +For more information on the different types of Pyth accounts, see the [account structure documentation](https://docs.pyth.network/how-pyth-works/account-structure). +The pyth.network website also lists the public keys of the accounts (e.g., [BTC/USD accounts](https://pyth.network/markets/#BTC/USD)). + +This library provides several `load_*` methods that translate the binary data in each account into an appropriate struct: + +```rust +// replace with account data, either passed to on-chain program or from RPC node +let price_account_data: Vec = ...; +let price_account: Price = load_price( &price_account_data ).unwrap(); + +let product_account_data: Vec = ...; +let product_account: Product = load_product( &product_account_data ).unwrap(); + +let mapping_account_data: Vec = ...; +let mapping_account: Mapping = load_mapping( &mapping_account_data ).unwrap(); +``` + +### Get the current price + +Read the current price from a `Price` account: + +```rust +let price: PriceConf = price_account.get_current_price().unwrap(); +println!("price: ({} +- {}) x 10^{}", price.price, price.conf, price.expo); +``` + +The price is returned along with a confidence interval that represents the degree of uncertainty in the price. +Both values are represented as fixed-point numbers, `a * 10^e`. +The method will return `None` if the price is not currently available. + +### Non-USD prices + +Most assets in Pyth are priced in USD. +Applications can combine two USD prices to price an asset in a different quote currency: + +```rust +let btc_usd: Price = ...; +let eth_usd: Price = ...; +// -8 is the desired exponent for the result +let btc_eth: PriceConf = btc_usd.get_price_in_quote(ð_usd, -8); +println!("BTC/ETH price: ({} +- {}) x 10^{}", price.price, price.conf, price.expo); +``` + +### Price a basket of assets + +Applications can also compute the value of a basket of multiple assets: + +```rust +let btc_usd: Price = ...; +let eth_usd: Price = ...; +// Quantity of each asset in fixed-point a * 10^e. +// This represents 0.1 BTC and .05 ETH. +// -8 is desired exponent for result +let basket_price: PriceConf = Price::price_basket(&[ + (btc_usd, 10, -2), + (eth_usd, 5, -2) + ], -8); +println!("0.1 BTC and 0.05 ETH are worth: ({} +- {}) x 10^{} USD", + basket_price.price, basket_price.conf, basket_price.expo); +``` + +This function additionally propagates any uncertainty in the price into uncertainty in the value of the basket. + +### Off-chain example program The example program prints the product reference data and current price information for Pyth on Solana devnet. Run the following commands to try this example program: @@ -37,4 +136,25 @@ product_account .. 6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8 publish_slot . 91340925 twap ......... 7426390900 twac ......... 2259870 -``` \ No newline at end of file +``` + +## 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 + +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/Xargo.toml b/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/examples/get_accounts.rs b/examples/get_accounts.rs index f9e3976..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,20 +92,15 @@ 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(); match maybe_price { - Some((price, confidence, expo)) => { - println!(" price ........ {} x 10^{}", price, expo); - println!(" conf ......... {} x 10^{}", confidence, expo); + Some(p) => { + println!(" price ........ {} x 10^{}", p.price, p.expo); + println!(" conf ......... {} x 10^{}", p.conf, p.expo); } None => { println!(" price ........ unavailable"); @@ -138,16 +119,16 @@ fn main() { let maybe_twap = pa.get_twap(); match maybe_twap { - Some((twap, expo)) => { - println!( " twap ......... {} x 10^{}", twap, expo ); + Some(twap) => { + println!( " twap ......... {} x 10^{}", twap.price, twap.expo ); + println!( " twac ......... {} x 10^{}", twap.conf, twap.expo ); } None => { println!( " twap ......... unavailable"); + println!( " twac ......... unavailable"); } } - println!( " twac ......... {}", pa.twac.val ); - // go to next price account in list if pa.next.is_valid() { px_pkey = Pubkey::new( &pa.next.val ); diff --git a/src/entrypoint.rs b/src/entrypoint.rs new file mode 100644 index 0000000..bac23e4 --- /dev/null +++ b/src/entrypoint.rs @@ -0,0 +1,16 @@ +//! Program entrypoint + +#![cfg(not(feature = "no-entrypoint"))] + +use solana_program::{ + account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, pubkey::Pubkey, +}; + +entrypoint!(process_instruction); +fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + crate::processor::process_instruction(program_id, accounts, instruction_data) +} 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 new file mode 100644 index 0000000..e6718bd --- /dev/null +++ b/src/instruction.rs @@ -0,0 +1,96 @@ +//! Program instructions for end-to-end testing and instruction counts + +use { + crate::id, + borsh::{BorshDeserialize, BorshSerialize}, + solana_program::instruction::Instruction, + crate::PriceConf, +}; + +/// Instructions supported by the pyth-client program, used for testing and +/// instruction counts +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize, PartialEq)] +pub enum PythClientInstruction { + Divide { + numerator: PriceConf, + denominator: PriceConf, + }, + Multiply { + x: PriceConf, + y: PriceConf, + }, + Add { + x: PriceConf, + y: PriceConf, + }, + ScaleToExponent { + x: PriceConf, + expo: i32, + }, + Normalize { + x: PriceConf, + }, + /// Don't do anything for comparison + /// + /// No accounts required for this instruction + Noop, +} + +pub fn divide(numerator: PriceConf, denominator: PriceConf) -> Instruction { + Instruction { + program_id: id(), + accounts: vec![], + data: PythClientInstruction::Divide { numerator, denominator } + .try_to_vec() + .unwrap(), + } +} + +pub fn multiply(x: PriceConf, y: PriceConf) -> Instruction { + Instruction { + program_id: id(), + accounts: vec![], + data: PythClientInstruction::Multiply { x, y } + .try_to_vec() + .unwrap(), + } +} + +pub fn add(x: PriceConf, y: PriceConf) -> Instruction { + Instruction { + program_id: id(), + accounts: vec![], + data: PythClientInstruction::Add { x, y } + .try_to_vec() + .unwrap(), + } +} + +pub fn scale_to_exponent(x: PriceConf, expo: i32) -> Instruction { + Instruction { + program_id: id(), + accounts: vec![], + data: PythClientInstruction::ScaleToExponent { x, expo } + .try_to_vec() + .unwrap(), + } +} + +pub fn normalize(x: PriceConf) -> Instruction { + Instruction { + program_id: id(), + accounts: vec![], + data: PythClientInstruction::Normalize { x } + .try_to_vec() + .unwrap(), + } +} + +/// Noop instruction for comparison purposes +pub fn noop() -> Instruction { + Instruction { + program_id: id(), + accounts: vec![], + data: PythClientInstruction::Noop.try_to_vec().unwrap(), + } +} diff --git a/src/lib.rs b/src/lib.rs index 4ce5cbd..627e148 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,25 @@ +//! A Rust library for consuming price feeds from the [pyth.network](https://pyth.network/) oracle on the Solana network. +//! +//! Please see the [crates.io page](https://crates.io/crates/pyth-client/) for documentation and example usage. + +pub use self::price_conf::PriceConf; +pub use self::error::PythError; + +mod entrypoint; +mod error; +mod price_conf; + +pub mod processor; +pub mod instruction; + +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; pub const VERSION_2 : u32 = 2; pub const VERSION : u32 = VERSION_2; @@ -6,7 +28,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 { @@ -16,25 +39,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 { @@ -42,143 +72,318 @@ 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 triple of the current price, confidence interval, and the exponent for both - * numbers. For example: - * - * get_current_price() -> Some((12345, 267, -2)) // represents 123.45 +- 2.67 - * get_current_price() -> Some((123, 1, 2)) // represents 12300 +- 100 - * - * Returns None if price information is currently unavailable. + * 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<(i64, u64, i32)> { + pub fn get_current_price(&self) -> Option { if !matches!(self.agg.status, PriceStatus::Trading) { None } else { - Some((self.agg.price, self.agg.conf, self.expo)) + Some(PriceConf { + price: self.agg.price, + conf: self.agg.conf, + expo: self.expo + }) } } /** - * Get the time-weighted average price (TWAP) as a fixed point number of the form a * 10^e. - * Returns a tuple of the current twap and its exponent. For example: + * Get the time-weighted average price (TWAP) and a confidence interval on the result. + * Returns `None` if the twap is currently unavailable. * - * get_twap() -> Some((123, -2)) // represents 1.23 - * get_twap() -> Some((45, 3)) // represents 45000 - * - * 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. */ - pub fn get_twap(&self) -> Option<(i64, i32)> { + pub fn get_twap(&self) -> Option { // This method currently cannot return None, but may do so in the future. - Some((self.twap.val, self.expo)) + // Note that the twac is a positive number in i64, so safe to cast to u64. + Some(PriceConf { price: self.twap.val, conf: self.twac.val as u64, expo: self.expo }) + } + + /** + * Get the current price of this account in a different quote currency. If this account + * represents the price of the product X/Z, and `quote` represents the price of the product Y/Z, + * this method returns the price of X/Y. Use this method to get the price of e.g., mSOL/SOL from + * the mSOL/USD and SOL/USD accounts. + * + * `result_expo` determines the exponent of the result, i.e., the number of digits below the decimal + * point. This method returns `None` if either the price or confidence are too large to be + * represented with the requested exponent. + */ + pub fn get_price_in_quote(&self, quote: &Price, result_expo: i32) -> Option { + return match (self.get_current_price(), quote.get_current_price()) { + (Some(base_price_conf), Some(quote_price_conf)) => + base_price_conf.div("e_price_conf)?.scale_to_exponent(result_expo), + (_, _) => None, + } + } + + /** + * Get the price of a basket of currencies. Each entry in `amounts` is of the form + * `(price, qty, qty_expo)`, and the result is the sum of `price * qty * 10^qty_expo`. + * The result is returned with exponent `result_expo`. + * + * An example use case for this function is to get the value of an LP token. + */ + pub fn price_basket(amounts: &[(Price, i64, i32)], result_expo: i32) -> Option { + assert!(amounts.len() > 0); + let mut res = PriceConf { price: 0, conf: 0, expo: result_expo }; + for i in 0..amounts.len() { + res = res.add( + &amounts[i].0.get_current_price()?.cmul(amounts[i].1, amounts[i].2)?.scale_to_exponent(result_expo)? + )? + } + Some(res) } } +#[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/price_conf.rs b/src/price_conf.rs new file mode 100644 index 0000000..04af6f5 --- /dev/null +++ b/src/price_conf.rs @@ -0,0 +1,591 @@ +use { + borsh::{BorshDeserialize, BorshSerialize}, +}; + +// Constants for working with pyth's number representation +const PD_EXPO: i32 = -9; +const PD_SCALE: u64 = 1_000_000_000; +const MAX_PD_V_U64: u64 = (1 << 28) - 1; + +/** + * A price with a degree of uncertainty, represented as a price +- a confidence interval. + * The confidence interval roughly corresponds to the standard error of a normal distribution. + * Both the price and confidence are stored in a fixed-point numeric representation, `x * 10^expo`, + * where `expo` is the exponent. For example: + * + * ``` + * use pyth_client::PriceConf; + * PriceConf { price: 12345, conf: 267, expo: -2 }; // represents 123.45 +- 2.67 + * PriceConf { price: 123, conf: 1, expo: 2 }; // represents 12300 +- 100 + * ``` + * + * `PriceConf` supports a limited set of mathematical operations. All of these operations will + * propagate any uncertainty in the arguments into the result. However, the uncertainty in the + * result may overestimate the true uncertainty (by at most a factor of `sqrt(2)`) due to + * computational limitations. Furthermore, all of these operations may return `None` if their + * result cannot be represented within the numeric representation (e.g., the exponent is so + * small that the price does not fit into an i64). Users of these methods should (1) select + * their exponents to avoid this problem, and (2) handle the `None` case gracefully. + */ +#[derive(PartialEq, Debug, BorshSerialize, BorshDeserialize, Clone)] +pub struct PriceConf { + pub price: i64, + pub conf: u64, + pub expo: i32, +} + +impl PriceConf { + /** + * Divide this price by `other` while propagating the uncertainty in both prices into the result. + * + * This method will automatically select a reasonable exponent for the result. If both + * `self` and `other` are normalized, the exponent is `self.expo + PD_EXPO - other.expo` (i.e., + * the fraction has `PD_EXPO` digits of additional precision). If they are not normalized, + * this method will normalize them, resulting in an unpredictable result exponent. + * If the result is used in a context that requires a specific exponent, please call + * `scale_to_exponent` on it. + */ + pub fn div(&self, other: &PriceConf) -> Option { + // PriceConf is not guaranteed to store its price/confidence in normalized form. + // Normalize them here to bound the range of price/conf, which is required to perform + // arithmetic operations. + let base = self.normalize()?; + let other = other.normalize()?; + + if other.price == 0 { + return None; + } + + // These use at most 27 bits each + let (base_price, base_sign) = PriceConf::to_unsigned(base.price); + let (other_price, other_sign) = PriceConf::to_unsigned(other.price); + + // Compute the midprice, base in terms of other. + // Uses at most 57 bits + let midprice = base_price.checked_mul(PD_SCALE)?.checked_div(other_price)?; + let midprice_expo = base.expo.checked_sub(other.expo)?.checked_add(PD_EXPO)?; + + // Compute the confidence interval. + // This code uses the 1-norm instead of the 2-norm for computational reasons. + // Let p +- a and q +- b be the two arguments to this method. The correct + // formula is p/q * sqrt( (a/p)^2 + (b/q)^2 ). This quantity + // is difficult to compute due to the sqrt and overflow/underflow considerations. + // + // This code instead computes p/q * (a/p + b/q) = a/q + pb/q^2 . + // This quantity is at most a factor of sqrt(2) greater than the correct result, which + // shouldn't matter considering that confidence intervals are typically ~0.1% of the price. + + // This uses 57 bits and has an exponent of PD_EXPO. + let other_confidence_pct: u64 = other.conf.checked_mul(PD_SCALE)?.checked_div(other_price)?; + + // first term is 57 bits, second term is 57 + 58 - 29 = 86 bits. Same exponent as the midprice. + // Note: the computation of the 2nd term consumes about 3k ops. We may want to optimize this. + let conf = (base.conf.checked_mul(PD_SCALE)?.checked_div(other_price)? as u128).checked_add( + (other_confidence_pct as u128).checked_mul(midprice as u128)?.checked_div(PD_SCALE as u128)?)?; + + // Note that this check only fails if an argument's confidence interval was >> its price, + // in which case None is a reasonable result, as we have essentially 0 information about the price. + if conf < (u64::MAX as u128) { + Some(PriceConf { + price: (midprice as i64).checked_mul(base_sign)?.checked_mul(other_sign)?, + conf: conf as u64, + expo: midprice_expo, + }) + } else { + None + } + } + + /** + * Add `other` to this, propagating uncertainty in both prices. Requires both + * `PriceConf`s to have the same exponent -- use `scale_to_exponent` on the arguments + * if necessary. + * + * TODO: could generalize this method to support different exponents. + */ + pub fn add(&self, other: &PriceConf) -> Option { + assert_eq!(self.expo, other.expo); + + let price = self.price.checked_add(other.price)?; + // The conf should technically be sqrt(a^2 + b^2), but that's harder to compute. + let conf = self.conf.checked_add(other.conf)?; + Some(PriceConf { + price, + conf, + expo: self.expo, + }) + } + + /** Multiply this `PriceConf` by a constant `c * 10^e`. */ + pub fn cmul(&self, c: i64, e: i32) -> Option { + self.mul(&PriceConf { price: c, conf: 0, expo: e }) + } + + /** Multiply this `PriceConf` by `other`, propagating any uncertainty. */ + pub fn mul(&self, other: &PriceConf) -> Option { + // PriceConf is not guaranteed to store its price/confidence in normalized form. + // Normalize them here to bound the range of price/conf, which is required to perform + // arithmetic operations. + let base = self.normalize()?; + let other = other.normalize()?; + + // These use at most 27 bits each + let (base_price, base_sign) = PriceConf::to_unsigned(base.price); + let (other_price, other_sign) = PriceConf::to_unsigned(other.price); + + // Uses at most 27*2 = 54 bits + let midprice = base_price.checked_mul(other_price)?; + let midprice_expo = base.expo.checked_add(other.expo)?; + + // Compute the confidence interval. + // This code uses the 1-norm instead of the 2-norm for computational reasons. + // Note that this simplifies: pq * (a/p + b/q) = qa + pb + // 27*2 + 1 bits + let conf = base.conf.checked_mul(other_price)?.checked_add(other.conf.checked_mul(base_price)?)?; + + Some(PriceConf { + price: (midprice as i64).checked_mul(base_sign)?.checked_mul(other_sign)?, + conf, + expo: midprice_expo, + }) + } + + /** + * Get a copy of this struct where the price and confidence + * have been normalized to be between `MIN_PD_V_I64` and `MAX_PD_V_I64`. + */ + pub fn normalize(&self) -> Option { + // signed division is very expensive in op count + let (mut p, s) = PriceConf::to_unsigned(self.price); + let mut c = self.conf; + let mut e = self.expo; + + while p > MAX_PD_V_U64 || c > MAX_PD_V_U64 { + p = p.checked_div(10)?; + c = c.checked_div(10)?; + e = e.checked_add(1)?; + } + + Some(PriceConf { + price: (p as i64).checked_mul(s)?, + conf: c, + expo: e, + }) + } + + /** + * Scale this price/confidence so that its exponent is `target_expo`. Return `None` if + * this number is outside the range of numbers representable in `target_expo`, which will + * happen if `target_expo` is too small. + * + * Warning: if `target_expo` is significantly larger than the current exponent, this function + * will return 0 +- 0. + */ + pub fn scale_to_exponent( + &self, + target_expo: i32, + ) -> Option { + let mut delta = target_expo.checked_sub(self.expo)?; + if delta >= 0 { + let mut p = self.price; + let mut c = self.conf; + // 2nd term is a short-circuit to bound op consumption + while delta > 0 && (p != 0 || c != 0) { + p = p.checked_div(10)?; + c = c.checked_div(10)?; + delta = delta.checked_sub(1)?; + } + + Some(PriceConf { + price: p, + conf: c, + expo: target_expo, + }) + } else { + let mut p = self.price; + let mut c = self.conf; + + // Either p or c == None will short-circuit to bound op consumption + while delta < 0 { + p = p.checked_mul(10)?; + c = c.checked_mul(10)?; + delta = delta.checked_add(1)?; + } + + Some(PriceConf { + price: p, + conf: c, + expo: target_expo, + }) + } + } + + /** + * Helper function to convert signed integers to unsigned and a sign bit, which simplifies + * some of the computations above. + */ + fn to_unsigned(x: i64) -> (u64, i64) { + if x == i64::MIN { + // special case because i64::MIN == -i64::MIN + (i64::MAX as u64 + 1, -1) + } else if x < 0 { + (-x as u64, -1) + } else { + (x as u64, 1) + } + } +} + +#[cfg(test)] +mod test { + use crate::price_conf::{MAX_PD_V_U64, PD_EXPO, PD_SCALE, PriceConf}; + + const MAX_PD_V_I64: i64 = MAX_PD_V_U64 as i64; + const MIN_PD_V_I64: i64 = -MAX_PD_V_I64; + + fn pc(price: i64, conf: u64, expo: i32) -> PriceConf { + PriceConf { + price: price, + conf: conf, + expo: expo, + } + } + + fn pc_scaled(price: i64, conf: u64, cur_expo: i32, expo: i32) -> PriceConf { + PriceConf { + price: price, + conf: conf, + expo: cur_expo, + }.scale_to_exponent(expo).unwrap() + } + + #[test] + fn test_normalize() { + fn succeeds( + price1: PriceConf, + expected: PriceConf, + ) { + assert_eq!(price1.normalize().unwrap(), expected); + } + + fn fails( + price1: PriceConf, + ) { + assert_eq!(price1.normalize(), None); + } + + succeeds( + pc(2 * (PD_SCALE as i64), 3 * PD_SCALE, 0), + pc(2 * (PD_SCALE as i64) / 100, 3 * PD_SCALE / 100, 2) + ); + + succeeds( + pc(-2 * (PD_SCALE as i64), 3 * PD_SCALE, 0), + pc(-2 * (PD_SCALE as i64) / 100, 3 * PD_SCALE / 100, 2) + ); + + // the i64 / u64 max values are a factor of 10^11 larger than MAX_PD_V + let expo = -(PD_EXPO - 2); + let scale_i64 = (PD_SCALE as i64) * 100; + let scale_u64 = scale_i64 as u64; + succeeds(pc(i64::MAX, 1, 0), pc(i64::MAX / scale_i64, 0, expo)); + succeeds(pc(i64::MIN, 1, 0), pc(i64::MIN / scale_i64, 0, expo)); + succeeds(pc(1, u64::MAX, 0), pc(0, u64::MAX / scale_u64, expo)); + + // exponent overflows + succeeds(pc(i64::MAX, 1, i32::MAX - expo), pc(i64::MAX / scale_i64, 0, i32::MAX)); + fails(pc(i64::MAX, 1, i32::MAX - expo + 1)); + succeeds(pc(i64::MAX, 1, i32::MIN), pc(i64::MAX / scale_i64, 0, i32::MIN + expo)); + + succeeds(pc(1, u64::MAX, i32::MAX - expo), pc(0, u64::MAX / scale_u64, i32::MAX)); + fails(pc(1, u64::MAX, i32::MAX - expo + 1)); + } + + #[test] + fn test_scale_to_exponent() { + fn succeeds( + price1: PriceConf, + target: i32, + expected: PriceConf, + ) { + assert_eq!(price1.scale_to_exponent(target).unwrap(), expected); + } + + fn fails( + price1: PriceConf, + target: i32, + ) { + assert_eq!(price1.scale_to_exponent(target), None); + } + + succeeds(pc(1234, 1234, 0), 0, pc(1234, 1234, 0)); + succeeds(pc(1234, 1234, 0), 1, pc(123, 123, 1)); + succeeds(pc(1234, 1234, 0), 2, pc(12, 12, 2)); + succeeds(pc(-1234, 1234, 0), 2, pc(-12, 12, 2)); + succeeds(pc(1234, 1234, 0), 4, pc(0, 0, 4)); + succeeds(pc(1234, 1234, 0), -1, pc(12340, 12340, -1)); + succeeds(pc(1234, 1234, 0), -2, pc(123400, 123400, -2)); + succeeds(pc(1234, 1234, 0), -8, pc(123400000000, 123400000000, -8)); + // insufficient precision to represent the result in this exponent + fails(pc(1234, 1234, 0), -20); + fails(pc(1234, 0, 0), -20); + fails(pc(0, 1234, 0), -20); + + // fails because exponent delta overflows + fails(pc(1, 1, i32::MIN), i32::MAX); + } + + #[test] + fn test_div() { + fn succeeds( + price1: PriceConf, + price2: PriceConf, + expected: PriceConf, + ) { + assert_eq!(price1.div(&price2).unwrap(), expected); + } + + fn fails( + price1: PriceConf, + price2: PriceConf, + ) { + let result = price1.div(&price2); + assert_eq!(result, None); + } + + succeeds(pc(1, 1, 0), pc(1, 1, 0), pc_scaled(1, 2, 0, PD_EXPO)); + succeeds(pc(1, 1, -8), pc(1, 1, -8), pc_scaled(1, 2, 0, PD_EXPO)); + succeeds(pc(10, 1, 0), pc(1, 1, 0), pc_scaled(10, 11, 0, PD_EXPO)); + succeeds(pc(1, 1, 1), pc(1, 1, 0), pc_scaled(10, 20, 0, PD_EXPO + 1)); + succeeds(pc(1, 1, 0), pc(5, 1, 0), pc_scaled(20, 24, -2, PD_EXPO)); + + // Negative numbers + succeeds(pc(-1, 1, 0), pc(1, 1, 0), pc_scaled(-1, 2, 0, PD_EXPO)); + succeeds(pc(1, 1, 0), pc(-1, 1, 0), pc_scaled(-1, 2, 0, PD_EXPO)); + succeeds(pc(-1, 1, 0), pc(-1, 1, 0), pc_scaled(1, 2, 0, PD_EXPO)); + + // Different exponents in the two inputs + succeeds(pc(100, 10, -8), pc(2, 1, -7), pc_scaled(500_000_000, 300_000_000, -8, PD_EXPO - 1)); + succeeds(pc(100, 10, -4), pc(2, 1, 0), pc_scaled(500_000, 300_000, -8, PD_EXPO + -4)); + + // Test with end range of possible inputs where the output should not lose precision. + succeeds(pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), pc_scaled(1, 2, 0, PD_EXPO)); + succeeds(pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), pc(1, 1, 0), pc_scaled(MAX_PD_V_I64, 2 * MAX_PD_V_U64, 0, PD_EXPO)); + succeeds(pc(1, 1, 0), + pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), + pc((PD_SCALE as i64) / MAX_PD_V_I64, 2 * (PD_SCALE / MAX_PD_V_U64), PD_EXPO)); + + succeeds(pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), pc_scaled(1, 2, 0, PD_EXPO)); + succeeds(pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), pc(1, 1, 0), pc_scaled(MIN_PD_V_I64, 2 * MAX_PD_V_U64, 0, PD_EXPO)); + succeeds(pc(1, 1, 0), + pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), + pc((PD_SCALE as i64) / MIN_PD_V_I64, 2 * (PD_SCALE / MAX_PD_V_U64), PD_EXPO)); + + succeeds(pc(1, MAX_PD_V_U64, 0), pc(1, MAX_PD_V_U64, 0), pc_scaled(1, 2 * MAX_PD_V_U64, 0, PD_EXPO)); + // This fails because the confidence interval is too large to be represented in PD_EXPO + fails(pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), pc(1, MAX_PD_V_U64, 0)); + + // Unnormalized tests below here + + // More realistic inputs (get BTC price in ETH) + let ten_e7: i64 = 10000000; + let uten_e7: u64 = 10000000; + succeeds(pc(520010 * ten_e7, 310 * uten_e7, -8), + pc(38591 * ten_e7, 18 * uten_e7, -8), + pc(1347490347, 1431804, -8)); + + // Test with end range of possible inputs to identify overflow + // These inputs will lose precision due to the initial normalization. + // Get the rounded versions of these inputs in order to compute the expected results. + let normed = pc(i64::MAX, u64::MAX, 0).normalize().unwrap(); + + succeeds(pc(i64::MAX, u64::MAX, 0), pc(i64::MAX, u64::MAX, 0), pc_scaled(1, 4, 0, PD_EXPO)); + succeeds(pc(i64::MAX, u64::MAX, 0), + pc(1, 1, 0), + pc_scaled(normed.price, 3 * (normed.price as u64), normed.expo, normed.expo + PD_EXPO)); + succeeds(pc(1, 1, 0), + pc(i64::MAX, u64::MAX, 0), + pc((PD_SCALE as i64) / normed.price, 3 * (PD_SCALE / (normed.price as u64)), PD_EXPO - normed.expo)); + + succeeds(pc(i64::MAX, 1, 0), pc(i64::MAX, 1, 0), pc_scaled(1, 0, 0, PD_EXPO)); + succeeds(pc(i64::MAX, 1, 0), + pc(1, 1, 0), + pc_scaled(normed.price, normed.price as u64, normed.expo, normed.expo + PD_EXPO)); + succeeds(pc(1, 1, 0), + pc(i64::MAX, 1, 0), + pc((PD_SCALE as i64) / normed.price, PD_SCALE / (normed.price as u64), PD_EXPO - normed.expo)); + + let normed = pc(i64::MIN, u64::MAX, 0).normalize().unwrap(); + let normed_c = (-normed.price) as u64; + + succeeds(pc(i64::MIN, u64::MAX, 0), pc(i64::MIN, u64::MAX, 0), pc_scaled(1, 4, 0, PD_EXPO)); + succeeds(pc(i64::MIN, u64::MAX, 0), pc(i64::MAX, u64::MAX, 0), pc_scaled(-1, 4, 0, PD_EXPO)); + succeeds(pc(i64::MIN, u64::MAX, 0), + pc(1, 1, 0), + pc_scaled(normed.price, 3 * normed_c, normed.expo, normed.expo + PD_EXPO)); + succeeds(pc(1, 1, 0), + pc(i64::MIN, u64::MAX, 0), + pc((PD_SCALE as i64) / normed.price, 3 * (PD_SCALE / normed_c), PD_EXPO - normed.expo)); + + succeeds(pc(i64::MIN, 1, 0), pc(i64::MIN, 1, 0), pc_scaled(1, 0, 0, PD_EXPO)); + succeeds(pc(i64::MIN, 1, 0), + pc(1, 1, 0), + pc_scaled(normed.price, normed_c, normed.expo, normed.expo + PD_EXPO)); + succeeds(pc(1, 1, 0), + pc(i64::MIN, 1, 0), + pc((PD_SCALE as i64) / normed.price, PD_SCALE / (normed_c), PD_EXPO - normed.expo)); + + // Price is zero pre-normalization + succeeds(pc(0, 1, 0), pc(1, 1, 0), pc_scaled(0, 1, 0, PD_EXPO)); + succeeds(pc(0, 1, 0), pc(100, 1, 0), pc_scaled(0, 1, -2, PD_EXPO)); + fails(pc(1, 1, 0), pc(0, 1, 0)); + + // Normalizing the input when the confidence is >> price produces a price of 0. + fails(pc(1, 1, 0), pc(1, u64::MAX, 0)); + succeeds( + pc(1, u64::MAX, 0), + pc(1, 1, 0), + pc_scaled(0, normed.conf, normed.expo, normed.expo + PD_EXPO) + ); + + // Exponent under/overflow. + succeeds(pc(1, 1, i32::MAX), pc(1, 1, 0), pc(PD_SCALE as i64, 2 * PD_SCALE, i32::MAX + PD_EXPO)); + fails(pc(1, 1, i32::MAX), pc(1, 1, -1)); + + succeeds(pc(1, 1, i32::MIN - PD_EXPO), pc(1, 1, 0), pc(PD_SCALE as i64, 2 * PD_SCALE, i32::MIN)); + succeeds(pc(1, 1, i32::MIN), pc(1, 1, PD_EXPO), pc(PD_SCALE as i64, 2 * PD_SCALE, i32::MIN)); + fails(pc(1, 1, i32::MIN - PD_EXPO), pc(1, 1, 1)); + } + + #[test] + fn test_mul() { + fn succeeds( + price1: PriceConf, + price2: PriceConf, + expected: PriceConf, + ) { + assert_eq!(price1.mul(&price2).unwrap(), expected); + } + + fn fails( + price1: PriceConf, + price2: PriceConf, + ) { + let result = price1.mul(&price2); + assert_eq!(result, None); + } + + succeeds(pc(1, 1, 0), pc(1, 1, 0), pc(1, 2, 0)); + succeeds(pc(1, 1, -8), pc(1, 1, -8), pc(1, 2, -16)); + succeeds(pc(10, 1, 0), pc(1, 1, 0), pc(10, 11, 0)); + succeeds(pc(1, 1, 1), pc(1, 1, 0), pc(1, 2, 1)); + succeeds(pc(1, 1, 0), pc(5, 1, 0), pc(5, 6, 0)); + + // Different exponents in the two inputs + succeeds(pc(100, 10, -8), pc(2, 1, -7), pc(200, 120, -15)); + succeeds(pc(100, 10, -4), pc(2, 1, 0), pc(200, 120, -4)); + + // Zero + succeeds(pc(0, 10, -4), pc(2, 1, 0), pc(0, 20, -4)); + succeeds(pc(2, 1, 0), pc(0, 10, -4), pc(0, 20, -4)); + + // Test with end range of possible inputs where the output should not lose precision. + succeeds( + pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), + pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), + pc(MAX_PD_V_I64 * MAX_PD_V_I64, 2 * MAX_PD_V_U64 * MAX_PD_V_U64, 0) + ); + succeeds(pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), pc(1, 1, 0), pc(MAX_PD_V_I64, 2 * MAX_PD_V_U64, 0)); + succeeds( + pc(1, MAX_PD_V_U64, 0), + pc(3, 1, 0), + pc(3, 1 + 3 * MAX_PD_V_U64, 0) + ); + + succeeds(pc(1, MAX_PD_V_U64, 0), pc(1, MAX_PD_V_U64, 0), pc(1, 2 * MAX_PD_V_U64, 0)); + succeeds( + pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), + pc(1, MAX_PD_V_U64, 0), + pc(MAX_PD_V_I64, MAX_PD_V_U64 + MAX_PD_V_U64 * MAX_PD_V_U64, 0) + ); + + succeeds( + pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), + pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), + pc(MIN_PD_V_I64 * MIN_PD_V_I64, 2 * MAX_PD_V_U64 * MAX_PD_V_U64, 0) + ); + succeeds( + pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), + pc(MAX_PD_V_I64, MAX_PD_V_U64, 0), + pc(MIN_PD_V_I64 * MAX_PD_V_I64, 2 * MAX_PD_V_U64 * MAX_PD_V_U64, 0) + ); + succeeds(pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), pc(1, 1, 0), pc(MIN_PD_V_I64, 2 * MAX_PD_V_U64, 0)); + succeeds( + pc(MIN_PD_V_I64, MAX_PD_V_U64, 0), + pc(1, MAX_PD_V_U64, 0), + pc(MIN_PD_V_I64, MAX_PD_V_U64 + MAX_PD_V_U64 * MAX_PD_V_U64, 0) + ); + + // Unnormalized tests below here + let ten_e7: i64 = 10000000; + let uten_e7: u64 = 10000000; + succeeds( + pc(3 * (PD_SCALE as i64), 3 * PD_SCALE, PD_EXPO), + pc(2 * (PD_SCALE as i64), 4 * PD_SCALE, PD_EXPO), + pc(6 * ten_e7 * ten_e7, 18 * uten_e7 * uten_e7, -14) + ); + + // Test with end range of possible inputs to identify overflow + // These inputs will lose precision due to the initial normalization. + // Get the rounded versions of these inputs in order to compute the expected results. + let normed = pc(i64::MAX, u64::MAX, 0).normalize().unwrap(); + + succeeds( + pc(i64::MAX, u64::MAX, 0), + pc(i64::MAX, u64::MAX, 0), + pc(normed.price * normed.price, 4 * ((normed.price * normed.price) as u64), normed.expo * 2) + ); + succeeds(pc(i64::MAX, u64::MAX, 0), + pc(1, 1, 0), + pc(normed.price, 3 * (normed.price as u64), normed.expo)); + + succeeds( + pc(i64::MAX, 1, 0), + pc(i64::MAX, 1, 0), + pc(normed.price * normed.price, 0, normed.expo * 2) + ); + succeeds(pc(i64::MAX, 1, 0), + pc(1, 1, 0), + pc(normed.price, normed.price as u64, normed.expo)); + + let normed = pc(i64::MIN, u64::MAX, 0).normalize().unwrap(); + let normed_c = (-normed.price) as u64; + + succeeds( + pc(i64::MIN, u64::MAX, 0), + pc(i64::MIN, u64::MAX, 0), + pc(normed.price * normed.price, 4 * (normed_c * normed_c), normed.expo * 2) + ); + succeeds(pc(i64::MIN, u64::MAX, 0), + pc(1, 1, 0), + pc(normed.price, 3 * normed_c, normed.expo)); + + succeeds( + pc(i64::MIN, 1, 0), + pc(i64::MIN, 1, 0), + pc(normed.price * normed.price, 0, normed.expo * 2) + ); + succeeds(pc(i64::MIN, 1, 0), + pc(1, 1, 0), + pc(normed.price, normed_c, normed.expo)); + + // Exponent under/overflow. + succeeds(pc(1, 1, i32::MAX), pc(1, 1, 0), pc(1, 2, i32::MAX)); + succeeds(pc(1, 1, i32::MAX), pc(1, 1, -1), pc(1, 2, i32::MAX - 1)); + fails(pc(1, 1, i32::MAX), pc(1, 1, 1)); + + succeeds(pc(1, 1, i32::MIN), pc(1, 1, 0), pc(1, 2, i32::MIN)); + succeeds(pc(1, 1, i32::MIN), pc(1, 1, 1), pc(1, 2, i32::MIN + 1)); + fails(pc(1, 1, i32::MIN), pc(1, 1, -1)); + } +} diff --git a/src/processor.rs b/src/processor.rs new file mode 100644 index 0000000..97e44e3 --- /dev/null +++ b/src/processor.rs @@ -0,0 +1,45 @@ +//! Program instruction processor for end-to-end testing and instruction counts + +use borsh::BorshDeserialize; +use solana_program::{ + account_info::AccountInfo, + entrypoint::ProgramResult, + pubkey::Pubkey, +}; + +use crate::{ + instruction::PythClientInstruction, +}; + +pub fn process_instruction( + _program_id: &Pubkey, + _accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + let instruction = PythClientInstruction::try_from_slice(input).unwrap(); + match instruction { + PythClientInstruction::Divide { numerator, denominator } => { + numerator.div(&denominator); + Ok(()) + } + PythClientInstruction::Multiply { x, y } => { + x.mul(&y); + Ok(()) + } + PythClientInstruction::Add { x, y } => { + x.add(&y); + Ok(()) + } + PythClientInstruction::Normalize { x } => { + x.normalize(); + Ok(()) + } + PythClientInstruction::ScaleToExponent { x, expo } => { + x.scale_to_exponent(expo); + Ok(()) + } + PythClientInstruction::Noop => { + Ok(()) + } + } +} diff --git a/tests/instruction_count.rs b/tests/instruction_count.rs new file mode 100644 index 0000000..f730264 --- /dev/null +++ b/tests/instruction_count.rs @@ -0,0 +1,106 @@ +use { + pyth_client::{id, instruction, PriceConf}, + pyth_client::processor::process_instruction, + solana_program::{ + instruction::Instruction, + pubkey::Pubkey, + }, + 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(); +} + +fn pc(price: i64, conf: u64, expo: i32) -> PriceConf { + PriceConf { + price: price, + conf: conf, + expo: expo, + } +} + +#[tokio::test] +async fn test_noop() { + test_instr(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 +} + +#[tokio::test] +async fn test_scale_to_exponent_up() { + test_instr(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 +} + +#[tokio::test] +async fn test_normalize_max_conf() { + test_instr(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 +} + +#[tokio::test] +async fn test_normalize_min_price() { + test_instr(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 +} + +#[tokio::test] +async fn test_div_max_price() { + test_instr(instruction::divide( + pc(i64::MAX, 1, 0), + pc(1, 1, 0) + )).await; +} + +#[tokio::test] +async fn test_div_max_price_2() { + test_instr(instruction::divide( + pc(i64::MAX, 1, 0), + pc(i64::MAX, 1, 0) + )).await; +} + +#[tokio::test] +async fn test_mul_max_price() { + test_instr(instruction::multiply( + pc(i64::MAX, 1, 2), + pc(123, 1, 2), + )).await; +} + +#[tokio::test] +async fn test_mul_max_price_2() { + test_instr(instruction::multiply( + pc(i64::MAX, 1, 2), + pc(i64::MAX, 1, 2), + )).await; +}