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}