Skip to main content

chio_link/
pyth.rs

1use chio_egress_contract::{client_builder_with_contract, send_with_contract, HttpEgressContract};
2use reqwest::{Client, Url};
3use serde::Deserialize;
4
5use crate::config::{PairConfig, PythFeedConfig};
6use crate::{ExchangeRate, OracleBackend, OracleBackendKind, OracleFuture, PriceOracleError};
7
8#[derive(Debug)]
9pub struct PythHermesClient {
10    base_url: String,
11    http_client: Client,
12    egress_contract: HttpEgressContract,
13}
14
15impl PythHermesClient {
16    /// Construct a [`PythHermesClient`] bound to a typed
17    /// [`HttpEgressContract`]. The contract is required: every Hermes
18    /// dispatch routes through `send_with_contract` so the URL, every
19    /// redirect target, and the response body size are validated before
20    /// bytes leave the substrate.
21    pub fn new(
22        base_url: impl Into<String>,
23        egress_contract: HttpEgressContract,
24    ) -> Result<Self, PriceOracleError> {
25        let base_url = base_url.into();
26        egress_contract
27            .validate_dispatchable_with_pinned_dns()
28            .map_err(|err| {
29                PriceOracleError::InvalidConfiguration(format!(
30                    "Pyth Hermes HttpEgressContract is not dispatchable with pinned DNS: {err}"
31                ))
32            })?;
33        let http_client = client_builder_with_contract(&egress_contract)
34            .build()
35            .map_err(|err| {
36                PriceOracleError::Unavailable(format!("building Hermes client failed: {err}"))
37            })?;
38        Ok(Self {
39            base_url,
40            http_client,
41            egress_contract,
42        })
43    }
44
45    /// Alias for [`PythHermesClient::new`]. The contract is required on
46    /// production paths; tests that need a permissive contract should use
47    /// [`HttpEgressContract::permissive_for_tests`].
48    pub fn with_contract(
49        base_url: impl Into<String>,
50        egress_contract: HttpEgressContract,
51    ) -> Result<Self, PriceOracleError> {
52        Self::new(base_url, egress_contract)
53    }
54}
55
56impl OracleBackend for PythHermesClient {
57    fn kind(&self) -> OracleBackendKind {
58        OracleBackendKind::Pyth
59    }
60
61    fn read_rate<'a>(&'a self, pair: &'a PairConfig, now: u64) -> OracleFuture<'a> {
62        Box::pin(async move {
63            let feed = pair
64                .pyth
65                .as_ref()
66                .ok_or_else(|| PriceOracleError::NoPairAvailable {
67                    base: pair.base.clone(),
68                    quote: pair.quote.clone(),
69                })?;
70            read_pyth_rate(
71                &self.http_client,
72                &self.base_url,
73                pair,
74                feed,
75                now,
76                &self.egress_contract,
77            )
78            .await
79        })
80    }
81}
82
83async fn read_pyth_rate(
84    http_client: &Client,
85    base_url: &str,
86    pair: &PairConfig,
87    feed: &PythFeedConfig,
88    now: u64,
89    egress_contract: &HttpEgressContract,
90) -> Result<ExchangeRate, PriceOracleError> {
91    let url = build_latest_price_url(base_url, &feed.id)?;
92    // HttpEgressContract: route every Hermes dispatch through
93    // send_with_contract so the URL, redirect chain, and response size are
94    // validated by the typed egress contract before bytes leave the
95    // substrate.
96    let request = http_client.get(url).build().map_err(|err| {
97        PriceOracleError::Unavailable(format!("building Hermes request failed: {err}"))
98    })?;
99    let response = send_with_contract(egress_contract, http_client, request)
100        .await
101        .map_err(|err| {
102            PriceOracleError::Unavailable(format!(
103                "Hermes request rejected by HttpEgressContract for {} id {}: {err}",
104                pair.pair(),
105                feed.id
106            ))
107        })?;
108    let status = response.status();
109    if !status.is_success() {
110        return Err(PriceOracleError::Unavailable(format!(
111            "Hermes returned HTTP {} for {} id {}",
112            status,
113            pair.pair(),
114            feed.id
115        )));
116    }
117    let feeds: Vec<PythLatestPriceFeed> = response.json().await.map_err(|err| {
118        PriceOracleError::InvalidFeed(format!(
119            "Hermes JSON decode failed for {} id {}: {err}",
120            pair.pair(),
121            feed.id
122        ))
123    })?;
124    let latest = feeds.into_iter().next().ok_or_else(|| {
125        PriceOracleError::InvalidFeed(format!(
126            "Hermes returned no price feeds for {} id {}",
127            pair.pair(),
128            feed.id
129        ))
130    })?;
131    let expected = canonicalize_pyth_feed_id(&feed.id);
132    let actual = canonicalize_pyth_feed_id(&latest.id);
133    if expected != actual {
134        return Err(PriceOracleError::InvalidFeed(format!(
135            "Hermes returned feed id {} but {} was requested for {}",
136            latest.id,
137            feed.id,
138            pair.pair()
139        )));
140    }
141    build_exchange_rate(pair, feed, latest.price, now)
142}
143
144fn build_latest_price_url(base_url: &str, id: &str) -> Result<Url, PriceOracleError> {
145    let trimmed = base_url.trim_end_matches('/');
146    let base = format!("{trimmed}/api/latest_price_feeds");
147    Url::parse_with_params(
148        &base,
149        [(String::from("ids[]"), canonicalize_pyth_feed_id(id))],
150    )
151    .map_err(|err| {
152        PriceOracleError::InvalidConfiguration(format!("invalid Hermes base URL {base_url}: {err}"))
153    })
154}
155
156fn build_exchange_rate(
157    pair: &PairConfig,
158    feed: &PythFeedConfig,
159    price: PythPriceComponent,
160    now: u64,
161) -> Result<ExchangeRate, PriceOracleError> {
162    let (rate_numerator, rate_denominator) =
163        decimal_components_to_ratio(&price.price, price.expo, pair, feed)?;
164    let confidence = decimal_components_to_ratio(&price.conf, price.expo, pair, feed).ok();
165    let rate = ExchangeRate {
166        base: pair.base.clone(),
167        quote: pair.quote.clone(),
168        rate_numerator,
169        rate_denominator,
170        updated_at: price.publish_time,
171        fetched_at: now,
172        source: "pyth".to_string(),
173        feed_reference: feed.id.clone(),
174        max_age_seconds: pair.policy.max_age_seconds,
175        conversion_margin_bps: pair.policy.exchange_rate_margin_bps,
176        confidence_numerator: confidence.as_ref().map(|value| value.0),
177        confidence_denominator: confidence.as_ref().map(|value| value.1),
178    };
179    rate.ensure_fresh(now)?;
180    Ok(rate)
181}
182
183fn decimal_components_to_ratio(
184    raw_value: &str,
185    expo: i32,
186    pair: &PairConfig,
187    feed: &PythFeedConfig,
188) -> Result<(u128, u128), PriceOracleError> {
189    let signed = raw_value.parse::<i128>().map_err(|err| {
190        PriceOracleError::InvalidFeed(format!(
191            "Pyth value parse failed for {} id {}: {err}",
192            pair.pair(),
193            feed.id
194        ))
195    })?;
196    let value = u128::try_from(signed).map_err(|_| {
197        PriceOracleError::InvalidFeed(format!(
198            "Pyth returned a negative value for {} id {}",
199            pair.pair(),
200            feed.id
201        ))
202    })?;
203    if value == 0 {
204        return Err(PriceOracleError::InvalidFeed(format!(
205            "Pyth returned zero for {} id {}",
206            pair.pair(),
207            feed.id
208        )));
209    }
210    if expo >= 0 {
211        let scale = 10_u128.checked_pow(expo as u32).ok_or_else(|| {
212            PriceOracleError::ArithmeticOverflow(format!(
213                "Pyth positive exponent overflowed for {} id {}",
214                pair.pair(),
215                feed.id
216            ))
217        })?;
218        let numerator = value.checked_mul(scale).ok_or_else(|| {
219            PriceOracleError::ArithmeticOverflow(format!(
220                "Pyth numerator overflowed for {} id {}",
221                pair.pair(),
222                feed.id
223            ))
224        })?;
225        return Ok((numerator, 1));
226    }
227    let denominator = 10_u128.checked_pow(expo.unsigned_abs()).ok_or_else(|| {
228        PriceOracleError::ArithmeticOverflow(format!(
229            "Pyth denominator overflowed for {} id {}",
230            pair.pair(),
231            feed.id
232        ))
233    })?;
234    Ok((value, denominator))
235}
236
237fn canonicalize_pyth_feed_id(id: &str) -> String {
238    id.trim_start_matches("0x").to_ascii_lowercase()
239}
240
241#[derive(Debug, Clone, Deserialize)]
242#[serde(deny_unknown_fields)]
243struct PythLatestPriceFeed {
244    id: String,
245    price: PythPriceComponent,
246}
247
248#[derive(Debug, Clone, Deserialize)]
249#[serde(deny_unknown_fields)]
250struct PythPriceComponent {
251    price: String,
252    conf: String,
253    expo: i32,
254    publish_time: u64,
255}
256
257#[cfg(test)]
258mod tests {
259    use crate::config::{PairConfig, PairPolicy, PythFeedConfig, BASE_MAINNET_CHAIN_ID};
260    use crate::test_support::{TestUnwrap, TestUnwrapErr};
261    use crate::OracleBackend;
262
263    use super::{
264        build_exchange_rate, build_latest_price_url, canonicalize_pyth_feed_id,
265        decimal_components_to_ratio, PythHermesClient, PythPriceComponent,
266    };
267
268    fn pair() -> PairConfig {
269        PairConfig {
270            base: "ETH".to_string(),
271            quote: "USD".to_string(),
272            chain_id: BASE_MAINNET_CHAIN_ID,
273            chainlink: None,
274            pyth: Some(PythFeedConfig {
275                id: "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"
276                    .to_string(),
277            }),
278            policy: PairPolicy::volatile_default(),
279        }
280    }
281
282    #[test]
283    fn normalizes_pyth_decimal_components() {
284        let rate = build_exchange_rate(
285            &pair(),
286            pair().pyth.as_ref().test_unwrap("feed"),
287            PythPriceComponent {
288                price: "184136023127".to_string(),
289                conf: "177166324".to_string(),
290                expo: -8,
291                publish_time: 1_743_292_740,
292            },
293            1_743_292_780,
294        )
295        .test_unwrap("exchange rate");
296        assert_eq!(rate.rate_numerator, 184_136_023_127);
297        assert_eq!(rate.rate_denominator, 100_000_000);
298        assert_eq!(rate.confidence_numerator, Some(177_166_324));
299    }
300
301    #[test]
302    fn canonicalizes_feed_ids() {
303        assert_eq!(
304            canonicalize_pyth_feed_id(
305                "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"
306            ),
307            "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"
308        );
309    }
310
311    #[test]
312    fn latest_price_url_normalizes_ids_and_base_urls() {
313        let url = build_latest_price_url(
314            "https://hermes.pyth.network/",
315            "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
316        )
317        .test_unwrap("latest price url");
318
319        assert_eq!(
320            url.as_str(),
321            "https://hermes.pyth.network/api/latest_price_feeds?ids%5B%5D=ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"
322        );
323    }
324
325    #[test]
326    fn rejects_invalid_base_urls() {
327        let error = build_latest_price_url("not a url", "0xfeed").test_unwrap_err("invalid url");
328
329        assert!(matches!(
330            error,
331            crate::PriceOracleError::InvalidConfiguration(_)
332        ));
333    }
334
335    #[test]
336    fn decimal_component_conversion_handles_positive_exponents() {
337        let ratio =
338            decimal_components_to_ratio("15", 2, &pair(), pair().pyth.as_ref().test_unwrap("feed"))
339                .test_unwrap("ratio");
340
341        assert_eq!(ratio, (1_500, 1));
342    }
343
344    #[test]
345    fn decimal_component_conversion_rejects_negative_values() {
346        let error = decimal_components_to_ratio(
347            "-5",
348            -8,
349            &pair(),
350            pair().pyth.as_ref().test_unwrap("feed"),
351        )
352        .test_unwrap_err("negative values should fail");
353
354        assert!(matches!(error, crate::PriceOracleError::InvalidFeed(_)));
355    }
356
357    #[test]
358    fn decimal_component_conversion_rejects_zero_and_overflow() {
359        let zero_error =
360            decimal_components_to_ratio("0", -8, &pair(), pair().pyth.as_ref().test_unwrap("feed"))
361                .test_unwrap_err("zero values should fail");
362        assert!(matches!(
363            zero_error,
364            crate::PriceOracleError::InvalidFeed(_)
365        ));
366
367        let overflow_error =
368            decimal_components_to_ratio("1", 39, &pair(), pair().pyth.as_ref().test_unwrap("feed"))
369                .test_unwrap_err("positive exponent overflow");
370        assert!(matches!(
371            overflow_error,
372            crate::PriceOracleError::ArithmeticOverflow(_)
373        ));
374    }
375
376    #[test]
377    fn exchange_rates_fail_when_the_quote_is_stale() {
378        let error = build_exchange_rate(
379            &pair(),
380            pair().pyth.as_ref().test_unwrap("feed"),
381            PythPriceComponent {
382                price: "184136023127".to_string(),
383                conf: "177166324".to_string(),
384                expo: -8,
385                publish_time: 1_743_292_000,
386            },
387            1_743_292_780,
388        )
389        .test_unwrap_err("stale rates should fail");
390
391        assert!(matches!(error, crate::PriceOracleError::Stale { .. }));
392    }
393
394    #[tokio::test]
395    async fn backend_rejects_pairs_without_pyth_feeds() {
396        let contract =
397            chio_egress_contract::HttpEgressContract::permissive_for_tests("127.0.0.1:8080");
398        let backend =
399            PythHermesClient::new("http://127.0.0.1:8080", contract).test_unwrap("client");
400        let pair = PairConfig {
401            base: "ETH".to_string(),
402            quote: "USD".to_string(),
403            chain_id: BASE_MAINNET_CHAIN_ID,
404            chainlink: None,
405            pyth: None,
406            policy: PairPolicy::volatile_default(),
407        };
408
409        let error = backend
410            .read_rate(&pair, 1_743_292_780)
411            .await
412            .test_unwrap_err("missing feed");
413
414        assert!(matches!(
415            error,
416            crate::PriceOracleError::NoPairAvailable { .. }
417        ));
418    }
419
420    #[test]
421    fn new_accepts_hostname_contract_with_pinned_dns() {
422        let client = PythHermesClient::new(
423            "https://hermes.pyth.network",
424            chio_egress_contract::HttpEgressContract::permissive_for_tests("hermes.pyth.network"),
425        )
426        .test_unwrap("hostname contract is resolver-enforced at dispatch");
427
428        assert_eq!(client.base_url, "https://hermes.pyth.network");
429    }
430}