chio-link 0.1.2

Oracle runtime for Chio cross-currency budget enforcement
Documentation
use std::collections::BTreeMap;

use super::*;
use crate::config::{
    build_default_egress_contract, DegradedModePolicy, PriceOracleConfig, BASE_MAINNET_CHAIN_ID,
};
use crate::test_support::{TestUnwrap, TestUnwrapErr};

struct StaticBackend {
    kind: OracleBackendKind,
    responses: BTreeMap<String, Result<ExchangeRate, PriceOracleError>>,
}

impl StaticBackend {
    fn new(
        kind: OracleBackendKind,
        responses: impl IntoIterator<Item = (String, Result<ExchangeRate, PriceOracleError>)>,
    ) -> Self {
        Self {
            kind,
            responses: responses.into_iter().collect(),
        }
    }
}

impl OracleBackend for StaticBackend {
    fn kind(&self) -> OracleBackendKind {
        self.kind
    }

    fn read_rate<'a>(&'a self, pair: &'a PairConfig, _now: u64) -> OracleFuture<'a> {
        let response = self
            .responses
            .get(&pair.pair())
            .cloned()
            .unwrap_or_else(|| {
                Err(PriceOracleError::NoPairAvailable {
                    base: pair.base.clone(),
                    quote: pair.quote.clone(),
                })
            });
        Box::pin(async move { response })
    }
}

fn sample_rate(source: &str, feed_reference: &str, numerator: u128) -> ExchangeRate {
    let fetched_at = now_unix().test_unwrap("now");
    ExchangeRate {
        base: "ETH".to_string(),
        quote: "USD".to_string(),
        rate_numerator: numerator,
        rate_denominator: 100,
        updated_at: fetched_at.saturating_sub(45),
        fetched_at,
        source: source.to_string(),
        feed_reference: feed_reference.to_string(),
        max_age_seconds: 600,
        conversion_margin_bps: 200,
        confidence_numerator: None,
        confidence_denominator: None,
    }
}

fn test_config() -> PriceOracleConfig {
    let mut config =
        PriceOracleConfig::base_arbitrum_default("http://127.0.0.1:8545", "http://127.0.0.1:9545");
    config.pyth.hermes_url = "http://127.0.0.1:9000".to_string();
    for chain in &mut config.operator.chains {
        chain.sequencer_uptime_feed = None;
    }
    config.egress_contract = build_default_egress_contract(&config.pyth, &config.operator.chains);
    config.egress_contract.deny_loopback = false;
    config
}

#[test]
fn invalid_configuration_constructor_preserves_message() {
    let error = PriceOracleError::invalid_configuration("bad oracle config");
    assert!(matches!(
        error,
        PriceOracleError::InvalidConfiguration(message) if message == "bad oracle config"
    ));
}

#[test]
fn future_rate_updates_fail_closed() {
    let now = now_unix().test_unwrap("now");
    let mut rate = sample_rate("chainlink", "feed-1", 300_000);
    rate.updated_at = now + 1;

    assert!(matches!(
        rate.ensure_fresh(now),
        Err(PriceOracleError::InvalidFeed(message))
            if message.contains("future updated_at")
    ));
}

#[tokio::test]
async fn falls_back_when_primary_is_unavailable() {
    let config = test_config();
    let primary = Arc::new(StaticBackend::new(
        OracleBackendKind::Chainlink,
        [(
            "ETH/USD".to_string(),
            Err(PriceOracleError::Unavailable("chainlink down".to_string())),
        )],
    ));
    let fallback = Arc::new(StaticBackend::new(
        OracleBackendKind::Pyth,
        [(
            "ETH/USD".to_string(),
            Ok(sample_rate(
                "pyth",
                "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
                305_000,
            )),
        )],
    ));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, Some(fallback))
        .test_unwrap("oracle config");

    let rate = oracle
        .get_rate("ETH", "USD")
        .await
        .test_unwrap("fallback rate");
    assert_eq!(rate.source, "pyth");
}

#[tokio::test]
async fn divergence_trips_fail_closed_policy() {
    let config = test_config();
    let primary = Arc::new(StaticBackend::new(
        OracleBackendKind::Chainlink,
        [(
            "ETH/USD".to_string(),
            Ok(sample_rate(
                "chainlink",
                "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
                300_000,
            )),
        )],
    ));
    let fallback = Arc::new(StaticBackend::new(
        OracleBackendKind::Pyth,
        [(
            "ETH/USD".to_string(),
            Ok(sample_rate(
                "pyth",
                "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
                330_000,
            )),
        )],
    ));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, Some(fallback))
        .test_unwrap("oracle config");

    let error = oracle
        .refresh_pair("ETH", "USD")
        .await
        .test_unwrap_err("should fail closed");
    assert!(matches!(
        error,
        PriceOracleError::CircuitBreakerTripped { .. }
    ));
}

#[tokio::test]
async fn backend_pair_mismatch_fails_closed_before_cache_insert() {
    let config = test_config();
    let mut mismatched = sample_rate(
        "chainlink",
        "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
        300_000,
    );
    mismatched.quote = "EUR".to_string();
    let primary = Arc::new(StaticBackend::new(
        OracleBackendKind::Chainlink,
        [("ETH/USD".to_string(), Ok(mismatched))],
    ));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, None).test_unwrap("oracle");

    let error = oracle
        .refresh_pair("ETH", "USD")
        .await
        .test_unwrap_err("pair mismatch must fail closed");

    assert!(matches!(
        error,
        PriceOracleError::InvalidFeed(message)
            if message.contains("returned ETH/EUR for ETH/USD")
    ));
    assert!(
        oracle
            .cached_rate("ETH", "USD")
            .await
            .test_unwrap("cache lookup")
            .is_none(),
        "mismatched backend rates must not enter the cache"
    );
}

#[tokio::test]
async fn global_pause_stops_budget_resolution() {
    let config = test_config();
    let primary = Arc::new(StaticBackend::new(
        OracleBackendKind::Chainlink,
        [(
            "ETH/USD".to_string(),
            Ok(sample_rate(
                "chainlink",
                "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
                300_000,
            )),
        )],
    ));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, None).test_unwrap("oracle");
    oracle
        .set_global_pause(true, Some("manual operator stop".to_string()))
        .await
        .test_unwrap("pause");
    let error = oracle
        .get_rate("ETH", "USD")
        .await
        .test_unwrap_err("paused");
    assert!(matches!(error, PriceOracleError::OperatorPaused { .. }));
}

#[tokio::test]
async fn disabling_trusted_chain_blocks_the_pair() {
    let config = test_config();
    let primary = Arc::new(StaticBackend::new(
        OracleBackendKind::Chainlink,
        [(
            "ETH/USD".to_string(),
            Ok(sample_rate(
                "chainlink",
                "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
                300_000,
            )),
        )],
    ));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, None).test_unwrap("oracle");
    oracle
        .set_chain_enabled(BASE_MAINNET_CHAIN_ID, false)
        .await
        .test_unwrap("disable chain");
    let error = oracle
        .get_rate("ETH", "USD")
        .await
        .test_unwrap_err("disabled chain should fail");
    assert!(matches!(error, PriceOracleError::ChainDisabled { .. }));
}

#[tokio::test]
async fn operator_can_force_specific_backend() {
    let config = test_config();
    let primary = Arc::new(StaticBackend::new(
        OracleBackendKind::Chainlink,
        [(
            "ETH/USD".to_string(),
            Ok(sample_rate(
                "chainlink",
                "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
                300_000,
            )),
        )],
    ));
    let fallback = Arc::new(StaticBackend::new(
        OracleBackendKind::Pyth,
        [(
            "ETH/USD".to_string(),
            Ok(sample_rate(
                "pyth",
                "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
                305_000,
            )),
        )],
    ));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, Some(fallback))
        .test_unwrap("oracle config");
    oracle
        .set_pair_override(PairRuntimeOverride {
            base: "ETH".to_string(),
            quote: "USD".to_string(),
            enabled: true,
            force_backend: Some(OracleBackendKind::Pyth),
            allow_fallback: false,
            divergence_threshold_bps: None,
            degraded_mode: None,
        })
        .await
        .test_unwrap("override");

    let rate = oracle
        .get_rate("ETH", "USD")
        .await
        .test_unwrap("forced backend");
    assert_eq!(rate.source, "pyth");
}

#[tokio::test]
async fn unsupported_pair_fails_closed() {
    let config = test_config();
    let primary = Arc::new(StaticBackend::new(OracleBackendKind::Chainlink, []));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, None).test_unwrap("oracle");
    let error = oracle
        .get_rate("EUR", "USD")
        .await
        .test_unwrap_err("unsupported pair");
    assert!(matches!(error, PriceOracleError::NoPairAvailable { .. }));
}

#[test]
fn degraded_mode_reuses_stale_cached_rate_with_extra_margin() {
    let mut pair = test_config().pair("ETH", "USD").test_unwrap("pair").clone();
    pair.policy.degraded_mode = DegradedModePolicy::conservative_default();
    let stale_rate = ExchangeRate {
        updated_at: 100,
        fetched_at: 150,
        max_age_seconds: 600,
        conversion_margin_bps: 200,
        ..sample_rate(
            "chainlink",
            "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
            300_000,
        )
    };
    let override_config = PairRuntimeOverride::from_pair(&pair);
    let degraded = degraded_rate_if_allowed(&pair, &override_config, stale_rate, 850)
        .test_unwrap("degraded rate");
    assert_eq!(degraded.max_age_seconds, 900);
    assert_eq!(degraded.conversion_margin_bps, 1_000);
    assert!(degraded.source.ends_with(":degraded"));
}

#[tokio::test]
async fn runtime_report_surfaces_pause_alert() {
    let config = test_config();
    let primary = Arc::new(StaticBackend::new(OracleBackendKind::Chainlink, []));
    let oracle = ChioLinkOracle::new_with_backends(config, primary, None).test_unwrap("oracle");
    oracle
        .set_global_pause(true, Some("manual operator stop".to_string()))
        .await
        .test_unwrap("pause");
    let report = oracle.runtime_report().await.test_unwrap("report");
    assert!(report.global_pause);
    assert!(report
        .alerts
        .iter()
        .any(|alert| alert.code == "global_pause"));
}

#[test]
fn builds_conversion_evidence() {
    let rate = sample_rate(
        "chainlink",
        "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
        300_000,
    );
    let now = rate.fetched_at + 35;
    let evidence = rate
        .to_conversion_evidence(100_000_000_000_000, "ETH", "USD", 300, now)
        .test_unwrap("evidence");
    assert_eq!(evidence.schema, CHIO_ORACLE_CONVERSION_EVIDENCE_SCHEMA);
    assert_eq!(evidence.authority, CHIO_LINK_ORACLE_AUTHORITY);
    assert_eq!(
        evidence.feed_address,
        "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70"
    );
    assert_eq!(evidence.cache_age_seconds, 35);
}