templar_common/oracle/
pyth.rs1use 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
75#[near(serializers = [json, borsh])]
76pub struct Price {
77 pub price: I64,
78 pub conf: U64,
80 pub expo: i32,
82 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 pub fn from_secs(secs: i64) -> Self {
94 Self(secs)
95 }
96
97 pub fn from_ms(ms: i64) -> Self {
100 Self(ms / 1000)
101 }
102
103 pub fn as_secs(&self) -> i64 {
105 self.0
106 }
107
108 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 fn price_feed_exists(&self, price_identifier: PriceIdentifier) -> bool;
132 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 let truncated_to_zero = PythTimestamp::from_ms(-1);
239 assert_eq!(truncated_to_zero.try_into_time(), Some(Nanoseconds::zero()));
240
241 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}