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};
6
7/// Pyth price updates are bridged to all blockchains via Wormhole.
8/// Using the price updates on another chain requires verifying the signatures of the Wormhole guardians.
9/// 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,
10/// so we also allow for partial verification.
11///
12/// This enum represents how much a price update has been verified:
13/// - If `Full`, we have verified the signatures for two thirds of the current guardians.
14/// - If `Partial`, only `num_signatures` guardian signatures have been checked.
15///
16/// # Warning
17/// Using partially verified price updates is dangerous, as it lowers the threshold of guardians that need to collude to produce a malicious price update.
18#[derive(AnchorSerialize, AnchorDeserialize, Copy, Clone, PartialEq, BorshSchema, Debug)]
19pub enum VerificationLevel {
20    Partial {
21        #[allow(unused)]
22        num_signatures: u8,
23    },
24    Full,
25}
26
27impl VerificationLevel {
28    /// Compare two `VerificationLevel`.
29    /// `Full` is always greater than `Partial`, and `Partial` with more signatures is greater than `Partial` with fewer signatures.
30    pub fn gte(&self, other: VerificationLevel) -> bool {
31        match self {
32            VerificationLevel::Full => true,
33            VerificationLevel::Partial { num_signatures } => match other {
34                VerificationLevel::Full => false,
35                VerificationLevel::Partial {
36                    num_signatures: other_num_signatures,
37                } => *num_signatures >= other_num_signatures,
38            },
39        }
40    }
41}
42
43/// A price update account. This account is used by the Pyth Receiver program to store a verified price update from a Pyth price feed.
44/// It contains:
45/// - `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.
46/// - `verification_level`: The [`VerificationLevel`] of this price update. This represents how many Wormhole guardian signatures have been verified for this price update.
47/// - `price_message`: The actual price update.
48/// - `posted_slot`: The slot at which this price update was posted.
49#[account]
50#[derive(BorshSchema)]
51pub struct PriceUpdateV2 {
52    pub write_authority: Pubkey,
53    pub verification_level: VerificationLevel,
54    pub price_message: PriceFeedMessage,
55    pub posted_slot: u64,
56}
57
58impl PriceUpdateV2 {
59    pub const LEN: usize = 8 + 32 + 2 + 32 + 8 + 8 + 4 + 8 + 8 + 8 + 8 + 8;
60}
61/// A time weighted average price account.
62/// This account is used by the Pyth Receiver program to store a TWAP update from a Pyth price feed.
63/// TwapUpdates can only be created after the client has verified the VAAs via the Wormhole contract.
64/// Check out `target_chains/solana/cli/src/main.rs` for an example of how to do this.
65///
66/// It contains:
67/// - `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.
68/// - `twap`: The actual TWAP update.
69#[account]
70#[derive(BorshSchema)]
71pub struct TwapUpdate {
72    pub write_authority: Pubkey,
73    pub twap: TwapPrice,
74}
75
76impl TwapUpdate {
77    pub const LEN: usize = (
78        8 // account discriminator (anchor)
79        + 32 // write_authority
80        + (32 + 8 + 8 + 8 + 8 + 4 + 4)
81        // twap
82    );
83
84    /// Get a `TwapPrice` from a `TwapUpdate` account for a given `FeedId`.
85    ///
86    /// # Warning
87    /// This function does not check :
88    /// - How recent the price is
89    /// - If the TWAP's window size is expected
90    /// - Whether the price update has been verified
91    ///
92    /// It is therefore unsafe to use this function without any extra checks,
93    /// as it allows for the possibility of using unverified, outdated, or arbitrary window length twap updates.
94    pub fn get_twap_unchecked(
95        &self,
96        feed_id: &FeedId,
97    ) -> std::result::Result<TwapPrice, GetPriceError> {
98        check!(
99            self.twap.feed_id == *feed_id,
100            GetPriceError::MismatchedFeedId
101        );
102        Ok(self.twap)
103    }
104    /// Get a `TwapPrice` from a `TwapUpdate` account for a given `FeedId` no older than `maximum_age` with a specific window size.
105    ///
106    /// # Example
107    /// ```ignore
108    /// use pyth_solana_receiver_sdk::price_update::{get_feed_id_from_hex, TwapUpdate};
109    /// use anchor_lang::prelude::*;
110    ///
111    /// const MAXIMUM_AGE: u64 = 30;
112    /// const WINDOW_SECONDS: u64 = 300; // 5-minute TWAP
113    /// const FEED_ID: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; // SOL/USD
114    ///
115    /// #[derive(Accounts)]
116    /// pub struct ReadTwapAccount<'info> {
117    ///     pub twap_update: Account<'info, TwapUpdate>,
118    /// }
119    ///
120    /// pub fn read_twap_account(ctx: Context<ReadTwapAccount>) -> Result<()> {
121    ///     let twap_update = &ctx.accounts.twap_update;
122    ///     let twap = twap_update.get_twap_no_older_than(
123    ///         &Clock::get()?,
124    ///         MAXIMUM_AGE,
125    ///         WINDOW_SECONDS,
126    ///         &get_feed_id_from_hex(FEED_ID)?
127    ///     )?;
128    ///     Ok(())
129    /// }
130    /// ```
131    pub fn get_twap_no_older_than(
132        &self,
133        clock: &Clock,
134        maximum_age: u64,
135        window_seconds: u64,
136        feed_id: &FeedId,
137    ) -> std::result::Result<TwapPrice, GetPriceError> {
138        // Ensure the update isn't outdated
139        let twap_price = self.get_twap_unchecked(feed_id)?;
140        check!(
141            twap_price
142                .end_time
143                .saturating_add(maximum_age.try_into().unwrap())
144                >= clock.unix_timestamp,
145            GetPriceError::PriceTooOld
146        );
147
148        // Ensure the twap window size is as expected
149        let actual_window = twap_price.end_time.saturating_sub(twap_price.start_time);
150        check!(
151            actual_window == i64::try_from(window_seconds).unwrap(),
152            GetPriceError::InvalidWindowSize
153        );
154
155        Ok(twap_price)
156    }
157}
158/// The time weighted average price & conf for a feed over the window [start_time, end_time].
159/// This type is used to persist the calculated TWAP in TwapUpdate accounts on Solana.
160#[derive(AnchorSerialize, AnchorDeserialize, Copy, Clone, PartialEq, BorshSchema, Debug)]
161pub struct TwapPrice {
162    /// `FeedId` but avoid the type alias because of compatibility issues with Anchor's `idl-build` feature.
163    pub feed_id: [u8; 32],
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    /// ```ignore
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    /// ```ignore
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::{prelude::*, solana_program::borsh0_10},
327        pythnet_sdk::messages::PriceFeedMessage,
328    };
329
330    #[test]
331    fn check_size() {
332        // borsh0_10 is deprecated, v1::get_packed_len should be used in the future
333        #[allow(deprecated)]
334        let len = PriceUpdateV2::DISCRIMINATOR.len() + borsh0_10::get_packed_len::<PriceUpdateV2>();
335        assert_eq!(len, PriceUpdateV2::LEN);
336    }
337
338    #[test]
339    fn gte() {
340        assert!(VerificationLevel::Full.gte(VerificationLevel::Full));
341        assert!(VerificationLevel::Full.gte(VerificationLevel::Partial {
342            num_signatures: 255,
343        }));
344        assert!(VerificationLevel::Partial { num_signatures: 8 }
345            .gte(VerificationLevel::Partial { num_signatures: 8 }));
346        assert!(!VerificationLevel::Partial { num_signatures: 8 }.gte(VerificationLevel::Full));
347        assert!(!VerificationLevel::Partial { num_signatures: 8 }
348            .gte(VerificationLevel::Partial { num_signatures: 9 }));
349    }
350
351    #[test]
352    fn get_feed_id_from_hex() {
353        let feed_id = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
354        let expected_feed_id = [
355            239, 13, 139, 111, 218, 44, 235, 164, 29, 161, 93, 64, 149, 209, 218, 57, 42, 13, 47,
356            142, 208, 198, 199, 188, 15, 76, 250, 200, 194, 128, 181, 109,
357        ];
358        assert_eq!(super::get_feed_id_from_hex(feed_id), Ok(expected_feed_id));
359        assert_eq!(
360            super::get_feed_id_from_hex(&feed_id[2..]),
361            Ok(expected_feed_id)
362        );
363
364        assert_eq!(
365            super::get_feed_id_from_hex(&feed_id[..64]),
366            Err(GetPriceError::FeedIdNonHexCharacter)
367        );
368
369        assert_eq!(
370            super::get_feed_id_from_hex(
371                "ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b5"
372            ),
373            Err(GetPriceError::FeedIdMustBe32Bytes)
374        );
375    }
376
377    #[test]
378    fn get_price() {
379        let expected_price = Price {
380            price: 1,
381            conf: 2,
382            exponent: 3,
383            publish_time: 900,
384        };
385
386        let feed_id = [0; 32];
387        let mismatched_feed_id = [1; 32];
388        let mock_clock = Clock {
389            unix_timestamp: 1000,
390            ..Default::default()
391        };
392
393        let price_update_unverified = PriceUpdateV2 {
394            write_authority: Pubkey::new_unique(),
395            verification_level: VerificationLevel::Partial { num_signatures: 0 },
396            price_message: PriceFeedMessage {
397                feed_id,
398                ema_conf: 0,
399                ema_price: 0,
400                price: 1,
401                conf: 2,
402                exponent: 3,
403                prev_publish_time: 899,
404                publish_time: 900,
405            },
406            posted_slot: 0,
407        };
408
409        let price_update_partially_verified = PriceUpdateV2 {
410            write_authority: Pubkey::new_unique(),
411            verification_level: VerificationLevel::Partial { num_signatures: 5 },
412            price_message: PriceFeedMessage {
413                feed_id,
414                ema_conf: 0,
415                ema_price: 0,
416                price: 1,
417                conf: 2,
418                exponent: 3,
419                prev_publish_time: 899,
420                publish_time: 900,
421            },
422            posted_slot: 0,
423        };
424
425        let price_update_fully_verified = PriceUpdateV2 {
426            write_authority: Pubkey::new_unique(),
427            verification_level: VerificationLevel::Full,
428            price_message: PriceFeedMessage {
429                feed_id,
430                ema_conf: 0,
431                ema_price: 0,
432                price: 1,
433                conf: 2,
434                exponent: 3,
435                prev_publish_time: 899,
436                publish_time: 900,
437            },
438            posted_slot: 0,
439        };
440
441        assert_eq!(
442            price_update_unverified.get_price_unchecked(&feed_id),
443            Ok(expected_price)
444        );
445        assert_eq!(
446            price_update_partially_verified.get_price_unchecked(&feed_id),
447            Ok(expected_price)
448        );
449        assert_eq!(
450            price_update_fully_verified.get_price_unchecked(&feed_id),
451            Ok(expected_price)
452        );
453
454        assert_eq!(
455            price_update_unverified.get_price_no_older_than_with_custom_verification_level(
456                &mock_clock,
457                100,
458                &feed_id,
459                VerificationLevel::Partial { num_signatures: 5 }
460            ),
461            Err(GetPriceError::InsufficientVerificationLevel)
462        );
463        assert_eq!(
464            price_update_partially_verified.get_price_no_older_than_with_custom_verification_level(
465                &mock_clock,
466                100,
467                &feed_id,
468                VerificationLevel::Partial { num_signatures: 5 }
469            ),
470            Ok(expected_price)
471        );
472        assert_eq!(
473            price_update_fully_verified.get_price_no_older_than_with_custom_verification_level(
474                &mock_clock,
475                100,
476                &feed_id,
477                VerificationLevel::Partial { num_signatures: 5 }
478            ),
479            Ok(expected_price)
480        );
481
482        assert_eq!(
483            price_update_unverified.get_price_no_older_than(&mock_clock, 100, &feed_id,),
484            Err(GetPriceError::InsufficientVerificationLevel)
485        );
486        assert_eq!(
487            price_update_partially_verified.get_price_no_older_than(&mock_clock, 100, &feed_id,),
488            Err(GetPriceError::InsufficientVerificationLevel)
489        );
490        assert_eq!(
491            price_update_fully_verified.get_price_no_older_than(&mock_clock, 100, &feed_id,),
492            Ok(expected_price)
493        );
494
495        // Reduce maximum_age
496        assert_eq!(
497            price_update_unverified.get_price_no_older_than_with_custom_verification_level(
498                &mock_clock,
499                10,
500                &feed_id,
501                VerificationLevel::Partial { num_signatures: 5 }
502            ),
503            Err(GetPriceError::InsufficientVerificationLevel)
504        );
505        assert_eq!(
506            price_update_partially_verified.get_price_no_older_than_with_custom_verification_level(
507                &mock_clock,
508                10,
509                &feed_id,
510                VerificationLevel::Partial { num_signatures: 5 }
511            ),
512            Err(GetPriceError::PriceTooOld)
513        );
514        assert_eq!(
515            price_update_fully_verified.get_price_no_older_than_with_custom_verification_level(
516                &mock_clock,
517                10,
518                &feed_id,
519                VerificationLevel::Partial { num_signatures: 5 }
520            ),
521            Err(GetPriceError::PriceTooOld)
522        );
523
524        assert_eq!(
525            price_update_unverified.get_price_no_older_than(&mock_clock, 10, &feed_id,),
526            Err(GetPriceError::InsufficientVerificationLevel)
527        );
528        assert_eq!(
529            price_update_partially_verified.get_price_no_older_than(&mock_clock, 10, &feed_id,),
530            Err(GetPriceError::InsufficientVerificationLevel)
531        );
532        assert_eq!(
533            price_update_fully_verified.get_price_no_older_than(&mock_clock, 10, &feed_id,),
534            Err(GetPriceError::PriceTooOld)
535        );
536
537        // Mismatched feed id
538        assert_eq!(
539            price_update_fully_verified.get_price_unchecked(&mismatched_feed_id),
540            Err(GetPriceError::MismatchedFeedId)
541        );
542        assert_eq!(
543            price_update_fully_verified.get_price_no_older_than_with_custom_verification_level(
544                &mock_clock,
545                100,
546                &mismatched_feed_id,
547                VerificationLevel::Partial { num_signatures: 5 }
548            ),
549            Err(GetPriceError::MismatchedFeedId)
550        );
551        assert_eq!(
552            price_update_fully_verified.get_price_no_older_than(
553                &mock_clock,
554                100,
555                &mismatched_feed_id,
556            ),
557            Err(GetPriceError::MismatchedFeedId)
558        );
559    }
560    #[test]
561    fn test_get_twap_no_older_than() {
562        let expected_twap = TwapPrice {
563            feed_id: [0; 32],
564            start_time: 800,
565            end_time: 900, // Window size is 100 seconds (900 - 800)
566            price: 1,
567            conf: 2,
568            exponent: -3,
569            down_slots_ratio: 0,
570        };
571
572        let feed_id = [0; 32];
573        let mismatched_feed_id = [1; 32];
574        let mock_clock = Clock {
575            unix_timestamp: 1000,
576            ..Default::default()
577        };
578
579        let update = TwapUpdate {
580            write_authority: Pubkey::new_unique(),
581            twap: expected_twap,
582        };
583
584        // Test unchecked access
585        assert_eq!(update.get_twap_unchecked(&feed_id), Ok(expected_twap));
586
587        // Test with correct window size (100 seconds)
588        assert_eq!(
589            update.get_twap_no_older_than(&mock_clock, 100, 100, &feed_id),
590            Ok(expected_twap)
591        );
592
593        // Test with incorrect window size
594        assert_eq!(
595            update.get_twap_no_older_than(&mock_clock, 100, 101, &feed_id),
596            Err(GetPriceError::InvalidWindowSize)
597        );
598
599        // Test with incorrect window size
600        assert_eq!(
601            update.get_twap_no_older_than(&mock_clock, 100, 99, &feed_id),
602            Err(GetPriceError::InvalidWindowSize)
603        );
604
605        // Test with reduced maximum age
606        assert_eq!(
607            update.get_twap_no_older_than(&mock_clock, 10, 100, &feed_id),
608            Err(GetPriceError::PriceTooOld)
609        );
610
611        // Test with mismatched feed id
612        assert_eq!(
613            update.get_twap_unchecked(&mismatched_feed_id),
614            Err(GetPriceError::MismatchedFeedId)
615        );
616        assert_eq!(
617            update.get_twap_no_older_than(&mock_clock, 100, 100, &mismatched_feed_id),
618            Err(GetPriceError::MismatchedFeedId)
619        );
620    }
621}