templar_common/oracle/
lazer.rs

1//! Pyth Lazer adapter feed data — the native, feed-id-keyed shape the `contract/pyth-lazer`
2//! adapter stores and serves. The adapter is a pure store-and-serve oracle: it hands back the raw
3//! [`FeedData`], and each consumer (the proxy-oracle's `Lazer` source, the gateway) projects it to
4//! a [`pyth::Price`] itself, mirroring [`redstone::FeedData::to_pyth_price`](super::redstone::FeedData).
5//! Freshness is likewise the consumer's concern; the adapter applies no age filter on reads.
6
7use 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
18/// Response shape of the adapter's feed-id-keyed read (`get_feeds_data`): the raw stored feed for
19/// each requested `u32` feed id (`None` when the feed is absent).
20pub type FeedDataResponse = HashMap<u32, Option<FeedData>>;
21
22/// EMA price/confidence for a feed, following the same fixed-point `expo` as the spot price.
23#[derive(Clone, Debug, PartialEq, Eq)]
24#[near(serializers = [borsh, json])]
25pub struct EmaData {
26    pub price: I64,
27    pub conf: U64,
28}
29
30/// The latest stored data for one Lazer feed. Prices/exponent follow the Pyth fixed-point
31/// convention (`value * 10^expo`); the timestamp is stored as [`Nanoseconds`].
32#[derive(Clone, Debug, PartialEq, Eq)]
33#[near(serializers = [borsh, json])]
34pub struct FeedData {
35    pub price: I64,
36    pub conf: U64,
37    /// EMA data. The adapter's stateful storage path requires it, so a stored feed always carries
38    /// EMA; it is never synthesized from spot.
39    pub ema: EmaData,
40    pub expo: i32,
41    /// Per-feed publish time in nanoseconds. The `_ns` suffix marks the unit for JSON consumers
42    /// (the [`Nanoseconds`] type is erased in JSON).
43    pub publish_time_ns: Nanoseconds,
44}
45
46impl FeedData {
47    /// Build a Pyth [`Price`](pyth::Price) from a `(price, conf)` pair using this feed's exponent
48    /// and publish time. `None` if the publish time cannot be represented as a [`PythTimestamp`].
49    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    /// Spot [`Price`](pyth::Price) projection.
59    pub fn to_pyth_price(&self) -> Option<pyth::Price> {
60        self.to_price(self.price, self.conf)
61    }
62
63    /// EMA [`Price`](pyth::Price) projection (the form the proxy-oracle's `Lazer` source consumes).
64    pub fn to_ema_price(&self) -> Option<pyth::Price> {
65        self.to_price(self.ema.price, self.ema.conf)
66    }
67}
68
69/// JSON view of one verified feed returned by the adapter's `verify_update` — the full Lazer
70/// property set (not just the Pyth-compatible subset). Price-like values are raw `i64` mantissas
71/// (interpret with `exponent`); `_ns` timestamps are nanoseconds. `None` = property absent.
72#[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/// JSON view of a verified update returned by the adapter's `verify_update`.
92#[derive(Clone, Debug, PartialEq, Eq)]
93#[near(serializers = [json])]
94pub struct VerifiedUpdateView {
95    /// The trusted ed25519 signer public key (hex).
96    #[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
106/// Fallibly build a storable [`FeedData`] from a verified feed, owning every intrinsic validity
107/// rule. Returns `None` for missing price or exponent, missing or invalid spot/EMA confidence,
108/// missing EMA price, or an effective publish timestamp more than `max_ahead_s` seconds beyond
109/// `now`. Anti-replay is relational and handled by the caller.
110///
111/// EMA is required: a spot-only signed payload must never overwrite a stored feed and drop its
112/// EMA. The adapter's stateless `verify_update` does not call this projection and permits
113/// spot-only payloads.
114pub 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    // Effective per-feed publish time: FeedUpdateTimestamp when present, else the payload's.
125    let publish_time_ns = parsed.feed_update_timestamp_ns.unwrap_or(package);
126
127    // The verifier only bounds the package timestamp; reject a per-feed time too far ahead.
128    if publish_time_ns.as_secs() > now.as_secs().saturating_add(max_ahead_s) {
129        return None;
130    }
131
132    // Never synthesize EMA from spot: half-specified EMA skips the whole feed.
133    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
149/// A confidence is usable only when explicitly present and strictly positive. On the Lazer wire
150/// zero is indistinguishable from absent, so reject both rather than invent a precise-looking zero.
151fn 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/// Feed-id-keyed read ABI of the Pyth Lazer adapter (`contract/pyth-lazer`). Feeds are addressed
158/// by their native `u32` id; the adapter serves the raw stored [`FeedData`] and the consumer
159/// projects it (mirroring the RedStone adapter, which serves [`redstone::FeedData`](super::redstone::FeedData)).
160#[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}