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    #[cfg_attr(
31        not(target_arch = "wasm32"),
32        schemars(schema_with = "price_identifier_json_schema")
33    )]
34    #[serde(
35        serialize_with = "hex::serde::serialize",
36        deserialize_with = "hex::serde::deserialize"
37    )]
38    pub [u8; 32],
39);
40
41#[cfg(not(target_arch = "wasm32"))]
42fn price_identifier_json_schema(
43    gen: &mut schemars::gen::SchemaGenerator,
44) -> schemars::schema::Schema {
45    let mut schema = gen.subschema_for::<String>().into_object();
46    let validation = schema.string();
47    validation.min_length = Some(64);
48    validation.max_length = Some(64);
49    validation.pattern = Some("^[0-9A-Fa-f]{64}$".to_string());
50    schema.into()
51}
52
53impl std::fmt::Debug for PriceIdentifier {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "{}", hex::encode(self.0))
56    }
57}
58
59impl Display for PriceIdentifier {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "{}", hex::encode(self.0))
62    }
63}
64
65/// A price with a degree of uncertainty, represented as a price +- a confidence interval.
66///
67/// The confidence interval roughly corresponds to the standard error of a normal distribution.
68/// Both the price and confidence are stored in a fixed-point numeric representation,
69/// `x * (10^expo)`, where `expo` is the exponent.
70//
71/// Please refer to the documentation at
72/// <https://docs.pyth.network/documentation/pythnet-price-feeds/best-practices>
73/// for how to use this price safely.
74#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
75#[near(serializers = [json, borsh])]
76pub struct Price {
77    pub price: I64,
78    /// Confidence interval around the price
79    pub conf: U64,
80    /// The exponent
81    pub expo: i32,
82    /// Unix timestamp of when this price was computed
83    pub publish_time: PythTimestamp,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
87#[near(serializers = [json, borsh])]
88#[serde(transparent)]
89pub struct PythTimestamp(i64);
90
91impl PythTimestamp {
92    /// Creates a `PythTimestamp` from a value in seconds.
93    pub fn from_secs(secs: i64) -> Self {
94        Self(secs)
95    }
96
97    /// Converts milliseconds to a [`PythTimestamp`], stored in whole seconds,
98    /// truncating any fractional seconds.
99    pub fn from_ms(ms: i64) -> Self {
100        Self(ms / 1000)
101    }
102
103    /// Returns the timestamp value in seconds.
104    pub fn as_secs(&self) -> i64 {
105        self.0
106    }
107
108    /// Converts a [`PythTimestamp`] (stored in whole seconds) to milliseconds
109    /// by performing a checked multiplication by 1000.
110    pub fn as_ms(&self) -> Option<i64> {
111        self.0.checked_mul(1000)
112    }
113
114    pub fn try_into_time(self) -> Option<templar_primitives::Nanoseconds> {
115        let ms = self.as_ms()?;
116        Some(templar_primitives::Nanoseconds::from_ms(
117            u64::try_from(ms).ok()?,
118        ))
119    }
120
121    pub fn try_from_time(value: templar_primitives::Nanoseconds) -> Option<Self> {
122        let ms = value.as_ms();
123        Some(PythTimestamp::from_ms(i64::try_from(ms).ok()?))
124    }
125}
126
127#[ext_contract(ext_pyth)]
128pub trait Pyth {
129    // See implementations for details, PriceIdentifier can be passed either as a 64 character
130    // hex price ID which can be found on the Pyth homepage.
131    fn price_feed_exists(&self, price_identifier: PriceIdentifier) -> bool;
132    // fn get_price(&self, price_identifier: PriceIdentifier) -> Option<Price>;
133    // fn get_price_unsafe(&self, price_identifier: PriceIdentifier) -> Option<Price>;
134    // fn get_price_no_older_than(&self, price_id: PriceIdentifier, age: u64) -> Option<Price>;
135    // fn get_ema_price(&self, price_id: PriceIdentifier) -> Option<Price>;
136    // fn get_ema_price_unsafe(&self, price_id: PriceIdentifier) -> Option<Price>;
137    // fn get_ema_price_no_older_than(&self, price_id: PriceIdentifier, age: u64) -> Option<Price>;
138    // fn list_prices(
139    //     &self,
140    //     price_ids: Vec<PriceIdentifier>,
141    // ) -> HashMap<PriceIdentifier, Option<Price>>;
142    // fn list_prices_unsafe(
143    //     &self,
144    //     price_ids: Vec<PriceIdentifier>,
145    // ) -> HashMap<PriceIdentifier, Option<Price>>;
146    // fn list_prices_no_older_than(
147    //     &self,
148    //     price_ids: Vec<PriceIdentifier>,
149    // ) -> HashMap<PriceIdentifier, Option<Price>>;
150    // fn list_ema_prices(
151    //     &self,
152    //     price_ids: Vec<PriceIdentifier>,
153    // ) -> HashMap<PriceIdentifier, Option<Price>>;
154    fn list_ema_prices_unsafe(
155        &self,
156        price_ids: Vec<PriceIdentifier>,
157    ) -> HashMap<PriceIdentifier, Option<Price>>;
158    fn list_ema_prices_no_older_than(
159        &self,
160        price_ids: Vec<PriceIdentifier>,
161        age: u64,
162    ) -> HashMap<PriceIdentifier, Option<Price>>;
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use templar_primitives::Nanoseconds;
169
170    #[test]
171    fn price_identifier_schema_matches_serde_wire_format() {
172        let schema = near_sdk::serde_json::to_value(schemars::schema_for!(PriceIdentifier))
173            .expect("price identifier schema serializes");
174        let validator =
175            jsonschema::draft7::new(&schema).expect("price identifier schema is Draft 7");
176        let identifier = PriceIdentifier([0xaa; 32]);
177        let serialized =
178            near_sdk::serde_json::to_value(identifier).expect("price identifier serializes");
179
180        assert_eq!(
181            serialized,
182            near_sdk::serde_json::json!(
183                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
184            )
185        );
186        validator
187            .validate(&serialized)
188            .expect("serialized price identifier is valid under its schema");
189        assert_eq!(
190            near_sdk::serde_json::from_value::<PriceIdentifier>(serialized)
191                .expect("price identifier deserializes"),
192            identifier
193        );
194
195        let uppercase = near_sdk::serde_json::json!(
196            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
197        );
198        validator
199            .validate(&uppercase)
200            .expect("uppercase hexadecimal is schema-valid");
201        assert_eq!(
202            near_sdk::serde_json::from_value::<PriceIdentifier>(uppercase)
203                .expect("uppercase hexadecimal deserializes"),
204            identifier
205        );
206
207        for malformed in [
208            near_sdk::serde_json::json!(
209                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
210            ),
211            near_sdk::serde_json::json!(
212                "gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
213            ),
214            near_sdk::serde_json::json!([0, 1]),
215        ] {
216            validator
217                .validate(&malformed)
218                .expect_err("malformed price identifier is not schema-valid");
219            near_sdk::serde_json::from_value::<PriceIdentifier>(malformed)
220                .expect_err("malformed price identifier does not deserialize");
221        }
222    }
223
224    #[test]
225    fn can_parse_real_price() {
226        let real_price = r#"{ "conf": "2696300000", "expo": -8, "price": "7154901300000", "publish_time": 1773381271 }"#;
227
228        let parsed = near_sdk::serde_json::from_str::<Price>(real_price).unwrap();
229        assert_eq!(parsed.price.0, 7_154_901_300_000);
230        assert_eq!(parsed.conf.0, 2_696_300_000);
231        assert_eq!(parsed.expo, -8);
232        assert_eq!(parsed.publish_time.as_secs(), 1_773_381_271);
233    }
234
235    #[test]
236    fn try_into_time_handles_negative_millisecond_inputs_per_current_truncation() {
237        // `from_ms` stores whole seconds, so negative sub-second values truncate toward zero.
238        let truncated_to_zero = PythTimestamp::from_ms(-1);
239        assert_eq!(truncated_to_zero.try_into_time(), Some(Nanoseconds::zero()));
240
241        // Negative whole-second values remain negative and cannot convert to unsigned time.
242        let negative_second = PythTimestamp::from_ms(-1_000);
243        assert_eq!(negative_second.try_into_time(), None);
244    }
245
246    #[test]
247    fn try_from_time_accepts_max_representable_nanoseconds_range() {
248        let value = Nanoseconds::from_ns(u64::MAX);
249        let expected = PythTimestamp::from_ms(i64::try_from(value.as_ms()).unwrap());
250
251        assert_eq!(PythTimestamp::try_from_time(value), Some(expected));
252    }
253
254    #[test]
255    fn try_from_time_and_try_into_time_round_trip_truncates_to_whole_seconds() {
256        let value = Nanoseconds::from_ns(1_234_567_890);
257
258        let round_tripped = PythTimestamp::try_from_time(value)
259            .and_then(PythTimestamp::try_into_time)
260            .unwrap();
261
262        assert_eq!(round_tripped, Nanoseconds::from_secs(1));
263    }
264}