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#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum PriceBrick {
25 Pyth(PythBrick),
26 AccountRatio(AccountRatioBrick),
27 MaturityDiscount(MaturityDiscountBrick),
28 ClampMax {
30 max: u128,
31 },
32 GrowthCap(GrowthCapBrick),
33}
34
35#[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#[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#[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#[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#[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#[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 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 #[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}