solend_sdk/
oracles.rs

1#![allow(missing_docs)]
2use crate::{
3    self as solend_program,
4    error::LendingError,
5    math::{Decimal, TryDiv, TryMul},
6};
7use pyth_sdk_solana;
8use solana_program::{
9    account_info::AccountInfo, msg, program_error::ProgramError, sysvar::clock::Clock,
10};
11use std::{convert::TryInto, result::Result};
12
13pub fn get_pyth_price(
14    pyth_price_info: &AccountInfo,
15    clock: &Clock,
16) -> Result<Decimal, ProgramError> {
17    const PYTH_CONFIDENCE_RATIO: u64 = 10;
18    const STALE_AFTER_SLOTS_ELAPSED: u64 = 240; // roughly 2 min
19
20    if *pyth_price_info.key == solend_program::NULL_PUBKEY {
21        return Err(LendingError::NullOracleConfig.into());
22    }
23
24    let data = &pyth_price_info.try_borrow_data()?;
25    let price_account = pyth_sdk_solana::state::load_price_account(data).map_err(|e| {
26        msg!("Couldn't load price feed from account info: {:?}", e);
27        LendingError::InvalidOracleConfig
28    })?;
29    let pyth_price = price_account
30        .get_price_no_older_than(clock, STALE_AFTER_SLOTS_ELAPSED)
31        .ok_or_else(|| {
32            msg!("Pyth oracle price is too stale!");
33            LendingError::InvalidOracleConfig
34        })?;
35
36    let price: u64 = pyth_price.price.try_into().map_err(|_| {
37        msg!("Oracle price cannot be negative");
38        LendingError::InvalidOracleConfig
39    })?;
40
41    // Perhaps confidence_ratio should exist as a per reserve config
42    // 100/confidence_ratio = maximum size of confidence range as a percent of price
43    // confidence_ratio of 10 filters out pyth prices with conf > 10% of price
44    if pyth_price.conf.saturating_mul(PYTH_CONFIDENCE_RATIO) > price {
45        msg!(
46            "Oracle price confidence is too wide. price: {}, conf: {}",
47            price,
48            pyth_price.conf,
49        );
50        return Err(LendingError::InvalidOracleConfig.into());
51    }
52
53    let market_price = if pyth_price.expo >= 0 {
54        let exponent = pyth_price
55            .expo
56            .try_into()
57            .map_err(|_| LendingError::MathOverflow)?;
58        let zeros = 10u64
59            .checked_pow(exponent)
60            .ok_or(LendingError::MathOverflow)?;
61        Decimal::from(price).try_mul(zeros)?
62    } else {
63        let exponent = pyth_price
64            .expo
65            .checked_abs()
66            .ok_or(LendingError::MathOverflow)?
67            .try_into()
68            .map_err(|_| LendingError::MathOverflow)?;
69        let decimals = 10u64
70            .checked_pow(exponent)
71            .ok_or(LendingError::MathOverflow)?;
72        Decimal::from(price).try_div(decimals)?
73    };
74
75    Ok(market_price)
76}
77
78#[cfg(test)]
79mod test {
80    use super::*;
81    use bytemuck::bytes_of_mut;
82    use proptest::prelude::*;
83    use pyth_sdk_solana::state::{
84        AccountType, CorpAction, PriceAccount, PriceInfo, PriceStatus, PriceType, MAGIC, VERSION_2,
85    };
86    use solana_program::pubkey::Pubkey;
87
88    #[derive(Clone, Debug)]
89    struct PythPriceTestCase {
90        price_account: PriceAccount,
91        clock: Clock,
92        expected_result: Result<Decimal, ProgramError>,
93    }
94
95    fn pyth_price_cases() -> impl Strategy<Value = PythPriceTestCase> {
96        prop_oneof![
97            // case 2: failure. bad magic value
98            Just(PythPriceTestCase {
99                price_account: PriceAccount {
100                    magic: MAGIC + 1,
101                    ver: VERSION_2,
102                    atype: AccountType::Price as u32,
103                    ptype: PriceType::Price,
104                    expo: 10,
105                    agg: PriceInfo {
106                        price: 10,
107                        conf: 1,
108                        status: PriceStatus::Trading,
109                        corp_act: CorpAction::NoCorpAct,
110                        pub_slot: 0
111                    },
112                    ..PriceAccount::default()
113                },
114                clock: Clock {
115                    slot: 4,
116                    ..Clock::default()
117                },
118                // PythError::InvalidAccountData.
119                expected_result: Err(LendingError::InvalidOracleConfig.into()),
120            }),
121            // case 3: failure. bad version number
122            Just(PythPriceTestCase {
123                price_account: PriceAccount {
124                    magic: MAGIC,
125                    ver: VERSION_2 - 1,
126                    atype: AccountType::Price as u32,
127                    ptype: PriceType::Price,
128                    expo: 10,
129                    agg: PriceInfo {
130                        price: 10,
131                        conf: 1,
132                        status: PriceStatus::Trading,
133                        corp_act: CorpAction::NoCorpAct,
134                        pub_slot: 0
135                    },
136                    ..PriceAccount::default()
137                },
138                clock: Clock {
139                    slot: 4,
140                    ..Clock::default()
141                },
142                expected_result: Err(LendingError::InvalidOracleConfig.into()),
143            }),
144            // case 4: failure. bad account type
145            Just(PythPriceTestCase {
146                price_account: PriceAccount {
147                    magic: MAGIC,
148                    ver: VERSION_2,
149                    atype: AccountType::Product as u32,
150                    ptype: PriceType::Price,
151                    expo: 10,
152                    agg: PriceInfo {
153                        price: 10,
154                        conf: 1,
155                        status: PriceStatus::Trading,
156                        corp_act: CorpAction::NoCorpAct,
157                        pub_slot: 0
158                    },
159                    ..PriceAccount::default()
160                },
161                clock: Clock {
162                    slot: 4,
163                    ..Clock::default()
164                },
165                expected_result: Err(LendingError::InvalidOracleConfig.into()),
166            }),
167            // case 5: ignore. bad price type is fine. not testing this
168            // case 6: success. most recent price has status == trading, not stale
169            Just(PythPriceTestCase {
170                price_account: PriceAccount {
171                    magic: MAGIC,
172                    ver: VERSION_2,
173                    atype: AccountType::Price as u32,
174                    ptype: PriceType::Price,
175                    expo: 1,
176                    timestamp: 0,
177                    agg: PriceInfo {
178                        price: 200,
179                        conf: 1,
180                        status: PriceStatus::Trading,
181                        corp_act: CorpAction::NoCorpAct,
182                        pub_slot: 0
183                    },
184                    ..PriceAccount::default()
185                },
186                clock: Clock {
187                    slot: 240,
188                    ..Clock::default()
189                },
190                expected_result: Ok(Decimal::from(2000_u64))
191            }),
192            // case 7: success. most recent price has status == unknown, previous price not stale
193            Just(PythPriceTestCase {
194                price_account: PriceAccount {
195                    magic: MAGIC,
196                    ver: VERSION_2,
197                    atype: AccountType::Price as u32,
198                    ptype: PriceType::Price,
199                    expo: 1,
200                    timestamp: 20,
201                    agg: PriceInfo {
202                        price: 200,
203                        conf: 1,
204                        status: PriceStatus::Unknown,
205                        corp_act: CorpAction::NoCorpAct,
206                        pub_slot: 1
207                    },
208                    prev_price: 190,
209                    prev_conf: 10,
210                    prev_slot: 0,
211                    ..PriceAccount::default()
212                },
213                clock: Clock {
214                    slot: 240,
215                    ..Clock::default()
216                },
217                expected_result: Ok(Decimal::from(1900_u64))
218            }),
219            // case 8: failure. most recent price is stale
220            Just(PythPriceTestCase {
221                price_account: PriceAccount {
222                    magic: MAGIC,
223                    ver: VERSION_2,
224                    atype: AccountType::Price as u32,
225                    ptype: PriceType::Price,
226                    expo: 1,
227                    timestamp: 0,
228                    agg: PriceInfo {
229                        price: 200,
230                        conf: 1,
231                        status: PriceStatus::Trading,
232                        corp_act: CorpAction::NoCorpAct,
233                        pub_slot: 1
234                    },
235                    prev_slot: 0, // there is no case where prev_slot > agg.pub_slot
236                    ..PriceAccount::default()
237                },
238                clock: Clock {
239                    slot: 242,
240                    ..Clock::default()
241                },
242                expected_result: Err(LendingError::InvalidOracleConfig.into())
243            }),
244            // case 9: failure. most recent price has status == unknown and previous price is stale
245            Just(PythPriceTestCase {
246                price_account: PriceAccount {
247                    magic: MAGIC,
248                    ver: VERSION_2,
249                    atype: AccountType::Price as u32,
250                    ptype: PriceType::Price,
251                    expo: 1,
252                    timestamp: 1,
253                    agg: PriceInfo {
254                        price: 200,
255                        conf: 1,
256                        status: PriceStatus::Unknown,
257                        corp_act: CorpAction::NoCorpAct,
258                        pub_slot: 1
259                    },
260                    prev_price: 190,
261                    prev_conf: 10,
262                    prev_slot: 0,
263                    ..PriceAccount::default()
264                },
265                clock: Clock {
266                    slot: 241,
267                    ..Clock::default()
268                },
269                expected_result: Err(LendingError::InvalidOracleConfig.into())
270            }),
271            // case 10: failure. price is negative
272            Just(PythPriceTestCase {
273                price_account: PriceAccount {
274                    magic: MAGIC,
275                    ver: VERSION_2,
276                    atype: AccountType::Price as u32,
277                    ptype: PriceType::Price,
278                    expo: 1,
279                    timestamp: 1,
280                    agg: PriceInfo {
281                        price: -200,
282                        conf: 1,
283                        status: PriceStatus::Trading,
284                        corp_act: CorpAction::NoCorpAct,
285                        pub_slot: 0
286                    },
287                    ..PriceAccount::default()
288                },
289                clock: Clock {
290                    slot: 240,
291                    ..Clock::default()
292                },
293                expected_result: Err(LendingError::InvalidOracleConfig.into())
294            }),
295            // case 11: failure. confidence interval is too wide
296            Just(PythPriceTestCase {
297                price_account: PriceAccount {
298                    magic: MAGIC,
299                    ver: VERSION_2,
300                    atype: AccountType::Price as u32,
301                    ptype: PriceType::Price,
302                    expo: 1,
303                    timestamp: 1,
304                    agg: PriceInfo {
305                        price: 200,
306                        conf: 40,
307                        status: PriceStatus::Trading,
308                        corp_act: CorpAction::NoCorpAct,
309                        pub_slot: 0
310                    },
311                    ..PriceAccount::default()
312                },
313                clock: Clock {
314                    slot: 240,
315                    ..Clock::default()
316                },
317                expected_result: Err(LendingError::InvalidOracleConfig.into())
318            }),
319        ]
320    }
321
322    proptest! {
323        #[test]
324        fn test_pyth_price(mut test_case in pyth_price_cases()) {
325            // wrap price account into an account info
326            let mut lamports = 20;
327            let pubkey = Pubkey::new_unique();
328            let account_info = AccountInfo::new(
329                &pubkey,
330                false,
331                false,
332                &mut lamports,
333                bytes_of_mut(&mut test_case.price_account),
334                &pubkey,
335                false,
336                0,
337            );
338
339            let result = get_pyth_price(&account_info, &test_case.clock);
340            assert_eq!(
341                result,
342                test_case.expected_result,
343                "actual: {:#?} expected: {:#?}",
344                result,
345                test_case.expected_result
346            );
347        }
348    }
349}