Skip to main content

acta_maker_sdk/chain/vault/
price_route.rs

1use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey};
2
3use super::VaultChainError;
4use super::account::{header, pubkey};
5
6pub const PYTH_PUSH_ORACLE_ID: Pubkey =
7    solana_sdk::pubkey!("pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT");
8pub const MAX_PRICE_BRICKS: usize = 6;
9pub const MAX_PRICE_ROUTE_BYTES: usize = 640;
10pub const MAX_RATIO_DISCRIMINATOR_LEN: usize = 8;
11
12const PRICE_ROUTE_DISCRIMINATOR: u8 = 15;
13const PRICE_ROUTE_VERSION: u8 = 1;
14const BRICKS_OFFSET: usize = 48;
15
16#[must_use]
17pub fn pyth_price_feed_account(feed_id: &[u8; 32], shard: u16) -> Pubkey {
18    Pubkey::find_program_address(&[&shard.to_le_bytes(), feed_id], &PYTH_PUSH_ORACLE_ID).0
19}
20
21/// One factor of a price route; the route is their product, left to right.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum PriceBrick {
25    Pyth(PythBrick),
26    AccountRatio(AccountRatioBrick),
27    MaturityDiscount(MaturityDiscountBrick),
28    /// Lowers every bound of the running product to at most `max` (1e18).
29    ClampMax {
30        max: u128,
31    },
32    GrowthCap(GrowthCapBrick),
33}
34
35/// A Pyth `PriceUpdateV2` of `feed_id`, read from the push-oracle account on the served shard.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct PythBrick {
38    pub feed_id: [u8; 32],
39    pub max_age_secs: u32,
40    pub min_band_ppm: u32,
41}
42
43/// `numerator / denominator * 10^scale_exp` of a pinned `account`, read with its `program_data`.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct AccountRatioBrick {
46    pub account: Pubkey,
47    pub owner: Pubkey,
48    pub program_data: Pubkey,
49    pub deploy_slot: u64,
50    pub discriminator: Vec<u8>,
51    pub min_len: u16,
52    pub mint_at: Option<u16>,
53    pub numerator: RatioField,
54    pub denominator: RatioField,
55    pub scale_exp: i8,
56    pub min_denominator: u64,
57    pub min: u128,
58    pub max: u128,
59    pub freshness: Option<RatioFreshness>,
60}
61
62/// An unsigned LE integer of `width` bytes at `at`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct RatioField {
65    pub at: u16,
66    pub width: u8,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct RatioFreshness {
71    pub unit: FreshnessUnit,
72    pub at: u16,
73    pub max_lag: u32,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum FreshnessUnit {
78    Epoch,
79    Slot,
80    UnixSecond,
81}
82
83/// Multiplies by `(1 + apy)^-years` to `maturity`.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct MaturityDiscountBrick {
86    pub maturity: u64,
87    pub apy_low_ppm: u32,
88    pub apy_mid_ppm: u32,
89    pub apy_high_ppm: u32,
90    pub maturity_source: Option<MaturitySource>,
91}
92
93/// `maturity` equals the sum of two `u32` fields of the account of brick `brick`.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct MaturitySource {
96    pub brick: u8,
97    pub start_at: u16,
98    pub duration_at: u16,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct GrowthCapBrick {
103    pub anchor_rate: u128,
104    pub anchor_ts: u64,
105    pub max_growth_ppm: u32,
106    pub max_anchor_age_secs: u32,
107}
108
109impl PriceBrick {
110    fn decode(r: &mut Reader<'_>) -> Result<Self, VaultChainError> {
111        Ok(match r.u8()? {
112            0 => Self::Pyth(PythBrick {
113                feed_id: r.array()?,
114                max_age_secs: r.u32()?,
115                min_band_ppm: r.u32()?,
116            }),
117            1 => Self::AccountRatio(AccountRatioBrick {
118                account: r.pubkey()?,
119                owner: r.pubkey()?,
120                program_data: r.pubkey()?,
121                deploy_slot: r.u64()?,
122                discriminator: {
123                    let len = usize::from(r.u8()?);
124                    if len > MAX_RATIO_DISCRIMINATOR_LEN {
125                        return Err(VaultChainError::PriceRoute(
126                            "ratio discriminator exceeds 8 bytes",
127                        ));
128                    }
129                    r.take(len)?.to_vec()
130                },
131                min_len: r.u16()?,
132                mint_at: r.option(Reader::u16)?,
133                numerator: r.field()?,
134                denominator: r.field()?,
135                scale_exp: i8::from_le_bytes(r.array()?),
136                min_denominator: r.u64()?,
137                min: r.u128()?,
138                max: r.u128()?,
139                freshness: r.option(|r| {
140                    Ok(RatioFreshness {
141                        unit: match r.u8()? {
142                            0 => FreshnessUnit::Epoch,
143                            1 => FreshnessUnit::Slot,
144                            2 => FreshnessUnit::UnixSecond,
145                            _ => return Err(VaultChainError::PriceRoute("unknown freshness unit")),
146                        },
147                        at: r.u16()?,
148                        max_lag: r.u32()?,
149                    })
150                })?,
151            }),
152            2 => Self::MaturityDiscount(MaturityDiscountBrick {
153                maturity: r.u64()?,
154                apy_low_ppm: r.u32()?,
155                apy_mid_ppm: r.u32()?,
156                apy_high_ppm: r.u32()?,
157                maturity_source: r.option(|r| {
158                    Ok(MaturitySource {
159                        brick: r.u8()?,
160                        start_at: r.u16()?,
161                        duration_at: r.u16()?,
162                    })
163                })?,
164            }),
165            3 => Self::ClampMax { max: r.u128()? },
166            4 => Self::GrowthCap(GrowthCapBrick {
167                anchor_rate: r.u128()?,
168                anchor_ts: r.u64()?,
169                max_growth_ppm: r.u32()?,
170                max_anchor_age_secs: r.u32()?,
171            }),
172            _ => return Err(VaultChainError::PriceRoute("unknown brick kind")),
173        })
174    }
175
176    #[must_use]
177    pub const fn account_count(&self) -> usize {
178        match self {
179            Self::Pyth(_) => 1,
180            Self::AccountRatio(_) => 2,
181            Self::MaturityDiscount(_) | Self::ClampMax { .. } | Self::GrowthCap(_) => 0,
182        }
183    }
184}
185
186/// `PriceRoute` account (`["price_route", mint]`): how the program prices `mint`.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct PriceRoute {
189    pub mint: Pubkey,
190    pub max_skew_secs: u32,
191    pub bricks: Vec<PriceBrick>,
192    pub updated_slot: u64,
193}
194
195impl PriceRoute {
196    pub const LEN: usize = 696;
197
198    /// [`Self::parse`] for an account that must be owned by `program_id`.
199    pub fn from_account(
200        account: &solana_sdk::account::Account,
201        program_id: &Pubkey,
202    ) -> Result<Self, VaultChainError> {
203        if account.owner != *program_id {
204            return Err(VaultChainError::Owner {
205                expected: *program_id,
206                actual: account.owner,
207            });
208        }
209        Self::parse(&account.data)
210    }
211
212    pub fn parse(data: &[u8]) -> Result<Self, VaultChainError> {
213        if data.len() != Self::LEN {
214            return Err(VaultChainError::AccountLength {
215                expected: Self::LEN,
216                actual: data.len(),
217            });
218        }
219        let data = header(
220            data,
221            Self::LEN,
222            PRICE_ROUTE_DISCRIMINATOR,
223            PRICE_ROUTE_VERSION,
224        )?;
225        let len = usize::from(u16::from_le_bytes([data[4], data[5]]));
226        if len > MAX_PRICE_ROUTE_BYTES {
227            return Err(VaultChainError::PriceRoute("bricks exceed 640 bytes"));
228        }
229        let mut reader = Reader {
230            bytes: &data[BRICKS_OFFSET..BRICKS_OFFSET + len],
231        };
232        let mut bricks = Vec::new();
233        while !reader.bytes.is_empty() {
234            if bricks.len() == MAX_PRICE_BRICKS {
235                return Err(VaultChainError::PriceRoute("more than 6 bricks"));
236            }
237            bricks.push(PriceBrick::decode(&mut reader)?);
238        }
239        if bricks.is_empty() {
240            return Err(VaultChainError::PriceRoute("route has no bricks"));
241        }
242        let accounts: usize = bricks.iter().map(PriceBrick::account_count).sum();
243        if accounts != usize::from(data[3]) {
244            return Err(VaultChainError::PriceRoute(
245                "account count does not match the bricks",
246            ));
247        }
248        Ok(Self {
249            mint: pubkey(data, 8),
250            max_skew_secs: u32::from_le_bytes([data[40], data[41], data[42], data[43]]),
251            bricks,
252            updated_slot: u64::from_le_bytes([
253                data[688], data[689], data[690], data[691], data[692], data[693], data[694],
254                data[695],
255            ]),
256        })
257    }
258
259    /// One leg of a price section: `address`, then its bricks' accounts, Pyth read on `shard`.
260    #[must_use]
261    pub fn accounts(&self, address: Pubkey, shard: u16) -> Vec<AccountMeta> {
262        let mut accounts = vec![AccountMeta::new_readonly(address, false)];
263        for brick in &self.bricks {
264            match brick {
265                PriceBrick::Pyth(pyth) => accounts.push(AccountMeta::new_readonly(
266                    pyth_price_feed_account(&pyth.feed_id, shard),
267                    false,
268                )),
269                PriceBrick::AccountRatio(ratio) => accounts.extend(
270                    [ratio.account, ratio.program_data]
271                        .map(|key| AccountMeta::new_readonly(key, false)),
272                ),
273                PriceBrick::MaturityDiscount(_)
274                | PriceBrick::ClampMax { .. }
275                | PriceBrick::GrowthCap(_) => {}
276            }
277        }
278        accounts
279    }
280
281    pub fn account_ratios(&self) -> impl Iterator<Item = &AccountRatioBrick> {
282        self.bricks.iter().filter_map(|brick| match brick {
283            PriceBrick::AccountRatio(ratio) => Some(ratio),
284            _ => None,
285        })
286    }
287}
288
289struct Reader<'a> {
290    bytes: &'a [u8],
291}
292
293impl<'a> Reader<'a> {
294    fn take(&mut self, len: usize) -> Result<&'a [u8], VaultChainError> {
295        let (head, tail) = self
296            .bytes
297            .split_at_checked(len)
298            .ok_or(VaultChainError::PriceRoute("brick is truncated"))?;
299        self.bytes = tail;
300        Ok(head)
301    }
302
303    fn array<const N: usize>(&mut self) -> Result<[u8; N], VaultChainError> {
304        let mut out = [0; N];
305        out.copy_from_slice(self.take(N)?);
306        Ok(out)
307    }
308
309    fn pubkey(&mut self) -> Result<Pubkey, VaultChainError> {
310        self.array().map(Pubkey::new_from_array)
311    }
312
313    fn u8(&mut self) -> Result<u8, VaultChainError> {
314        self.array::<1>().map(|[byte]| byte)
315    }
316
317    fn u16(&mut self) -> Result<u16, VaultChainError> {
318        self.array().map(u16::from_le_bytes)
319    }
320
321    fn u32(&mut self) -> Result<u32, VaultChainError> {
322        self.array().map(u32::from_le_bytes)
323    }
324
325    fn u64(&mut self) -> Result<u64, VaultChainError> {
326        self.array().map(u64::from_le_bytes)
327    }
328
329    fn u128(&mut self) -> Result<u128, VaultChainError> {
330        self.array().map(u128::from_le_bytes)
331    }
332
333    fn field(&mut self) -> Result<RatioField, VaultChainError> {
334        Ok(RatioField {
335            at: self.u16()?,
336            width: self.u8()?,
337        })
338    }
339
340    fn option<T>(
341        &mut self,
342        read: impl FnOnce(&mut Self) -> Result<T, VaultChainError>,
343    ) -> Result<Option<T>, VaultChainError> {
344        match self.u8()? {
345            0 => Ok(None),
346            1 => read(self).map(Some),
347            _ => Err(VaultChainError::PriceRoute("option tag must be 0 or 1")),
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn key(byte: u8) -> Pubkey {
357        Pubkey::new_from_array([byte; 32])
358    }
359
360    fn pyth(feed: u8) -> Vec<u8> {
361        let mut out = vec![0];
362        out.extend([feed; 32]);
363        out.extend(60u32.to_le_bytes());
364        out.extend(500u32.to_le_bytes());
365        out
366    }
367
368    fn ratio(account: u8, discriminator: &[u8]) -> Vec<u8> {
369        let mut out = vec![1];
370        for byte in [account, account + 1, account + 2] {
371            out.extend([byte; 32]);
372        }
373        out.extend(7u64.to_le_bytes());
374        out.push(u8::try_from(discriminator.len()).unwrap());
375        out.extend(discriminator);
376        out.extend(300u16.to_le_bytes());
377        out.extend([1, 8, 0]);
378        out.extend([2, 1, 8, 10, 1, 16]);
379        out.push((-3i8).to_le_bytes()[0]);
380        out.extend(1u64.to_le_bytes());
381        out.extend(2u128.to_le_bytes());
382        out.extend(3u128.to_le_bytes());
383        out.extend([1, 0]);
384        out.extend(274u16.to_le_bytes());
385        out.extend(1u32.to_le_bytes());
386        out
387    }
388
389    fn discount() -> Vec<u8> {
390        let mut out = vec![2];
391        out.extend(1_800_000_000u64.to_le_bytes());
392        for apy in [50_000u32, 71_246, 100_000] {
393            out.extend(apy.to_le_bytes());
394        }
395        out.extend([1, 0]);
396        out.extend(264u16.to_le_bytes());
397        out.extend(268u16.to_le_bytes());
398        out
399    }
400
401    fn route_bytes(bricks: &[Vec<u8>], account_count: u8) -> Vec<u8> {
402        let blob = bricks.concat();
403        let mut data = vec![0; PriceRoute::LEN];
404        data[..4].copy_from_slice(&[15, 1, 255, account_count]);
405        data[4..6].copy_from_slice(&u16::try_from(blob.len()).unwrap().to_le_bytes());
406        data[8..40].fill(9);
407        data[40..44].copy_from_slice(&30u32.to_le_bytes());
408        data[BRICKS_OFFSET..BRICKS_OFFSET + blob.len()].copy_from_slice(&blob);
409        data
410    }
411
412    fn pt_route() -> Vec<u8> {
413        let mut clamp = vec![3];
414        clamp.extend(1_000_000_000_000_000_000u128.to_le_bytes());
415        let mut cap = vec![4];
416        cap.extend(5u128.to_le_bytes());
417        cap.extend(6u64.to_le_bytes());
418        cap.extend(7u32.to_le_bytes());
419        cap.extend(8u32.to_le_bytes());
420        route_bytes(
421            &[
422                ratio(48, &[1, 2, 3, 4, 5, 6, 7, 8]),
423                ratio(64, &[]),
424                clamp,
425                discount(),
426                cap,
427                pyth(16),
428            ],
429            5,
430        )
431    }
432
433    #[test]
434    fn price_feed_address_is_shard_and_feed_specific() {
435        let first = [1; 32];
436        let second = [2; 32];
437        assert_ne!(
438            pyth_price_feed_account(&first, 0),
439            pyth_price_feed_account(&first, 1)
440        );
441        assert_ne!(
442            pyth_price_feed_account(&first, 0),
443            pyth_price_feed_account(&second, 0)
444        );
445    }
446
447    #[test]
448    fn sol_usd_shard_zero_matches_the_sponsored_feed_account() {
449        let mut feed = [0; 32];
450        hex::decode_to_slice(
451            "ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d",
452            &mut feed,
453        )
454        .unwrap();
455        assert_eq!(
456            pyth_price_feed_account(&feed, 0).to_string(),
457            "7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE"
458        );
459    }
460
461    #[test]
462    fn route_decodes_bricks_and_lists_their_accounts_in_key_order() {
463        let mut data = pt_route();
464        data[688..696].copy_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes());
465        assert!(matches!(
466            PriceRoute::parse(&data[..688]),
467            Err(VaultChainError::AccountLength {
468                expected: 696,
469                actual: 688
470            })
471        ));
472        let route = PriceRoute::parse(&data).unwrap();
473        assert_eq!(route.updated_slot, 0x0102_0304_0506_0708);
474        assert_eq!(route.mint, key(9));
475        assert_eq!(route.max_skew_secs, 30);
476        assert_eq!(route.bricks.len(), 6);
477        assert_eq!(
478            route.bricks[0],
479            PriceBrick::AccountRatio(AccountRatioBrick {
480                account: key(48),
481                owner: key(49),
482                program_data: key(50),
483                deploy_slot: 7,
484                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
485                min_len: 300,
486                mint_at: Some(8),
487                numerator: RatioField { at: 258, width: 8 },
488                denominator: RatioField { at: 266, width: 16 },
489                scale_exp: -3,
490                min_denominator: 1,
491                min: 2,
492                max: 3,
493                freshness: Some(RatioFreshness {
494                    unit: FreshnessUnit::Epoch,
495                    at: 274,
496                    max_lag: 1,
497                }),
498            })
499        );
500        assert_eq!(
501            route.bricks[2],
502            PriceBrick::ClampMax {
503                max: 1_000_000_000_000_000_000
504            }
505        );
506        assert_eq!(
507            route.bricks[3],
508            PriceBrick::MaturityDiscount(MaturityDiscountBrick {
509                maturity: 1_800_000_000,
510                apy_low_ppm: 50_000,
511                apy_mid_ppm: 71_246,
512                apy_high_ppm: 100_000,
513                maturity_source: Some(MaturitySource {
514                    brick: 0,
515                    start_at: 264,
516                    duration_at: 268,
517                }),
518            })
519        );
520        assert_eq!(
521            route.bricks[4],
522            PriceBrick::GrowthCap(GrowthCapBrick {
523                anchor_rate: 5,
524                anchor_ts: 6,
525                max_growth_ppm: 7,
526                max_anchor_age_secs: 8,
527            })
528        );
529        assert_eq!(
530            route.bricks[5],
531            PriceBrick::Pyth(PythBrick {
532                feed_id: [16; 32],
533                max_age_secs: 60,
534                min_band_ppm: 500,
535            })
536        );
537        assert_eq!(
538            route
539                .account_ratios()
540                .map(|r| r.account)
541                .collect::<Vec<_>>(),
542            [key(48), key(64)]
543        );
544        let address = key(1);
545        let accounts: Vec<_> = route
546            .accounts(address, 3)
547            .into_iter()
548            .map(|meta| {
549                assert!(!meta.is_signer && !meta.is_writable);
550                meta.pubkey
551            })
552            .collect();
553        assert_eq!(
554            accounts,
555            [
556                address,
557                key(48),
558                key(50),
559                key(64),
560                key(66),
561                pyth_price_feed_account(&[16; 32], 3),
562            ]
563        );
564    }
565
566    #[test]
567    fn route_rejects_bad_shape() {
568        assert!(PriceRoute::parse(&pt_route()).is_ok());
569        assert!(PriceRoute::parse(&route_bytes(&[], 0)).is_err());
570        assert!(PriceRoute::parse(&route_bytes(&[pyth(1)], 2)).is_err());
571        assert!(PriceRoute::parse(&route_bytes(&vec![pyth(1); 7], 7)).is_err());
572        assert!(PriceRoute::parse(&route_bytes(&[vec![5]], 0)).is_err());
573        let mut truncated = route_bytes(&[pyth(1)], 1);
574        truncated[4] = 40;
575        assert!(PriceRoute::parse(&truncated).is_err());
576        let mut long = route_bytes(&[pyth(1)], 1);
577        long[4..6].copy_from_slice(&641u16.to_le_bytes());
578        assert!(PriceRoute::parse(&long).is_err());
579        let mut tag = ratio(48, &[]);
580        let at = tag.len() - 8;
581        tag[at] = 2;
582        assert!(PriceRoute::parse(&route_bytes(&[tag, pyth(1)], 3)).is_err());
583        assert!(PriceRoute::parse(&route_bytes(&[ratio(48, &[0; 9]), pyth(1)], 3)).is_err());
584        let mut version = route_bytes(&[pyth(1)], 1);
585        version[1] = 2;
586        assert_eq!(
587            PriceRoute::parse(&version),
588            Err(VaultChainError::Version(2))
589        );
590        let mut discriminator = route_bytes(&[pyth(1)], 1);
591        discriminator[0] = 7;
592        assert!(PriceRoute::parse(&discriminator).is_err());
593        assert!(PriceRoute::parse(&route_bytes(&[pyth(1)], 1)[..687]).is_err());
594    }
595}