Skip to main content

chio_link/
config.rs

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    /// Typed HTTP egress contract that gates every outbound oracle dispatch
269    /// (Chainlink RPC reads, Pyth Hermes fetches, sequencer uptime probes).
270    /// Required in production; the `*_default` constructors derive a contract
271    /// scoped to the configured Pyth and chain RPC authorities.
272    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/// Build a default `HttpEgressContract` whose authority allow-list spans
547/// the configured Pyth Hermes URL and every chain RPC endpoint. This
548/// keeps production wiring fail-closed: dispatches to any other host are
549/// rejected by the typed contract before bytes leave the substrate.
550#[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        // Use loopback addresses so validate() does not perform a live DNS lookup.
684        // The test intent is to verify the egress contract shape is accepted, not
685        // to test live DNS resolution.
686        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}