templar_common/oracle/pyth.rs
1//! Derived from <https://github.com/pyth-network/pyth-crosschain/blob/main/target_chains/near>.
2//! Modified for use with the Templar Protocol contracts.
3//!
4//! The original code was released under the following license:
5//!
6//! Copyright 2025 Pyth Data Association.
7//!
8//! Licensed under the Apache License, Version 2.0 (the "License");
9//! you may not use this file except in compliance with the License.
10//! You may obtain a copy of the License at <http://www.apache.org/licenses/LICENSE-2.0>
11//!
12//! Unless required by applicable law or agreed to in writing, software
13//! distributed under the License is distributed on an "AS IS" BASIS,
14//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15//! See the License for the specific language governing permissions and
16//! limitations under the License.
17use std::{collections::HashMap, fmt::Display};
18
19use near_sdk::{
20 ext_contract,
21 json_types::{I64, U64},
22 near,
23};
24
25pub type OracleResponse = HashMap<PriceIdentifier, Option<Price>>;
26
27#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28#[near(serializers = [borsh, json])]
29pub struct PriceIdentifier(
30 #[serde(
31 serialize_with = "hex::serde::serialize",
32 deserialize_with = "hex::serde::deserialize"
33 )]
34 pub [u8; 32],
35);
36
37impl std::fmt::Debug for PriceIdentifier {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 write!(f, "{}", hex::encode(self.0))
40 }
41}
42
43impl Display for PriceIdentifier {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 write!(f, "{}", hex::encode(self.0))
46 }
47}
48
49/// A price with a degree of uncertainty, represented as a price +- a confidence interval.
50///
51/// The confidence interval roughly corresponds to the standard error of a normal distribution.
52/// Both the price and confidence are stored in a fixed-point numeric representation,
53/// `x * (10^expo)`, where `expo` is the exponent.
54//
55/// Please refer to the documentation at
56/// <https://docs.pyth.network/documentation/pythnet-price-feeds/best-practices>
57/// for how to use this price safely.
58#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
59#[near(serializers = [json, borsh])]
60pub struct Price {
61 pub price: I64,
62 /// Confidence interval around the price
63 pub conf: U64,
64 /// The exponent
65 pub expo: i32,
66 /// Unix timestamp of when this price was computed
67 pub publish_time: PythTimestamp,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
71#[near(serializers = [json, borsh])]
72#[serde(transparent)]
73pub struct PythTimestamp(i64);
74
75impl PythTimestamp {
76 /// Creates a `PythTimestamp` from a value in seconds.
77 pub fn from_secs(secs: i64) -> Self {
78 Self(secs)
79 }
80
81 /// Converts milliseconds to a [`PythTimestamp`], stored in whole seconds,
82 /// truncating any fractional seconds.
83 pub fn from_ms(ms: i64) -> Self {
84 Self(ms / 1000)
85 }
86
87 /// Returns the timestamp value in seconds.
88 pub fn as_secs(&self) -> i64 {
89 self.0
90 }
91
92 /// Converts a [`PythTimestamp`] (stored in whole seconds) to milliseconds
93 /// by performing a checked multiplication by 1000.
94 pub fn as_ms(&self) -> Option<i64> {
95 self.0.checked_mul(1000)
96 }
97
98 pub fn try_into_time(self) -> Option<templar_primitives::Nanoseconds> {
99 let ms = self.as_ms()?;
100 Some(templar_primitives::Nanoseconds::from_ms(
101 u64::try_from(ms).ok()?,
102 ))
103 }
104
105 pub fn try_from_time(value: templar_primitives::Nanoseconds) -> Option<Self> {
106 let ms = value.as_ms();
107 Some(PythTimestamp::from_ms(i64::try_from(ms).ok()?))
108 }
109}
110
111#[ext_contract(ext_pyth)]
112pub trait Pyth {
113 // See implementations for details, PriceIdentifier can be passed either as a 64 character
114 // hex price ID which can be found on the Pyth homepage.
115 fn price_feed_exists(&self, price_identifier: PriceIdentifier) -> bool;
116 // fn get_price(&self, price_identifier: PriceIdentifier) -> Option<Price>;
117 // fn get_price_unsafe(&self, price_identifier: PriceIdentifier) -> Option<Price>;
118 // fn get_price_no_older_than(&self, price_id: PriceIdentifier, age: u64) -> Option<Price>;
119 // fn get_ema_price(&self, price_id: PriceIdentifier) -> Option<Price>;
120 // fn get_ema_price_unsafe(&self, price_id: PriceIdentifier) -> Option<Price>;
121 // fn get_ema_price_no_older_than(&self, price_id: PriceIdentifier, age: u64) -> Option<Price>;
122 // fn list_prices(
123 // &self,
124 // price_ids: Vec<PriceIdentifier>,
125 // ) -> HashMap<PriceIdentifier, Option<Price>>;
126 // fn list_prices_unsafe(
127 // &self,
128 // price_ids: Vec<PriceIdentifier>,
129 // ) -> HashMap<PriceIdentifier, Option<Price>>;
130 // fn list_prices_no_older_than(
131 // &self,
132 // price_ids: Vec<PriceIdentifier>,
133 // ) -> HashMap<PriceIdentifier, Option<Price>>;
134 // fn list_ema_prices(
135 // &self,
136 // price_ids: Vec<PriceIdentifier>,
137 // ) -> HashMap<PriceIdentifier, Option<Price>>;
138 fn list_ema_prices_unsafe(
139 &self,
140 price_ids: Vec<PriceIdentifier>,
141 ) -> HashMap<PriceIdentifier, Option<Price>>;
142 fn list_ema_prices_no_older_than(
143 &self,
144 price_ids: Vec<PriceIdentifier>,
145 age: u64,
146 ) -> HashMap<PriceIdentifier, Option<Price>>;
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use templar_primitives::Nanoseconds;
153
154 #[test]
155 fn can_parse_real_price() {
156 let real_price = r#"{ "conf": "2696300000", "expo": -8, "price": "7154901300000", "publish_time": 1773381271 }"#;
157
158 let parsed = near_sdk::serde_json::from_str::<Price>(real_price).unwrap();
159 assert_eq!(parsed.price.0, 7_154_901_300_000);
160 assert_eq!(parsed.conf.0, 2_696_300_000);
161 assert_eq!(parsed.expo, -8);
162 assert_eq!(parsed.publish_time.as_secs(), 1_773_381_271);
163 }
164
165 #[test]
166 fn try_into_time_handles_negative_millisecond_inputs_per_current_truncation() {
167 // `from_ms` stores whole seconds, so negative sub-second values truncate toward zero.
168 let truncated_to_zero = PythTimestamp::from_ms(-1);
169 assert_eq!(truncated_to_zero.try_into_time(), Some(Nanoseconds::zero()));
170
171 // Negative whole-second values remain negative and cannot convert to unsigned time.
172 let negative_second = PythTimestamp::from_ms(-1_000);
173 assert_eq!(negative_second.try_into_time(), None);
174 }
175
176 #[test]
177 fn try_from_time_accepts_max_representable_nanoseconds_range() {
178 let value = Nanoseconds::from_ns(u64::MAX);
179 let expected = PythTimestamp::from_ms(i64::try_from(value.as_ms()).unwrap());
180
181 assert_eq!(PythTimestamp::try_from_time(value), Some(expected));
182 }
183
184 #[test]
185 fn try_from_time_and_try_into_time_round_trip_truncates_to_whole_seconds() {
186 let value = Nanoseconds::from_ns(1_234_567_890);
187
188 let round_tripped = PythTimestamp::try_from_time(value)
189 .and_then(PythTimestamp::try_into_time)
190 .unwrap();
191
192 assert_eq!(round_tripped, Nanoseconds::from_secs(1));
193 }
194}