From 9eae06648dbe1561e33bc54217ec0ca3fef99376 Mon Sep 17 00:00:00 2001 From: Jon Gurary <91919816+jgur-psyops@users.noreply.github.com> Date: Thu, 15 Aug 2024 13:10:20 -0400 Subject: [PATCH] Switch pull oracles (#231) Adds support for switch pull oracles (aka sb-on-demand) when configuring a bank. --- Cargo.lock | 134 +++++++++- Cargo.toml | 1 + README.md | 11 + programs/marginfi/Cargo.toml | 1 + programs/marginfi/src/state/price.rs | 253 +++++++++++++++++- programs/marginfi/src/utils.rs | 14 + .../tests/admin_actions/setup_bank.rs | 3 +- .../marginfi/tests/user_actions/borrow.rs | 2 + scripts/single-test.sh | 31 +++ test-utils/Cargo.toml | 1 + ...4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP.bin | Bin 0 -> 3208 bytes test-utils/src/test.rs | 27 ++ test-utils/src/utils.rs | 10 + 13 files changed, 472 insertions(+), 16 deletions(-) create mode 100755 scripts/single-test.sh create mode 100644 test-utils/data/BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP.bin diff --git a/Cargo.lock b/Cargo.lock index bf7e1e03..bdfb5c7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,7 +424,7 @@ dependencies = [ "anchor-lang 0.29.0", "solana-program", "spl-associated-token-account 2.3.0", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 0.9.0", ] @@ -436,7 +436,7 @@ dependencies = [ "anchor-lang 0.30.1", "spl-associated-token-account 3.0.2", "spl-pod 0.2.2", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 3.0.2", "spl-token-group-interface 0.2.3", "spl-token-metadata-interface 0.3.3", @@ -522,6 +522,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "ark-bn254" version = "0.4.0" @@ -2908,6 +2914,7 @@ dependencies = [ "spl-tlv-account-resolution 0.6.3", "spl-transfer-hook-interface 0.6.3", "static_assertions", + "switchboard-on-demand", "switchboard-solana", "test-case", "test-utilities", @@ -2946,7 +2953,7 @@ dependencies = [ "solana-client", "solana-sdk", "spl-associated-token-account 2.3.0", - "spl-token", + "spl-token 4.0.0", "switchboard-solana", "type-layout", ] @@ -3164,10 +3171,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" dependencies = [ "num-bigint 0.2.6", - "num-complex", + "num-complex 0.2.4", + "num-integer", + "num-iter", + "num-rational 0.2.4", + "num-traits", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex 0.4.6", "num-integer", "num-iter", - "num-rational", + "num-rational 0.4.2", "num-traits", ] @@ -3202,6 +3223,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3262,6 +3292,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3281,6 +3322,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive 0.5.11", +] + [[package]] name = "num_enum" version = "0.6.1" @@ -3299,6 +3349,18 @@ dependencies = [ "num_enum_derive 0.7.2", ] +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num_enum_derive" version = "0.6.1" @@ -3529,7 +3591,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" dependencies = [ - "num", + "num 0.2.1", ] [[package]] @@ -4754,7 +4816,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58267dd2fbaa6dceecba9e3e106d2d90a2b02497c0e8b01b8759beccf5113938" dependencies = [ - "num", + "num 0.2.1", ] [[package]] @@ -4801,7 +4863,7 @@ dependencies = [ "serde_json", "solana-config-program", "solana-sdk", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 1.0.0", "spl-token-group-interface 0.1.0", "spl-token-metadata-interface 0.2.0", @@ -5825,7 +5887,7 @@ dependencies = [ "solana-sdk", "spl-associated-token-account 2.3.0", "spl-memo", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 1.0.0", "thiserror", ] @@ -5997,7 +6059,7 @@ dependencies = [ "num-derive 0.4.2", "num-traits", "solana-program", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 1.0.0", "thiserror", ] @@ -6013,7 +6075,7 @@ dependencies = [ "num-derive 0.4.2", "num-traits", "solana-program", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 3.0.2", "thiserror", ] @@ -6215,6 +6277,21 @@ dependencies = [ "spl-type-length-value 0.4.3", ] +[[package]] +name = "spl-token" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e85e168a785e82564160dcb87b2a8e04cee9bfd1f4d488c729d53d6a4bd300d" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive 0.3.3", + "num-traits", + "num_enum 0.5.11", + "solana-program", + "thiserror", +] + [[package]] name = "spl-token" version = "4.0.0" @@ -6245,7 +6322,7 @@ dependencies = [ "solana-zk-token-sdk", "spl-memo", "spl-pod 0.1.0", - "spl-token", + "spl-token 4.0.0", "spl-token-metadata-interface 0.2.0", "spl-transfer-hook-interface 0.3.0", "spl-type-length-value 0.3.0", @@ -6268,7 +6345,7 @@ dependencies = [ "solana-zk-token-sdk", "spl-memo", "spl-pod 0.1.0", - "spl-token", + "spl-token 4.0.0", "spl-token-group-interface 0.1.0", "spl-token-metadata-interface 0.2.0", "spl-transfer-hook-interface 0.4.1", @@ -6292,7 +6369,7 @@ dependencies = [ "solana-zk-token-sdk", "spl-memo", "spl-pod 0.2.2", - "spl-token", + "spl-token 4.0.0", "spl-token-group-interface 0.2.3", "spl-token-metadata-interface 0.3.3", "spl-transfer-hook-interface 0.6.3", @@ -6579,6 +6656,34 @@ dependencies = [ "sha3 0.10.8", ] +[[package]] +name = "switchboard-on-demand" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25f9c5e5776ee0db744d4adf4d61711416677eb03b03a499d2d895e348ebc24" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.21.7", + "bincode", + "borsh 0.10.3", + "bytemuck", + "futures", + "lazy_static", + "libsecp256k1 0.7.1", + "log", + "num 0.4.3", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.10.8", + "solana-address-lookup-table-program", + "solana-program", + "spl-associated-token-account 2.3.0", + "spl-token 3.5.0", + "switchboard-common", +] + [[package]] name = "switchboard-solana" version = "0.29.109" @@ -6823,6 +6928,7 @@ dependencies = [ "spl-token-2022 3.0.2", "spl-transfer-hook-interface 0.6.3", "static_assertions", + "switchboard-on-demand", "switchboard-solana", "test_transfer_hook", "type-layout", diff --git a/Cargo.toml b/Cargo.toml index 411d5804..ef40bf6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ anchor-client = { git = "https://github.com/mrgnlabs/anchor.git", rev = "fdcf299 pyth-sdk-solana = "=0.10.1" pyth-solana-receiver-sdk = "0.3.0" switchboard-solana = "0.29.0" +switchboard-on-demand = "0.1.13" borsh = "0.10.3" [profile.release] diff --git a/README.md b/README.md index 8e8c897e..df10a22d 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,14 @@ Run `./scripts/verify_mainnet.sh` Integration tests for the on-chain marginfi programs are located under `/programs/marginfi/tests`. To run the tests, use `cargo test-bpf`. Be sure to use an x86 toolchain when compiling and running the tests. + +Run the full test suite with `.scripts/test-program.sh ` +* e.g. `.scripts/test-program.sh all --sane` + +Run a single test: +`.scripts/test-program.sh ` +* e.g. `.scripts/test-program.sh marginfi configure_bank_success --verbose` + +## Footguns + +Debugging `I80F48`s by `msg!("val: {:?}", some_val_I80F48);` can cause silent build issues leading to `Program is not deployed`. Convert these values to string before printing them. diff --git a/programs/marginfi/Cargo.toml b/programs/marginfi/Cargo.toml index d4667c92..bd5feb6f 100644 --- a/programs/marginfi/Cargo.toml +++ b/programs/marginfi/Cargo.toml @@ -35,6 +35,7 @@ anchor-spl = { workspace = true } pyth-sdk-solana = { workspace = true } pyth-solana-receiver-sdk = { workspace = true } switchboard-solana = { workspace = true } +switchboard-on-demand = { workspace = true } borsh = "0.10.3" bytemuck = "1.9.1" diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 99b005b6..7ac1f8ec 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -1,10 +1,11 @@ -use std::cmp::min; +use std::{cell::Ref, cmp::min}; use anchor_lang::prelude::*; use enum_dispatch::enum_dispatch; use fixed::types::I80F48; use pyth_sdk_solana::{state::SolanaPriceAccount, Price, PriceFeed}; use pyth_solana_receiver_sdk::price_update::{self, FeedId, PriceUpdateV2}; +use switchboard_on_demand::{CurrentResult, PullFeedAccountData}; use switchboard_solana::{ AggregatorAccountData, AggregatorResolutionMode, SwitchboardDecimal, SWITCHBOARD_PROGRAM_ID, }; @@ -33,6 +34,7 @@ pub enum OracleSetup { PythLegacy, SwitchboardV2, PythPushOracle, + SwitchboardPull, } #[derive(Copy, Clone, Debug)] @@ -65,6 +67,7 @@ pub enum OraclePriceFeedAdapter { PythLegacy(PythLegacyPriceFeed), SwitchboardV2(SwitchboardV2PriceFeed), PythPushOracle(PythPushOraclePriceFeed), + SwitchboardPull(SwitchboardPullPriceFeed), } impl OraclePriceFeedAdapter { @@ -134,6 +137,17 @@ impl OraclePriceFeedAdapter { )?, )) } + OracleSetup::SwitchboardPull => { + check!(ais.len() == 1, MarginfiError::InvalidOracleAccount); + check!( + ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + + Ok(OraclePriceFeedAdapter::SwitchboardPull( + SwitchboardPullPriceFeed::load_checked(&ais[0], clock.unix_timestamp, max_age)?, + )) + } } } @@ -173,6 +187,17 @@ impl OraclePriceFeedAdapter { bank_config.get_pyth_push_oracle_feed_id().unwrap(), )?; + Ok(()) + } + OracleSetup::SwitchboardPull => { + check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); + check!( + oracle_ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + + SwitchboardPullPriceFeed::check_ais(&oracle_ais[0])?; + Ok(()) } } @@ -281,6 +306,119 @@ impl PriceAdapter for PythLegacyPriceFeed { } } +#[cfg_attr(feature = "client", derive(Clone, Debug))] +pub struct SwitchboardPullPriceFeed { + feed: Box, +} + +impl SwitchboardPullPriceFeed { + pub fn load_checked( + ai: &AccountInfo, + current_timestamp: i64, + max_age: u64, + ) -> MarginfiResult { + let ai_data = ai.data.borrow(); + + check!( + ai.owner.eq(&switchboard_on_demand::SWITCHBOARD_PROGRAM_ID), + MarginfiError::InvalidOracleAccount + ); + + let feed = + PullFeedAccountData::parse(ai_data).map_err(|_| MarginfiError::InvalidOracleAccount)?; + + // Check staleness + let last_updated = feed.last_update_timestamp; + if current_timestamp.saturating_sub(last_updated) > max_age as i64 { + return err!(MarginfiError::StaleOracle); + } + + Ok(Self { + feed: Box::new(feed.into()), + }) + } + + fn check_ais(ai: &AccountInfo) -> MarginfiResult { + let ai_data = ai.data.borrow(); + + check!( + ai.owner.eq(&switchboard_on_demand::SWITCHBOARD_PROGRAM_ID), + MarginfiError::InvalidOracleAccount + ); + + PullFeedAccountData::parse(ai_data).map_err(|_| MarginfiError::InvalidOracleAccount)?; + + Ok(()) + } + + fn get_price(&self) -> MarginfiResult { + let sw_result = self.feed.result; + // Note: Pull oracles support mean (result.mean) or median (result.value) + let price: I80F48 = I80F48::from_num(sw_result.value) + .checked_div(EXP_10_I80F48[switchboard_on_demand::PRECISION as usize]) + .ok_or_else(math_error!())?; + + // WARNING: Adding a line like the following will cause the entire project to silently fail + // to build, resulting in `Program not deployed` errors downstream when testing + + // msg!("recorded price: {:?}", price); + + Ok(price) + } + + fn get_confidence_interval(&self) -> MarginfiResult { + let std_div: I80F48 = I80F48::from_num(self.feed.result.std_dev); + + let conf_interval = std_div + .checked_mul(STD_DEV_MULTIPLE) + .ok_or_else(math_error!())?; + + let price = self.get_price()?; + + let max_conf_interval = price + .checked_mul(MAX_CONF_INTERVAL) + .ok_or_else(math_error!())?; + + assert!( + max_conf_interval >= I80F48::ZERO, + "Negative max confidence interval" + ); + + assert!( + conf_interval >= I80F48::ZERO, + "Negative confidence interval" + ); + + Ok(min(conf_interval, max_conf_interval)) + } +} + +impl PriceAdapter for SwitchboardPullPriceFeed { + fn get_price_of_type( + &self, + _price_type: OraclePriceType, + bias: Option, + ) -> MarginfiResult { + let price = self.get_price()?; + + match bias { + Some(price_bias) => { + let confidence_interval = self.get_confidence_interval()?; + + match price_bias { + PriceBias::Low => Ok(price + .checked_sub(confidence_interval) + .ok_or_else(math_error!())?), + PriceBias::High => Ok(price + .checked_add(confidence_interval) + .ok_or_else(math_error!())?), + } + } + None => Ok(price), + } + } +} + #[cfg_attr(feature = "client", derive(Clone, Debug))] pub struct SwitchboardV2PriceFeed { aggregator_account: Box, @@ -626,6 +764,29 @@ impl PriceAdapter for PythPushOraclePriceFeed { } } +/// A slimmed down version of the PullFeedAccountData struct copied from the +/// switchboard-on-demand/src/pull_feed.rs +#[cfg_attr(feature = "client", derive(Clone, Debug))] +struct LitePullFeedAccountData { + pub result: CurrentResult, +} + +impl From<&PullFeedAccountData> for LitePullFeedAccountData { + fn from(feed: &PullFeedAccountData) -> Self { + Self { + result: feed.result, + } + } +} + +impl From> for LitePullFeedAccountData { + fn from(feed: Ref<'_, PullFeedAccountData>) -> Self { + Self { + result: feed.result, + } + } +} + /// A slimmed down version of the AggregatorAccountData struct copied from the switchboard-v2/src/aggregator.rs #[cfg_attr(feature = "client", derive(Clone, Debug))] struct LiteAggregatorAccountData { @@ -739,6 +900,8 @@ mod tests { use pretty_assertions::assert_eq; use rust_decimal::Decimal; + use crate::utils::hex_to_bytes; + use super::*; #[test] fn swb_decimal_test_18() { @@ -1021,4 +1184,92 @@ mod tests { .unwrap() ); } + + use solana_sdk::account::Account; + use std::cell::RefCell; + use std::rc::Rc; + + /// Convert an account to info, useful if you only care about data for testing purposes. + pub fn account_to_account_info<'a>( + account: &'a mut Account, + key: &'a Pubkey, + ) -> AccountInfo<'a> { + AccountInfo { + key, + lamports: Rc::new(RefCell::new(&mut account.lamports)), + data: Rc::new(RefCell::new(&mut account.data[..])), + owner: &account.owner, + rent_epoch: account.rent_epoch, + is_signer: false, + is_writable: true, + executable: account.executable, + } + } + + pub fn create_switch_pull_oracle_account_from_bytes(data: Vec) -> Account { + Account { + lamports: 1_000_000, + data, + owner: switchboard_on_demand::SWITCHBOARD_PROGRAM_ID, + executable: false, + rent_epoch: 361, + } + } + + #[test] + fn swb_pull_get_price() { + // From mainnet: https://solana.fm/address/BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP + // Actual price $155.59404527 + // conf/Std_dev ~5% + let bytes = hex_to_bytes("c41b6cc40ad7db286f5e7566ac000a9530e56b1db49585772719aeaaeeadb4d9bd8c2357b88e9e782e53d81000000000000000000000000000985f538057856308000000000000005cba953f3f15356b17703e554d3983801916531d7976aa424ad64348ec50e4224650d81000000000000000000000000000a0d5a780cc7f580800000000000000a20b742cedab55efd1faf60aef2cb872a092d24dfba8a48c8b953a5e90ac7bbf874ed81000000000000000000000000000c04958360093580800000000000000e7ef024ea756f8beec2eaa40234070da356754a8eeb2ac6a17c32d17c3e99f8ddc50d81000000000000000000000000000bc8739b45d215b0800000000000000e3e5130902c3e9c27917789769f1ae05de15cf504658beafeed2c598a949b3b7bf53d810000000000000000000000000007cec168c94d667080000000000000020e270b743473d87eff321663e267ba1c9a151f7969cef8147f625e9a2af7287ea54d81000000000000000000000000000dc65eccc174d6f0800000000000000ab605484238ac93f225c65f24d7705bb74b00cdb576555c3995e196691a4de5f484ed8100000000000000000000000000088f28dc9271d59080000000000000015196392573dc9043242716f629d4c0fb93bc0cff7a1a10ede24281b0e98fb7d5454d810000000000000000000000000000441a10ca4a268080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048ac38271f28ab1b12e49439bddf54871094e4832a56c7a8ec57bd18d357980086807068432f186a147cf0b13a30067d386204ea9d6c8b04743ac2ef010b07524c935636f2523f6aeeb6dc7b7dab0e86a13ff2c794f7895fc78851d69fdb593bdccdb36600000000000000000000000000e40b540200000001000000534f4c2f55534400000000000000000000000000000000000000000000000000000000019e9eb66600000000fca3d11000000000000000000000000000000000000000000000000000000000000000000000000000dc65eccc174d6f0800000000000000006c9225e039550300000000000000000070d3c6ecddf76b080000000000000000d8244bc073aa060000000000000000000441a10ca4a268080000000000000000dc65eccc174d6f08000000000000000200000000000000ea54d810000000005454d81000000000ea54d81000000000fa0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"); + let mut acc = create_switch_pull_oracle_account_from_bytes(bytes); + let key = pubkey!("BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP"); + let ai = account_to_account_info(&mut acc, &key); + let ai_check = SwitchboardPullPriceFeed::check_ais(&ai); + assert!(ai_check.is_ok()); + + let current_timestamp = 42; + let max_age = 100; + let feed: SwitchboardPullPriceFeed = + SwitchboardPullPriceFeed::load_checked(&ai, current_timestamp, max_age).unwrap(); + let price: I80F48 = feed.get_price().unwrap(); + let conf: I80F48 = feed.get_confidence_interval().unwrap(); + + //println!("price: {:?}, conf: {:?}", price, conf); + + let target_price: I80F48 = I80F48::from_num(155); // Target price is $155 + let price_tolerance: I80F48 = target_price * I80F48::from_num(0.01); + + let target_conf: I80F48 = target_price * I80F48::from_num(0.05); + let conf_tolerance: I80F48 = target_conf * I80F48::from_num(0.005); + + let min_price: I80F48 = target_price.checked_sub(price_tolerance).unwrap(); + let max_price: I80F48 = target_price.checked_add(price_tolerance).unwrap(); + assert!(price >= min_price && price <= max_price); + + let min_conf: I80F48 = target_conf.checked_sub(conf_tolerance).unwrap(); + let max_conf: I80F48 = target_conf.checked_add(conf_tolerance).unwrap(); + assert!(conf >= min_conf && conf <= max_conf); + + let price_bias_none: I80F48 = feed + .get_price_of_type(OraclePriceType::RealTime, None) + .unwrap(); + assert_eq!(price, price_bias_none); + + let price_bias_low: I80F48 = feed + .get_price_of_type(OraclePriceType::RealTime, Some(PriceBias::Low)) + .unwrap(); + let target_price_low: I80F48 = target_price.checked_sub(target_conf).unwrap(); + let min_price: I80F48 = target_price_low.checked_sub(price_tolerance).unwrap(); + let max_price: I80F48 = target_price_low.checked_add(price_tolerance).unwrap(); + assert!(price_bias_low >= min_price && price_bias_low <= max_price); + + let price_bias_high: I80F48 = feed + .get_price_of_type(OraclePriceType::RealTime, Some(PriceBias::High)) + .unwrap(); + let target_price_high: I80F48 = target_price.checked_add(target_conf).unwrap(); + let min_price: I80F48 = target_price_high.checked_sub(price_tolerance).unwrap(); + let max_price: I80F48 = target_price_high.checked_add(price_tolerance).unwrap(); + assert!(price_bias_high >= min_price && price_bias_high <= max_price); + } } diff --git a/programs/marginfi/src/utils.rs b/programs/marginfi/src/utils.rs index 1d629eda..fc79d68b 100644 --- a/programs/marginfi/src/utils.rs +++ b/programs/marginfi/src/utils.rs @@ -178,3 +178,17 @@ fn ceil_div(numerator: u128, denominator: u128) -> Option { .checked_sub(1)? .checked_div(denominator) } + +/// A minimal tool to convert a hex string like "22f123639" into the byte equivalent. +pub fn hex_to_bytes(hex: &str) -> Vec { + hex.as_bytes() + .chunks(2) + .map(|chunk| { + let high = chunk[0] as char; + let low = chunk[1] as char; + let high = high.to_digit(16).expect("Invalid hex character") as u8; + let low = low.to_digit(16).expect("Invalid hex character") as u8; + (high << 4) | low + }) + .collect() +} diff --git a/programs/marginfi/tests/admin_actions/setup_bank.rs b/programs/marginfi/tests/admin_actions/setup_bank.rs index 4a3abe99..5a555538 100644 --- a/programs/marginfi/tests/admin_actions/setup_bank.rs +++ b/programs/marginfi/tests/admin_actions/setup_bank.rs @@ -240,6 +240,7 @@ async fn marginfi_group_add_bank_failure_inexistent_pyth_feed() -> anyhow::Resul #[test_case(BankMint::Usdc)] #[test_case(BankMint::PyUSD)] #[test_case(BankMint::T22WithFee)] +#[test_case(BankMint::SolSwbPull)] #[tokio::test] async fn configure_bank_success(bank_mint: BankMint) -> anyhow::Result<()> { let test_f = TestFixture::new(Some(TestSettings::all_banks_payer_not_admin())).await; @@ -322,7 +323,7 @@ async fn configure_bank_success(bank_mint: BankMint) -> anyhow::Result<()> { check_bank_field!(total_asset_value_init_limit); check_bank_field!(oracle_max_age); - + assert!(permissionless_bad_debt_settlement // If Some(...) check flag set properly diff --git a/programs/marginfi/tests/user_actions/borrow.rs b/programs/marginfi/tests/user_actions/borrow.rs index cdefaebf..b2755368 100644 --- a/programs/marginfi/tests/user_actions/borrow.rs +++ b/programs/marginfi/tests/user_actions/borrow.rs @@ -147,6 +147,7 @@ async fn marginfi_account_borrow_success( #[test_case(128_932., 10_000., 15_000.0, BankMint::PyUSD, BankMint::SolSwb)] #[test_case(240., 0.092, 500., BankMint::PyUSD, BankMint::T22WithFee)] #[test_case(36., 1.7, 1.9, BankMint::T22WithFee, BankMint::Sol)] +#[test_case(1., 100., 155.1, BankMint::SolSwbPull, BankMint::Usdc)] // Sol @ $155 #[tokio::test] async fn marginfi_account_borrow_failure_not_enough_collateral( deposit_amount: f64, @@ -236,6 +237,7 @@ async fn marginfi_account_borrow_failure_not_enough_collateral( #[test_case(11_000., 10_000., 15_000., BankMint::PyUSD, BankMint::SolSwb)] #[test_case(505., 0.092, 500., BankMint::PyUSD, BankMint::T22WithFee)] #[test_case(1.8, 1.7, 1.9, BankMint::T22WithFee, BankMint::Sol)] +#[test_case(1.5, 1.4, 1.6, BankMint::SolSwbPull, BankMint::Usdc)] #[tokio::test] async fn marginfi_account_borrow_failure_borrow_limit( borrow_cap: f64, diff --git a/scripts/single-test.sh b/scripts/single-test.sh new file mode 100755 index 00000000..f3174c35 --- /dev/null +++ b/scripts/single-test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 [--verbose]" + exit 1 +fi + +program_name=$1 +test_name=$2 +verbose=false + +if [ "$#" -eq 3 ] && [ "$3" == "--verbose" ]; then + verbose=true +fi + +ROOT=$(git rev-parse --show-toplevel) +cd $ROOT + +SBF_OUT_DIR="$ROOT/target/deploy" +RUST_LOG="solana_runtime::message_processor::stable_log=debug" +CARGO_CMD="SBF_OUT_DIR=$SBF_OUT_DIR RUST_LOG=$RUST_LOG cargo nextest run --package $program_name --features=test,test-bpf --test-threads=1 -- $test_name" + +echo "Running: $CARGO_CMD" + +if [ "$verbose" == true ]; then + eval $CARGO_CMD +else + eval $CARGO_CMD 2>&1 | awk '/PASS/ {print "\033[32m" $0 "\033[39m"} /FAIL/ {print "\033[31m" $0 "\033[39m"}' +fi diff --git a/test-utils/Cargo.toml b/test-utils/Cargo.toml index 70420d41..d0486a3c 100644 --- a/test-utils/Cargo.toml +++ b/test-utils/Cargo.toml @@ -25,6 +25,7 @@ anchor-spl = { workspace = true } pyth-sdk-solana = { workspace = true } pyth-solana-receiver-sdk = { workspace = true } switchboard-solana = { workspace = true } +switchboard-on-demand = { workspace = true } bytemuck = "1.9.1" fixed = "1.12.0" diff --git a/test-utils/data/BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP.bin b/test-utils/data/BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP.bin new file mode 100644 index 0000000000000000000000000000000000000000..70257e12a4d5f9ff6af6b2729b4d114cbade4f4c GIT binary patch literal 3208 zcmX>iopXfi`fZK;xYD#W3|vzUo@UE#nc7;eF1c>iyR}%Gp7q6L#K`Tkz9q^EnTRosL%)%)B1(DWbhj4)%E4A=ks zJ(J(^upj&0=&f>4b||=InjW&^-KI5J;)iv`55Jt>dk5YAJ?)lTVilud_CJ0q%*k~4 z<)KRPis_jj*RkFcJs;o}v2XpmOGjs{^xV9CKf3>G-iY-~xt0#oukff~yR*A(`}@y| zX?AMW3r{W#{61~Y`$qR~sxKF z7cS(xr=lUvH{*A02)g|&jthB~EXsiCM~>Q2_Gk!pSzjVcq~^m$D+9J#izJp;b91^`N~{jO zXXIuN@|hfF_9@6d>)p0H)wQen+7{Y>IzHulXZ-Pwz-#kwM_S)GyEzRdG@o#XFaZS^ zfjHRTM?W;!1w|T-!#Ho=Hn9Fbi!Y+KL(mk$xoGt(L(U}C2bQ7CaQTAE$KKrio(-@6 qZm4)4C|<<|mq)K3(aZz69Ry(YI;?($@qa<2N6FC;7!3g;LI40|fBks? literal 0 HcmV?d00001 diff --git a/test-utils/src/test.rs b/test-utils/src/test.rs index c366b147..f8e53db7 100644 --- a/test-utils/src/test.rs +++ b/test-utils/src/test.rs @@ -51,6 +51,10 @@ impl TestSettings { mint: BankMint::SolSwb, ..TestBankSetting::default() }, + TestBankSetting { + mint: BankMint::SolSwbPull, + ..TestBankSetting::default() + }, TestBankSetting { mint: BankMint::SolEquivalent, ..TestBankSetting::default() @@ -153,6 +157,7 @@ pub enum BankMint { UsdcSwb, Sol, SolSwb, + SolSwbPull, SolEquivalent, SolEquivalent1, SolEquivalent2, @@ -210,6 +215,9 @@ pub const PYTH_SOL_REAL_FEED: Pubkey = pubkey!("PythSo1Rea1Price1111111111111111 pub const PYTH_USDC_REAL_FEED: Pubkey = pubkey!("PythUsdcRea1Price11111111111111111111111111"); pub const PYTH_PUSH_SOL_REAL_FEED: Pubkey = pubkey!("PythPushSo1Rea1Price11111111111111111111111"); +pub const SWITCH_PULL_SOL_REAL_FEED: Pubkey = + pubkey!("BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP"); + pub fn get_oracle_id_from_feed_id(feed_id: Pubkey) -> Option { match feed_id.to_bytes() { PYTH_PUSH_FULLV_FEED_ID => Some(PYTH_PUSH_SOL_FULLV_FEED), @@ -359,6 +367,13 @@ lazy_static! { oracle_max_age: 100, ..*DEFAULT_TEST_BANK_CONFIG }; + pub static ref DEFAULT_SB_PULL_SOL_TEST_REAL_BANK_CONFIG: BankConfig = BankConfig { + oracle_setup: OracleSetup::SwitchboardPull, + deposit_limit: native!(1_000_000, "SOL"), + borrow_limit: native!(1_000_000, "SOL"), + oracle_keys: create_oracle_key_array(SWITCH_PULL_SOL_REAL_FEED), + ..*DEFAULT_TEST_BANK_CONFIG + }; } pub const USDC_MINT_DECIMALS: u8 = 6; @@ -514,6 +529,15 @@ impl TestFixture { ), ); + // From mainnet: https://solana.fm/address/BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP + // Sol @ ~ $153 + program.add_account( + SWITCH_PULL_SOL_REAL_FEED, + create_switch_pull_oracle_account_from_bytes( + include_bytes!("../data/BSzfJs4d1tAkSDqkepnfzEVcx2WtDVnwwXa2giy9PLeP.bin").to_vec(), + ), + ); + let context = Rc::new(RefCell::new(program.start_with_context().await)); { @@ -583,6 +607,9 @@ impl TestFixture { BankMint::UsdcSwb => (&usdc_mint_f, *DEFAULT_USDC_TEST_SW_BANK_CONFIG), BankMint::Sol => (&sol_mint_f, *DEFAULT_SOL_TEST_BANK_CONFIG), BankMint::SolSwb => (&sol_mint_f, *DEFAULT_SOL_TEST_SW_BANK_CONFIG), + BankMint::SolSwbPull => { + (&sol_mint_f, *DEFAULT_SB_PULL_SOL_TEST_REAL_BANK_CONFIG) + } BankMint::SolEquivalent => ( &sol_equivalent_mint_f, *DEFAULT_SOL_EQUIVALENT_TEST_BANK_CONFIG, diff --git a/test-utils/src/utils.rs b/test-utils/src/utils.rs index e45c8313..c95446b9 100644 --- a/test-utils/src/utils.rs +++ b/test-utils/src/utils.rs @@ -148,6 +148,16 @@ pub fn create_pyth_push_oracle_account( create_pyth_push_oracle_account_from_bytes(data) } +pub fn create_switch_pull_oracle_account_from_bytes(data: Vec) -> Account { + Account { + lamports: 1_000_000, + data, + owner: switchboard_on_demand::SWITCHBOARD_PROGRAM_ID, + executable: false, + rent_epoch: 361, + } +} + pub fn create_switchboard_price_feed(ui_price: i64, mint_decimals: i32) -> Account { let native_price = ui_price * 10_i64.pow(mint_decimals as u32); let aggregator_account = switchboard_solana::AggregatorAccountData {