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 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 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 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}