diff --git a/Cargo.toml b/Cargo.toml index 69b5b60..17c6e6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,10 +10,23 @@ 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" [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..272606f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pyth-client-rs -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 +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 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. @@ -37,4 +37,4 @@ product_account .. 6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8 publish_slot . 91340925 twap ......... 7426390900 twac ......... 2259870 -``` \ No newline at end of file +``` 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..759cfa8 100644 --- a/examples/get_accounts.rs +++ b/examples/get_accounts.rs @@ -117,9 +117,9 @@ fn main() { 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 +138,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/instruction.rs b/src/instruction.rs new file mode 100644 index 0000000..d04df30 --- /dev/null +++ b/src/instruction.rs @@ -0,0 +1,55 @@ +//! Program instructions, used 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, + }, + /// 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(), + } +} + +/// 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..9dbebb8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,12 @@ +pub use self::price_conf::PriceConf; + +mod entrypoint; +pub mod processor; +pub mod instruction; + +mod price_conf; +solana_program::declare_id!("PythC11111111111111111111111111111111111111"); + pub const MAGIC : u32 = 0xa1b2c3d4; pub const VERSION_2 : u32 = 2; pub const VERSION : u32 = VERSION_2; @@ -134,34 +143,68 @@ pub struct 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. */ - 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_twap() -> Some((123, -2)) // represents 1.23 - * get_twap() -> Some((45, 3)) // represents 45000 - * + * Get the time-weighted average price (TWAP) and a confidence interval on the result. * 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) } } diff --git a/src/price_conf.rs b/src/price_conf.rs new file mode 100644 index 0000000..87f75ad --- /dev/null +++ b/src/price_conf.rs @@ -0,0 +1,590 @@ +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; +const MAX_PD_V_I64: i64 = MAX_PD_V_U64 as i64; +const MIN_PD_V_I64: i64 = -MAX_PD_V_I64; + +/** + * 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 * PD_SCALE / 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 * PD_SCALE) / other_price; + + // first term is 57 bits, second term is 57 + 58 - 29 = 86 bits. Same exponent as the midprice. + let conf = (((base.conf * PD_SCALE) / other_price) as u128) + ((other_confidence_pct as u128) * (midprice as u128)) / (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) * base_sign * 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 * 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 * other_price + other.conf * base_price; + + Some(PriceConf { + price: (midprice as i64) * base_sign * 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 { + let mut p = self.price; + let mut c = self.conf; + let mut e = self.expo; + + while p > MAX_PD_V_I64 || p < MIN_PD_V_I64 || c > MAX_PD_V_U64 { + p = p / 10; + c = c / 10; + e = e.checked_add(1)?; + } + + Some(PriceConf { + price: p, + 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; + while delta > 0 { + p /= 10; + c /= 10; + delta -= 1; + } + + Some(PriceConf { + price: p, + conf: c, + expo: target_expo, + }) + } else { + let mut p = Some(self.price); + let mut c = Some(self.conf); + + while delta < 0 { + p = p?.checked_mul(10); + c = c?.checked_mul(10); + delta += 1; + } + + match (p, c) { + (Some(price), Some(conf)) => + Some(PriceConf { + price, + conf, + expo: target_expo, + }), + (_, _) => None, + } + } + } + + /** + * 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) { + // this check is stricter than necessary. it technically only needs to guard against + // i64::MIN, which can't be negated. However, this method should only be used in the context + // of normalized numbers. + assert!(x <= MAX_PD_V_I64 && x >= MIN_PD_V_I64); + if x < 0 { + (-x as u64, -1) + } else { + (x as u64, 1) + } + } +} + +#[cfg(test)] +mod test { + use crate::price_conf::{MAX_PD_V_U64, MAX_PD_V_I64, MIN_PD_V_I64, PD_EXPO, PD_SCALE, PriceConf}; + + 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..ad74d38 --- /dev/null +++ b/src/processor.rs @@ -0,0 +1,45 @@ +//! Program instruction processor + +use borsh::BorshDeserialize; +use solana_program::{ + account_info::AccountInfo, + entrypoint::ProgramResult, + log::sol_log_compute_units, + msg, + 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 } => { + msg!("Calculating numerator.div(denominator)"); + sol_log_compute_units(); + let result = numerator.div(&denominator); + sol_log_compute_units(); + msg!("result: {:?}", result); + Ok(()) + } + PythClientInstruction::Multiply { x, y } => { + msg!("Calculating numerator.mul(denominator)"); + sol_log_compute_units(); + let result = x.mul(&y); + sol_log_compute_units(); + msg!("result: {:?}", result); + Ok(()) + } + PythClientInstruction::Noop => { + msg!("Do nothing"); + msg!("{}", 0_u64); + Ok(()) + } + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..d5ec2e3 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,65 @@ +use { + borsh::BorshDeserialize, + pyth_client::{id, instruction, PriceConf}, + pyth_client::processor::process_instruction, + solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + }, + solana_program_test::*, + solana_sdk::{signature::Signer, transaction::Transaction}, + std::str::FromStr, +}; + +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(); +} + +#[tokio::test] +async fn test_noop() { + test_instr(instruction::noop()).await; +} + +#[tokio::test] +async fn test_div() { + test_instr(instruction::divide( + PriceConf { + price: i64::MAX, + conf: 1, + expo: 0 + }, + PriceConf { + price: 1, + conf: 1, + expo: 0 + } + )).await; +} + +#[tokio::test] +async fn test_mul() { + test_instr(instruction::multiply( + PriceConf { + price: 100, + conf: 1, + expo: 2 + }, + PriceConf { + price: 123, + conf: 1, + expo: -2 + } + )).await; +}