1use std::collections::HashMap;
8
9use near_sdk::{
10 ext_contract,
11 json_types::{I64, U64},
12 near,
13};
14use templar_primitives::time::Nanoseconds;
15
16use crate::oracle::pyth::{self, PythTimestamp};
17
18pub type FeedDataResponse = HashMap<u32, Option<FeedData>>;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24#[near(serializers = [borsh, json])]
25pub struct EmaData {
26 pub price: I64,
27 pub conf: U64,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
33#[near(serializers = [borsh, json])]
34pub struct FeedData {
35 pub price: I64,
36 pub conf: U64,
37 pub ema: EmaData,
40 pub expo: i32,
41 pub publish_time_ns: Nanoseconds,
44}
45
46impl FeedData {
47 fn to_price(&self, price: I64, conf: U64) -> Option<pyth::Price> {
50 Some(pyth::Price {
51 price,
52 conf,
53 expo: self.expo,
54 publish_time: PythTimestamp::try_from_time(self.publish_time_ns)?,
55 })
56 }
57
58 pub fn to_pyth_price(&self) -> Option<pyth::Price> {
60 self.to_price(self.price, self.conf)
61 }
62
63 pub fn to_ema_price(&self) -> Option<pyth::Price> {
65 self.to_price(self.ema.price, self.ema.conf)
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
73#[near(serializers = [json])]
74pub struct ParsedFeedView {
75 pub feed_id: u32,
76 pub price: Option<I64>,
77 pub best_bid_price: Option<I64>,
78 pub best_ask_price: Option<I64>,
79 pub publisher_count: Option<u16>,
80 pub exponent: Option<i16>,
81 pub confidence: Option<I64>,
82 pub funding_rate: Option<I64>,
83 pub funding_timestamp_ns: Option<Nanoseconds>,
84 pub funding_rate_interval_ns: Option<Nanoseconds>,
85 pub market_session: Option<i16>,
86 pub ema_price: Option<I64>,
87 pub ema_confidence: Option<I64>,
88 pub feed_update_timestamp_ns: Option<Nanoseconds>,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq)]
93#[near(serializers = [json])]
94pub struct VerifiedUpdateView {
95 #[serde(
97 serialize_with = "hex::serde::serialize",
98 deserialize_with = "hex::serde::deserialize"
99 )]
100 pub signer: [u8; 32],
101 pub channel_id: u8,
102 pub timestamp_ns: Nanoseconds,
103 pub feeds: Vec<ParsedFeedView>,
104}
105
106pub fn feed_data_from_parsed(
115 parsed: &ParsedFeedView,
116 package: Nanoseconds,
117 now: Nanoseconds,
118 max_ahead_s: u64,
119) -> Option<FeedData> {
120 let price = parsed.price?;
121 let exponent = parsed.exponent?;
122 let conf = require_confidence(parsed.confidence.map(|value| value.0))?;
123
124 let publish_time_ns = parsed.feed_update_timestamp_ns.unwrap_or(package);
126
127 if publish_time_ns.as_secs() > now.as_secs().saturating_add(max_ahead_s) {
129 return None;
130 }
131
132 let ema = EmaData {
134 price: parsed.ema_price?,
135 conf: U64(require_confidence(
136 parsed.ema_confidence.map(|value| value.0),
137 )?),
138 };
139
140 Some(FeedData {
141 price,
142 conf: U64(conf),
143 ema,
144 expo: i32::from(exponent),
145 publish_time_ns,
146 })
147}
148
149fn require_confidence(confidence: Option<i64>) -> Option<u64> {
152 confidence
153 .and_then(|value| u64::try_from(value).ok())
154 .filter(|&value| value > 0)
155}
156
157#[ext_contract(ext_pyth_lazer)]
161pub trait PythLazer {
162 fn get_feeds_data(&self, feed_ids: Vec<u32>) -> FeedDataResponse;
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use near_sdk::serde_json::{self, json};
169 use rstest::rstest;
170
171 fn parsed_feed() -> ParsedFeedView {
172 ParsedFeedView {
173 feed_id: 7,
174 price: Some(I64(123_456)),
175 best_bid_price: Some(I64(123_450)),
176 best_ask_price: Some(I64(123_460)),
177 publisher_count: Some(4),
178 exponent: Some(-8),
179 confidence: Some(I64(50)),
180 funding_rate: Some(I64(-2)),
181 funding_timestamp_ns: Some(Nanoseconds::from_ns(98_123_456_789)),
182 funding_rate_interval_ns: Some(Nanoseconds::from_ns(3_456_789)),
183 market_session: Some(1),
184 ema_price: Some(I64(123_000)),
185 ema_confidence: Some(I64(40)),
186 feed_update_timestamp_ns: Some(Nanoseconds::from_ns(99_987_654_321)),
187 }
188 }
189
190 fn expected_projection(publish_ns: u64) -> FeedData {
191 FeedData {
192 price: I64(123_456),
193 conf: U64(50),
194 ema: EmaData {
195 price: I64(123_000),
196 conf: U64(40),
197 },
198 expo: -8,
199 publish_time_ns: Nanoseconds::from_ns(publish_ns),
200 }
201 }
202
203 #[rstest]
204 #[case::valid(Some(123_456), Some(-8), Some(123_000), true)]
205 #[case::missing_spot(None, Some(-8), Some(123_000), false)]
206 #[case::missing_exponent(Some(123_456), None, Some(123_000), false)]
207 #[case::missing_ema(Some(123_456), Some(-8), None, false)]
208 fn projection_requires_spot_exponent_and_ema(
209 #[case] price: Option<i64>,
210 #[case] exponent: Option<i16>,
211 #[case] ema_price: Option<i64>,
212 #[case] valid: bool,
213 ) {
214 let parsed = ParsedFeedView {
215 price: price.map(I64),
216 exponent,
217 ema_price: ema_price.map(I64),
218 ..parsed_feed()
219 };
220 let projected = feed_data_from_parsed(
221 &parsed,
222 Nanoseconds::from_secs(98),
223 Nanoseconds::from_secs(100),
224 2,
225 );
226 let expected = valid.then_some(expected_projection(99_987_654_321));
227 assert_eq!(projected, expected);
228 }
229
230 #[rstest]
231 #[case::missing_spot(None, Some(40))]
232 #[case::zero_spot(Some(0), Some(40))]
233 #[case::negative_spot(Some(-1), Some(40))]
234 #[case::missing_ema(Some(50), None)]
235 #[case::zero_ema(Some(50), Some(0))]
236 #[case::negative_ema(Some(50), Some(-1))]
237 fn projection_requires_positive_confidences(
238 #[case] confidence: Option<i64>,
239 #[case] ema_confidence: Option<i64>,
240 ) {
241 let parsed = ParsedFeedView {
242 confidence: confidence.map(I64),
243 ema_confidence: ema_confidence.map(I64),
244 ..parsed_feed()
245 };
246 let projected = feed_data_from_parsed(
247 &parsed,
248 Nanoseconds::from_secs(98),
249 Nanoseconds::from_secs(100),
250 2,
251 );
252 assert!(projected.is_none());
253 }
254
255 #[rstest]
256 #[case::package_fallback(None, 98_123_456_789, 2, Some(98_123_456_789))]
257 #[case::feed_overrides_package(Some(99_987_654_321), 103_000_000_000, 2, Some(99_987_654_321))]
258 #[case::too_far_ahead(Some(103_000_000_000), 98_000_000_000, 2, None)]
259 #[case::whole_second_boundary(Some(102_999_999_999), 98_000_000_000, 2, Some(102_999_999_999))]
260 #[case::future_package(None, 103_000_000_000, 2, None)]
261 #[case::saturating_ahead(Some(u64::MAX), 98_000_000_000, u64::MAX, Some(u64::MAX))]
262 fn projection_preserves_effective_timestamp_and_tolerance(
263 #[case] feed_ns: Option<u64>,
264 #[case] package_ns: u64,
265 #[case] max_ahead_s: u64,
266 #[case] expected_ns: Option<u64>,
267 ) {
268 let parsed = ParsedFeedView {
269 feed_update_timestamp_ns: feed_ns.map(Nanoseconds::from_ns),
270 ..parsed_feed()
271 };
272 let projected = feed_data_from_parsed(
273 &parsed,
274 Nanoseconds::from_ns(package_ns),
275 Nanoseconds::from_secs(100),
276 max_ahead_s,
277 );
278 let expected = expected_ns.map(expected_projection);
279 assert_eq!(projected, expected);
280 }
281
282 #[test]
283 fn verified_update_preserves_deployed_json_contract() {
284 let view = VerifiedUpdateView {
285 signer: [0xab; 32],
286 channel_id: 3,
287 timestamp_ns: Nanoseconds::from_ns(100_123_456_789),
288 feeds: vec![parsed_feed()],
289 };
290 let expected = json!({
291 "signer": "abababababababababababababababababababababababababababababababab",
292 "channel_id": 3,
293 "timestamp_ns": "100123456789",
294 "feeds": [{
295 "feed_id": 7,
296 "price": "123456",
297 "best_bid_price": "123450",
298 "best_ask_price": "123460",
299 "publisher_count": 4,
300 "exponent": -8,
301 "confidence": "50",
302 "funding_rate": "-2",
303 "funding_timestamp_ns": "98123456789",
304 "funding_rate_interval_ns": "3456789",
305 "market_session": 1,
306 "ema_price": "123000",
307 "ema_confidence": "40",
308 "feed_update_timestamp_ns": "99987654321"
309 }]
310 });
311 assert_eq!(serde_json::to_value(&view).unwrap(), expected);
312 assert_eq!(
313 serde_json::from_value::<VerifiedUpdateView>(expected).unwrap(),
314 view,
315 );
316 }
317
318 fn feed(price: i64, ema: i64, conf: u64, publish_s: u64) -> FeedData {
319 FeedData {
320 price: I64(price),
321 conf: U64(conf),
322 ema: EmaData {
323 price: I64(ema),
324 conf: U64(conf),
325 },
326 expo: -8,
327 publish_time_ns: Nanoseconds::from_secs(publish_s),
328 }
329 }
330
331 #[test]
332 fn spot_and_ema_projections_use_the_right_mantissa() {
333 let feed = feed(123_456, 123_000, 50, 1_700_000_000);
334
335 let spot = feed.to_pyth_price().unwrap();
336 assert_eq!(spot.price.0, 123_456);
337 assert_eq!(spot.conf.0, 50);
338 assert_eq!(spot.expo, -8);
339 assert_eq!(spot.publish_time.as_secs(), 1_700_000_000);
340
341 let ema = feed.to_ema_price().unwrap();
342 assert_eq!(ema.price.0, 123_000);
343 assert_eq!(ema.conf.0, 50);
344 assert_eq!(ema.expo, -8);
345 assert_eq!(ema.publish_time.as_secs(), 1_700_000_000);
346 }
347}