pyth_solana_receiver_sdk/
price_update.rs

1pub use pythnet_sdk::messages::{FeedId, PriceFeedMessage};
2use {
3    crate::{check, error::GetPriceError},
4    anchor_lang::prelude::{borsh::BorshSchema, *},
5    solana_program::pubkey::Pubkey,
6};
7
8/// Pyth price updates are bridged to all blockchains via Wormhole.
9/// Using the price updates on another chain requires verifying the signatures of the Wormhole guardians.
10/// The usual process is to check the signatures for two thirds of the total number of guardians, but this can be cumbersome on Solana because of the transaction size limits,
11/// so we also allow for partial verification.
12///
13/// This enum represents how much a price update has been verified:
14/// - If `Full`, we have verified the signatures for two thirds of the current guardians.
15/// - If `Partial`, only `num_signatures` guardian signatures have been checked.
16///
17/// # Warning
18/// Using partially verified price updates is dangerous, as it lowers the threshold of guardians that need to collude to produce a malicious price update.
19#[derive(AnchorSerialize, AnchorDeserialize, Copy, Clone, PartialEq, BorshSchema, Debug)]
20pub enum VerificationLevel {
21    Partial {
22        #[allow(unused)]
23        num_signatures: u8,
24    },
25    Full,
26}
27
28impl VerificationLevel {
29    /// Compare two `VerificationLevel`.
30    /// `Full` is always greater than `Partial`, and `Partial` with more signatures is greater than `Partial` with fewer signatures.
31    pub fn gte(&self, other: VerificationLevel) -> bool {
32        match self {
33            VerificationLevel::Full => true,
34            VerificationLevel::Partial { num_signatures } => match other {
35                VerificationLevel::Full => false,
36                VerificationLevel::Partial {
37                    num_signatures: other_num_signatures,
38                } => *num_signatures >= other_num_signatures,
39            },
40        }
41    }
42}
43
44/// A price update account. This account is used by the Pyth Receiver program to store a verified price update from a Pyth price feed.
45/// It contains:
46/// - `write_authority`: The write authority for this account. This authority can close this account to reclaim rent or update the account to contain a different price update.
47/// - `verification_level`: The [`VerificationLevel`] of this price update. This represents how many Wormhole guardian signatures have been verified for this price update.
48/// - `price_message`: The actual price update.
49/// - `posted_slot`: The slot at which this price update was posted.
50#[account]
51#[derive(BorshSchema)]
52pub struct PriceUpdateV2 {
53    pub write_authority: Pubkey,
54    pub verification_level: VerificationLevel,
55    pub price_message: PriceFeedMessage,
56    pub posted_slot: u64,
57}
58
59impl PriceUpdateV2 {
60    pub const LEN: usize = 8 + 32 + 2 + 32 + 8 + 8 + 4 + 8 + 8 + 8 + 8 + 8;
61}
62/// A time weighted average price account.
63/// This account is used by the Pyth Receiver program to store a TWAP update from a Pyth price feed.
64/// TwapUpdates can only be created after the client has verified the VAAs via the Wormhole contract.
65/// Check out `target_chains/solana/cli/src/main.rs` for an example of how to do this.
66///
67/// It contains:
68/// - `write_authority`: The write authority for this account. This authority can close this account to reclaim rent or update the account to contain a different TWAP update.
69/// - `twap`: The actual TWAP update.
70#[account]
71#[derive(BorshSchema)]
72pub struct TwapUpdate {
73    pub write_authority: Pubkey,
74    pub twap: TwapPrice,
75}
76
77impl TwapUpdate {
78    pub const LEN: usize = (
79        8 // account discriminator (anchor)
80        + 32 // write_authority
81        + (32 + 8 + 8 + 8 + 8 + 4 + 4)
82        // twap
83    );
84
85    /// Get a `TwapPrice` from a `TwapUpdate` account for a given `FeedId`.
86    ///
87    /// # Warning
88    /// This function does not check :
89    /// - How recent the price is
90    /// - If the TWAP's window size is expected
91    /// - Whether the price update has been verified
92    ///
93    /// It is therefore unsafe to use this function without any extra checks,
94    /// as it allows for the possibility of using unverified, outdated, or arbitrary window length twap updates.
95    pub fn get_twap_unchecked(
96        &self,
97        feed_id: &FeedId,
98    ) -> std::result::Result<TwapPrice, GetPriceError> {
99        check!(
100            self.twap.feed_id == *feed_id,
101            GetPriceError::MismatchedFeedId
102        );
103        Ok(self.twap)
104    }
105    /// Get a `TwapPrice` from a `TwapUpdate` account for a given `FeedId` no older than `maximum_age` with a specific window size.
106    ///
107    /// # Example
108    /// ```
109    /// use pyth_solana_receiver_sdk::price_update::{get_feed_id_from_hex, TwapUpdate};
110    /// use anchor_lang::prelude::*;
111    ///
112    /// const MAXIMUM_AGE: u64 = 30;
113    /// const WINDOW_SECONDS: u64 = 300; // 5-minute TWAP
114    /// const FEED_ID: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; // SOL/USD
115    ///
116    /// #[derive(Accounts)]
117    /// pub struct ReadTwapAccount<'info> {
118    ///     pub twap_update: Account<'info, TwapUpdate>,
119    /// }
120    ///
121    /// pub fn read_twap_account(ctx: Context<ReadTwapAccount>) -> Result<()> {
122    ///     let twap_update = &ctx.accounts.twap_update;
123    ///     let twap = twap_update.get_twap_no_older_than(
124    ///         &Clock::get()?,
125    ///         MAXIMUM_AGE,
126    ///         WINDOW_SECONDS,
127    ///         &get_feed_id_from_hex(FEED_ID)?
128    ///     )?;
129    ///     Ok(())
130    /// }
131    /// ```
132    pub fn get_twap_no_older_than(
133        &self,
134        clock: &Clock,
135        maximum_age: u64,
136        window_seconds: u64,
137        feed_id: &FeedId,
138    ) -> std::result::Result<TwapPrice, GetPriceError> {
139        // Ensure the update isn't outdated
140        let twap_price = self.get_twap_unchecked(feed_id)?;
141        check!(
142            twap_price
143                .end_time
144                .saturating_add(maximum_age.try_into().unwrap())
145                >= clock.unix_timestamp,
146            GetPriceError::PriceTooOld
147        );
148
149        // Ensure the twap window size is as expected
150        let actual_window = twap_price.end_time.saturating_sub(twap_price.start_time);
151        check!(
152            actual_window == i64::try_from(window_seconds).unwrap(),
153            GetPriceError::InvalidWindowSize
154        );
155
156        Ok(twap_price)
157    }
158}
159/// The time weighted average price & conf for a feed over the window [start_time, end_time].
160/// This type is used to persist the calculated TWAP in TwapUpdate accounts on Solana.
161#[derive(AnchorSerialize, AnchorDeserialize, Copy, Clone, PartialEq, BorshSchema, Debug)]
162pub struct TwapPrice {
163    pub feed_id: FeedId,
164    pub start_time: i64,
165    pub end_time: i64,
166    pub price: i64,
167    pub conf: u64,
168    pub exponent: i32,
169    /// Ratio out of 1_000_000, where a value of 1_000_000 represents
170    /// all slots were missed and 0 represents no slots were missed.
171    pub down_slots_ratio: u32,
172}
173
174/// A Pyth price.
175/// The actual price is `(price ± conf)* 10^exponent`. `publish_time` may be used to check the recency of the price.
176#[derive(PartialEq, Debug, Clone, Copy)]
177pub struct Price {
178    pub price: i64,
179    pub conf: u64,
180    pub exponent: i32,
181    pub publish_time: i64,
182}
183
184impl PriceUpdateV2 {
185    /// Get a `Price` from a `PriceUpdateV2` account for a given `FeedId`.
186    ///
187    /// # Warning
188    /// This function does not check :
189    /// - How recent the price is
190    /// - Whether the price update has been verified
191    ///
192    /// It is therefore unsafe to use this function without any extra checks, as it allows for the possibility of using unverified or outdated price updates.
193    pub fn get_price_unchecked(
194        &self,
195        feed_id: &FeedId,
196    ) -> std::result::Result<Price, GetPriceError> {
197        check!(
198            self.price_message.feed_id == *feed_id,
199            GetPriceError::MismatchedFeedId
200        );
201        Ok(Price {
202            price: self.price_message.price,
203            conf: self.price_message.conf,
204            exponent: self.price_message.exponent,
205            publish_time: self.price_message.publish_time,
206        })
207    }
208
209    /// Get a `Price` from a `PriceUpdateV2` account for a given `FeedId` no older than `maximum_age` with customizable verification level.
210    ///
211    /// # Warning
212    /// Lowering the verification level from `Full` to `Partial` increases the risk of using a malicious price update.
213    /// Please read the documentation for [`VerificationLevel`] for more information.
214    ///
215    /// # Example
216    /// ```
217    /// use pyth_solana_receiver_sdk::price_update::{get_feed_id_from_hex, VerificationLevel, PriceUpdateV2};
218    /// use anchor_lang::prelude::*;
219    ///
220    /// const MAXIMUM_AGE : u64 = 30;
221    /// const FEED_ID: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; // SOL/USD
222    ///
223    /// #[derive(Accounts)]
224    /// #[instruction(amount_in_usd : u64)]
225    /// pub struct ReadPriceAccount<'info> {
226    ///     pub price_update: Account<'info, PriceUpdateV2>,
227    /// }
228    ///
229    /// pub fn read_price_account(ctx : Context<ReadPriceAccount>) -> Result<()> {
230    ///     let price_update = &mut ctx.accounts.price_update;
231    ///     let price = price_update.get_price_no_older_than_with_custom_verification_level(&Clock::get()?, MAXIMUM_AGE, &get_feed_id_from_hex(FEED_ID)?, VerificationLevel::Partial{num_signatures: 5})?;
232    ///     Ok(())
233    /// }
234    ///```
235    pub fn get_price_no_older_than_with_custom_verification_level(
236        &self,
237        clock: &Clock,
238        maximum_age: u64,
239        feed_id: &FeedId,
240        verification_level: VerificationLevel,
241    ) -> std::result::Result<Price, GetPriceError> {
242        check!(
243            self.verification_level.gte(verification_level),
244            GetPriceError::InsufficientVerificationLevel
245        );
246        let price = self.get_price_unchecked(feed_id)?;
247        check!(
248            price
249                .publish_time
250                .saturating_add(maximum_age.try_into().unwrap())
251                >= clock.unix_timestamp,
252            GetPriceError::PriceTooOld
253        );
254        Ok(price)
255    }
256
257    /// Get a `Price` from a `PriceUpdateV2` account for a given `FeedId` no older than `maximum_age` with `Full` verification.
258    ///
259    /// # Example
260    /// ```
261    /// use pyth_solana_receiver_sdk::price_update::{get_feed_id_from_hex, PriceUpdateV2};
262    /// use anchor_lang::prelude::*;
263    ///
264    /// const MAXIMUM_AGE : u64 = 30;
265    /// const FEED_ID: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; // SOL/USD
266    ///
267    /// #[derive(Accounts)]
268    /// #[instruction(amount_in_usd : u64)]
269    /// pub struct ReadPriceAccount<'info> {
270    ///     pub price_update: Account<'info, PriceUpdateV2>,
271    /// }
272    ///
273    /// pub fn read_price_account(ctx : Context<ReadPriceAccount>) -> Result<()> {
274    ///     let price_update = &mut ctx.accounts.price_update;
275    ///     let price = price_update.get_price_no_older_than(&Clock::get()?, MAXIMUM_AGE, &get_feed_id_from_hex(FEED_ID)?)?;
276    ///     Ok(())
277    /// }
278    ///```
279    pub fn get_price_no_older_than(
280        &self,
281        clock: &Clock,
282        maximum_age: u64,
283        feed_id: &FeedId,
284    ) -> std::result::Result<Price, GetPriceError> {
285        self.get_price_no_older_than_with_custom_verification_level(
286            clock,
287            maximum_age,
288            feed_id,
289            VerificationLevel::Full,
290        )
291    }
292}
293
294/// Get a `FeedId` from a hex string.
295///
296/// Price feed ids are a 32 byte unique identifier for each price feed in the Pyth network.
297/// They are sometimes represented as a 64 character hex string (with or without a 0x prefix).
298///
299/// # Example
300///
301/// ```
302/// use pyth_solana_receiver_sdk::price_update::get_feed_id_from_hex;
303/// let feed_id = get_feed_id_from_hex("0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d").unwrap();
304/// ```
305pub fn get_feed_id_from_hex(input: &str) -> std::result::Result<FeedId, GetPriceError> {
306    let mut feed_id: FeedId = [0; 32];
307    match input.len() {
308        66 => feed_id.copy_from_slice(
309            &hex::decode(&input[2..]).map_err(|_| GetPriceError::FeedIdNonHexCharacter)?,
310        ),
311        64 => feed_id.copy_from_slice(
312            &hex::decode(input).map_err(|_| GetPriceError::FeedIdNonHexCharacter)?,
313        ),
314        _ => return Err(GetPriceError::FeedIdMustBe32Bytes),
315    }
316    Ok(feed_id)
317}
318
319#[cfg(test)]
320pub mod tests {
321    use {
322        crate::{
323            error::GetPriceError,
324            price_update::{Price, PriceUpdateV2, TwapPrice, TwapUpdate, VerificationLevel},
325        },
326        anchor_lang::Discriminator,
327        pythnet_sdk::messages::PriceFeedMessage,
328        solana_program::{borsh0_10, clock::Clock, pubkey::Pubkey},
329    };
330
331    #[test]
332    fn check_size() {
333        assert!(
334            PriceUpdateV2::discriminator().len() + borsh0_10::get_packed_len::<PriceUpdateV2>()
335                == PriceUpdateV2::LEN
336        );
337    }
338
339    #[test]
340    fn gte() {
341        assert!(VerificationLevel::Full.gte(VerificationLevel::Full));
342        assert!(VerificationLevel::Full.gte(VerificationLevel::Partial {
343            num_signatures: 255,
344        }));
345        assert!(VerificationLevel::Partial { num_signatures: 8 }
346            .gte(VerificationLevel::Partial { num_signatures: 8 }));
347        assert!(!VerificationLevel::Partial { num_signatures: 8 }.gte(VerificationLevel::Full));
348        assert!(!VerificationLevel::Partial { num_signatures: 8 }
349            .gte(VerificationLevel::Partial { num_signatures: 9 }));
350    }
351
352    #[test]
353    fn get_feed_id_from_hex() {
354        let feed_id = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
355        let expected_feed_id = [
356            239, 13, 139, 111, 218, 44, 235, 164, 29, 161, 93, 64, 149, 209, 218, 57, 42, 13, 47,
357            142, 208, 198, 199, 188, 15, 76, 250, 200, 194, 128, 181, 109,
358        ];
359        assert_eq!(super::get_feed_id_from_hex(feed_id), Ok(expected_feed_id));
360        assert_eq!(
361            super::get_feed_id_from_hex(&feed_id[2..]),
362            Ok(expected_feed_id)
363        );
364
365        assert_eq!(
366            super::get_feed_id_from_hex(&feed_id[..64]),
367            Err(GetPriceError::FeedIdNonHexCharacter)
368        );
369
370        assert_eq!(
371            super::get_feed_id_from_hex(
372                "ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b5"
373            ),
374            Err(GetPriceError::FeedIdMustBe32Bytes)
375        );
376    }
377
378    #[test]
379    fn get_price() {
380        let expected_price = Price {
381            price: 1,
382            conf: 2,
383            exponent: 3,
384            publish_time: 900,
385        };
386
387        let feed_id = [0; 32];
388        let mismatched_feed_id = [1; 32];
389        let mock_clock = Clock {
390            unix_timestamp: 1000,
391            ..Default::default()
392        };
393
394        let price_update_unverified = PriceUpdateV2 {
395            write_authority: Pubkey::new_unique(),
396            verification_level: VerificationLevel::Partial { num_signatures: 0 },
397            price_message: PriceFeedMessage {
398                feed_id,
399                ema_conf: 0,
400                ema_price: 0,
401                price: 1,
402                conf: 2,
403                exponent: 3,
404                prev_publish_time: 899,
405                publish_time: 900,
406            },
407            posted_slot: 0,
408        };
409
410        let price_update_partially_verified = PriceUpdateV2 {
411            write_authority: Pubkey::new_unique(),
412            verification_level: VerificationLevel::Partial { num_signatures: 5 },
413            price_message: PriceFeedMessage {
414                feed_id,
415                ema_conf: 0,
416                ema_price: 0,
417                price: 1,
418                conf: 2,
419                exponent: 3,
420                prev_publish_time: 899,
421                publish_time: 900,
422            },
423            posted_slot: 0,
424        };
425
426        let price_update_fully_verified = PriceUpdateV2 {
427            write_authority: Pubkey::new_unique(),
428            verification_level: VerificationLevel::Full,
429            price_message: PriceFeedMessage {
430                feed_id,
431                ema_conf: 0,
432                ema_price: 0,
433                price: 1,
434                conf: 2,
435                exponent: 3,
436                prev_publish_time: 899,
437                publish_time: 900,
438            },
439            posted_slot: 0,
440        };
441
442        assert_eq!(
443            price_update_unverified.get_price_unchecked(&feed_id),
444            Ok(expected_price)
445        );
446        assert_eq!(
447            price_update_partially_verified.get_price_unchecked(&feed_id),
448            Ok(expected_price)
449        );
450        assert_eq!(
451            price_update_fully_verified.get_price_unchecked(&feed_id),
452            Ok(expected_price)
453        );
454
455        assert_eq!(
456            price_update_unverified.get_price_no_older_than_with_custom_verification_level(
457                &mock_clock,
458                100,
459                &feed_id,
460                VerificationLevel::Partial { num_signatures: 5 }
461            ),
462            Err(GetPriceError::InsufficientVerificationLevel)
463        );
464        assert_eq!(
465            price_update_partially_verified.get_price_no_older_than_with_custom_verification_level(
466                &mock_clock,
467                100,
468                &feed_id,
469                VerificationLevel::Partial { num_signatures: 5 }
470            ),
471            Ok(expected_price)
472        );
473        assert_eq!(
474            price_update_fully_verified.get_price_no_older_than_with_custom_verification_level(
475                &mock_clock,
476                100,
477                &feed_id,
478                VerificationLevel::Partial { num_signatures: 5 }
479            ),
480            Ok(expected_price)
481        );
482
483        assert_eq!(
484            price_update_unverified.get_price_no_older_than(&mock_clock, 100, &feed_id,),
485            Err(GetPriceError::InsufficientVerificationLevel)
486        );
487        assert_eq!(
488            price_update_partially_verified.get_price_no_older_than(&mock_clock, 100, &feed_id,),
489            Err(GetPriceError::InsufficientVerificationLevel)
490        );
491        assert_eq!(
492            price_update_fully_verified.get_price_no_older_than(&mock_clock, 100, &feed_id,),
493            Ok(expected_price)
494        );
495
496        // Reduce maximum_age
497        assert_eq!(
498            price_update_unverified.get_price_no_older_than_with_custom_verification_level(
499                &mock_clock,
500                10,
501                &feed_id,
502                VerificationLevel::Partial { num_signatures: 5 }
503            ),
504            Err(GetPriceError::InsufficientVerificationLevel)
505        );
506        assert_eq!(
507            price_update_partially_verified.get_price_no_older_than_with_custom_verification_level(
508                &mock_clock,
509                10,
510                &feed_id,
511                VerificationLevel::Partial { num_signatures: 5 }
512            ),
513            Err(GetPriceError::PriceTooOld)
514        );
515        assert_eq!(
516            price_update_fully_verified.get_price_no_older_than_with_custom_verification_level(
517                &mock_clock,
518                10,
519                &feed_id,
520                VerificationLevel::Partial { num_signatures: 5 }
521            ),
522            Err(GetPriceError::PriceTooOld)
523        );
524
525        assert_eq!(
526            price_update_unverified.get_price_no_older_than(&mock_clock, 10, &feed_id,),
527            Err(GetPriceError::InsufficientVerificationLevel)
528        );
529        assert_eq!(
530            price_update_partially_verified.get_price_no_older_than(&mock_clock, 10, &feed_id,),
531            Err(GetPriceError::InsufficientVerificationLevel)
532        );
533        assert_eq!(
534            price_update_fully_verified.get_price_no_older_than(&mock_clock, 10, &feed_id,),
535            Err(GetPriceError::PriceTooOld)
536        );
537
538        // Mismatched feed id
539        assert_eq!(
540            price_update_fully_verified.get_price_unchecked(&mismatched_feed_id),
541            Err(GetPriceError::MismatchedFeedId)
542        );
543        assert_eq!(
544            price_update_fully_verified.get_price_no_older_than_with_custom_verification_level(
545                &mock_clock,
546                100,
547                &mismatched_feed_id,
548                VerificationLevel::Partial { num_signatures: 5 }
549            ),
550            Err(GetPriceError::MismatchedFeedId)
551        );
552        assert_eq!(
553            price_update_fully_verified.get_price_no_older_than(
554                &mock_clock,
555                100,
556                &mismatched_feed_id,
557            ),
558            Err(GetPriceError::MismatchedFeedId)
559        );
560    }
561    #[test]
562    fn test_get_twap_no_older_than() {
563        let expected_twap = TwapPrice {
564            feed_id: [0; 32],
565            start_time: 800,
566            end_time: 900, // Window size is 100 seconds (900 - 800)
567            price: 1,
568            conf: 2,
569            exponent: -3,
570            down_slots_ratio: 0,
571        };
572
573        let feed_id = [0; 32];
574        let mismatched_feed_id = [1; 32];
575        let mock_clock = Clock {
576            unix_timestamp: 1000,
577            ..Default::default()
578        };
579
580        let update = TwapUpdate {
581            write_authority: Pubkey::new_unique(),
582            twap: expected_twap,
583        };
584
585        // Test unchecked access
586        assert_eq!(update.get_twap_unchecked(&feed_id), Ok(expected_twap));
587
588        // Test with correct window size (100 seconds)
589        assert_eq!(
590            update.get_twap_no_older_than(&mock_clock, 100, 100, &feed_id),
591            Ok(expected_twap)
592        );
593
594        // Test with incorrect window size
595        assert_eq!(
596            update.get_twap_no_older_than(&mock_clock, 100, 101, &feed_id),
597            Err(GetPriceError::InvalidWindowSize)
598        );
599
600        // Test with incorrect window size
601        assert_eq!(
602            update.get_twap_no_older_than(&mock_clock, 100, 99, &feed_id),
603            Err(GetPriceError::InvalidWindowSize)
604        );
605
606        // Test with reduced maximum age
607        assert_eq!(
608            update.get_twap_no_older_than(&mock_clock, 10, 100, &feed_id),
609            Err(GetPriceError::PriceTooOld)
610        );
611
612        // Test with mismatched feed id
613        assert_eq!(
614            update.get_twap_unchecked(&mismatched_feed_id),
615            Err(GetPriceError::MismatchedFeedId)
616        );
617        assert_eq!(
618            update.get_twap_no_older_than(&mock_clock, 100, 100, &mismatched_feed_id),
619            Err(GetPriceError::MismatchedFeedId)
620        );
621    }
622}