1use std::collections::BTreeSet;
2
3use chio_egress_contract::HttpEgressContract;
4use reqwest::Url;
5use serde::{Deserialize, Serialize};
6
7use crate::PriceOracleError;
8
9pub const BASE_MAINNET_CHAIN_ID: u64 = 8453;
10pub const BASE_MAINNET_CAIP2: &str = "eip155:8453";
11pub const BASE_MAINNET_SEQUENCER_UPTIME_FEED: &str = "0xBCF85224fc0756B9Fa45aA7892530B47e10b6433";
12pub const ARBITRUM_ONE_CHAIN_ID: u64 = 42_161;
13pub const ARBITRUM_ONE_CAIP2: &str = "eip155:42161";
14pub const ARBITRUM_ONE_SEQUENCER_UPTIME_FEED: &str = "0xFdB631F5EE196F0ed6FAa767959853A9F217697D";
15pub const DEFAULT_REFRESH_INTERVAL_SECONDS: u64 = 60;
16pub const DEFAULT_MAX_PRICE_AGE_SECONDS: u64 = 600;
17pub const DEFAULT_DIVERGENCE_THRESHOLD_BPS: u32 = 500;
18pub const DEFAULT_MARGIN_BPS: u32 = 200;
19pub const DEFAULT_TWAP_WINDOW_SECONDS: u64 = 600;
20pub const DEFAULT_TWAP_MAX_OBSERVATIONS: usize = 10;
21pub const DEFAULT_SEQUENCER_GRACE_PERIOD_SECONDS: u64 = 300;
22pub const DEFAULT_DEGRADED_MODE_EXTRA_STALE_SECONDS: u64 = 300;
23pub const DEFAULT_DEGRADED_MODE_EXTRA_MARGIN_BPS: u32 = 800;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum OracleBackendKind {
28 Chainlink,
29 Pyth,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ChainlinkNetworkConfig {
35 pub chain_id: u64,
36 pub label: String,
37 pub caip2: String,
38 pub rpc_endpoint: String,
39 pub enabled: bool,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub sequencer_uptime_feed: Option<String>,
42 pub sequencer_grace_period_seconds: u64,
43}
44
45impl ChainlinkNetworkConfig {
46 #[must_use]
47 pub fn base_mainnet(rpc_endpoint: impl Into<String>) -> Self {
48 Self {
49 chain_id: BASE_MAINNET_CHAIN_ID,
50 label: "base-mainnet".to_string(),
51 caip2: BASE_MAINNET_CAIP2.to_string(),
52 rpc_endpoint: rpc_endpoint.into(),
53 enabled: true,
54 sequencer_uptime_feed: Some(BASE_MAINNET_SEQUENCER_UPTIME_FEED.to_string()),
55 sequencer_grace_period_seconds: DEFAULT_SEQUENCER_GRACE_PERIOD_SECONDS,
56 }
57 }
58
59 #[must_use]
60 pub fn arbitrum_one(rpc_endpoint: impl Into<String>) -> Self {
61 Self {
62 chain_id: ARBITRUM_ONE_CHAIN_ID,
63 label: "arbitrum-one".to_string(),
64 caip2: ARBITRUM_ONE_CAIP2.to_string(),
65 rpc_endpoint: rpc_endpoint.into(),
66 enabled: false,
67 sequencer_uptime_feed: Some(ARBITRUM_ONE_SEQUENCER_UPTIME_FEED.to_string()),
68 sequencer_grace_period_seconds: DEFAULT_SEQUENCER_GRACE_PERIOD_SECONDS,
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct PythNetworkConfig {
76 pub hermes_url: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ChainlinkFeedConfig {
82 pub address: String,
83 pub decimals: u8,
84 pub heartbeat_seconds: u64,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct PythFeedConfig {
90 pub id: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct DegradedModePolicy {
96 pub enabled: bool,
97 pub max_stale_age_seconds: u64,
98 pub extra_margin_bps: u32,
99}
100
101impl DegradedModePolicy {
102 #[must_use]
103 pub fn disabled() -> Self {
104 Self {
105 enabled: false,
106 max_stale_age_seconds: DEFAULT_DEGRADED_MODE_EXTRA_STALE_SECONDS,
107 extra_margin_bps: DEFAULT_DEGRADED_MODE_EXTRA_MARGIN_BPS,
108 }
109 }
110
111 #[must_use]
112 pub fn conservative_default() -> Self {
113 Self {
114 enabled: true,
115 max_stale_age_seconds: DEFAULT_DEGRADED_MODE_EXTRA_STALE_SECONDS,
116 extra_margin_bps: DEFAULT_DEGRADED_MODE_EXTRA_MARGIN_BPS,
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct PairPolicy {
124 pub max_age_seconds: u64,
125 pub divergence_threshold_bps: u32,
126 pub exchange_rate_margin_bps: u32,
127 pub twap_enabled: bool,
128 pub twap_window_seconds: u64,
129 pub twap_max_observations: usize,
130 pub stable_pair: bool,
131 pub degraded_mode: DegradedModePolicy,
132}
133
134impl PairPolicy {
135 #[must_use]
136 pub fn volatile_default() -> Self {
137 Self {
138 max_age_seconds: DEFAULT_MAX_PRICE_AGE_SECONDS,
139 divergence_threshold_bps: DEFAULT_DIVERGENCE_THRESHOLD_BPS,
140 exchange_rate_margin_bps: DEFAULT_MARGIN_BPS,
141 twap_enabled: true,
142 twap_window_seconds: DEFAULT_TWAP_WINDOW_SECONDS,
143 twap_max_observations: DEFAULT_TWAP_MAX_OBSERVATIONS,
144 stable_pair: false,
145 degraded_mode: DegradedModePolicy::disabled(),
146 }
147 }
148
149 #[must_use]
150 pub fn stable_default() -> Self {
151 Self {
152 twap_enabled: false,
153 stable_pair: true,
154 degraded_mode: DegradedModePolicy::disabled(),
155 ..Self::volatile_default()
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct PairConfig {
163 pub base: String,
164 pub quote: String,
165 pub chain_id: u64,
166 pub chainlink: Option<ChainlinkFeedConfig>,
167 pub pyth: Option<PythFeedConfig>,
168 pub policy: PairPolicy,
169}
170
171impl PairConfig {
172 #[must_use]
173 pub fn pair(&self) -> String {
174 pair_key(&self.base, &self.quote)
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct PairRuntimeOverride {
181 pub base: String,
182 pub quote: String,
183 pub enabled: bool,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub force_backend: Option<OracleBackendKind>,
186 pub allow_fallback: bool,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub divergence_threshold_bps: Option<u32>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub degraded_mode: Option<DegradedModePolicy>,
191}
192
193impl PairRuntimeOverride {
194 #[must_use]
195 pub fn pair(&self) -> String {
196 pair_key(&self.base, &self.quote)
197 }
198
199 #[must_use]
200 pub fn from_pair(pair: &PairConfig) -> Self {
201 Self {
202 base: pair.base.clone(),
203 quote: pair.quote.clone(),
204 enabled: true,
205 force_backend: None,
206 allow_fallback: true,
207 divergence_threshold_bps: None,
208 degraded_mode: None,
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct MonitoringConfig {
216 pub alert_on_fallback: bool,
217 pub alert_on_degraded: bool,
218 pub alert_on_pause: bool,
219 pub alert_on_sequencer: bool,
220}
221
222impl Default for MonitoringConfig {
223 fn default() -> Self {
224 Self {
225 alert_on_fallback: true,
226 alert_on_degraded: true,
227 alert_on_pause: true,
228 alert_on_sequencer: true,
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct OperatorConfig {
236 pub global_pause: bool,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub pause_reason: Option<String>,
239 pub chains: Vec<ChainlinkNetworkConfig>,
240 pub pair_overrides: Vec<PairRuntimeOverride>,
241 pub monitoring: MonitoringConfig,
242}
243
244impl OperatorConfig {
245 #[must_use]
246 pub fn pair_override(&self, base: &str, quote: &str) -> Option<&PairRuntimeOverride> {
247 let wanted = pair_key(base, quote);
248 self.pair_overrides
249 .iter()
250 .find(|pair| pair.pair() == wanted)
251 }
252
253 #[must_use]
254 pub fn chain(&self, chain_id: u64) -> Option<&ChainlinkNetworkConfig> {
255 self.chains.iter().find(|chain| chain.chain_id == chain_id)
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct PriceOracleConfig {
262 pub primary: OracleBackendKind,
263 pub fallback: Option<OracleBackendKind>,
264 pub refresh_interval_seconds: u64,
265 pub pyth: PythNetworkConfig,
266 pub pairs: Vec<PairConfig>,
267 pub operator: OperatorConfig,
268 pub egress_contract: HttpEgressContract,
273}
274
275impl PriceOracleConfig {
276 #[must_use]
277 pub fn base_mainnet_default(rpc_endpoint: impl Into<String>) -> Self {
278 Self::base_arbitrum_default(
279 rpc_endpoint.into(),
280 "https://arbitrum-mainnet.example.invalid".to_string(),
281 )
282 }
283
284 #[must_use]
285 pub fn base_arbitrum_default(
286 base_rpc_endpoint: impl Into<String>,
287 arbitrum_rpc_endpoint: impl Into<String>,
288 ) -> Self {
289 let pairs = vec![
290 PairConfig {
291 base: "ETH".to_string(),
292 quote: "USD".to_string(),
293 chain_id: BASE_MAINNET_CHAIN_ID,
294 chainlink: Some(ChainlinkFeedConfig {
295 address: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70".to_string(),
296 decimals: 8,
297 heartbeat_seconds: 300,
298 }),
299 pyth: Some(PythFeedConfig {
300 id: "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"
301 .to_string(),
302 }),
303 policy: PairPolicy::volatile_default(),
304 },
305 PairConfig {
306 base: "BTC".to_string(),
307 quote: "USD".to_string(),
308 chain_id: BASE_MAINNET_CHAIN_ID,
309 chainlink: Some(ChainlinkFeedConfig {
310 address: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F".to_string(),
311 decimals: 8,
312 heartbeat_seconds: 180,
313 }),
314 pyth: Some(PythFeedConfig {
315 id: "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"
316 .to_string(),
317 }),
318 policy: PairPolicy::volatile_default(),
319 },
320 PairConfig {
321 base: "USDC".to_string(),
322 quote: "USD".to_string(),
323 chain_id: BASE_MAINNET_CHAIN_ID,
324 chainlink: Some(ChainlinkFeedConfig {
325 address: "0x7e860098F58bBFC8648a4311b374B1D669a2bc6B".to_string(),
326 decimals: 8,
327 heartbeat_seconds: 68_400,
328 }),
329 pyth: Some(PythFeedConfig {
330 id: "0xeaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a"
331 .to_string(),
332 }),
333 policy: PairPolicy::stable_default(),
334 },
335 PairConfig {
336 base: "LINK".to_string(),
337 quote: "USD".to_string(),
338 chain_id: BASE_MAINNET_CHAIN_ID,
339 chainlink: Some(ChainlinkFeedConfig {
340 address: "0x17CAb8FE31E32f08326e5E27412894e49B0f9D65".to_string(),
341 decimals: 8,
342 heartbeat_seconds: 86_400,
343 }),
344 pyth: None,
345 policy: PairPolicy::volatile_default(),
346 },
347 ];
348 let pyth = PythNetworkConfig {
349 hermes_url: "https://hermes.pyth.network".to_string(),
350 };
351 let chains = vec![
352 ChainlinkNetworkConfig::base_mainnet(base_rpc_endpoint),
353 ChainlinkNetworkConfig::arbitrum_one(arbitrum_rpc_endpoint),
354 ];
355 let egress_contract = build_default_egress_contract(&pyth, &chains);
356 Self {
357 primary: OracleBackendKind::Chainlink,
358 fallback: Some(OracleBackendKind::Pyth),
359 refresh_interval_seconds: DEFAULT_REFRESH_INTERVAL_SECONDS,
360 pyth,
361 operator: OperatorConfig {
362 global_pause: false,
363 pause_reason: None,
364 chains,
365 pair_overrides: pairs.iter().map(PairRuntimeOverride::from_pair).collect(),
366 monitoring: MonitoringConfig::default(),
367 },
368 pairs,
369 egress_contract,
370 }
371 }
372
373 pub fn validate(&self) -> Result<(), PriceOracleError> {
374 if self.refresh_interval_seconds == 0 {
375 return Err(PriceOracleError::invalid_configuration(
376 "refresh_interval_seconds must be non-zero",
377 ));
378 }
379 if self.fallback == Some(self.primary) {
380 return Err(PriceOracleError::invalid_configuration(
381 "primary and fallback backends must be different",
382 ));
383 }
384 if self.pairs.is_empty() {
385 return Err(PriceOracleError::invalid_configuration(
386 "price oracle config must define at least one supported pair",
387 ));
388 }
389 if self.operator.chains.is_empty() {
390 return Err(PriceOracleError::invalid_configuration(
391 "operator config must define at least one chain",
392 ));
393 }
394 self.egress_contract
395 .validate_dispatchable_with_pinned_dns()
396 .map_err(|error| {
397 PriceOracleError::invalid_configuration(format!(
398 "price oracle HttpEgressContract is not dispatchable with pinned DNS: {error}"
399 ))
400 })?;
401 self.egress_contract
402 .enforce_url_with_dns(&self.pyth.hermes_url, 0)
403 .map_err(|error| {
404 PriceOracleError::invalid_configuration(format!(
405 "HttpEgressContract rejects Pyth Hermes URL: {error}"
406 ))
407 })?;
408 let mut seen_chains = BTreeSet::new();
409 for chain in &self.operator.chains {
410 if !seen_chains.insert(chain.chain_id) {
411 return Err(PriceOracleError::invalid_configuration(format!(
412 "duplicate chain_id {} in operator chain config",
413 chain.chain_id
414 )));
415 }
416 if chain.label.trim().is_empty() || chain.caip2.trim().is_empty() {
417 return Err(PriceOracleError::invalid_configuration(format!(
418 "chain {} must define both label and caip2",
419 chain.chain_id
420 )));
421 }
422 if chain.rpc_endpoint.trim().is_empty() {
423 return Err(PriceOracleError::invalid_configuration(format!(
424 "chain {} rpc_endpoint must be non-empty",
425 chain.chain_id
426 )));
427 }
428 self.egress_contract
429 .enforce_url_with_dns(&chain.rpc_endpoint, 0)
430 .map_err(|error| {
431 PriceOracleError::invalid_configuration(format!(
432 "HttpEgressContract rejects chain {} RPC endpoint: {error}",
433 chain.chain_id
434 ))
435 })?;
436 if chain.sequencer_uptime_feed.is_some() && chain.sequencer_grace_period_seconds == 0 {
437 return Err(PriceOracleError::invalid_configuration(format!(
438 "chain {} sequencer_grace_period_seconds must be non-zero",
439 chain.chain_id
440 )));
441 }
442 }
443
444 let mut seen_pairs = BTreeSet::new();
445 for pair in &self.pairs {
446 if pair.base.trim().is_empty() || pair.quote.trim().is_empty() {
447 return Err(PriceOracleError::invalid_configuration(
448 "pair base/quote must be non-empty",
449 ));
450 }
451 if !seen_pairs.insert(pair.pair()) {
452 return Err(PriceOracleError::invalid_configuration(format!(
453 "duplicate pair configuration for {}",
454 pair.pair()
455 )));
456 }
457 if self.operator.chain(pair.chain_id).is_none() {
458 return Err(PriceOracleError::invalid_configuration(format!(
459 "{} references unknown chain_id {}",
460 pair.pair(),
461 pair.chain_id
462 )));
463 }
464 if pair.policy.max_age_seconds == 0 {
465 return Err(PriceOracleError::invalid_configuration(format!(
466 "{} max_age_seconds must be non-zero",
467 pair.pair()
468 )));
469 }
470 if pair.policy.twap_enabled
471 && (pair.policy.twap_window_seconds == 0 || pair.policy.twap_max_observations == 0)
472 {
473 return Err(PriceOracleError::invalid_configuration(format!(
474 "{} TWAP settings must be non-zero when enabled",
475 pair.pair()
476 )));
477 }
478 if pair.policy.degraded_mode.enabled
479 && pair.policy.degraded_mode.max_stale_age_seconds == 0
480 {
481 return Err(PriceOracleError::invalid_configuration(format!(
482 "{} degraded-mode max_stale_age_seconds must be non-zero when enabled",
483 pair.pair()
484 )));
485 }
486 if matches!(self.primary, OracleBackendKind::Chainlink) && pair.chainlink.is_none() {
487 return Err(PriceOracleError::invalid_configuration(format!(
488 "{} requires a Chainlink feed for the configured primary backend",
489 pair.pair()
490 )));
491 }
492 }
493
494 for pair_override in &self.operator.pair_overrides {
495 let pair = self
496 .pair(&pair_override.base, &pair_override.quote)
497 .ok_or_else(|| {
498 PriceOracleError::invalid_configuration(format!(
499 "operator override references unsupported pair {}",
500 pair_override.pair()
501 ))
502 })?;
503 if let Some(kind) = pair_override.force_backend {
504 match kind {
505 OracleBackendKind::Chainlink if pair.chainlink.is_none() => {
506 return Err(PriceOracleError::invalid_configuration(format!(
507 "{} override forces Chainlink but no Chainlink feed is configured",
508 pair.pair()
509 )));
510 }
511 OracleBackendKind::Pyth if pair.pyth.is_none() => {
512 return Err(PriceOracleError::invalid_configuration(format!(
513 "{} override forces Pyth but no Pyth feed is configured",
514 pair.pair()
515 )));
516 }
517 _ => {}
518 }
519 }
520 if let Some(degraded_mode) = pair_override.degraded_mode.as_ref() {
521 if degraded_mode.enabled && degraded_mode.max_stale_age_seconds == 0 {
522 return Err(PriceOracleError::invalid_configuration(format!(
523 "{} override degraded-mode max_stale_age_seconds must be non-zero when enabled",
524 pair.pair()
525 )));
526 }
527 }
528 }
529 Ok(())
530 }
531
532 #[must_use]
533 pub fn pair(&self, base: &str, quote: &str) -> Option<&PairConfig> {
534 let wanted = pair_key(base, quote);
535 self.pairs.iter().find(|pair| pair.pair() == wanted)
536 }
537
538 #[must_use]
539 pub fn supported_pairs(&self) -> Vec<String> {
540 let mut pairs = self.pairs.iter().map(PairConfig::pair).collect::<Vec<_>>();
541 pairs.sort();
542 pairs
543 }
544}
545
546#[must_use]
551pub fn build_default_egress_contract(
552 pyth: &PythNetworkConfig,
553 chains: &[ChainlinkNetworkConfig],
554) -> HttpEgressContract {
555 let mut allowed_schemes = BTreeSet::new();
556 let mut allowed_authority_set = BTreeSet::new();
557 for url in std::iter::once(pyth.hermes_url.as_str())
558 .chain(chains.iter().map(|chain| chain.rpc_endpoint.as_str()))
559 {
560 if let Ok(parsed) = Url::parse(url) {
561 allowed_schemes.insert(parsed.scheme().to_ascii_lowercase());
562 if let Some(host) = parsed.host_str() {
563 let authority = match parsed.port() {
564 Some(port) => format!("{}:{port}", host.to_ascii_lowercase()),
565 None => host.to_ascii_lowercase(),
566 };
567 allowed_authority_set.insert(authority);
568 }
569 }
570 }
571 if allowed_schemes.is_empty() {
572 allowed_schemes.insert("https".to_string());
573 }
574 if allowed_authority_set.is_empty() {
575 return fail_closed_default_egress_contract(allowed_schemes);
576 }
577 let contract = HttpEgressContract {
578 tenant_egress_namespace: "chio-link".to_string(),
579 allowed_schemes,
580 allowed_authority_set,
581 deny_loopback: true,
582 deny_link_local: true,
583 deny_ipv6_ula: true,
584 max_redirect_chain: 1,
585 max_response_bytes: 1 << 22,
586 };
587 if contract.validate_dispatchable_with_pinned_dns().is_ok() {
588 contract
589 } else {
590 fail_closed_default_egress_contract(contract.allowed_schemes.clone())
591 }
592}
593
594fn fail_closed_default_egress_contract(
595 mut allowed_schemes: BTreeSet<String>,
596) -> HttpEgressContract {
597 if allowed_schemes.is_empty() {
598 allowed_schemes.insert("https".to_string());
599 }
600 let mut allowed_authority_set = BTreeSet::new();
601 allowed_authority_set.insert("invalid.localhost".to_string());
602 HttpEgressContract {
603 tenant_egress_namespace: "chio-link".to_string(),
604 allowed_schemes,
605 allowed_authority_set,
606 deny_loopback: true,
607 deny_link_local: true,
608 deny_ipv6_ula: true,
609 max_redirect_chain: 0,
610 max_response_bytes: 1,
611 }
612}
613
614#[must_use]
615pub fn normalize_symbol(value: &str) -> String {
616 value.trim().to_ascii_uppercase()
617}
618
619#[must_use]
620pub fn pair_key(base: &str, quote: &str) -> String {
621 format!("{}/{}", normalize_symbol(base), normalize_symbol(quote))
622}
623
624#[cfg(test)]
625mod tests {
626 use crate::test_support::TestUnwrap;
627
628 use super::{
629 build_default_egress_contract, pair_key, PriceOracleConfig, ARBITRUM_ONE_CAIP2,
630 ARBITRUM_ONE_CHAIN_ID, BASE_MAINNET_CAIP2, BASE_MAINNET_CHAIN_ID,
631 };
632
633 fn local_dispatchable_config() -> PriceOracleConfig {
634 let mut config = PriceOracleConfig::base_arbitrum_default(
635 "http://127.0.0.1:8545",
636 "http://127.0.0.1:9545",
637 );
638 config.pyth.hermes_url = "http://127.0.0.1:9000".to_string();
639 config.egress_contract =
640 build_default_egress_contract(&config.pyth, &config.operator.chains);
641 config.egress_contract.deny_loopback = false;
642 config
643 }
644
645 #[test]
646 fn base_mainnet_default_exposes_supported_pairs() {
647 let config = PriceOracleConfig::base_mainnet_default("https://example.invalid");
648 let pairs = config.supported_pairs();
649 assert_eq!(pairs, vec!["BTC/USD", "ETH/USD", "LINK/USD", "USDC/USD"]);
650 }
651
652 #[test]
653 fn base_mainnet_default_includes_base_and_arbitrum_chain_inventory() {
654 let config = PriceOracleConfig::base_mainnet_default("https://example.invalid");
655 assert_eq!(config.operator.chains.len(), 2);
656 assert_eq!(config.operator.chains[0].chain_id, BASE_MAINNET_CHAIN_ID);
657 assert_eq!(config.operator.chains[0].caip2, BASE_MAINNET_CAIP2);
658 assert_eq!(config.operator.chains[1].chain_id, ARBITRUM_ONE_CHAIN_ID);
659 assert_eq!(config.operator.chains[1].caip2, ARBITRUM_ONE_CAIP2);
660 assert!(!config.operator.chains[1].enabled);
661 }
662
663 #[test]
664 fn operator_overrides_cover_each_supported_pair() {
665 let config = PriceOracleConfig::base_mainnet_default("https://example.invalid");
666 assert_eq!(config.operator.pair_overrides.len(), config.pairs.len());
667 assert!(
668 config
669 .operator
670 .pair_override("ETH", "USD")
671 .test_unwrap("override")
672 .allow_fallback
673 );
674 }
675
676 #[test]
677 fn pair_key_normalizes_symbols() {
678 assert_eq!(pair_key(" eth ", "usd"), "ETH/USD");
679 }
680
681 #[test]
682 fn validate_accepts_default_hostname_egress_with_pinned_resolver() {
683 let mut config = PriceOracleConfig::base_arbitrum_default(
687 "http://127.0.0.1:8545",
688 "http://127.0.0.1:9545",
689 );
690 config.egress_contract =
691 build_default_egress_contract(&config.pyth, &config.operator.chains);
692 config.egress_contract.deny_loopback = false;
693 config
694 .validate()
695 .test_unwrap("default hostname egress is resolver-enforced at dispatch");
696 }
697
698 #[test]
699 fn validate_accepts_literal_ip_egress_contract() {
700 let config = local_dispatchable_config();
701 config.validate().test_unwrap("literal IP egress config");
702 }
703}