diff --git a/Cargo.lock b/Cargo.lock index 89f5a3ebcfe..9fcd9fc0f71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -865,6 +865,12 @@ dependencies = [ "syn 0.15.44", ] +[[package]] +name = "dyn-clone" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f94fa09c2aeea5b8839e414b7b841bf429fd25b9c522116ac97ee87856d88b2" + [[package]] name = "ed25519" version = "1.5.2" @@ -1280,6 +1286,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hidapi" @@ -2039,6 +2048,36 @@ dependencies = [ "tempfile", ] +[[package]] +name = "pyth-sdk" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bf2540203ca3c7a5712fdb8b5897534b7f6a0b6e7b0923ff00466c5f9efcb3" +dependencies = [ + "borsh", + "borsh-derive", + "hex", + "schemars", + "serde", +] + +[[package]] +name = "pyth-sdk-solana" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c637b4d8e9558e596a13cd5a11e406431c80eed4fbed01e4b1ff474257b04db9" +dependencies = [ + "borsh", + "borsh-derive", + "bytemuck", + "num-derive", + "num-traits", + "pyth-sdk", + "serde", + "solana-program", + "thiserror", +] + [[package]] name = "qstring" version = "0.7.2" @@ -2407,6 +2446,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a5fb6c61f29e723026dc8e923d94c694313212abbecbbe5f55a7748eec5b307" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f188d036977451159430f3b8dc82ec76364a42b7e289c2b18a9a18f4470058e9" +dependencies = [ + "proc-macro2 1.0.42", + "quote 1.0.20", + "serde_derive_internals", + "syn 1.0.98", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -2478,6 +2541,17 @@ dependencies = [ "syn 1.0.98", ] +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2 1.0.42", + "quote 1.0.20", + "syn 1.0.98", +] + [[package]] name = "serde_json" version = "1.0.82" @@ -3345,12 +3419,14 @@ dependencies = [ "num-derive", "num-traits", "proptest", + "pyth-sdk-solana", "serde", "serde_yaml", "solana-program", "solana-program-test", "solana-sdk", "spl-token", + "static_assertions", "switchboard-program", "switchboard-v2", "thiserror", diff --git a/token-lending/program/Cargo.toml b/token-lending/program/Cargo.toml index 7af701018f5..95e86d3052b 100644 --- a/token-lending/program/Cargo.toml +++ b/token-lending/program/Cargo.toml @@ -16,8 +16,10 @@ arrayref = "0.3.6" bytemuck = "1.5.1" num-derive = "0.3" num-traits = "0.2" +pyth-sdk-solana = "0.7.0" solana-program = "=1.9.18" spl-token = { version = "3.2.0", features=["no-entrypoint"] } +static_assertions = "1.1.0" switchboard-program = "0.2.0" switchboard-v2 = "0.1.3" thiserror = "1.0" diff --git a/token-lending/program/src/lib.rs b/token-lending/program/src/lib.rs index d9d1f5265bc..c74299fdf28 100644 --- a/token-lending/program/src/lib.rs +++ b/token-lending/program/src/lib.rs @@ -6,8 +6,8 @@ pub mod entrypoint; pub mod error; pub mod instruction; pub mod math; +pub mod oracles; pub mod processor; -pub mod pyth; pub mod state; // Export current sdk types for downstream users building with a different sdk version diff --git a/token-lending/program/src/oracles.rs b/token-lending/program/src/oracles.rs new file mode 100644 index 00000000000..ed5113bbca0 --- /dev/null +++ b/token-lending/program/src/oracles.rs @@ -0,0 +1,349 @@ +#![allow(missing_docs)] +use crate::{ + self as solend_program, + error::LendingError, + math::{Decimal, TryDiv, TryMul}, +}; +use pyth_sdk_solana; +use solana_program::{ + account_info::AccountInfo, msg, program_error::ProgramError, sysvar::clock::Clock, +}; +use std::{convert::TryInto, result::Result}; + +pub fn get_pyth_price( + pyth_price_info: &AccountInfo, + clock: &Clock, +) -> Result { + const PYTH_CONFIDENCE_RATIO: u64 = 10; + const STALE_AFTER_SLOTS_ELAPSED: u64 = 240; // roughly 2 min + + if *pyth_price_info.key == solend_program::NULL_PUBKEY { + return Err(LendingError::NullOracleConfig.into()); + } + + let data = &pyth_price_info.try_borrow_data()?; + let price_account = pyth_sdk_solana::state::load_price_account(data).map_err(|e| { + msg!("Couldn't load price feed from account info: {:?}", e); + LendingError::InvalidOracleConfig + })?; + let pyth_price = price_account + .get_price_no_older_than(clock, STALE_AFTER_SLOTS_ELAPSED) + .ok_or_else(|| { + msg!("Pyth oracle price is too stale!"); + LendingError::InvalidOracleConfig + })?; + + let price: u64 = pyth_price.price.try_into().map_err(|_| { + msg!("Oracle price cannot be negative"); + LendingError::InvalidOracleConfig + })?; + + // Perhaps confidence_ratio should exist as a per reserve config + // 100/confidence_ratio = maximum size of confidence range as a percent of price + // confidence_ratio of 10 filters out pyth prices with conf > 10% of price + if pyth_price.conf.saturating_mul(PYTH_CONFIDENCE_RATIO) > price { + msg!( + "Oracle price confidence is too wide. price: {}, conf: {}", + price, + pyth_price.conf, + ); + return Err(LendingError::InvalidOracleConfig.into()); + } + + let market_price = if pyth_price.expo >= 0 { + let exponent = pyth_price + .expo + .try_into() + .map_err(|_| LendingError::MathOverflow)?; + let zeros = 10u64 + .checked_pow(exponent) + .ok_or(LendingError::MathOverflow)?; + Decimal::from(price).try_mul(zeros)? + } else { + let exponent = pyth_price + .expo + .checked_abs() + .ok_or(LendingError::MathOverflow)? + .try_into() + .map_err(|_| LendingError::MathOverflow)?; + let decimals = 10u64 + .checked_pow(exponent) + .ok_or(LendingError::MathOverflow)?; + Decimal::from(price).try_div(decimals)? + }; + + Ok(market_price) +} + +#[cfg(test)] +mod test { + use super::*; + use bytemuck::bytes_of_mut; + use proptest::prelude::*; + use pyth_sdk_solana::state::{ + AccountType, CorpAction, PriceAccount, PriceInfo, PriceStatus, PriceType, MAGIC, VERSION_2, + }; + use solana_program::pubkey::Pubkey; + + #[derive(Clone, Debug)] + struct PythPriceTestCase { + price_account: PriceAccount, + clock: Clock, + expected_result: Result, + } + + fn pyth_price_cases() -> impl Strategy { + prop_oneof![ + // case 2: failure. bad magic value + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC + 1, + ver: VERSION_2, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 10, + agg: PriceInfo { + price: 10, + conf: 1, + status: PriceStatus::Trading, + corp_act: CorpAction::NoCorpAct, + pub_slot: 0 + }, + ..PriceAccount::default() + }, + clock: Clock { + slot: 4, + ..Clock::default() + }, + // PythError::InvalidAccountData. + expected_result: Err(LendingError::InvalidOracleConfig.into()), + }), + // case 3: failure. bad version number + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2 - 1, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 10, + agg: PriceInfo { + price: 10, + conf: 1, + status: PriceStatus::Trading, + corp_act: CorpAction::NoCorpAct, + pub_slot: 0 + }, + ..PriceAccount::default() + }, + clock: Clock { + slot: 4, + ..Clock::default() + }, + expected_result: Err(LendingError::InvalidOracleConfig.into()), + }), + // case 4: failure. bad account type + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Product as u32, + ptype: PriceType::Price, + expo: 10, + agg: PriceInfo { + price: 10, + conf: 1, + status: PriceStatus::Trading, + corp_act: CorpAction::NoCorpAct, + pub_slot: 0 + }, + ..PriceAccount::default() + }, + clock: Clock { + slot: 4, + ..Clock::default() + }, + expected_result: Err(LendingError::InvalidOracleConfig.into()), + }), + // case 5: ignore. bad price type is fine. not testing this + // case 6: success. most recent price has status == trading, not stale + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 1, + timestamp: 0, + agg: PriceInfo { + price: 200, + conf: 1, + status: PriceStatus::Trading, + corp_act: CorpAction::NoCorpAct, + pub_slot: 0 + }, + ..PriceAccount::default() + }, + clock: Clock { + slot: 240, + ..Clock::default() + }, + expected_result: Ok(Decimal::from(2000_u64)) + }), + // case 7: success. most recent price has status == unknown, previous price not stale + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 1, + timestamp: 20, + agg: PriceInfo { + price: 200, + conf: 1, + status: PriceStatus::Unknown, + corp_act: CorpAction::NoCorpAct, + pub_slot: 1 + }, + prev_price: 190, + prev_conf: 10, + prev_slot: 0, + ..PriceAccount::default() + }, + clock: Clock { + slot: 240, + ..Clock::default() + }, + expected_result: Ok(Decimal::from(1900_u64)) + }), + // case 8: failure. most recent price is stale + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 1, + timestamp: 0, + agg: PriceInfo { + price: 200, + conf: 1, + status: PriceStatus::Trading, + corp_act: CorpAction::NoCorpAct, + pub_slot: 1 + }, + prev_slot: 0, // there is no case where prev_slot > agg.pub_slot + ..PriceAccount::default() + }, + clock: Clock { + slot: 242, + ..Clock::default() + }, + expected_result: Err(LendingError::InvalidOracleConfig.into()) + }), + // case 9: failure. most recent price has status == unknown and previous price is stale + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 1, + timestamp: 1, + agg: PriceInfo { + price: 200, + conf: 1, + status: PriceStatus::Unknown, + corp_act: CorpAction::NoCorpAct, + pub_slot: 1 + }, + prev_price: 190, + prev_conf: 10, + prev_slot: 0, + ..PriceAccount::default() + }, + clock: Clock { + slot: 241, + ..Clock::default() + }, + expected_result: Err(LendingError::InvalidOracleConfig.into()) + }), + // case 10: failure. price is negative + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 1, + timestamp: 1, + agg: PriceInfo { + price: -200, + conf: 1, + status: PriceStatus::Trading, + corp_act: CorpAction::NoCorpAct, + pub_slot: 0 + }, + ..PriceAccount::default() + }, + clock: Clock { + slot: 240, + ..Clock::default() + }, + expected_result: Err(LendingError::InvalidOracleConfig.into()) + }), + // case 11: failure. confidence interval is too wide + Just(PythPriceTestCase { + price_account: PriceAccount { + magic: MAGIC, + ver: VERSION_2, + atype: AccountType::Price as u32, + ptype: PriceType::Price, + expo: 1, + timestamp: 1, + agg: PriceInfo { + price: 200, + conf: 40, + status: PriceStatus::Trading, + corp_act: CorpAction::NoCorpAct, + pub_slot: 0 + }, + ..PriceAccount::default() + }, + clock: Clock { + slot: 240, + ..Clock::default() + }, + expected_result: Err(LendingError::InvalidOracleConfig.into()) + }), + ] + } + + proptest! { + #[test] + fn test_pyth_price(mut test_case in pyth_price_cases()) { + // wrap price account into an account info + let mut lamports = 20; + let pubkey = Pubkey::new_unique(); + let account_info = AccountInfo::new( + &pubkey, + false, + false, + &mut lamports, + bytes_of_mut(&mut test_case.price_account), + &pubkey, + false, + 0, + ); + + let result = get_pyth_price(&account_info, &test_case.clock); + assert_eq!( + result, + test_case.expected_result, + "actual: {:#?} expected: {:#?}", + result, + test_case.expected_result + ); + } + } +} diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 0db74973d53..64ce17d074d 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -5,7 +5,7 @@ use crate::{ error::LendingError, instruction::LendingInstruction, math::{Decimal, Rate, TryAdd, TryDiv, TryMul, TrySub, WAD}, - pyth, + oracles::get_pyth_price, state::{ CalculateBorrowResult, CalculateLiquidationResult, CalculateRepayResult, InitLendingMarketParams, InitObligationParams, InitReserveParams, LendingMarket, @@ -14,6 +14,7 @@ use crate::{ }, }; use num_traits::FromPrimitive; +use pyth_sdk_solana::{self, state::ProductAccount}; use solana_program::{ account_info::{next_account_info, AccountInfo}, decode_error::DecodeError, @@ -32,7 +33,7 @@ use solana_program::{ }, }; use spl_token::state::Mint; -use std::{cmp::min, convert::TryInto, result::Result}; +use std::{cmp::min, result::Result}; use switchboard_program::{ get_aggregator, get_aggregator_result, AggregatorState, RoundResult, SwitchboardAccountType, }; @@ -2530,46 +2531,24 @@ fn unpack_mint(data: &[u8]) -> Result { Mint::unpack(data).map_err(|_| LendingError::InvalidTokenMint) } -fn get_pyth_product_quote_currency(pyth_product: &pyth::Product) -> Result<[u8; 32], ProgramError> { - const LEN: usize = 14; - const KEY: &[u8; LEN] = b"quote_currency"; - - let mut start = 0; - while start < pyth::PROD_ATTR_SIZE { - let mut length = pyth_product.attr[start] as usize; - start += 1; - - if length == LEN { - let mut end = start + length; - if end > pyth::PROD_ATTR_SIZE { - msg!("Pyth product attribute key length too long"); - return Err(LendingError::InvalidOracleConfig.into()); - } - - let key = &pyth_product.attr[start..end]; - if key == KEY { - start += length; - length = pyth_product.attr[start] as usize; - start += 1; - - end = start + length; - if length > 32 || end > pyth::PROD_ATTR_SIZE { - msg!("Pyth product quote currency value too long"); - return Err(LendingError::InvalidOracleConfig.into()); - } - +fn get_pyth_product_quote_currency( + pyth_product: &ProductAccount, +) -> Result<[u8; 32], ProgramError> { + pyth_product + .iter() + .find_map(|(key, val)| { + if key == "quote_currency" { let mut value = [0u8; 32]; - value[0..length].copy_from_slice(&pyth_product.attr[start..end]); - return Ok(value); + value[0..val.len()].copy_from_slice(val.as_bytes()); + Some(value) + } else { + None } - } - - start += length; - start += 1 + pyth_product.attr[start] as usize; - } - - msg!("Pyth product quote currency not found"); - Err(LendingError::InvalidOracleConfig.into()) + }) + .ok_or_else(|| { + msg!("Pyth product quote currency not found"); + LendingError::InvalidOracleConfig.into() + }) } fn get_price( @@ -2590,84 +2569,6 @@ fn get_price( Err(LendingError::InvalidOracleConfig.into()) } -fn get_pyth_price(pyth_price_info: &AccountInfo, clock: &Clock) -> Result { - const STALE_AFTER_SLOTS_ELAPSED: u64 = 240; - - if *pyth_price_info.key == solend_program::NULL_PUBKEY { - return Err(LendingError::NullOracleConfig.into()); - } - - let pyth_price_data = pyth_price_info.try_borrow_data()?; - let pyth_price = pyth::load::(&pyth_price_data) - .map_err(|_| ProgramError::InvalidAccountData)?; - - if pyth_price.ptype != pyth::PriceType::Price { - msg!("Oracle price type is invalid {}", pyth_price.ptype as u8); - return Err(LendingError::InvalidOracleConfig.into()); - } - - if pyth_price.agg.status != pyth::PriceStatus::Trading { - msg!( - "Oracle price status is invalid: {}", - pyth_price.agg.status as u8 - ); - return Err(LendingError::InvalidOracleConfig.into()); - } - - let slots_elapsed = clock - .slot - .checked_sub(pyth_price.valid_slot) - .ok_or(LendingError::MathOverflow)?; - if slots_elapsed >= STALE_AFTER_SLOTS_ELAPSED { - msg!("Pyth oracle price is stale"); - return Err(LendingError::InvalidOracleConfig.into()); - } - - let price: u64 = pyth_price.agg.price.try_into().map_err(|_| { - msg!("Oracle price cannot be negative"); - LendingError::InvalidOracleConfig - })?; - - let conf = pyth_price.agg.conf; - - let confidence_ratio: u64 = 10; - // Perhaps confidence_ratio should exist as a per reserve config - // 100/confidence_ratio = maximum size of confidence range as a percent of price - // confidence_ratio of 10 filters out pyth prices with conf > 10% of price - if conf.checked_mul(confidence_ratio).unwrap() > price { - msg!( - "Oracle price confidence is too wide. price: {}, conf: {}", - price, - conf, - ); - return Err(LendingError::InvalidOracleConfig.into()); - } - - let market_price = if pyth_price.expo >= 0 { - let exponent = pyth_price - .expo - .try_into() - .map_err(|_| LendingError::MathOverflow)?; - let zeros = 10u64 - .checked_pow(exponent) - .ok_or(LendingError::MathOverflow)?; - Decimal::from(price).try_mul(zeros)? - } else { - let exponent = pyth_price - .expo - .checked_abs() - .ok_or(LendingError::MathOverflow)? - .try_into() - .map_err(|_| LendingError::MathOverflow)?; - let decimals = 10u64 - .checked_pow(exponent) - .ok_or(LendingError::MathOverflow)?; - Decimal::from(price).try_div(decimals)? - }; - - Ok(market_price) -} - fn get_switchboard_price( switchboard_feed_info: &AccountInfo, clock: &Clock, @@ -2943,27 +2844,9 @@ fn validate_pyth_keys( } let pyth_product_data = pyth_product_info.try_borrow_data()?; - let pyth_product = pyth::load::(&pyth_product_data) - .map_err(|_| ProgramError::InvalidAccountData)?; - if pyth_product.magic != pyth::MAGIC { - msg!("Pyth product account provided is not a valid Pyth account"); - return Err(LendingError::InvalidOracleConfig.into()); - } - if pyth_product.ver != pyth::VERSION_2 { - msg!("Pyth product account provided has a different version than expected"); - return Err(LendingError::InvalidOracleConfig.into()); - } - if pyth_product.atype != pyth::AccountType::Product as u32 { - msg!("Pyth product account provided is not a valid Pyth product account"); - return Err(LendingError::InvalidOracleConfig.into()); - } + let pyth_product = pyth_sdk_solana::state::load_product_account(&pyth_product_data)?; - let pyth_price_pubkey_bytes: &[u8; 32] = pyth_price_info - .key - .as_ref() - .try_into() - .map_err(|_| LendingError::InvalidAccountInput)?; - if &pyth_product.px_acc.val != pyth_price_pubkey_bytes { + if &pyth_product.px_acc != pyth_price_info.key { msg!("Pyth product price account does not match the Pyth price provided"); return Err(LendingError::InvalidOracleConfig.into()); } diff --git a/token-lending/program/src/pyth.rs b/token-lending/program/src/pyth.rs deleted file mode 100644 index 10ec0408a62..00000000000 --- a/token-lending/program/src/pyth.rs +++ /dev/null @@ -1,135 +0,0 @@ -#![allow(missing_docs)] -/// Derived from https://github.com/project-serum/anchor/blob/9224e0fa99093943a6190e396bccbc3387e5b230/examples/pyth/programs/pyth/src/pc.rs -use bytemuck::{ - cast_slice, cast_slice_mut, from_bytes, from_bytes_mut, try_cast_slice, try_cast_slice_mut, - Pod, PodCastError, Zeroable, -}; -use std::mem::size_of; - -pub const MAGIC: u32 = 0xa1b2c3d4; -pub const VERSION_2: u32 = 2; -pub const VERSION: u32 = VERSION_2; -pub const MAP_TABLE_SIZE: usize = 640; -pub const PROD_ACCT_SIZE: usize = 512; -pub const PROD_HDR_SIZE: usize = 48; -pub const PROD_ATTR_SIZE: usize = PROD_ACCT_SIZE - PROD_HDR_SIZE; - -#[derive(Copy, Clone)] -#[repr(C)] -pub struct AccKey { - pub val: [u8; 32], -} - -#[derive(PartialEq, Copy, Clone)] -#[repr(C)] -pub enum AccountType { - Unknown, - Mapping, - Product, - Price, -} - -#[derive(PartialEq, Copy, Clone)] -#[repr(C)] -pub enum PriceStatus { - Unknown, - Trading, - Halted, - Auction, -} - -#[derive(PartialEq, Copy, Clone)] -#[repr(C)] -pub enum CorpAction { - NoCorpAct, -} - -#[derive(Copy, Clone)] -#[repr(C)] -pub struct PriceInfo { - pub price: i64, - pub conf: u64, - pub status: PriceStatus, - pub corp_act: CorpAction, - pub pub_slot: u64, -} - -#[derive(Copy, Clone)] -#[repr(C)] -pub struct PriceComp { - publisher: AccKey, - agg: PriceInfo, - latest: PriceInfo, -} - -#[derive(PartialEq, Copy, Clone)] -#[repr(C)] -pub enum PriceType { - Unknown, - Price, -} - -#[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 unused: u32, - pub curr_slot: u64, // currently accumulating price slot - pub valid_slot: u64, // valid slot-time of agg. price - pub twap: i64, // time-weighted average price - pub avol: u64, // annualized price volatility - pub drv0: i64, // space for future derived values - pub drv1: i64, // space for future derived values - pub drv2: i64, // space for future derived values - pub drv3: i64, // space for future derived values - pub drv4: i64, // space for future derived values - pub drv5: i64, // space for future derived values - pub prod: AccKey, // product account key - pub next: AccKey, // next Price account in linked list - pub agg_pub: AccKey, // quoter who computed last aggregate price - pub agg: PriceInfo, // aggregate price info - pub comp: [PriceComp; 32], // price components one per quoter -} - -#[cfg(target_endian = "little")] -unsafe impl Zeroable for Price {} - -#[cfg(target_endian = "little")] -unsafe impl Pod for Price {} - -#[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. -} - -#[cfg(target_endian = "little")] -unsafe impl Zeroable for Product {} - -#[cfg(target_endian = "little")] -unsafe impl Pod for Product {} - -pub fn load(data: &[u8]) -> Result<&T, PodCastError> { - let size = size_of::(); - Ok(from_bytes(cast_slice::(try_cast_slice( - &data[0..size], - )?))) -} - -pub fn load_mut(data: &mut [u8]) -> Result<&mut T, PodCastError> { - let size = size_of::(); - Ok(from_bytes_mut(cast_slice_mut::( - try_cast_slice_mut(&mut data[0..size])?, - ))) -} diff --git a/token-lending/program/src/state/lending_market.rs b/token-lending/program/src/state/lending_market.rs index b15fd78ff50..4edb3e92019 100644 --- a/token-lending/program/src/state/lending_market.rs +++ b/token-lending/program/src/state/lending_market.rs @@ -8,7 +8,7 @@ use solana_program::{ }; /// Lending market state -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LendingMarket { /// Version of lending market pub version: u8, diff --git a/token-lending/program/src/state/obligation.rs b/token-lending/program/src/state/obligation.rs index 42891d87392..f6071e8193e 100644 --- a/token-lending/program/src/state/obligation.rs +++ b/token-lending/program/src/state/obligation.rs @@ -247,7 +247,7 @@ impl IsInitialized for Obligation { } /// Obligation collateral state -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ObligationCollateral { /// Reserve collateral is deposited to pub deposit_reserve: Pubkey, @@ -287,7 +287,7 @@ impl ObligationCollateral { } /// Obligation liquidity state -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ObligationLiquidity { /// Reserve liquidity is borrowed from pub borrow_reserve: Pubkey, diff --git a/token-lending/program/src/state/reserve.rs b/token-lending/program/src/state/reserve.rs index 5d63aa0c854..580fc4e329c 100644 --- a/token-lending/program/src/state/reserve.rs +++ b/token-lending/program/src/state/reserve.rs @@ -396,7 +396,7 @@ pub struct CalculateLiquidationResult { } /// Reserve liquidity -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ReserveLiquidity { /// Reserve liquidity mint address pub mint_pubkey: Pubkey, @@ -563,7 +563,7 @@ pub struct NewReserveLiquidityParams { } /// Reserve collateral -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ReserveCollateral { /// Reserve collateral mint address pub mint_pubkey: Pubkey, @@ -666,7 +666,7 @@ impl From for Rate { } /// Reserve configuration values -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ReserveConfig { /// Optimal utilization rate, as a percentage pub optimal_utilization_rate: u8, @@ -702,7 +702,7 @@ pub struct ReserveConfig { /// These exist separately from interest accrual fees, and are specifically for the program owner /// and frontend host. The fees are paid out as a percentage of liquidity token amounts during /// repayments and liquidations. -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ReserveFees { /// Fee assessed on `BorrowObligationLiquidity`, expressed as a Wad. /// Must be between 0 and 10^18, such that 10^18 = 1. A few examples for diff --git a/token-lending/program/tests/borrow_obligation_liquidity.rs b/token-lending/program/tests/borrow_obligation_liquidity.rs index 59fa4fe6e44..fd5562b9157 100644 --- a/token-lending/program/tests/borrow_obligation_liquidity.rs +++ b/token-lending/program/tests/borrow_obligation_liquidity.rs @@ -464,11 +464,31 @@ async fn test_borrow_limit() { }, ); - let (mut banks_client, payer, recent_blockhash) = test.start().await; + let mut test_context = test.start_with_context().await; + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 + + let ProgramTestContext { + mut banks_client, + payer, + last_blockhash: recent_blockhash, + .. + } = test_context; // Try to borrow more than the borrow limit. This transaction should fail let mut transaction = Transaction::new_with_payer( &[ + refresh_reserve( + solend_program::id(), + sol_test_reserve.pubkey, + sol_oracle.pyth_price_pubkey, + sol_oracle.switchboard_feed_pubkey, + ), + refresh_reserve( + solend_program::id(), + usdc_test_reserve.pubkey, + usdc_oracle.pyth_price_pubkey, + usdc_oracle.switchboard_feed_pubkey, + ), refresh_obligation( solend_program::id(), test_obligation.pubkey, @@ -497,7 +517,7 @@ async fn test_borrow_limit() { .unwrap_err() .unwrap(), TransactionError::InstructionError( - 1, + 3, InstructionError::Custom(LendingError::InvalidAmount as u32) ) ); @@ -508,6 +528,18 @@ async fn test_borrow_limit() { // Also try borrowing INT MAX, which should max out the reserve's borrows. let mut transaction = Transaction::new_with_payer( &[ + refresh_reserve( + solend_program::id(), + sol_test_reserve.pubkey, + sol_oracle.pyth_price_pubkey, + sol_oracle.switchboard_feed_pubkey, + ), + refresh_reserve( + solend_program::id(), + usdc_test_reserve.pubkey, + usdc_oracle.pyth_price_pubkey, + usdc_oracle.switchboard_feed_pubkey, + ), refresh_obligation( solend_program::id(), test_obligation.pubkey, diff --git a/token-lending/program/tests/fixtures/3Mnn2fX6rQyUsyELYms1sBJyChWofzSNRoqYzvgMVz5E.bin b/token-lending/program/tests/fixtures/3Mnn2fX6rQyUsyELYms1sBJyChWofzSNRoqYzvgMVz5E.bin index 32a3793d846..45cb4695d42 100644 Binary files a/token-lending/program/tests/fixtures/3Mnn2fX6rQyUsyELYms1sBJyChWofzSNRoqYzvgMVz5E.bin and b/token-lending/program/tests/fixtures/3Mnn2fX6rQyUsyELYms1sBJyChWofzSNRoqYzvgMVz5E.bin differ diff --git a/token-lending/program/tests/fixtures/6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8.bin b/token-lending/program/tests/fixtures/6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8.bin index 08c6d816cf1..9a8cbb7d823 100644 Binary files a/token-lending/program/tests/fixtures/6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8.bin and b/token-lending/program/tests/fixtures/6MEwdxe4g1NeAF9u6KDG14anJpFsVEa2cvr5H6iriFZ8.bin differ diff --git a/token-lending/program/tests/fixtures/992moaMQKs32GKZ9dxi8keyM2bUmbrwBZpK4p2K6X5Vs.bin b/token-lending/program/tests/fixtures/992moaMQKs32GKZ9dxi8keyM2bUmbrwBZpK4p2K6X5Vs.bin index 65bf11a0f27..09deb80330b 100644 Binary files a/token-lending/program/tests/fixtures/992moaMQKs32GKZ9dxi8keyM2bUmbrwBZpK4p2K6X5Vs.bin and b/token-lending/program/tests/fixtures/992moaMQKs32GKZ9dxi8keyM2bUmbrwBZpK4p2K6X5Vs.bin differ diff --git a/token-lending/program/tests/flash_borrow_repay.rs b/token-lending/program/tests/flash_borrow_repay.rs index b32f138880e..ebd26a4f487 100644 --- a/token-lending/program/tests/flash_borrow_repay.rs +++ b/token-lending/program/tests/flash_borrow_repay.rs @@ -3,11 +3,10 @@ mod helpers; use helpers::*; -use solana_program::instruction::{ - AccountMeta, Instruction, InstructionError::PrivilegeEscalation, -}; +use solana_program::instruction::{AccountMeta, Instruction}; use solana_program::sysvar; use solana_program_test::*; +use solana_sdk::transport::TransportError; use solana_sdk::{ instruction::InstructionError, pubkey::Pubkey, @@ -1296,15 +1295,19 @@ async fn test_fail_repay_from_diff_reserve() { ); transaction.sign(&[&payer], recent_blockhash); + // panics due to signer privilege escalation let err = banks_client .process_transaction(transaction) .await - .unwrap_err() - .unwrap(); - assert_eq!( - err, - TransactionError::InstructionError(1, PrivilegeEscalation) - ); + .unwrap_err(); + match err { + TransportError::IoError(..) => (), + TransportError::TransactionError(TransactionError::InstructionError( + 1, + InstructionError::PrivilegeEscalation, + )) => (), + _ => panic!("Unexpected error: {:?}", err), + }; } // don't explicitly check user_transfer_authority signer diff --git a/token-lending/program/tests/helpers/mod.rs b/token-lending/program/tests/helpers/mod.rs index 34090231ff9..3b3124fe171 100644 --- a/token-lending/program/tests/helpers/mod.rs +++ b/token-lending/program/tests/helpers/mod.rs @@ -5,6 +5,8 @@ pub mod flash_loan_receiver; pub mod genesis; use assert_matches::*; +use bytemuck::{cast_slice_mut, from_bytes_mut, try_cast_slice_mut, Pod, PodCastError}; +use pyth_sdk_solana::state::PriceAccount; use solana_program::{program_option::COption, program_pack::Pack, pubkey::Pubkey}; use solana_program_test::*; use solana_sdk::{ @@ -22,7 +24,6 @@ use solend_program::{ }, math::{Decimal, Rate, TryAdd, TryMul}, processor::switchboard_v2_mainnet, - pyth, state::{ InitLendingMarketParams, InitObligationParams, InitReserveParams, LendingMarket, NewReserveCollateralParams, NewReserveLiquidityParams, Obligation, ObligationCollateral, @@ -35,6 +36,10 @@ use spl_token::{ state::{Account as Token, AccountState, Mint}, }; use std::{convert::TryInto, str::FromStr}; +use std::{ + mem::size_of, + time::{SystemTime, UNIX_EPOCH}, +}; use switchboard_v2::AggregatorAccountData; pub const QUOTE_CURRENCY: [u8; 32] = @@ -1261,6 +1266,13 @@ pub fn add_usdc_oracle_switchboardv2(test: &mut ProgramTest) -> TestOracle { ) } +pub fn load_mut(data: &mut [u8]) -> Result<&mut T, PodCastError> { + let size = size_of::(); + Ok(from_bytes_mut(cast_slice_mut::( + try_cast_slice_mut(&mut data[0..size])?, + ))) +} + pub fn add_oracle( test: &mut ProgramTest, pyth_product_pubkey: Pubkey, @@ -1287,7 +1299,7 @@ pub fn add_oracle( panic!("Unable to locate {}", filename); })); - let mut pyth_price = pyth::load_mut::(pyth_price_data.as_mut_slice()).unwrap(); + let mut pyth_price = load_mut::(pyth_price_data.as_mut_slice()).unwrap(); let decimals = 10u64 .checked_pow(pyth_price.expo.checked_abs().unwrap().try_into().unwrap()) @@ -1302,6 +1314,12 @@ pub fn add_oracle( .try_into() .unwrap(); + pyth_price.agg.pub_slot = valid_slot; + pyth_price.timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + test.add_account( pyth_price_pubkey, Account { diff --git a/token-lending/program/tests/init_reserve.rs b/token-lending/program/tests/init_reserve.rs index a8f01dc1780..f3acb00f1ae 100644 --- a/token-lending/program/tests/init_reserve.rs +++ b/token-lending/program/tests/init_reserve.rs @@ -32,7 +32,15 @@ async fn test_success() { let lending_market = add_lending_market(&mut test); let sol_oracle = add_sol_oracle(&mut test); - let (mut banks_client, payer, _recent_blockhash) = test.start().await; + let mut test_context = test.start_with_context().await; + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 + + let ProgramTestContext { + mut banks_client, + payer, + last_blockhash: _recent_blockhash, + .. + } = test_context; const RESERVE_AMOUNT: u64 = 42; @@ -159,7 +167,15 @@ async fn test_null_switchboard() { let mut sol_oracle = add_sol_oracle(&mut test); sol_oracle.switchboard_feed_pubkey = solend_program::NULL_PUBKEY; - let (mut banks_client, payer, _recent_blockhash) = test.start().await; + let mut test_context = test.start_with_context().await; + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 + + let ProgramTestContext { + mut banks_client, + payer, + last_blockhash: _recent_blockhash, + .. + } = test_context; const RESERVE_AMOUNT: u64 = 42; diff --git a/token-lending/program/tests/obligation_end_to_end.rs b/token-lending/program/tests/obligation_end_to_end.rs index f9ca2398ef5..4a1cbbbe386 100644 --- a/token-lending/program/tests/obligation_end_to_end.rs +++ b/token-lending/program/tests/obligation_end_to_end.rs @@ -90,7 +90,15 @@ async fn test_success() { }, ); - let (mut banks_client, payer, recent_blockhash) = test.start().await; + let mut test_context = test.start_with_context().await; + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 + + let ProgramTestContext { + mut banks_client, + payer, + last_blockhash: recent_blockhash, + .. + } = test_context; let payer_pubkey = payer.pubkey(); let initial_collateral_supply_balance = diff --git a/token-lending/program/tests/refresh_obligation.rs b/token-lending/program/tests/refresh_obligation.rs index a7db4ad1828..0b8c0b0b056 100644 --- a/token-lending/program/tests/refresh_obligation.rs +++ b/token-lending/program/tests/refresh_obligation.rs @@ -59,7 +59,7 @@ async fn test_success() { liquidity_mint_decimals: 9, liquidity_mint_pubkey: spl_token::native_mint::id(), config: reserve_config, - slots_elapsed: 1, // elapsed from 1; clock.slot = 2 + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 ..AddReserveArgs::default() }, ); @@ -77,7 +77,7 @@ async fn test_success() { liquidity_mint_decimals: usdc_mint.decimals, liquidity_mint_pubkey: usdc_mint.pubkey, config: reserve_config, - slots_elapsed: 1, // elapsed from 1; clock.slot = 2 + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 ..AddReserveArgs::default() }, ); @@ -89,13 +89,13 @@ async fn test_success() { AddObligationArgs { deposits: &[(&sol_test_reserve, SOL_DEPOSIT_AMOUNT_LAMPORTS)], borrows: &[(&usdc_test_reserve, USDC_BORROW_AMOUNT_FRACTIONAL)], - slots_elapsed: 1, // elapsed from 1; clock.slot = 2 + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 ..AddObligationArgs::default() }, ); let mut test_context = test.start_with_context().await; - test_context.warp_to_slot(3).unwrap(); // clock.slot = 3 + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 let ProgramTestContext { mut banks_client, diff --git a/token-lending/program/tests/refresh_reserve.rs b/token-lending/program/tests/refresh_reserve.rs index a6b6d5b5b61..e18374c55be 100644 --- a/token-lending/program/tests/refresh_reserve.rs +++ b/token-lending/program/tests/refresh_reserve.rs @@ -4,21 +4,23 @@ mod helpers; use helpers::*; use solana_program::{ - instruction::{AccountMeta, Instruction}, + instruction::{AccountMeta, Instruction, InstructionError}, pubkey::Pubkey, sysvar, }; use solana_program_test::*; use solana_sdk::{ signature::{Keypair, Signer}, - transaction::Transaction, + transaction::{Transaction, TransactionError}, }; use solend_program::{ + error::LendingError, instruction::{refresh_reserve, LendingInstruction}, math::{Decimal, Rate, TryAdd, TryDiv, TryMul, TrySub}, processor::process_instruction, state::SLOTS_PER_YEAR, }; +use std::str::FromStr; #[tokio::test] async fn test_success() { @@ -60,7 +62,7 @@ async fn test_success() { liquidity_mint_decimals: usdc_mint.decimals, liquidity_mint_pubkey: usdc_mint.pubkey, config: reserve_config, - slots_elapsed: 1, // elapsed from 1; clock.slot = 2 + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 ..AddReserveArgs::default() }, ); @@ -77,13 +79,13 @@ async fn test_success() { liquidity_mint_decimals: 9, liquidity_mint_pubkey: spl_token::native_mint::id(), config: reserve_config, - slots_elapsed: 1, // elapsed from 1; clock.slot = 2 + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 ..AddReserveArgs::default() }, ); let mut test_context = test.start_with_context().await; - test_context.warp_to_slot(3).unwrap(); // clock.slot = 3 + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 let ProgramTestContext { mut banks_client, @@ -198,7 +200,7 @@ async fn test_success_no_switchboard() { liquidity_mint_decimals: usdc_mint.decimals, liquidity_mint_pubkey: usdc_mint.pubkey, config: reserve_config, - slots_elapsed: 1, // elapsed from 1; clock.slot = 2 + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 ..AddReserveArgs::default() }, ); @@ -215,13 +217,13 @@ async fn test_success_no_switchboard() { liquidity_mint_decimals: 9, liquidity_mint_pubkey: spl_token::native_mint::id(), config: reserve_config, - slots_elapsed: 1, // elapsed from 1; clock.slot = 2 + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 ..AddReserveArgs::default() }, ); let mut test_context = test.start_with_context().await; - test_context.warp_to_slot(3).unwrap(); // clock.slot = 3 + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 let ProgramTestContext { mut banks_client, @@ -316,3 +318,73 @@ pub fn refresh_reserve_no_switchboard( data: LendingInstruction::RefreshReserve.pack(), } } + +#[tokio::test] +async fn test_pyth_price_stale() { + let mut test = ProgramTest::new( + "solend_program", + solend_program::id(), + processor!(process_instruction), + ); + + // limit to track compute unit increase + test.set_compute_max_units(31_000); + + const USDC_RESERVE_LIQUIDITY_FRACTIONAL: u64 = 100 * FRACTIONAL_TO_USDC; + + let user_accounts_owner = Keypair::new(); + let lending_market = add_lending_market(&mut test); + + let reserve_config = test_reserve_config(); + + let usdc_mint = add_usdc_mint(&mut test); + let usdc_oracle = add_usdc_oracle(&mut test); + let usdc_test_reserve = add_reserve( + &mut test, + &lending_market, + &usdc_oracle, + &user_accounts_owner, + AddReserveArgs { + borrow_amount: 100, + liquidity_amount: USDC_RESERVE_LIQUIDITY_FRACTIONAL, + liquidity_mint_decimals: usdc_mint.decimals, + liquidity_mint_pubkey: usdc_mint.pubkey, + config: reserve_config, + slots_elapsed: 238, // elapsed from 1; clock.slot = 239 + ..AddReserveArgs::default() + }, + ); + + let mut test_context = test.start_with_context().await; + test_context.warp_to_slot(241).unwrap(); // clock.slot = 241 + + let ProgramTestContext { + mut banks_client, + payer, + last_blockhash: recent_blockhash, + .. + } = test_context; + + let mut transaction = Transaction::new_with_payer( + &[refresh_reserve( + solend_program::id(), + usdc_test_reserve.pubkey, + usdc_oracle.pyth_price_pubkey, + Pubkey::from_str(NULL_PUBKEY).unwrap(), + )], + Some(&payer.pubkey()), + ); + + transaction.sign(&[&payer], recent_blockhash); + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError( + 0, + InstructionError::Custom(LendingError::InvalidOracleConfig as u32), + ), + ); +} diff --git a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs index 47d3258c79e..b3ad3aa7df2 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs @@ -46,7 +46,15 @@ async fn test_success() { AddObligationArgs::default(), ); - let (mut banks_client, payer, _recent_blockhash) = test.start().await; + let mut test_context = test.start_with_context().await; + test_context.warp_to_slot(240).unwrap(); // clock.slot = 240 + + let ProgramTestContext { + mut banks_client, + payer, + last_blockhash: _recent_blockhash, + .. + } = test_context; test_obligation.validate_state(&mut banks_client).await;