From 499db024e66e935307e240d41b67c7c2d5bd9a38 Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Tue, 18 Aug 2026 02:50:14 +0300 Subject: [PATCH 1/8] apollo_l1_gas_price_types: currency pair and rate kind Co-Authored-By: Claude Opus 5 (1M context) --- crates/apollo_l1_gas_price_types/src/lib.rs | 55 ++++++++++++++++++++ crates/apollo_l1_gas_price_types/src/test.rs | 39 ++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 crates/apollo_l1_gas_price_types/src/test.rs diff --git a/crates/apollo_l1_gas_price_types/src/lib.rs b/crates/apollo_l1_gas_price_types/src/lib.rs index d910436d323..6e43cc5f1a1 100644 --- a/crates/apollo_l1_gas_price_types/src/lib.rs +++ b/crates/apollo_l1_gas_price_types/src/lib.rs @@ -1,4 +1,7 @@ pub mod errors; +#[cfg(test)] +mod test; + use std::fmt::Debug; use std::iter::Sum; use std::sync::Arc; @@ -26,6 +29,35 @@ pub const DEFAULT_ETH_TO_FRI_RATE: ExchangeRate = 10_u128.pow(21); /// A currency conversion rate, as an 18-decimal fixed-point integer. pub type ExchangeRate = u128; +/// Currency pair a reading or rate belongs to. +pub const LABEL_NAME_CURRENCY_PAIR: &str = "currency_pair"; + +/// The pair a reading or rate quotes. +// [Temporary comment] No consumer yet: `labels()` lands with the oracle metrics PR, `pair_name()` +// with the rate-bounds PR. +#[derive(Clone, Copy, Debug, EnumIter, IntoStaticStr, PartialEq, Eq, VariantNames)] +#[strum(serialize_all = "snake_case")] +pub enum CurrencyPair { + EthUsd, + StrkUsd, + /// Derived from the two USD pairs: no Chainlink feed on Starknet quotes ETH in STRK. + EthStrk, +} + +impl CurrencyPair { + pub fn pair_name(self) -> &'static str { + match self { + CurrencyPair::EthUsd => "ETH/USD", + CurrencyPair::StrkUsd => "STRK/USD", + CurrencyPair::EthStrk => "ETH/STRK", + } + } + + pub fn labels(self) -> [(&'static str, &'static str); 1] { + [(LABEL_NAME_CURRENCY_PAIR, self.into())] + } +} + pub type SharedL1GasPriceClient = Arc; pub type L1GasPriceProviderResult = Result; pub type L1GasPriceProviderClientResult = Result; @@ -125,6 +157,29 @@ pub trait ExchangeRateOracleClientTrait: Send + Sync + Debug { ) -> Result; } +/// The rate an oracle client instance produces. +// [Temporary comment] No implementors outside this module yet: the rate arithmetic and Chainlink +// client PRs add them. +pub trait RateKind: Send + Sync + Debug + 'static { + const PAIR: CurrencyPair; +} + +/// FRI per ETH, derived from the ETH/USD and STRK/USD pairs. +#[derive(Clone, Copy, Debug)] +pub struct EthToFri; + +impl RateKind for EthToFri { + const PAIR: CurrencyPair = CurrencyPair::EthStrk; +} + +/// USD per STRK. +#[derive(Clone, Copy, Debug)] +pub struct StrkToUsd; + +impl RateKind for StrkToUsd { + const PAIR: CurrencyPair = CurrencyPair::StrkUsd; +} + #[async_trait] impl L1GasPriceProviderClient for ComponentClientType where diff --git a/crates/apollo_l1_gas_price_types/src/test.rs b/crates/apollo_l1_gas_price_types/src/test.rs new file mode 100644 index 00000000000..d466839cb9e --- /dev/null +++ b/crates/apollo_l1_gas_price_types/src/test.rs @@ -0,0 +1,39 @@ +use std::collections::HashSet; + +use strum::{IntoEnumIterator, VariantNames}; + +use crate::{CurrencyPair, EthToFri, RateKind, StrkToUsd, LABEL_NAME_CURRENCY_PAIR}; + +/// Copy-pasted `pair_name` arms compile, so only a distinctness check catches them. +#[test] +fn pair_name_is_distinct_per_variant() { + let pair_names: HashSet<&str> = CurrencyPair::iter().map(CurrencyPair::pair_name).collect(); + assert_eq!(pair_names.len(), CurrencyPair::VARIANTS.len()); +} + +#[test] +fn labels_yield_snake_case_variant_name() { + for (currency_pair, snake_case_name) in [ + (CurrencyPair::EthUsd, "eth_usd"), + (CurrencyPair::StrkUsd, "strk_usd"), + (CurrencyPair::EthStrk, "eth_strk"), + ] { + assert_eq!(currency_pair.labels(), [(LABEL_NAME_CURRENCY_PAIR, snake_case_name)]); + } +} + +#[test] +fn enum_iter_and_variant_names_cover_all_variants() { + assert_eq!( + CurrencyPair::iter().collect::>(), + vec![CurrencyPair::EthUsd, CurrencyPair::StrkUsd, CurrencyPair::EthStrk] + ); + assert_eq!(CurrencyPair::VARIANTS, ["eth_usd", "strk_usd", "eth_strk"]); +} + +/// A transposed `RateKind::PAIR` compiles, since both markers map to a valid `CurrencyPair`. +#[test] +fn rate_kind_markers_map_to_their_pair() { + assert_eq!(EthToFri::PAIR, CurrencyPair::EthStrk); + assert_eq!(StrkToUsd::PAIR, CurrencyPair::StrkUsd); +} From a4094db7d542d160641fe7d53c47a54266218040 Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Tue, 18 Aug 2026 03:08:37 +0300 Subject: [PATCH 2/8] apollo_l1_gas_price,apollo_l1_gas_price_types: Chainlink guard counters and errors --- Cargo.lock | 1 + crates/apollo_l1_gas_price/Cargo.toml | 1 + crates/apollo_l1_gas_price/src/metrics.rs | 32 +++++++++++- .../apollo_l1_gas_price/src/metrics_test.rs | 33 +++++++++++- .../apollo_l1_gas_price_types/src/errors.rs | 27 ++++++++++ crates/apollo_l1_gas_price_types/src/test.rs | 52 +++++++++++++++++++ 6 files changed, 143 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7ddefbf80d..bfe29b6628c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1884,6 +1884,7 @@ dependencies = [ "rstest", "serde_json", "starknet_api", + "strum", "thiserror 1.0.69", "tokio", "tokio-util", diff --git a/crates/apollo_l1_gas_price/Cargo.toml b/crates/apollo_l1_gas_price/Cargo.toml index f93eddaab59..ffc2a81d8c4 100644 --- a/crates/apollo_l1_gas_price/Cargo.toml +++ b/crates/apollo_l1_gas_price/Cargo.toml @@ -22,6 +22,7 @@ reqwest.workspace = true rstest.workspace = true serde_json.workspace = true starknet_api.workspace = true +strum = { workspace = true, features = ["derive"] } thiserror.workspace = true tokio.workspace = true tokio-util = { workspace = true, features = ["rt"] } diff --git a/crates/apollo_l1_gas_price/src/metrics.rs b/crates/apollo_l1_gas_price/src/metrics.rs index 7ba3800c8d6..3eb69cb1dd9 100644 --- a/crates/apollo_l1_gas_price/src/metrics.rs +++ b/crates/apollo_l1_gas_price/src/metrics.rs @@ -7,9 +7,13 @@ use apollo_infra::metrics::{ RemoteClientMetrics, RemoteServerMetrics, }; -use apollo_l1_gas_price_types::L1_GAS_PRICE_REQUEST_LABELS; +use apollo_l1_gas_price_types::{ + CurrencyPair, + L1_GAS_PRICE_REQUEST_LABELS, + LABEL_NAME_CURRENCY_PAIR, +}; use apollo_metrics::metrics::{MetricCounter, MetricDetails, MetricGauge}; -use apollo_metrics::{define_infra_metrics, define_metrics}; +use apollo_metrics::{define_infra_metrics, define_metrics, generate_permutation_labels}; #[cfg(test)] #[path = "metrics_test.rs"] @@ -17,6 +21,11 @@ mod metrics_test; define_infra_metrics!(l1_gas_price); +generate_permutation_labels! { + CURRENCY_PAIR_LABELS, + (LABEL_NAME_CURRENCY_PAIR, CurrencyPair), +} + define_metrics!( L1GasPrice => { MetricCounter { L1_GAS_PRICE_PROVIDER_INSUFFICIENT_HISTORY, "l1_gas_price_provider_insufficient_history", "Number of times the L1 gas price provider calculated an average with too few blocks", init=0 }, @@ -27,6 +36,11 @@ define_metrics!( MetricCounter { ETH_TO_STRK_SUCCESS_COUNT, "eth_to_strk_success_count", "Number of times the query to the Eth to Strk oracle succeeded", init=0 }, MetricCounter { SNIP35_STRK_USD_ERROR_COUNT, "snip35_strk_usd_error_count", "Number of times the query to the STRK to USD oracle failed due to an error or timeout", init=0 }, MetricCounter { SNIP35_STRK_USD_SUCCESS_COUNT, "snip35_strk_usd_success_count", "Number of times the query to the STRK to USD oracle succeeded", init=0 }, + LabeledMetricCounter { CHAINLINK_ORACLE_STALE_FEED_COUNT, "chainlink_oracle_stale_feed_count", "Number of times a Chainlink price feed reading was rejected because its update timestamp was older than the accepted staleness bound", init=0, labels = CURRENCY_PAIR_LABELS }, + LabeledMetricCounter { CHAINLINK_ORACLE_FUTURE_FEED_COUNT, "chainlink_oracle_future_feed_count", "Number of times a Chainlink price feed reading was rejected because its update timestamp led the queried timestamp by more than the accepted tolerance", init=0, labels = CURRENCY_PAIR_LABELS }, + LabeledMetricCounter { CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT, "chainlink_oracle_rate_out_of_bounds_count", "Number of times a Chainlink rate was rejected because it fell outside the configured absolute sanity bounds", init=0, labels = CURRENCY_PAIR_LABELS }, + LabeledMetricCounter { CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT, "chainlink_oracle_invalid_feed_answer_count", "Number of times a Chainlink feed returned a zero answer or a decimals value outside the accepted range", init=0, labels = CURRENCY_PAIR_LABELS }, + LabeledMetricCounter { CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT, "chainlink_oracle_contract_call_error_count", "Number of times a Chainlink feed call to the batcher failed or returned undecodable retdata", init=0, labels = CURRENCY_PAIR_LABELS }, MetricGauge { L1_GAS_PRICE_SCRAPER_LAST_SUCCESS_TIMESTAMP_SECONDS, "l1_gas_price_scraper_last_success_timestamp_seconds", "Unix timestamp (seconds) of the last successful L1 gas price scrape" }, MetricGauge { ETH_TO_STRK_LAST_SUCCESS_TIMESTAMP_SECONDS, "eth_to_strk_last_success_timestamp_seconds", "Unix timestamp (seconds) of the last successful ETH→STRK oracle query" }, MetricGauge { SNIP35_STRK_USD_LAST_SUCCESS_TIMESTAMP_SECONDS, "snip35_strk_usd_last_success_timestamp_seconds", "Unix timestamp (seconds) of the last successful STRK→USD oracle query" }, @@ -101,6 +115,20 @@ pub(crate) fn register_provider_metrics() { L1_DATA_GAS_PRICE_LATEST_MEAN_VALUE.register(); } +/// Registers one counter series per `currency_pair`. Runs once per process. +// [Temporary comment] `pub` until the Chainlink client (A9) calls it; the feed guards (A7/A8) +// increment these counters. +pub fn register_chainlink_guard_metrics() { + static CHAINLINK_GUARD_METRICS_REGISTRATION: Once = Once::new(); + CHAINLINK_GUARD_METRICS_REGISTRATION.call_once(|| { + CHAINLINK_ORACLE_STALE_FEED_COUNT.register(); + CHAINLINK_ORACLE_FUTURE_FEED_COUNT.register(); + CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT.register(); + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT.register(); + CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT.register(); + }); +} + pub(crate) fn register_scraper_metrics() { L1_GAS_PRICE_SCRAPER_SUCCESS_COUNT.register(); L1_GAS_PRICE_SCRAPER_BASELAYER_ERROR_COUNT.register(); diff --git a/crates/apollo_l1_gas_price/src/metrics_test.rs b/crates/apollo_l1_gas_price/src/metrics_test.rs index 15f503b9ce0..66ce957874b 100644 --- a/crates/apollo_l1_gas_price/src/metrics_test.rs +++ b/crates/apollo_l1_gas_price/src/metrics_test.rs @@ -3,10 +3,21 @@ use std::sync::Once; use apollo_config::converters::UrlAndHeaders; use apollo_l1_gas_price_config::config::ExchangeRateOracleConfig; +use apollo_l1_gas_price_types::CurrencyPair; use metrics_exporter_prometheus::PrometheusBuilder; +use strum::IntoEnumIterator; use url::Url; -use super::{ExchangeRateOracleMetrics, ETH_TO_STRK_ORACLE_METRICS}; +use super::{ + register_chainlink_guard_metrics, + ExchangeRateOracleMetrics, + CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT, + CHAINLINK_ORACLE_FUTURE_FEED_COUNT, + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT, + CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT, + CHAINLINK_ORACLE_STALE_FEED_COUNT, + ETH_TO_STRK_ORACLE_METRICS, +}; use crate::exchange_rate_oracle::ExchangeRateOracleClient; /// Guard owned by this test, so the assertions on its state do not depend on whether another test @@ -45,3 +56,23 @@ fn repeated_client_construction_registers_metrics_once() { oracle_metrics.success_count.assert_eq::(&recorder.handle().render(), 1); } + +/// Registration publishes a zero sample per label permutation, so a guard that never trips renders +/// as 0 instead of being absent from the scrape. +#[test] +fn chainlink_guard_metrics_register_at_zero_for_every_pair() { + let recorder = PrometheusBuilder::new().build_recorder(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + + register_chainlink_guard_metrics(); + + let metrics_as_string = recorder.handle().render(); + for currency_pair in CurrencyPair::iter() { + let labels = currency_pair.labels(); + CHAINLINK_ORACLE_STALE_FEED_COUNT.assert_eq::(&metrics_as_string, 0, &labels); + CHAINLINK_ORACLE_FUTURE_FEED_COUNT.assert_eq::(&metrics_as_string, 0, &labels); + CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT.assert_eq::(&metrics_as_string, 0, &labels); + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT.assert_eq::(&metrics_as_string, 0, &labels); + CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT.assert_eq::(&metrics_as_string, 0, &labels); + } +} diff --git a/crates/apollo_l1_gas_price_types/src/errors.rs b/crates/apollo_l1_gas_price_types/src/errors.rs index ff74fed5451..5bbaba9ac13 100644 --- a/crates/apollo_l1_gas_price_types/src/errors.rs +++ b/crates/apollo_l1_gas_price_types/src/errors.rs @@ -49,6 +49,33 @@ pub enum ExchangeRateOracleClientError { AllUrlsFailedError(u64, usize), #[error("Invalid rate from oracle: {0}")] InvalidRateError(String), + // [Temporary comment] The five variants below are first returned by the feed guards (A7/A8). + #[error( + "Stale {pair_name} price feed: last updated at {updated_at}, priced for block timestamp \ + {block_timestamp}, maximum accepted staleness is {max_staleness_seconds} seconds" + )] + StaleFeedError { + pair_name: String, + updated_at: u64, + block_timestamp: u64, + max_staleness_seconds: u64, + }, + #[error( + "The {pair_name} price feed is dated {updated_at}, more than \ + {max_future_updated_at_seconds} seconds ahead of the block timestamp {block_timestamp}" + )] + FutureFeedError { + pair_name: String, + updated_at: u64, + block_timestamp: u64, + max_future_updated_at_seconds: u64, + }, + #[error("Rate {rate} for {pair_name} is outside the accepted range [{min_rate}, {max_rate}]")] + RateOutOfBoundsError { pair_name: String, rate: u128, min_rate: u128, max_rate: u128 }, + #[error("Contract call to price feed failed: {0}")] + ContractCallError(String), + #[error("Arithmetic overflow while computing rate: {0}")] + ArithmeticError(String), } impl From for ExchangeRateOracleClientError { diff --git a/crates/apollo_l1_gas_price_types/src/test.rs b/crates/apollo_l1_gas_price_types/src/test.rs index d466839cb9e..6fa31f388df 100644 --- a/crates/apollo_l1_gas_price_types/src/test.rs +++ b/crates/apollo_l1_gas_price_types/src/test.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use strum::{IntoEnumIterator, VariantNames}; +use crate::errors::ExchangeRateOracleClientError; use crate::{CurrencyPair, EthToFri, RateKind, StrkToUsd, LABEL_NAME_CURRENCY_PAIR}; /// Copy-pasted `pair_name` arms compile, so only a distinctness check catches them. @@ -37,3 +38,54 @@ fn rate_kind_markers_map_to_their_pair() { assert_eq!(EthToFri::PAIR, CurrencyPair::EthStrk); assert_eq!(StrkToUsd::PAIR, CurrencyPair::StrkUsd); } + +/// Each message names the pair, the values that tripped the guard and the bound they violated. +#[test] +fn guard_errors_render_their_operator_facing_fields() { + assert_eq!( + ExchangeRateOracleClientError::StaleFeedError { + pair_name: CurrencyPair::EthUsd.pair_name().to_string(), + updated_at: 100, + block_timestamp: 400, + max_staleness_seconds: 120, + } + .to_string(), + "Stale ETH/USD price feed: last updated at 100, priced for block timestamp 400, maximum \ + accepted staleness is 120 seconds" + ); + assert_eq!( + ExchangeRateOracleClientError::FutureFeedError { + pair_name: CurrencyPair::StrkUsd.pair_name().to_string(), + updated_at: 500, + block_timestamp: 400, + max_future_updated_at_seconds: 30, + } + .to_string(), + "The STRK/USD price feed is dated 500, more than 30 seconds ahead of the block timestamp \ + 400" + ); + assert_eq!( + ExchangeRateOracleClientError::RateOutOfBoundsError { + pair_name: CurrencyPair::EthStrk.pair_name().to_string(), + rate: 7, + min_rate: 10, + max_rate: 20, + } + .to_string(), + "Rate 7 for ETH/STRK is outside the accepted range [10, 20]" + ); + assert_eq!( + ExchangeRateOracleClientError::ContractCallError( + "retdata has 1 felt, expected 5".to_owned() + ) + .to_string(), + "Contract call to price feed failed: retdata has 1 felt, expected 5" + ); + assert_eq!( + ExchangeRateOracleClientError::ArithmeticError( + "ETH/USD 3000 * 10^18 exceeds u128".to_owned() + ) + .to_string(), + "Arithmetic overflow while computing rate: ETH/USD 3000 * 10^18 exceeds u128" + ); +} From f474fcbe99375ce612eea4afdcf7f826d67e2de3 Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Tue, 18 Aug 2026 03:39:39 +0300 Subject: [PATCH 3/8] apollo_l1_gas_price_config: source-agnostic rate bounds --- Cargo.lock | 2 + crates/apollo_l1_gas_price_config/Cargo.toml | 4 + .../apollo_l1_gas_price_config/src/config.rs | 193 +++++++++++++++++- .../src/config_test.rs | 53 ++++- 4 files changed, 249 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bfe29b6628c..4843262c01c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1897,6 +1897,8 @@ name = "apollo_l1_gas_price_config" version = "0.19.0-rc.2" dependencies = [ "apollo_config", + "apollo_l1_gas_price_types", + "rstest", "serde", "starknet_api", "url", diff --git a/crates/apollo_l1_gas_price_config/Cargo.toml b/crates/apollo_l1_gas_price_config/Cargo.toml index 01032dc5d24..c4144fbee88 100644 --- a/crates/apollo_l1_gas_price_config/Cargo.toml +++ b/crates/apollo_l1_gas_price_config/Cargo.toml @@ -11,7 +11,11 @@ workspace = true [dependencies] apollo_config.workspace = true +apollo_l1_gas_price_types.workspace = true serde = { workspace = true, features = ["derive"] } starknet_api.workspace = true url.workspace = true validator.workspace = true + +[dev-dependencies] +rstest.workspace = true diff --git a/crates/apollo_l1_gas_price_config/src/config.rs b/crates/apollo_l1_gas_price_config/src/config.rs index 47669938f5a..1d889518650 100644 --- a/crates/apollo_l1_gas_price_config/src/config.rs +++ b/crates/apollo_l1_gas_price_config/src/config.rs @@ -14,12 +14,13 @@ use apollo_config::dumping::{ SerializeConfig, }; use apollo_config::secrets::Sensitive; -use apollo_config::validators::validate_ascii; +use apollo_config::validators::{create_validation_error, validate_ascii}; use apollo_config::{ParamPath, ParamPrivacyInput, SerializedParam}; +use apollo_l1_gas_price_types::CurrencyPair; use serde::{Deserialize, Serialize}; use starknet_api::core::ChainId; use url::Url; -use validator::Validate; +use validator::{Validate, ValidationError}; #[cfg(test)] #[path = "config_test.rs"] @@ -94,6 +95,194 @@ impl Default for ExchangeRateOracleConfig { } } +/// Decimals of the micro-unit rate bounds. +pub const RATE_MICRO_UNIT_DECIMALS: u32 = 6; + +/// Inclusive absolute bounds on a rate, in micro units (1e-6) of the pair's quote currency, +/// together with the pair they bound. +// [Temporary comment] No reader yet: the guards arrive in A7. +#[derive(Clone, Copy, Debug)] +pub struct RateBounds { + pub minimum_micro_units: u64, + pub maximum_micro_units: u64, + pub pair: CurrencyPair, +} + +/// Bounds on a pair an oracle quotes directly. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] +pub struct QuotedRateBoundsConfig { + pub minimum_micro_units: u64, + pub maximum_micro_units: u64, +} + +impl QuotedRateBoundsConfig { + fn bounds(&self, pair: CurrencyPair) -> RateBounds { + RateBounds { + minimum_micro_units: self.minimum_micro_units, + maximum_micro_units: self.maximum_micro_units, + pair, + } + } +} + +impl SerializeConfig for QuotedRateBoundsConfig { + fn dump(&self) -> BTreeMap { + BTreeMap::from_iter([ + ser_param( + "minimum_micro_units", + &self.minimum_micro_units, + "Lowest accepted price for this pair, in micro units (1e-6) of the quote \ + currency, so a value of 20000000 on ETH/USD means $20.", + ParamPrivacyInput::Public, + ), + ser_param( + "maximum_micro_units", + &self.maximum_micro_units, + "Highest accepted price for this pair, in micro units (1e-6) of the quote \ + currency, so a value of 50000000000 on ETH/USD means $50,000.", + ParamPrivacyInput::Public, + ), + ]) + } +} + +/// Bounds on a pair derived from two quoted pairs. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] +pub struct DerivedRateBoundsConfig { + pub minimum_micro_units: u64, + pub maximum_micro_units: u64, +} + +impl DerivedRateBoundsConfig { + fn bounds(&self, pair: CurrencyPair) -> RateBounds { + RateBounds { + minimum_micro_units: self.minimum_micro_units, + maximum_micro_units: self.maximum_micro_units, + pair, + } + } +} + +impl SerializeConfig for DerivedRateBoundsConfig { + fn dump(&self) -> BTreeMap { + BTreeMap::from_iter([ + ser_param( + "minimum_micro_units", + &self.minimum_micro_units, + "Lowest accepted rate for this pair, in micro units (1e-6) of the quote currency, \ + so a value of 10000000000 on ETH/STRK means 10,000 STRK per ETH.", + ParamPrivacyInput::Public, + ), + ser_param( + "maximum_micro_units", + &self.maximum_micro_units, + "Highest accepted rate for this pair, in micro units (1e-6) of the quote \ + currency, so a value of 1000000000000 on ETH/STRK means 1,000,000 STRK per ETH.", + ParamPrivacyInput::Public, + ), + ]) + } +} + +/// Absolute bounds every exchange rate must fall in, whichever source reports it. +// [Temporary comment] No reader yet: A7 checks rates, B2 nests this in `L1GasPriceProviderConfig`. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] +#[validate(schema(function = "validate_rate_bounds_config"))] +pub struct RateBoundsConfig { + /// Micro-USD per ETH. + #[validate(nested)] + pub eth_usd: QuotedRateBoundsConfig, + /// Micro-USD per STRK. + #[validate(nested)] + pub strk_usd: QuotedRateBoundsConfig, + /// Micro-STRK per ETH. + #[validate(nested)] + pub eth_to_fri: DerivedRateBoundsConfig, +} + +impl RateBoundsConfig { + pub fn eth_usd_bounds(&self) -> RateBounds { + self.eth_usd.bounds(CurrencyPair::EthUsd) + } + + pub fn strk_usd_bounds(&self) -> RateBounds { + self.strk_usd.bounds(CurrencyPair::StrkUsd) + } + + pub fn eth_to_fri_bounds(&self) -> RateBounds { + self.eth_to_fri.bounds(CurrencyPair::EthStrk) + } +} + +impl Default for RateBoundsConfig { + fn default() -> Self { + const MICRO_UNITS_PER_UNIT: u64 = 10u64.pow(RATE_MICRO_UNIT_DECIMALS); + + Self { + // $20 .. $50,000 per ETH, ~10x above the all-time high. + eth_usd: QuotedRateBoundsConfig { + minimum_micro_units: 20 * MICRO_UNITS_PER_UNIT, + maximum_micro_units: 50_000 * MICRO_UNITS_PER_UNIT, + }, + // $0.0001 .. $10 per STRK. + strk_usd: QuotedRateBoundsConfig { + minimum_micro_units: MICRO_UNITS_PER_UNIT / 10_000, + maximum_micro_units: 10 * MICRO_UNITS_PER_UNIT, + }, + // 10,000 .. 1,000,000 STRK per ETH, roughly 10x either side of spot near 8.2e4. + eth_to_fri: DerivedRateBoundsConfig { + minimum_micro_units: 10_000 * MICRO_UNITS_PER_UNIT, + maximum_micro_units: 1_000_000 * MICRO_UNITS_PER_UNIT, + }, + } + } +} + +/// The config key a pair's bounds live under. +fn bounds_config_key(pair: CurrencyPair) -> &'static str { + match pair { + CurrencyPair::EthUsd => "eth_usd", + CurrencyPair::StrkUsd => "strk_usd", + CurrencyPair::EthStrk => "eth_to_fri", + } +} + +/// Cross-field checks the per-field `range` attributes cannot express. +fn validate_rate_bounds_config(config: &RateBoundsConfig) -> Result<(), ValidationError> { + for bounds in [config.eth_usd_bounds(), config.strk_usd_bounds(), config.eth_to_fri_bounds()] { + let pair_name = bounds_config_key(bounds.pair); + if bounds.minimum_micro_units == 0 { + return Err(create_validation_error( + format!("{pair_name}.minimum_micro_units is zero"), + "zero sanity bound", + "A zero minimum disables the lower sanity bound; set it to the lowest plausible \ + value.", + )); + } + if bounds.minimum_micro_units >= bounds.maximum_micro_units { + return Err(create_validation_error( + format!( + "{pair_name}.minimum_micro_units ({}) is not below \ + {pair_name}.maximum_micro_units ({})", + bounds.minimum_micro_units, bounds.maximum_micro_units + ), + "inverted sanity bounds", + "Ensure each minimum sanity bound is strictly below its maximum.", + )); + } + } + Ok(()) +} + +impl SerializeConfig for RateBoundsConfig { + fn dump(&self) -> BTreeMap { + let mut config = prepend_sub_config_name(self.eth_usd.dump(), "eth_usd"); + config.extend(prepend_sub_config_name(self.strk_usd.dump(), "strk_usd")); + config.extend(prepend_sub_config_name(self.eth_to_fri.dump(), "eth_to_fri")); + config + } +} + #[derive(Clone, Debug, Serialize, Deserialize, Validate, PartialEq)] pub struct L1GasPriceProviderConfig { // TODO(guyn): these two fields need to go into VersionedConstants. diff --git a/crates/apollo_l1_gas_price_config/src/config_test.rs b/crates/apollo_l1_gas_price_config/src/config_test.rs index b0d667c967d..236fdb40d4d 100644 --- a/crates/apollo_l1_gas_price_config/src/config_test.rs +++ b/crates/apollo_l1_gas_price_config/src/config_test.rs @@ -1,6 +1,9 @@ +use std::mem::swap; + +use rstest::rstest; use validator::Validate; -use super::L1GasPriceProviderConfig; +use super::{L1GasPriceProviderConfig, RateBoundsConfig}; // A zero mean window would make the provider divide by zero when computing the mean gas price, // so it must be rejected at config load instead of panicking later during block production. @@ -14,3 +17,51 @@ fn rejects_zero_number_of_blocks_for_mean() { fn accepts_default_number_of_blocks_for_mean() { assert!(L1GasPriceProviderConfig::default().validate().is_ok()); } + +#[test] +fn accepts_default_rate_bounds_config() { + assert!(RateBoundsConfig::default().validate().is_ok()); +} + +/// One pair's bounds, as `(minimum_micro_units, maximum_micro_units)`. +type SelectBounds = fn(&mut RateBoundsConfig) -> (&mut u64, &mut u64); + +/// Makes a pair's bounds unusable. +type BreakBounds = fn(&mut u64, &mut u64); + +fn select_eth_usd_bounds(config: &mut RateBoundsConfig) -> (&mut u64, &mut u64) { + (&mut config.eth_usd.minimum_micro_units, &mut config.eth_usd.maximum_micro_units) +} + +fn select_strk_usd_bounds(config: &mut RateBoundsConfig) -> (&mut u64, &mut u64) { + (&mut config.strk_usd.minimum_micro_units, &mut config.strk_usd.maximum_micro_units) +} + +fn select_eth_to_fri_bounds(config: &mut RateBoundsConfig) -> (&mut u64, &mut u64) { + (&mut config.eth_to_fri.minimum_micro_units, &mut config.eth_to_fri.maximum_micro_units) +} + +fn zero_minimum(minimum_micro_units: &mut u64, _maximum_micro_units: &mut u64) { + *minimum_micro_units = 0; +} + +fn invert_bounds(minimum_micro_units: &mut u64, maximum_micro_units: &mut u64) { + swap(minimum_micro_units, maximum_micro_units); +} + +fn equalize_bounds(minimum_micro_units: &mut u64, maximum_micro_units: &mut u64) { + *maximum_micro_units = *minimum_micro_units; +} + +// Every pair and failure mode must be rejected at config load. +#[rstest] +fn rejects_unusable_rate_bounds( + #[values(select_eth_usd_bounds, select_strk_usd_bounds, select_eth_to_fri_bounds)] + select_bounds: SelectBounds, + #[values(zero_minimum, invert_bounds, equalize_bounds)] break_bounds: BreakBounds, +) { + let mut config = RateBoundsConfig::default(); + let (minimum_micro_units, maximum_micro_units) = select_bounds(&mut config); + break_bounds(minimum_micro_units, maximum_micro_units); + assert!(config.validate().is_err()); +} From 00dcd799151be846bc2adf3d573b107e08fa5c14 Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Tue, 18 Aug 2026 08:28:00 +0300 Subject: [PATCH 4/8] apollo_l1_gas_price_config: Chainlink oracle config --- Cargo.lock | 1 + crates/apollo_l1_gas_price_config/Cargo.toml | 1 + .../apollo_l1_gas_price_config/src/config.rs | 139 +++++++++++++++++- .../src/config_test.rs | 59 +++++++- 4 files changed, 198 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4843262c01c..b83fec250c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1900,6 +1900,7 @@ dependencies = [ "apollo_l1_gas_price_types", "rstest", "serde", + "starknet-types-core", "starknet_api", "url", "validator", diff --git a/crates/apollo_l1_gas_price_config/Cargo.toml b/crates/apollo_l1_gas_price_config/Cargo.toml index c4144fbee88..76e84d18d66 100644 --- a/crates/apollo_l1_gas_price_config/Cargo.toml +++ b/crates/apollo_l1_gas_price_config/Cargo.toml @@ -13,6 +13,7 @@ workspace = true apollo_config.workspace = true apollo_l1_gas_price_types.workspace = true serde = { workspace = true, features = ["derive"] } +starknet-types-core.workspace = true starknet_api.workspace = true url.workspace = true validator.workspace = true diff --git a/crates/apollo_l1_gas_price_config/src/config.rs b/crates/apollo_l1_gas_price_config/src/config.rs index 1d889518650..468abf66065 100644 --- a/crates/apollo_l1_gas_price_config/src/config.rs +++ b/crates/apollo_l1_gas_price_config/src/config.rs @@ -18,7 +18,8 @@ use apollo_config::validators::{create_validation_error, validate_ascii}; use apollo_config::{ParamPath, ParamPrivacyInput, SerializedParam}; use apollo_l1_gas_price_types::CurrencyPair; use serde::{Deserialize, Serialize}; -use starknet_api::core::ChainId; +use starknet_api::core::{ChainId, ContractAddress}; +use starknet_types_core::felt::Felt; use url::Url; use validator::{Validate, ValidationError}; @@ -283,6 +284,142 @@ impl SerializeConfig for RateBoundsConfig { } } +/// The window a feed round's `updated_at` must fall in, relative to the block being priced. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Validate)] +#[validate(schema(function = "validate_freshness_window"))] +pub struct FreshnessWindow { + #[validate(range(min = 1))] + pub max_staleness_seconds: u64, + pub max_future_updated_at_seconds: u64, +} + +/// Catches exchanged bounds: the forward bound covers only clock skew, so it must sit strictly +/// below the backward one, which covers a full heartbeat. +fn validate_freshness_window(freshness: &FreshnessWindow) -> Result<(), ValidationError> { + if freshness.max_future_updated_at_seconds >= freshness.max_staleness_seconds { + return Err(create_validation_error( + format!( + "max_future_updated_at_seconds ({}) is not below max_staleness_seconds ({})", + freshness.max_future_updated_at_seconds, freshness.max_staleness_seconds + ), + "inverted freshness window", + "Keep max_future_updated_at_seconds, which covers clock skew, below \ + max_staleness_seconds, which covers the feed's heartbeat.", + )); + } + Ok(()) +} + +impl SerializeConfig for FreshnessWindow { + fn dump(&self) -> BTreeMap { + BTreeMap::from_iter([ + ser_param( + "max_staleness_seconds", + &self.max_staleness_seconds, + "Maximum age (seconds) of a feed's `updated_at` relative to the block timestamp \ + being priced. An older reading is rejected, and for the derived ETH/STRK rate a \ + single stale leg rejects the whole rate.", + ParamPrivacyInput::Public, + ), + ser_param( + "max_future_updated_at_seconds", + &self.max_future_updated_at_seconds, + "Maximum amount (seconds) by which a feed's `updated_at` may lead the block \ + timestamp being priced. Covers the clock skew between the sequencer that wrote \ + the round and this node.", + ParamPrivacyInput::Public, + ), + ]) + } +} + +/// Configuration for reading Chainlink's on-chain Starknet price feeds through the batcher. +// [Temporary comment] No reader yet: A8 reads the feeds through these fields and A9 wires the +// source in, and B2 nests this under `L1GasPriceProviderConfig`. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] +pub struct ChainlinkOracleConfig { + /// Quotes USD per ETH. + pub eth_usd_feed_address: ContractAddress, + /// Quotes USD per STRK. + pub strk_usd_feed_address: ContractAddress, + #[validate(nested)] + pub freshness: FreshnessWindow, + #[validate(range(min = 1))] + pub sampling_interval_seconds: u64, + #[validate(range(min = 1))] + pub failure_retry_interval_seconds: u64, +} + +impl Default for ChainlinkOracleConfig { + fn default() -> Self { + // Chainlink proxy addresses on Starknet mainnet. The proxies are used rather than the + // aggregators behind them, because aggregators are rotated without notice. + const ETH_USD_PROXY_ADDRESS: &str = + "0x06b2ef9b416ad0f996b2a8ac0dd771b1788196f51c96f5b000df2e47ac756d26"; + const STRK_USD_PROXY_ADDRESS: &str = + "0x076a0254cdadb59b86da3b5960bf8d73779cac88edc5ae587cab3cedf03226ec"; + // The feeds guarantee an update at least once per 24h heartbeat; the extra hour absorbs + // the delay between the heartbeat deadline and the update landing on-chain. + const HEARTBEAT_PLUS_MARGIN_SECONDS: u64 = (24 + 1) * 3600; + // `updated_at` and the block timestamp it is checked against both come from a + // sequencer's clock, so this only covers the skew between them. + const MAX_FUTURE_UPDATED_AT_SECONDS: u64 = 300; + + Self { + eth_usd_feed_address: parse_feed_address(ETH_USD_PROXY_ADDRESS), + strk_usd_feed_address: parse_feed_address(STRK_USD_PROXY_ADDRESS), + freshness: FreshnessWindow { + max_staleness_seconds: HEARTBEAT_PLUS_MARGIN_SECONDS, + max_future_updated_at_seconds: MAX_FUTURE_UPDATED_AT_SECONDS, + }, + sampling_interval_seconds: 900, // 15 minutes + // Successful reads are sampled once per sampling interval, so a failure that waited + // for the next sample would freeze the price for that long. + failure_retry_interval_seconds: 60, + } + } +} + +fn parse_feed_address(hex_address: &str) -> ContractAddress { + ContractAddress::try_from(Felt::from_hex(hex_address).expect("Invalid feed address felt")) + .expect("Invalid feed contract address") +} + +impl SerializeConfig for ChainlinkOracleConfig { + fn dump(&self) -> BTreeMap { + let mut config = BTreeMap::from_iter([ + ser_param( + "eth_usd_feed_address", + &self.eth_usd_feed_address, + "Address of the Chainlink proxy feed quoting ETH/USD on Starknet.", + ParamPrivacyInput::Public, + ), + ser_param( + "strk_usd_feed_address", + &self.strk_usd_feed_address, + "Address of the Chainlink proxy feed quoting STRK/USD on Starknet.", + ParamPrivacyInput::Public, + ), + ser_param( + "sampling_interval_seconds", + &self.sampling_interval_seconds, + "The size of the interval (seconds) a successful feed reading is sampled on, so \ + that every block priced within one interval shares a single reading.", + ParamPrivacyInput::Public, + ), + ser_param( + "failure_retry_interval_seconds", + &self.failure_retry_interval_seconds, + "How long (seconds) after a failed read the feed is read again. Successful reads \ + are governed by `sampling_interval_seconds` instead.", + ParamPrivacyInput::Public, + ), + ]); + config.extend(prepend_sub_config_name(self.freshness.dump(), "freshness")); + config + } +} + #[derive(Clone, Debug, Serialize, Deserialize, Validate, PartialEq)] pub struct L1GasPriceProviderConfig { // TODO(guyn): these two fields need to go into VersionedConstants. diff --git a/crates/apollo_l1_gas_price_config/src/config_test.rs b/crates/apollo_l1_gas_price_config/src/config_test.rs index 236fdb40d4d..33646e337c6 100644 --- a/crates/apollo_l1_gas_price_config/src/config_test.rs +++ b/crates/apollo_l1_gas_price_config/src/config_test.rs @@ -3,7 +3,7 @@ use std::mem::swap; use rstest::rstest; use validator::Validate; -use super::{L1GasPriceProviderConfig, RateBoundsConfig}; +use super::{ChainlinkOracleConfig, FreshnessWindow, L1GasPriceProviderConfig, RateBoundsConfig}; // A zero mean window would make the provider divide by zero when computing the mean gas price, // so it must be rejected at config load instead of panicking later during block production. @@ -65,3 +65,60 @@ fn rejects_unusable_rate_bounds( break_bounds(minimum_micro_units, maximum_micro_units); assert!(config.validate().is_err()); } + +#[test] +fn accepts_default_chainlink_oracle_config() { + assert!(ChainlinkOracleConfig::default().validate().is_ok()); +} + +// A zero `max_staleness_seconds` rejects every reading not written in the block's own second; +// a zero `failure_retry_interval_seconds` re-queries a failing feed on every proposal. +#[rstest] +#[case::zero_max_staleness(ChainlinkOracleConfig { + freshness: FreshnessWindow { + max_staleness_seconds: 0, + ..ChainlinkOracleConfig::default().freshness + }, + ..Default::default() +})] +#[case::zero_failure_retry_interval(ChainlinkOracleConfig { + failure_retry_interval_seconds: 0, + ..Default::default() +})] +fn rejects_out_of_range_chainlink_fields(#[case] config: ChainlinkOracleConfig) { + assert!(config.validate().is_err()); +} + +// A zero `sampling_interval_seconds` re-reads the feeds on every proposal, so one second is the +// smallest accepted interval. +#[rstest] +#[case::zero(0, false)] +#[case::one_second(1, true)] +fn validates_sampling_interval_seconds_range( + #[case] sampling_interval_seconds: u64, + #[case] is_accepted: bool, +) { + let config = ChainlinkOracleConfig { sampling_interval_seconds, ..Default::default() }; + + assert_eq!(config.validate().is_ok(), is_accepted); +} + +#[test] +fn default_freshness_window_reaches_further_back_than_forward() { + let freshness = ChainlinkOracleConfig::default().freshness; + + assert_eq!(freshness.max_staleness_seconds, (24 + 1) * 3600); + assert_eq!(freshness.max_future_updated_at_seconds, 300); +} + +#[rstest] +#[case::inverted(FreshnessWindow { + max_staleness_seconds: 300, + max_future_updated_at_seconds: 90_000, +})] +#[case::equal(FreshnessWindow { max_staleness_seconds: 300, max_future_updated_at_seconds: 300 })] +fn rejects_a_forward_bound_at_or_above_the_backward_bound(#[case] freshness: FreshnessWindow) { + let config = ChainlinkOracleConfig { freshness, ..Default::default() }; + + assert!(config.validate().is_err()); +} From 98dbff2747af40935ec2bcbdfd2c2fe93dc52d33 Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Tue, 18 Aug 2026 15:02:37 +0300 Subject: [PATCH 5/8] apollo_l1_gas_price: Chainlink retdata decoding and rate arithmetic --- Cargo.lock | 2 + crates/apollo_l1_gas_price/Cargo.toml | 2 + .../src/chainlink_oracle/feed_math.rs | 164 +++++++++++++++ .../src/chainlink_oracle/feed_math_test.rs | 194 ++++++++++++++++++ .../src/chainlink_oracle/mod.rs | 3 + crates/apollo_l1_gas_price/src/lib.rs | 2 + crates/apollo_l1_gas_price/src/rate_bounds.rs | 42 ++++ .../src/rate_bounds_test.rs | 64 ++++++ .../apollo_l1_gas_price_config/src/config.rs | 3 +- crates/apollo_l1_gas_price_types/src/lib.rs | 6 +- 10 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs create mode 100644 crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs create mode 100644 crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs create mode 100644 crates/apollo_l1_gas_price/src/rate_bounds.rs create mode 100644 crates/apollo_l1_gas_price/src/rate_bounds_test.rs diff --git a/Cargo.lock b/Cargo.lock index b83fec250c9..f90655e2020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1865,6 +1865,7 @@ dependencies = [ name = "apollo_l1_gas_price" version = "0.19.0-rc.2" dependencies = [ + "apollo_cairo_utils", "apollo_config", "apollo_infra", "apollo_infra_utils", @@ -1883,6 +1884,7 @@ dependencies = [ "reqwest 0.12.24", "rstest", "serde_json", + "starknet-types-core", "starknet_api", "strum", "thiserror 1.0.69", diff --git a/crates/apollo_l1_gas_price/Cargo.toml b/crates/apollo_l1_gas_price/Cargo.toml index ffc2a81d8c4..af77e0235f3 100644 --- a/crates/apollo_l1_gas_price/Cargo.toml +++ b/crates/apollo_l1_gas_price/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true description = "Tracks and provides L1 (Ethereum) gas prices for Starknet transaction pricing." [dependencies] +apollo_cairo_utils.workspace = true apollo_config.workspace = true apollo_infra.workspace = true apollo_infra_utils.workspace = true @@ -21,6 +22,7 @@ papyrus_base_layer.workspace = true reqwest.workspace = true rstest.workspace = true serde_json.workspace = true +starknet-types-core.workspace = true starknet_api.workspace = true strum = { workspace = true, features = ["derive"] } thiserror.workspace = true diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs new file mode 100644 index 00000000000..ef79ceadc77 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs @@ -0,0 +1,164 @@ +//! Decoding of Chainlink feed retdata, and the fixed-point arithmetic over the decoded answers. + +// [Temporary comment] No production caller yet, and every item is `pub` so this module compiles +// standalone. The feed read (A8) and the client (A9) call these and narrow them to `pub(super)`. + +use apollo_cairo_utils::{deserialize_retdata, RetdataDeserializationError, TryFromIterator}; +use apollo_l1_gas_price_config::config::RATE_MICRO_UNIT_DECIMALS; +use apollo_l1_gas_price_types::errors::ExchangeRateOracleClientError; +use apollo_l1_gas_price_types::{CurrencyPair, ExchangeRate}; +use starknet_types_core::felt::Felt; + +use crate::metrics::{ + CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT, + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT, +}; + +#[cfg(test)] +#[path = "feed_math_test.rs"] +mod feed_math_test; + +/// Fixed-point scale of every rate the Chainlink oracle returns, matching +/// `EXCHANGE_RATE_DECIMALS`. +pub const RATE_DECIMALS: u32 = 18; +pub const RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS); +pub const MICRO_UNIT_TO_RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS - RATE_MICRO_UNIT_DECIMALS); + +/// The Chainlink feeds report 8 decimals today. A range is accepted rather than the exact value so +/// that a feed upgrade does not halt pricing, bounded so the rescale to `RATE_DECIMALS` can +/// neither underflow nor produce an absurd scale factor. +pub const MIN_FEED_DECIMALS: u32 = 6; +pub const MAX_FEED_DECIMALS: u32 = RATE_DECIMALS; + +/// Cap on the batcher error text the Chainlink oracle relays. A reverting view call's panic data +/// reaches the logs, the failure cache, and (when the provider runs remotely) the RPC boundary, so +/// the cap is byte-based to bound what all three consume. +pub const MAX_CONTRACT_CALL_ERROR_BYTES: usize = 256; +pub const TRUNCATION_MARKER: &str = "...[truncated]"; + +/// A rate at `RATE_DECIMALS`, or the guard trip that rejected it. +pub type RateResult = Result; + +pub fn truncate_contract_call_error(error_text: String) -> String { + if error_text.len() <= MAX_CONTRACT_CALL_ERROR_BYTES { + return error_text; + } + // Cut on a character boundary so the relayed text stays valid UTF-8. The nearest boundary at + // or below the cap is at most three bytes down. + let head_end = (0..=MAX_CONTRACT_CALL_ERROR_BYTES) + .rev() + .find(|byte_index| error_text.is_char_boundary(*byte_index)) + .expect("Byte index 0 is always a character boundary"); + format!("{}{TRUNCATION_MARKER}", &error_text[..head_end]) +} + +pub fn decode_feed_decimals( + decimals_retdata: Vec, + pair: CurrencyPair, +) -> Result { + let pair_name = pair.pair_name(); + let raw_decimals: Felt = decode_retdata(decimals_retdata, pair)?; + let feed_decimals = u32::try_from(raw_decimals).map_err(|_| { + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT.increment(1, &pair.labels()); + ExchangeRateOracleClientError::ParseError(format!( + "{pair_name} decimals {raw_decimals} does not fit in u32" + )) + })?; + if !(MIN_FEED_DECIMALS..=MAX_FEED_DECIMALS).contains(&feed_decimals) { + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT.increment(1, &pair.labels()); + return Err(ExchangeRateOracleClientError::InvalidRateError(format!( + "{pair_name} reports {feed_decimals} decimals, outside the accepted range \ + [{MIN_FEED_DECIMALS}, {MAX_FEED_DECIMALS}]" + ))); + } + Ok(feed_decimals) +} + +pub fn decode_retdata( + retdata: Vec, + pair: CurrencyPair, +) -> Result +where + T: TryFromIterator, +{ + deserialize_retdata(retdata).map_err(|error| { + CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT.increment(1, &pair.labels()); + ExchangeRateOracleClientError::ParseError(error.to_string()) + }) +} + +pub fn rescale_to_rate_decimals(answer: u128, feed_decimals: u32) -> RateResult { + RATE_DECIMALS + .checked_sub(feed_decimals) + .and_then(|exponent| 10u128.checked_pow(exponent)) + .and_then(|scale| answer.checked_mul(scale)) + .ok_or_else(|| { + ExchangeRateOracleClientError::ArithmeticError(format!( + "rescaling answer {answer} from {feed_decimals} to {RATE_DECIMALS} decimals \ + overflowed" + )) + }) +} + +/// STRK per ETH, at `RATE_DECIMALS`, from two USD prices that already carry `RATE_DECIMALS`. +pub fn derive_eth_to_fri_rate( + eth_to_usd_rate: ExchangeRate, + strk_to_usd_rate: ExchangeRate, +) -> RateResult { + // The division cancels the two operands' scales, so the result must be scaled back up by + // `RATE_SCALE`. Scaling the numerator up front overflows u128, so the integer quotient and the + // remainder are scaled separately and recombined, which is exact: for + // `eth = quotient * strk + remainder`, `floor(eth * S / strk) = quotient * S + + // floor(remainder * S / strk)`. + let scaled_quotient = eth_to_usd_rate + .checked_div(strk_to_usd_rate) + .and_then(|quotient| quotient.checked_mul(RATE_SCALE)); + let scaled_remainder = eth_to_usd_rate + .checked_rem(strk_to_usd_rate) + .and_then(|remainder| remainder.checked_mul(RATE_SCALE)) + .and_then(|scaled_remainder| scaled_remainder.checked_div(strk_to_usd_rate)); + scaled_quotient + .zip(scaled_remainder) + .and_then(|(quotient, remainder)| quotient.checked_add(remainder)) + .ok_or_else(|| { + ExchangeRateOracleClientError::ArithmeticError(format!( + "deriving ETH/STRK from eth_to_usd_rate={eth_to_usd_rate} and \ + strk_to_usd_rate={strk_to_usd_rate} overflowed" + )) + }) +} + +/// The fields of Chainlink's `Round` that the oracle consumes. +#[derive(Debug)] +pub struct ChainlinkRoundData { + /// The price the feed reports, at the feed's own `decimals()`. + pub answer: u128, + /// Unix seconds at which the aggregator last wrote this round. + pub updated_at: u64, +} + +impl TryFromIterator for ChainlinkRoundData { + type Error = RetdataDeserializationError; + + // `latest_round_data` returns `Round { round_id: felt252, answer: u128, block_num: u64, + // started_at: u64, updated_at: u64 }`, serialized flat as exactly five felts in that order. + fn try_from_iter>(iter: &mut T) -> Result { + // `round_id` is phase-encoded as `(phase_id << 128) | aggregator_round_id`, so it exceeds + // every primitive integer type and is consumed without being decoded. + let _round_id = Felt::try_from_iter(iter)?; + // `answer` is unsigned on the Starknet feeds, so there is no sign extension to undo. + let raw_answer = Felt::try_from_iter(iter)?; + let answer = u128::try_from(raw_answer) + .map_err(|_| RetdataDeserializationError::U128ConversionError { felt: raw_answer })?; + let _block_number = Felt::try_from_iter(iter)?; + // `started_at` is consumed without being decoded: an aggregator that can lie about + // `updated_at` can lie about `started_at` too, so `started_at <= updated_at` adds no + // guarantee beyond the freshness window enforced on `updated_at`. + let _started_at = Felt::try_from_iter(iter)?; + let raw_updated_at = Felt::try_from_iter(iter)?; + let updated_at = u64::try_from(raw_updated_at).map_err(|_| { + RetdataDeserializationError::U64ConversionError { felt: raw_updated_at } + })?; + Ok(Self { answer, updated_at }) + } +} diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs new file mode 100644 index 00000000000..ef67070a64f --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs @@ -0,0 +1,194 @@ +use assert_matches::assert_matches; +use rstest::rstest; + +use super::*; + +/// The scale the Chainlink feeds report at today. +const FEED_DECIMALS: u32 = 8; +/// $3000 per ETH at `FEED_DECIMALS`. +const ETH_USD_ANSWER: u128 = 300_000_000_000; +/// $0.03 per STRK at `FEED_DECIMALS`. +const STRK_USD_ANSWER: u128 = 3_000_000; +/// The rate the two answers above derive to: 100,000 STRK per ETH. +const ETH_TO_FRI_RATE: u128 = 100_000 * RATE_SCALE; +/// $0.03 per STRK at `RATE_DECIMALS`. +const STRK_TO_USD_RATE: u128 = 30_000_000_000_000_000; +const UPDATED_AT: u64 = 1_700_000_000; + +fn decimals_retdata(feed_decimals: u32) -> Vec { + vec![Felt::from(feed_decimals)] +} + +fn round_retdata(answer: u128, updated_at: u64) -> Vec { + // A realistic phase-encoded `round_id`: `(phase_id << 128) | aggregator_round_id`, which + // exceeds u64. + const PHASE_ENCODED_ROUND_ID: &str = "0x100000000000000000000000000000042"; + const BLOCK_NUMBER: u64 = 987_654; + const STARTED_AT: u64 = 1_699_999_000; + vec![ + Felt::from_hex_unchecked(PHASE_ENCODED_ROUND_ID), + Felt::from(answer), + Felt::from(BLOCK_NUMBER), + Felt::from(STARTED_AT), + Felt::from(updated_at), + ] +} + +/// The two fields the oracle reads sit at positions two and five of the flat five-felt `Round`, so +/// this pins the layout the decoder assumes. +#[test] +fn round_data_decodes_the_answer_and_the_update_time() { + let round: ChainlinkRoundData = + decode_retdata(round_retdata(STRK_USD_ANSWER, UPDATED_AT), CurrencyPair::StrkUsd).unwrap(); + assert_eq!(round.answer, STRK_USD_ANSWER); + assert_eq!(round.updated_at, UPDATED_AT); +} + +#[rstest] +#[case::too_few_felts(round_retdata(STRK_USD_ANSWER, UPDATED_AT).into_iter().take(4).collect())] +#[case::too_many_felts([round_retdata(STRK_USD_ANSWER, UPDATED_AT), vec![Felt::ONE]].concat())] +#[case::answer_exceeding_u128(vec![Felt::ONE, Felt::MAX, Felt::ONE, Felt::ONE, Felt::ONE])] +#[case::updated_at_exceeding_u64( + vec![Felt::ONE, Felt::from(STRK_USD_ANSWER), Felt::ONE, Felt::ONE, Felt::MAX] +)] +fn malformed_retdata_rejected(#[case] malformed_round_retdata: Vec) { + assert_matches!( + decode_retdata::(malformed_round_retdata, CurrencyPair::StrkUsd), + Err(ExchangeRateOracleClientError::ParseError(_)) + ); +} + +/// The accepted range is what bounds the rescale; a feed reporting outside it is rejected rather +/// than mis-scaled. +#[rstest] +#[case::at_the_minimum(MIN_FEED_DECIMALS, true)] +#[case::at_the_maximum(MAX_FEED_DECIMALS, true)] +#[case::below_the_minimum(MIN_FEED_DECIMALS - 1, false)] +#[case::above_the_maximum(MAX_FEED_DECIMALS + 1, false)] +fn feed_decimals_range_is_enforced(#[case] feed_decimals: u32, #[case] is_accepted: bool) { + let result = decode_feed_decimals(decimals_retdata(feed_decimals), CurrencyPair::StrkUsd); + if is_accepted { + assert_eq!(result.unwrap(), feed_decimals); + } else { + assert_matches!( + result, + Err(ExchangeRateOracleClientError::InvalidRateError(message)) + if message.contains("decimals") + ); + } +} + +/// A decimals value too large for a `u32` must be reported as a parse failure rather than +/// truncated into a plausible scale. +#[test] +fn feed_decimals_exceeding_u32_rejected() { + assert_matches!( + decode_feed_decimals(vec![Felt::MAX], CurrencyPair::StrkUsd), + Err(ExchangeRateOracleClientError::ParseError(message)) if message.contains("u32") + ); +} + +/// $0.03 per STRK reaches the same rate from every scale a feed may report it at. +#[rstest] +#[case::at_the_minimum(MIN_FEED_DECIMALS)] +#[case::todays_feeds(FEED_DECIMALS)] +#[case::at_the_maximum(MAX_FEED_DECIMALS)] +fn rescaling_lands_every_feed_scale_on_rate_decimals(#[case] feed_decimals: u32) { + let answer = 3 * 10u128.pow(feed_decimals) / 100; + assert_eq!(rescale_to_rate_decimals(answer, feed_decimals).unwrap(), STRK_TO_USD_RATE); +} + +#[test] +fn extreme_answer_errors_instead_of_overflowing() { + assert_matches!( + rescale_to_rate_decimals(u128::MAX, FEED_DECIMALS), + Err(ExchangeRateOracleClientError::ArithmeticError(_)) + ); +} + +#[test] +fn eth_to_fri_divides_the_two_usd_legs() { + // $3000 per ETH over $0.03 per STRK is 100,000 STRK per ETH. + let eth_to_usd_rate = rescale_to_rate_decimals(ETH_USD_ANSWER, FEED_DECIMALS).unwrap(); + let strk_to_usd_rate = rescale_to_rate_decimals(STRK_USD_ANSWER, FEED_DECIMALS).unwrap(); + assert_eq!(derive_eth_to_fri_rate(eth_to_usd_rate, strk_to_usd_rate).unwrap(), ETH_TO_FRI_RATE); +} + +/// Each answer is rescaled to `RATE_DECIMALS` before the division, so the derived rate comes out +/// the same whatever scales the two feeds report at. The widest pairs also cover the rescale of the +/// largest answer accepted without overflowing. +#[rstest] +#[case::equal_decimals(8, 8)] +#[case::wider_strk_feed(8, 12)] +#[case::widest_strk_feed(6, 18)] +#[case::widest_eth_feed(18, 6)] +fn eth_to_fri_is_independent_of_the_feeds_decimals( + #[case] eth_usd_decimals: u32, + #[case] strk_usd_decimals: u32, +) { + // $3000 per ETH and $0.03 per STRK, expressed at each feed's own scale. + let eth_to_usd_rate = + rescale_to_rate_decimals(3000 * 10u128.pow(eth_usd_decimals), eth_usd_decimals).unwrap(); + let strk_to_usd_rate = + rescale_to_rate_decimals(3 * 10u128.pow(strk_usd_decimals) / 100, strk_usd_decimals) + .unwrap(); + assert_eq!(derive_eth_to_fri_rate(eth_to_usd_rate, strk_to_usd_rate).unwrap(), ETH_TO_FRI_RATE); +} + +/// The two legs do not divide evenly here, so this is the case that exercises recombining the +/// scaled quotient with the scaled remainder, the one step of the derivation whose result cannot be +/// read off the inputs. +#[test] +fn eth_to_fri_recombines_a_non_zero_remainder() { + /// $0.07 per STRK at `FEED_DECIMALS`. + const STRK_USD_ANSWER_SEVEN_CENTS: u128 = 7_000_000; + /// floor(3000 / 0.07 * 10^18), that is 42857.142857... STRK per ETH. + const EXPECTED_RATE: u128 = 42_857_142_857_142_857_142_857; + let eth_to_usd_rate = rescale_to_rate_decimals(ETH_USD_ANSWER, FEED_DECIMALS).unwrap(); + let strk_to_usd_rate = + rescale_to_rate_decimals(STRK_USD_ANSWER_SEVEN_CENTS, FEED_DECIMALS).unwrap(); + assert_eq!(derive_eth_to_fri_rate(eth_to_usd_rate, strk_to_usd_rate).unwrap(), EXPECTED_RATE); +} + +#[test] +fn eth_to_fri_errors_on_a_zero_strk_leg() { + assert_matches!( + derive_eth_to_fri_rate(STRK_TO_USD_RATE, 0), + Err(ExchangeRateOracleClientError::ArithmeticError(_)) + ); +} + +#[test] +fn eth_to_fri_errors_instead_of_overflowing() { + assert_matches!( + derive_eth_to_fri_rate(u128::MAX, 1), + Err(ExchangeRateOracleClientError::ArithmeticError(_)) + ); +} + +#[rstest] +#[case::ascii("a plain revert reason".to_string())] +#[case::multibyte("שלום".repeat(10))] +fn short_contract_call_error_is_relayed_verbatim(#[case] error_text: String) { + assert!(error_text.len() <= MAX_CONTRACT_CALL_ERROR_BYTES); + assert_eq!(truncate_contract_call_error(error_text.clone()), error_text); +} + +/// The cap counts bytes, so a multi-byte reason must be cut at a character boundary at or just +/// below it, never mid-character. +#[rstest] +#[case::single_byte_characters("a")] +#[case::four_byte_characters("😀")] +fn long_contract_call_error_is_truncated_on_a_character_boundary(#[case] repeated_text: &str) { + const NUM_REPETITIONS: usize = 1000; + let error_text = repeated_text.repeat(NUM_REPETITIONS); + let truncated = truncate_contract_call_error(error_text.clone()); + + let head = truncated + .strip_suffix(TRUNCATION_MARKER) + .expect("Truncated text must carry the truncation marker"); + assert!(error_text.starts_with(head), "the kept head must be a prefix of the original"); + assert!(head.len() <= MAX_CONTRACT_CALL_ERROR_BYTES); + // Nothing is dropped beyond what the boundary requires. + assert!(head.len() > MAX_CONTRACT_CALL_ERROR_BYTES - repeated_text.len()); +} diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs new file mode 100644 index 00000000000..6bae0a890f9 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs @@ -0,0 +1,3 @@ +//! Chainlink feed retdata decoding and rate arithmetic. + +pub mod feed_math; diff --git a/crates/apollo_l1_gas_price/src/lib.rs b/crates/apollo_l1_gas_price/src/lib.rs index b4dc3cb4c6a..ac1e10ded3a 100644 --- a/crates/apollo_l1_gas_price/src/lib.rs +++ b/crates/apollo_l1_gas_price/src/lib.rs @@ -28,8 +28,10 @@ //! unusable precisely when it is already degraded. The configured minimums and the default //! ETH→STRK rate are safe floors the operator has verified are always economically viable. +pub mod chainlink_oracle; pub mod communication; pub mod exchange_rate_oracle; pub mod l1_gas_price_provider; pub mod l1_gas_price_scraper; pub mod metrics; +pub mod rate_bounds; diff --git a/crates/apollo_l1_gas_price/src/rate_bounds.rs b/crates/apollo_l1_gas_price/src/rate_bounds.rs new file mode 100644 index 00000000000..4ac08af2862 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/rate_bounds.rs @@ -0,0 +1,42 @@ +//! The absolute sanity bounds every exchange rate must fall in, whichever source reports it. + +// [Temporary comment] `pub` with no production caller yet: the Chainlink feed read (A8) and the +// HTTP oracle (C1) both call this; A8 narrows it to `pub(crate)`. + +use apollo_l1_gas_price_config::config::RateBounds; +use apollo_l1_gas_price_types::errors::ExchangeRateOracleClientError; +use apollo_l1_gas_price_types::ExchangeRate; + +use crate::chainlink_oracle::feed_math::MICRO_UNIT_TO_RATE_SCALE; +use crate::metrics::CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT; + +#[cfg(test)] +#[path = "rate_bounds_test.rs"] +mod rate_bounds_test; + +// TODO(Asaf): bound the rate's change against the previous block's implied rate. The absolute +// bounds below are wide enough to pass a manipulated but plausible answer, the STRK/USD pair alone +// accepting anything from $0.0001 to $10, which only a bound relative to the last accepted rate +// catches. It must be anchored to the block header rather than to node-local history, so that every +// validator accepts and rejects the same values. +/// Absolute bounds are the only defense against a feed wired to the wrong asset or a +/// plausible-but-poisoned answer: consensus checks that validators agree with each other, never +/// that the agreed value is sane, and every node reads the same chain state. +pub fn check_rate_bounds( + rate: ExchangeRate, + bounds: RateBounds, +) -> Result<(), ExchangeRateOracleClientError> { + let pair = bounds.pair; + let min_rate = u128::from(bounds.minimum_micro_units).saturating_mul(MICRO_UNIT_TO_RATE_SCALE); + let max_rate = u128::from(bounds.maximum_micro_units).saturating_mul(MICRO_UNIT_TO_RATE_SCALE); + if rate < min_rate || rate > max_rate { + CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT.increment(1, &pair.labels()); + return Err(ExchangeRateOracleClientError::RateOutOfBoundsError { + pair_name: pair.pair_name().to_string(), + rate, + min_rate, + max_rate, + }); + } + Ok(()) +} diff --git a/crates/apollo_l1_gas_price/src/rate_bounds_test.rs b/crates/apollo_l1_gas_price/src/rate_bounds_test.rs new file mode 100644 index 00000000000..b4f0f26ef48 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/rate_bounds_test.rs @@ -0,0 +1,64 @@ +use apollo_l1_gas_price_config::config::RateBoundsConfig; +use apollo_l1_gas_price_types::CurrencyPair; +use assert_matches::assert_matches; +use rstest::rstest; + +use super::*; + +fn micro_units_to_rate(micro_units: u64) -> ExchangeRate { + u128::from(micro_units) * MICRO_UNIT_TO_RATE_SCALE +} + +/// The production bounds for a pair, whose edges the cases below probe. +fn default_bounds(pair: CurrencyPair) -> RateBounds { + let config = RateBoundsConfig::default(); + match pair { + CurrencyPair::EthUsd => config.eth_usd_bounds(), + CurrencyPair::StrkUsd => config.strk_usd_bounds(), + CurrencyPair::EthStrk => config.eth_to_fri_bounds(), + } +} + +#[rstest] +fn a_rate_exactly_on_either_bound_is_accepted( + #[values(CurrencyPair::EthUsd, CurrencyPair::StrkUsd, CurrencyPair::EthStrk)] + pair: CurrencyPair, +) { + let bounds = default_bounds(pair); + for micro_units in [bounds.minimum_micro_units, bounds.maximum_micro_units] { + check_rate_bounds(micro_units_to_rate(micro_units), bounds).unwrap(); + } +} + +/// One unit at `RATE_DECIMALS` outside either bound is rejected, so the accepted band is exactly +/// the configured one. +#[rstest] +fn a_rate_one_unit_outside_either_bound_is_rejected( + #[values(CurrencyPair::EthUsd, CurrencyPair::StrkUsd, CurrencyPair::EthStrk)] + pair: CurrencyPair, +) { + let bounds = default_bounds(pair); + let below_the_minimum = micro_units_to_rate(bounds.minimum_micro_units) - 1; + let above_the_maximum = micro_units_to_rate(bounds.maximum_micro_units) + 1; + for rate in [below_the_minimum, above_the_maximum] { + assert_matches!( + check_rate_bounds(rate, bounds), + Err(ExchangeRateOracleClientError::RateOutOfBoundsError { pair_name, .. }) + if pair_name == pair.pair_name() + ); + } +} + +/// The bounds are configured in micro units and the rate arrives at `RATE_DECIMALS`, so the error +/// reports the band the rate was actually compared against. +#[test] +fn the_rejection_reports_the_band_at_rate_decimals() { + let bounds = default_bounds(CurrencyPair::StrkUsd); + assert_matches!( + check_rate_bounds(0, bounds), + Err(ExchangeRateOracleClientError::RateOutOfBoundsError { rate, min_rate, max_rate, .. }) + if rate == 0 + && min_rate == micro_units_to_rate(bounds.minimum_micro_units) + && max_rate == micro_units_to_rate(bounds.maximum_micro_units) + ); +} diff --git a/crates/apollo_l1_gas_price_config/src/config.rs b/crates/apollo_l1_gas_price_config/src/config.rs index 468abf66065..325cd160087 100644 --- a/crates/apollo_l1_gas_price_config/src/config.rs +++ b/crates/apollo_l1_gas_price_config/src/config.rs @@ -101,7 +101,6 @@ pub const RATE_MICRO_UNIT_DECIMALS: u32 = 6; /// Inclusive absolute bounds on a rate, in micro units (1e-6) of the pair's quote currency, /// together with the pair they bound. -// [Temporary comment] No reader yet: the guards arrive in A7. #[derive(Clone, Copy, Debug)] pub struct RateBounds { pub minimum_micro_units: u64, @@ -186,7 +185,7 @@ impl SerializeConfig for DerivedRateBoundsConfig { } /// Absolute bounds every exchange rate must fall in, whichever source reports it. -// [Temporary comment] No reader yet: A7 checks rates, B2 nests this in `L1GasPriceProviderConfig`. +// [Temporary comment] No production reader yet: B2 nests this in `L1GasPriceProviderConfig`. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] #[validate(schema(function = "validate_rate_bounds_config"))] pub struct RateBoundsConfig { diff --git a/crates/apollo_l1_gas_price_types/src/lib.rs b/crates/apollo_l1_gas_price_types/src/lib.rs index 6e43cc5f1a1..9a35cbd34c3 100644 --- a/crates/apollo_l1_gas_price_types/src/lib.rs +++ b/crates/apollo_l1_gas_price_types/src/lib.rs @@ -33,8 +33,6 @@ pub type ExchangeRate = u128; pub const LABEL_NAME_CURRENCY_PAIR: &str = "currency_pair"; /// The pair a reading or rate quotes. -// [Temporary comment] No consumer yet: `labels()` lands with the oracle metrics PR, `pair_name()` -// with the rate-bounds PR. #[derive(Clone, Copy, Debug, EnumIter, IntoStaticStr, PartialEq, Eq, VariantNames)] #[strum(serialize_all = "snake_case")] pub enum CurrencyPair { @@ -158,8 +156,8 @@ pub trait ExchangeRateOracleClientTrait: Send + Sync + Debug { } /// The rate an oracle client instance produces. -// [Temporary comment] No implementors outside this module yet: the rate arithmetic and Chainlink -// client PRs add them. +// [Temporary comment] No implementors outside this module yet: the Chainlink client PR (A9) adds +// them. pub trait RateKind: Send + Sync + Debug + 'static { const PAIR: CurrencyPair; } From 6c9ef8e5560d916ee64ea7006ae0832dedd50d0a Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Tue, 18 Aug 2026 16:02:58 +0300 Subject: [PATCH 6/8] apollo_l1_gas_price,apollo_l1_gas_price_config,apollo_l1_gas_price_types: read a Chainlink feed --- Cargo.lock | 1 + crates/apollo_l1_gas_price/Cargo.toml | 2 + .../src/chainlink_oracle/feed_math.rs | 36 +-- .../src/chainlink_oracle/feed_read.rs | 156 +++++++++++ .../src/chainlink_oracle/feed_read_test.rs | 244 ++++++++++++++++++ .../src/chainlink_oracle/mod.rs | 7 +- .../src/chainlink_oracle/test_utils.rs | 76 ++++++ crates/apollo_l1_gas_price/src/lib.rs | 2 +- crates/apollo_l1_gas_price/src/metrics.rs | 3 +- crates/apollo_l1_gas_price/src/rate_bounds.rs | 5 +- .../apollo_l1_gas_price_config/src/config.rs | 5 +- .../apollo_l1_gas_price_types/src/errors.rs | 1 - 12 files changed, 508 insertions(+), 30 deletions(-) create mode 100644 crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs create mode 100644 crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs create mode 100644 crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index f90655e2020..f7ee19e3a22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1865,6 +1865,7 @@ dependencies = [ name = "apollo_l1_gas_price" version = "0.19.0-rc.2" dependencies = [ + "apollo_batcher_types", "apollo_cairo_utils", "apollo_config", "apollo_infra", diff --git a/crates/apollo_l1_gas_price/Cargo.toml b/crates/apollo_l1_gas_price/Cargo.toml index af77e0235f3..8a155b41349 100644 --- a/crates/apollo_l1_gas_price/Cargo.toml +++ b/crates/apollo_l1_gas_price/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true description = "Tracks and provides L1 (Ethereum) gas prices for Starknet transaction pricing." [dependencies] +apollo_batcher_types.workspace = true apollo_cairo_utils.workspace = true apollo_config.workspace = true apollo_infra.workspace = true @@ -32,6 +33,7 @@ tracing.workspace = true url.workspace = true [dev-dependencies] +apollo_batcher_types = { workspace = true, features = ["testing"] } apollo_l1_gas_price_types = { workspace = true, features = ["testing"] } apollo_metrics = { workspace = true, features = ["testing"] } metrics.workspace = true diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs index ef79ceadc77..ee9dadf2c4a 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs @@ -1,8 +1,5 @@ //! Decoding of Chainlink feed retdata, and the fixed-point arithmetic over the decoded answers. -// [Temporary comment] No production caller yet, and every item is `pub` so this module compiles -// standalone. The feed read (A8) and the client (A9) call these and narrow them to `pub(super)`. - use apollo_cairo_utils::{deserialize_retdata, RetdataDeserializationError, TryFromIterator}; use apollo_l1_gas_price_config::config::RATE_MICRO_UNIT_DECIMALS; use apollo_l1_gas_price_types::errors::ExchangeRateOracleClientError; @@ -20,26 +17,27 @@ mod feed_math_test; /// Fixed-point scale of every rate the Chainlink oracle returns, matching /// `EXCHANGE_RATE_DECIMALS`. -pub const RATE_DECIMALS: u32 = 18; -pub const RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS); -pub const MICRO_UNIT_TO_RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS - RATE_MICRO_UNIT_DECIMALS); +const RATE_DECIMALS: u32 = 18; +const RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS); +pub(crate) const MICRO_UNIT_TO_RATE_SCALE: u128 = + 10u128.pow(RATE_DECIMALS - RATE_MICRO_UNIT_DECIMALS); /// The Chainlink feeds report 8 decimals today. A range is accepted rather than the exact value so /// that a feed upgrade does not halt pricing, bounded so the rescale to `RATE_DECIMALS` can /// neither underflow nor produce an absurd scale factor. -pub const MIN_FEED_DECIMALS: u32 = 6; -pub const MAX_FEED_DECIMALS: u32 = RATE_DECIMALS; +const MIN_FEED_DECIMALS: u32 = 6; +const MAX_FEED_DECIMALS: u32 = RATE_DECIMALS; /// Cap on the batcher error text the Chainlink oracle relays. A reverting view call's panic data /// reaches the logs, the failure cache, and (when the provider runs remotely) the RPC boundary, so /// the cap is byte-based to bound what all three consume. -pub const MAX_CONTRACT_CALL_ERROR_BYTES: usize = 256; -pub const TRUNCATION_MARKER: &str = "...[truncated]"; +pub(super) const MAX_CONTRACT_CALL_ERROR_BYTES: usize = 256; +pub(super) const TRUNCATION_MARKER: &str = "...[truncated]"; /// A rate at `RATE_DECIMALS`, or the guard trip that rejected it. -pub type RateResult = Result; +pub(super) type RateResult = Result; -pub fn truncate_contract_call_error(error_text: String) -> String { +pub(super) fn truncate_contract_call_error(error_text: String) -> String { if error_text.len() <= MAX_CONTRACT_CALL_ERROR_BYTES { return error_text; } @@ -52,7 +50,7 @@ pub fn truncate_contract_call_error(error_text: String) -> String { format!("{}{TRUNCATION_MARKER}", &error_text[..head_end]) } -pub fn decode_feed_decimals( +pub(super) fn decode_feed_decimals( decimals_retdata: Vec, pair: CurrencyPair, ) -> Result { @@ -74,7 +72,7 @@ pub fn decode_feed_decimals( Ok(feed_decimals) } -pub fn decode_retdata( +pub(super) fn decode_retdata( retdata: Vec, pair: CurrencyPair, ) -> Result @@ -87,7 +85,7 @@ where }) } -pub fn rescale_to_rate_decimals(answer: u128, feed_decimals: u32) -> RateResult { +pub(super) fn rescale_to_rate_decimals(answer: u128, feed_decimals: u32) -> RateResult { RATE_DECIMALS .checked_sub(feed_decimals) .and_then(|exponent| 10u128.checked_pow(exponent)) @@ -100,6 +98,8 @@ pub fn rescale_to_rate_decimals(answer: u128, feed_decimals: u32) -> RateResult }) } +// [Temporary comment] `pub` with no caller yet: the client (A9) derives ETH/STRK from the two USD +// legs and narrows this to `pub(super)`. /// STRK per ETH, at `RATE_DECIMALS`, from two USD prices that already carry `RATE_DECIMALS`. pub fn derive_eth_to_fri_rate( eth_to_usd_rate: ExchangeRate, @@ -130,11 +130,11 @@ pub fn derive_eth_to_fri_rate( /// The fields of Chainlink's `Round` that the oracle consumes. #[derive(Debug)] -pub struct ChainlinkRoundData { +pub(super) struct ChainlinkRoundData { /// The price the feed reports, at the feed's own `decimals()`. - pub answer: u128, + pub(super) answer: u128, /// Unix seconds at which the aggregator last wrote this round. - pub updated_at: u64, + pub(super) updated_at: u64, } impl TryFromIterator for ChainlinkRoundData { diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs new file mode 100644 index 00000000000..a5ef0c14283 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs @@ -0,0 +1,156 @@ +//! One Chainlink feed read: two view calls through the batcher, and the guards their answer passes. + +// [Temporary comment] `pub` with no caller yet: the client (A9) calls `read_feed` through the +// feed accessors and narrows them to `pub(super)`. + +use apollo_batcher_types::batcher_types::CallContractInput; +use apollo_batcher_types::communication::SharedBatcherClient; +use apollo_l1_gas_price_config::config::{ + ChainlinkOracleConfig, + FreshnessWindow, + RateBounds, + RateBoundsConfig, +}; +use apollo_l1_gas_price_types::errors::ExchangeRateOracleClientError; +use apollo_l1_gas_price_types::CurrencyPair; +use starknet_api::core::ContractAddress; +use starknet_types_core::felt::Felt; + +use crate::chainlink_oracle::feed_math::{ + decode_feed_decimals, + decode_retdata, + rescale_to_rate_decimals, + truncate_contract_call_error, + ChainlinkRoundData, + RateResult, +}; +use crate::metrics::{ + CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT, + CHAINLINK_ORACLE_FUTURE_FEED_COUNT, + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT, + CHAINLINK_ORACLE_STALE_FEED_COUNT, +}; +use crate::rate_bounds::check_rate_bounds; + +#[cfg(test)] +#[path = "feed_read_test.rs"] +mod feed_read_test; + +pub(super) const LATEST_ROUND_DATA_ENTRY_POINT: &str = "latest_round_data"; +pub(super) const DECIMALS_ENTRY_POINT: &str = "decimals"; + +/// Everything one feed read needs: the feed's address, the bounds and pair its answer is judged +/// against, and the freshness window that answer must fall in. +#[derive(Clone, Copy, Debug)] +pub struct FeedRead { + feed_address: ContractAddress, + bounds: RateBounds, + freshness: FreshnessWindow, +} + +/// The reads a `ChainlinkOracleConfig` describes, judged against the bounds `bounds_config` holds. +/// One method per pair Chainlink quotes, so a read cannot be requested for the derived pair, which +/// has no feed. +pub trait ChainlinkFeeds { + fn eth_usd_feed(&self, bounds_config: &RateBoundsConfig) -> FeedRead; + fn strk_usd_feed(&self, bounds_config: &RateBoundsConfig) -> FeedRead; +} + +impl ChainlinkFeeds for ChainlinkOracleConfig { + fn eth_usd_feed(&self, bounds_config: &RateBoundsConfig) -> FeedRead { + FeedRead { + feed_address: self.eth_usd_feed_address, + bounds: bounds_config.eth_usd_bounds(), + freshness: self.freshness, + } + } + + fn strk_usd_feed(&self, bounds_config: &RateBoundsConfig) -> FeedRead { + FeedRead { + feed_address: self.strk_usd_feed_address, + bounds: bounds_config.strk_usd_bounds(), + freshness: self.freshness, + } + } +} + +/// The feed's answer, rescaled to `RATE_DECIMALS` and checked against the feed's bounds. +pub async fn read_feed( + batcher_client: &SharedBatcherClient, + feed: FeedRead, + block_timestamp: u64, +) -> RateResult { + let pair = feed.bounds.pair; + let pair_name = pair.pair_name(); + let feed_address = feed.feed_address; + // The feed's `decimals` is read alongside every rate rather than cached, because a feed that + // changes it would rescale the answer by a power of ten, and the absolute bounds are too wide + // to catch that. STRK/USD accepts $0.0001 to $10, so an 8-decimal answer read as 6 decimals + // passes as $3.00 instead of $0.03. + // Sequential, not `try_join`: an error in one call would drop the other mid-flight, and the + // local component server panics when the response channel of a dropped request closes. + let decimals_retdata = + call_view(batcher_client, feed_address, DECIMALS_ENTRY_POINT, pair).await?; + let round_retdata = + call_view(batcher_client, feed_address, LATEST_ROUND_DATA_ENTRY_POINT, pair).await?; + let feed_decimals = decode_feed_decimals(decimals_retdata, pair)?; + + let round: ChainlinkRoundData = decode_retdata(round_retdata, pair)?; + if round.answer == 0 { + CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT.increment(1, &pair.labels()); + return Err(ExchangeRateOracleClientError::InvalidRateError(format!( + "{pair_name} returned a zero answer" + ))); + } + if block_timestamp.saturating_sub(round.updated_at) > feed.freshness.max_staleness_seconds { + CHAINLINK_ORACLE_STALE_FEED_COUNT.increment(1, &pair.labels()); + return Err(ExchangeRateOracleClientError::StaleFeedError { + pair_name: pair_name.to_string(), + updated_at: round.updated_at, + block_timestamp, + max_staleness_seconds: feed.freshness.max_staleness_seconds, + }); + } + // Catches a round dated ahead of the block being priced: the staleness check above saturates + // such a subtraction to zero, which alone treats it as fresh regardless of age. + if round.updated_at.saturating_sub(block_timestamp) + > feed.freshness.max_future_updated_at_seconds + { + CHAINLINK_ORACLE_FUTURE_FEED_COUNT.increment(1, &pair.labels()); + return Err(ExchangeRateOracleClientError::FutureFeedError { + pair_name: pair_name.to_string(), + updated_at: round.updated_at, + block_timestamp, + max_future_updated_at_seconds: feed.freshness.max_future_updated_at_seconds, + }); + } + + let rate = rescale_to_rate_decimals(round.answer, feed_decimals)?; + check_rate_bounds(rate, feed.bounds)?; + Ok(rate) +} + +async fn call_view( + batcher_client: &SharedBatcherClient, + contract_address: ContractAddress, + entry_point: &str, + pair: CurrencyPair, +) -> Result, ExchangeRateOracleClientError> { + let call_result = batcher_client + .call_contract(CallContractInput { + contract_address, + entry_point: entry_point.to_string(), + calldata: vec![], + }) + .await; + match call_result { + Ok(output) => Ok(output.retdata), + Err(error) => { + CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT.increment(1, &pair.labels()); + Err(ExchangeRateOracleClientError::ContractCallError(format!( + "{entry_point} at {contract_address}: {}", + truncate_contract_call_error(error.to_string()) + ))) + } + } +} diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs new file mode 100644 index 00000000000..966e580aea0 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs @@ -0,0 +1,244 @@ +use std::sync::Arc; + +use apollo_batcher_types::communication::{BatcherClientError, MockBatcherClient}; +use apollo_batcher_types::errors::BatcherError; +use apollo_metrics::metrics::{LabeledMetricCounter, MetricDetails}; +use assert_matches::assert_matches; +use metrics::set_default_local_recorder; +use metrics_exporter_prometheus::PrometheusBuilder; +use rstest::rstest; +use strum::IntoEnumIterator; + +use super::*; +use crate::chainlink_oracle::feed_math::{MAX_CONTRACT_CALL_ERROR_BYTES, TRUNCATION_MARKER}; +use crate::chainlink_oracle::test_utils::{ + batcher_client_from_responses, + decimals_retdata, + feed_responses, + FeedFixture, + FeedResponses, + FEED_DECIMALS, +}; +use crate::metrics::CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT; + +/// The block timestamp every reading below is dated against, and every freshness bound measured +/// against. +const TIMESTAMP: u64 = 1_700_000_000; +/// $0.03 per STRK at `FEED_DECIMALS`. +const STRK_USD_ANSWER: u128 = 3_000_000; + +fn test_config() -> ChainlinkOracleConfig { + ChainlinkOracleConfig::default() +} + +fn strk_usd_feed() -> FeedRead { + test_config().strk_usd_feed(&RateBoundsConfig::default()) +} + +fn fresh_updated_at() -> u64 { + TIMESTAMP +} + +fn stale_updated_at() -> u64 { + TIMESTAMP - test_config().freshness.max_staleness_seconds - 1 +} + +fn future_updated_at() -> u64 { + TIMESTAMP + test_config().freshness.max_future_updated_at_seconds + 1 +} + +fn strk_usd_responses(strk_usd: FeedFixture) -> FeedResponses { + feed_responses(test_config().strk_usd_feed_address, strk_usd) +} + +/// The STRK/USD feed with a valid `decimals` reply but no round, so exactly one of the two calls a +/// read issues fails. +fn strk_usd_responses_without_round_data() -> FeedResponses { + FeedResponses::from([( + (test_config().strk_usd_feed_address, DECIMALS_ENTRY_POINT.to_string()), + decimals_retdata(FEED_DECIMALS), + )]) +} + +/// Reads the STRK/USD feed against `TIMESTAMP`, the timestamp every fixture is dated against. +async fn read_strk_usd(strk_usd: FeedFixture) -> RateResult { + read_strk_usd_responses(strk_usd_responses(strk_usd)).await +} + +async fn read_strk_usd_responses(responses: FeedResponses) -> RateResult { + read_feed(&batcher_client_from_responses(responses), strk_usd_feed(), TIMESTAMP).await +} + +#[tokio::test] +async fn strk_to_usd_rejects_stale_reading() { + assert_matches!( + read_strk_usd(FeedFixture::new(STRK_USD_ANSWER, stale_updated_at())).await, + Err(ExchangeRateOracleClientError::StaleFeedError { pair_name, .. }) + if pair_name == CurrencyPair::StrkUsd.pair_name() + ); +} + +#[tokio::test] +async fn strk_to_usd_accepts_reading_exactly_at_the_staleness_bound() { + let oldest_accepted_updated_at = TIMESTAMP - test_config().freshness.max_staleness_seconds; + assert!( + read_strk_usd(FeedFixture::new(STRK_USD_ANSWER, oldest_accepted_updated_at)).await.is_ok() + ); +} + +/// The future-dated bound, checked separately from the staleness bound (see `read_feed`). +#[rstest] +#[case::at_the_future_bound(0, true)] +#[case::just_past_the_future_bound(1, false)] +#[tokio::test] +async fn future_updated_at_is_bounded( + #[case] seconds_past_the_bound: u64, + #[case] is_accepted: bool, +) { + let updated_at = + TIMESTAMP + test_config().freshness.max_future_updated_at_seconds + seconds_past_the_bound; + + let result = read_strk_usd(FeedFixture::new(STRK_USD_ANSWER, updated_at)).await; + if is_accepted { + assert!(result.is_ok()); + } else { + assert_matches!( + result, + Err(ExchangeRateOracleClientError::FutureFeedError { pair_name, .. }) + if pair_name == CurrencyPair::StrkUsd.pair_name() + ); + } +} + +/// `u64::MAX` as `updated_at` is rejected by the future bound (see `read_feed`). +#[tokio::test] +async fn maximal_future_updated_at_rejected() { + assert_matches!( + read_strk_usd(FeedFixture::new(STRK_USD_ANSWER, u64::MAX)).await, + Err(ExchangeRateOracleClientError::FutureFeedError { .. }) + ); +} + +#[tokio::test] +async fn zero_answer_rejected() { + assert_matches!( + read_strk_usd(FeedFixture::new(0, fresh_updated_at())).await, + Err(ExchangeRateOracleClientError::InvalidRateError(message)) + if message.contains("zero answer") + ); +} + +// Each accessor names its pair and its feed independently, so what a read is attributed to and +// which feed it reads are pinned here rather than only by the accessor's name. +#[test] +fn feed_accessors_carry_their_own_pair_and_feed() { + let config = test_config(); + let bounds_config = RateBoundsConfig::default(); + + let eth_usd = config.eth_usd_feed(&bounds_config); + assert_eq!(eth_usd.bounds.pair, CurrencyPair::EthUsd); + assert_eq!(eth_usd.feed_address, config.eth_usd_feed_address); + assert_eq!(eth_usd.bounds.minimum_micro_units, bounds_config.eth_usd.minimum_micro_units); + assert_eq!(eth_usd.freshness.max_staleness_seconds, config.freshness.max_staleness_seconds); + assert_eq!( + eth_usd.freshness.max_future_updated_at_seconds, + config.freshness.max_future_updated_at_seconds + ); + + let strk_usd = config.strk_usd_feed(&bounds_config); + assert_eq!(strk_usd.bounds.pair, CurrencyPair::StrkUsd); + assert_eq!(strk_usd.feed_address, config.strk_usd_feed_address); + assert_eq!(strk_usd.bounds.minimum_micro_units, bounds_config.strk_usd.minimum_micro_units); +} + +#[tokio::test] +async fn batcher_call_failure_is_surfaced_as_an_error() { + assert_matches!( + read_strk_usd_responses(FeedResponses::new()).await, + Err(ExchangeRateOracleClientError::ContractCallError(_)) + ); +} + +/// A reverting view call carries the feed contract's panic data, which the contract sizes. +#[tokio::test] +async fn contract_call_error_text_is_truncated() { + // The relayed message is the entry point and the feed address, then the capped error text. + const MAX_ENTRY_POINT_AND_ADDRESS_LENGTH: usize = 128; + const REASON_LENGTH: usize = 10_000; + let mut reverting_batcher_client = MockBatcherClient::new(); + reverting_batcher_client.expect_call_contract().returning(|_| { + Err(BatcherClientError::BatcherError(BatcherError::ContractCallFailed { + reason: "a".repeat(REASON_LENGTH), + })) + }); + let batcher_client: SharedBatcherClient = Arc::new(reverting_batcher_client); + + assert_matches!( + read_feed(&batcher_client, strk_usd_feed(), TIMESTAMP).await, + Err(ExchangeRateOracleClientError::ContractCallError(message)) => { + assert!( + message.ends_with(TRUNCATION_MARKER), + "error text was not truncated: {message}" + ); + assert!( + message.len() + <= MAX_ENTRY_POINT_AND_ADDRESS_LENGTH + + MAX_CONTRACT_CALL_ERROR_BYTES + + TRUNCATION_MARKER.len(), + "error text exceeded the cap: {} bytes", + message.len() + ); + } + ); +} + +/// The guard counters record why a read was rejected and, through the `currency_pair` label, which +/// reading it was rejected on. Each guard must increment its own counter on the rejected pair's +/// series and on no other. +#[rstest] +#[case::stale_strk_feed( + strk_usd_responses(FeedFixture::new(STRK_USD_ANSWER, stale_updated_at())), + &CHAINLINK_ORACLE_STALE_FEED_COUNT +)] +#[case::future_feed( + strk_usd_responses(FeedFixture::new(STRK_USD_ANSWER, future_updated_at())), + &CHAINLINK_ORACLE_FUTURE_FEED_COUNT +)] +#[case::zero_answer( + strk_usd_responses(FeedFixture::new(0, fresh_updated_at())), + &CHAINLINK_ORACLE_INVALID_FEED_ANSWER_COUNT +)] +// One micro-cent per STRK, far below the configured floor. +#[case::rate_out_of_bounds( + strk_usd_responses(FeedFixture::new(1, fresh_updated_at())), + &CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT +)] +#[case::contract_call_failure( + strk_usd_responses_without_round_data(), + &CHAINLINK_ORACLE_CONTRACT_CALL_ERROR_COUNT +)] +#[tokio::test] +async fn guard_counters_record_the_rejection_reason_and_pair( + #[case] responses: FeedResponses, + #[case] guard_counter: &'static LabeledMetricCounter, +) { + let recorder = PrometheusBuilder::new().build_recorder(); + let _recorder_guard = set_default_local_recorder(&recorder); + + assert!(read_strk_usd_responses(responses).await.is_err()); + let rendered_metrics = recorder.handle().render(); + for pair in CurrencyPair::iter() { + // A series only the guard's own increment creates reads as absent, which is the same + // statement as a count of zero. + let count = guard_counter + .parse_numeric_metric::(&rendered_metrics, &pair.labels()) + .unwrap_or(0); + let expected_count = u64::from(pair == CurrencyPair::StrkUsd); + assert_eq!( + count, + expected_count, + "{} on pair {pair:?} recorded {count} rejections, expected {expected_count}", + guard_counter.get_name() + ); + } +} diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs index 6bae0a890f9..abdfb9acfc7 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs @@ -1,3 +1,8 @@ -//! Chainlink feed retdata decoding and rate arithmetic. +//! Reading the Chainlink price feeds on Starknet. pub mod feed_math; +pub mod feed_read; + +// [Temporary comment] A harness for a single test file so far; A9's `test.rs` shares it. +#[cfg(test)] +mod test_utils; diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs new file mode 100644 index 00000000000..f925d5d1a8f --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs @@ -0,0 +1,76 @@ +//! The batcher mock the Chainlink oracle tests read their feeds through. + +use std::collections::HashMap; +use std::sync::Arc; + +use apollo_batcher_types::batcher_types::CallContractOutput; +use apollo_batcher_types::communication::{ + BatcherClientError, + MockBatcherClient, + SharedBatcherClient, +}; +use apollo_batcher_types::errors::BatcherError; +use starknet_api::core::ContractAddress; +use starknet_types_core::felt::Felt; + +use crate::chainlink_oracle::feed_read::{DECIMALS_ENTRY_POINT, LATEST_ROUND_DATA_ENTRY_POINT}; + +/// The scale the Chainlink feeds report at today. +pub(super) const FEED_DECIMALS: u32 = 8; + +/// The mocked reply per feed address and entry point. A call with no entry here fails. +pub(super) type FeedResponses = HashMap<(ContractAddress, String), Vec>; + +/// One feed's mocked `decimals` and `latest_round_data` replies. +#[derive(Clone, Copy)] +pub(super) struct FeedFixture { + answer: u128, + updated_at: u64, + feed_decimals: u32, +} + +impl FeedFixture { + pub(super) fn new(answer: u128, updated_at: u64) -> Self { + Self { answer, updated_at, feed_decimals: FEED_DECIMALS } + } +} + +pub(super) fn decimals_retdata(feed_decimals: u32) -> Vec { + vec![Felt::from(feed_decimals)] +} + +pub(super) fn round_retdata(answer: u128, updated_at: u64) -> Vec { + // A realistic phase-encoded `round_id`: `(phase_id << 128) | aggregator_round_id`, which + // exceeds u64. + const PHASE_ENCODED_ROUND_ID: &str = "0x100000000000000000000000000000042"; + const BLOCK_NUMBER: u64 = 987_654; + const STARTED_AT: u64 = 1_699_999_000; + vec![ + Felt::from_hex_unchecked(PHASE_ENCODED_ROUND_ID), + Felt::from(answer), + Felt::from(BLOCK_NUMBER), + Felt::from(STARTED_AT), + Felt::from(updated_at), + ] +} + +pub(super) fn feed_responses(feed_address: ContractAddress, feed: FeedFixture) -> FeedResponses { + HashMap::from([ + ((feed_address, DECIMALS_ENTRY_POINT.to_string()), decimals_retdata(feed.feed_decimals)), + ( + (feed_address, LATEST_ROUND_DATA_ENTRY_POINT.to_string()), + round_retdata(feed.answer, feed.updated_at), + ), + ]) +} + +pub(super) fn batcher_client_from_responses(responses: FeedResponses) -> SharedBatcherClient { + let mut batcher_client = MockBatcherClient::new(); + batcher_client.expect_call_contract().returning(move |input| { + responses + .get(&(input.contract_address, input.entry_point.clone())) + .map(|retdata| CallContractOutput { retdata: retdata.clone() }) + .ok_or(BatcherClientError::BatcherError(BatcherError::InternalError)) + }); + Arc::new(batcher_client) +} diff --git a/crates/apollo_l1_gas_price/src/lib.rs b/crates/apollo_l1_gas_price/src/lib.rs index ac1e10ded3a..6797fcc86e6 100644 --- a/crates/apollo_l1_gas_price/src/lib.rs +++ b/crates/apollo_l1_gas_price/src/lib.rs @@ -34,4 +34,4 @@ pub mod exchange_rate_oracle; pub mod l1_gas_price_provider; pub mod l1_gas_price_scraper; pub mod metrics; -pub mod rate_bounds; +pub(crate) mod rate_bounds; diff --git a/crates/apollo_l1_gas_price/src/metrics.rs b/crates/apollo_l1_gas_price/src/metrics.rs index 3eb69cb1dd9..25730e3633d 100644 --- a/crates/apollo_l1_gas_price/src/metrics.rs +++ b/crates/apollo_l1_gas_price/src/metrics.rs @@ -116,8 +116,7 @@ pub(crate) fn register_provider_metrics() { } /// Registers one counter series per `currency_pair`. Runs once per process. -// [Temporary comment] `pub` until the Chainlink client (A9) calls it; the feed guards (A7/A8) -// increment these counters. +// [Temporary comment] `pub` until the Chainlink client (A9) calls it. pub fn register_chainlink_guard_metrics() { static CHAINLINK_GUARD_METRICS_REGISTRATION: Once = Once::new(); CHAINLINK_GUARD_METRICS_REGISTRATION.call_once(|| { diff --git a/crates/apollo_l1_gas_price/src/rate_bounds.rs b/crates/apollo_l1_gas_price/src/rate_bounds.rs index 4ac08af2862..642d5f01a33 100644 --- a/crates/apollo_l1_gas_price/src/rate_bounds.rs +++ b/crates/apollo_l1_gas_price/src/rate_bounds.rs @@ -1,8 +1,5 @@ //! The absolute sanity bounds every exchange rate must fall in, whichever source reports it. -// [Temporary comment] `pub` with no production caller yet: the Chainlink feed read (A8) and the -// HTTP oracle (C1) both call this; A8 narrows it to `pub(crate)`. - use apollo_l1_gas_price_config::config::RateBounds; use apollo_l1_gas_price_types::errors::ExchangeRateOracleClientError; use apollo_l1_gas_price_types::ExchangeRate; @@ -22,7 +19,7 @@ mod rate_bounds_test; /// Absolute bounds are the only defense against a feed wired to the wrong asset or a /// plausible-but-poisoned answer: consensus checks that validators agree with each other, never /// that the agreed value is sane, and every node reads the same chain state. -pub fn check_rate_bounds( +pub(crate) fn check_rate_bounds( rate: ExchangeRate, bounds: RateBounds, ) -> Result<(), ExchangeRateOracleClientError> { diff --git a/crates/apollo_l1_gas_price_config/src/config.rs b/crates/apollo_l1_gas_price_config/src/config.rs index 325cd160087..69e7d381fad 100644 --- a/crates/apollo_l1_gas_price_config/src/config.rs +++ b/crates/apollo_l1_gas_price_config/src/config.rs @@ -185,7 +185,7 @@ impl SerializeConfig for DerivedRateBoundsConfig { } /// Absolute bounds every exchange rate must fall in, whichever source reports it. -// [Temporary comment] No production reader yet: B2 nests this in `L1GasPriceProviderConfig`. +// [Temporary comment] B2 nests this in `L1GasPriceProviderConfig`. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] #[validate(schema(function = "validate_rate_bounds_config"))] pub struct RateBoundsConfig { @@ -333,8 +333,7 @@ impl SerializeConfig for FreshnessWindow { } /// Configuration for reading Chainlink's on-chain Starknet price feeds through the batcher. -// [Temporary comment] No reader yet: A8 reads the feeds through these fields and A9 wires the -// source in, and B2 nests this under `L1GasPriceProviderConfig`. +// [Temporary comment] A9 wires the source in; B2 nests this under `L1GasPriceProviderConfig`. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] pub struct ChainlinkOracleConfig { /// Quotes USD per ETH. diff --git a/crates/apollo_l1_gas_price_types/src/errors.rs b/crates/apollo_l1_gas_price_types/src/errors.rs index 5bbaba9ac13..e6e277480a4 100644 --- a/crates/apollo_l1_gas_price_types/src/errors.rs +++ b/crates/apollo_l1_gas_price_types/src/errors.rs @@ -49,7 +49,6 @@ pub enum ExchangeRateOracleClientError { AllUrlsFailedError(u64, usize), #[error("Invalid rate from oracle: {0}")] InvalidRateError(String), - // [Temporary comment] The five variants below are first returned by the feed guards (A7/A8). #[error( "Stale {pair_name} price feed: last updated at {updated_at}, priced for block timestamp \ {block_timestamp}, maximum accepted staleness is {max_staleness_seconds} seconds" From a47b43da0760f3e3810057ddf2774a491ac9ada5 Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Tue, 18 Aug 2026 18:31:42 +0300 Subject: [PATCH 7/8] apollo_l1_gas_price,apollo_l1_gas_price_config: ChainlinkOracleClient query lifecycle --- crates/apollo_l1_gas_price/Cargo.toml | 1 + .../src/chainlink_oracle/feed_math.rs | 6 +- .../src/chainlink_oracle/feed_math_test.rs | 38 +-- .../src/chainlink_oracle/feed_read.rs | 9 +- .../src/chainlink_oracle/feed_read_test.rs | 29 +- .../src/chainlink_oracle/mod.rs | 273 +++++++++++++++++- .../src/chainlink_oracle/test.rs | 262 +++++++++++++++++ .../src/chainlink_oracle/test_utils.rs | 92 +++++- crates/apollo_l1_gas_price/src/lib.rs | 11 +- crates/apollo_l1_gas_price/src/metrics.rs | 3 +- .../apollo_l1_gas_price_config/src/config.rs | 3 +- 11 files changed, 645 insertions(+), 82 deletions(-) create mode 100644 crates/apollo_l1_gas_price/src/chainlink_oracle/test.rs diff --git a/crates/apollo_l1_gas_price/Cargo.toml b/crates/apollo_l1_gas_price/Cargo.toml index 8a155b41349..fab153fa722 100644 --- a/crates/apollo_l1_gas_price/Cargo.toml +++ b/crates/apollo_l1_gas_price/Cargo.toml @@ -41,6 +41,7 @@ metrics-exporter-prometheus.workspace = true mockall.workspace = true mockito.workspace = true papyrus_base_layer = { workspace = true, features = ["testing"] } +tokio = { workspace = true, features = ["test-util"] } [features] testing = [] diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs index ee9dadf2c4a..8399bf4d30b 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs @@ -18,7 +18,7 @@ mod feed_math_test; /// Fixed-point scale of every rate the Chainlink oracle returns, matching /// `EXCHANGE_RATE_DECIMALS`. const RATE_DECIMALS: u32 = 18; -const RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS); +pub(super) const RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS); pub(crate) const MICRO_UNIT_TO_RATE_SCALE: u128 = 10u128.pow(RATE_DECIMALS - RATE_MICRO_UNIT_DECIMALS); @@ -98,10 +98,8 @@ pub(super) fn rescale_to_rate_decimals(answer: u128, feed_decimals: u32) -> Rate }) } -// [Temporary comment] `pub` with no caller yet: the client (A9) derives ETH/STRK from the two USD -// legs and narrows this to `pub(super)`. /// STRK per ETH, at `RATE_DECIMALS`, from two USD prices that already carry `RATE_DECIMALS`. -pub fn derive_eth_to_fri_rate( +pub(super) fn derive_eth_to_fri_rate( eth_to_usd_rate: ExchangeRate, strk_to_usd_rate: ExchangeRate, ) -> RateResult { diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs index ef67070a64f..a5f8b3cf892 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs @@ -2,38 +2,18 @@ use assert_matches::assert_matches; use rstest::rstest; use super::*; +use crate::chainlink_oracle::test_utils::{ + decimals_retdata, + round_retdata, + ETH_TO_FRI_RATE, + ETH_USD_ANSWER, + FEED_DECIMALS, + STRK_TO_USD_RATE, + STRK_USD_ANSWER, +}; -/// The scale the Chainlink feeds report at today. -const FEED_DECIMALS: u32 = 8; -/// $3000 per ETH at `FEED_DECIMALS`. -const ETH_USD_ANSWER: u128 = 300_000_000_000; -/// $0.03 per STRK at `FEED_DECIMALS`. -const STRK_USD_ANSWER: u128 = 3_000_000; -/// The rate the two answers above derive to: 100,000 STRK per ETH. -const ETH_TO_FRI_RATE: u128 = 100_000 * RATE_SCALE; -/// $0.03 per STRK at `RATE_DECIMALS`. -const STRK_TO_USD_RATE: u128 = 30_000_000_000_000_000; const UPDATED_AT: u64 = 1_700_000_000; -fn decimals_retdata(feed_decimals: u32) -> Vec { - vec![Felt::from(feed_decimals)] -} - -fn round_retdata(answer: u128, updated_at: u64) -> Vec { - // A realistic phase-encoded `round_id`: `(phase_id << 128) | aggregator_round_id`, which - // exceeds u64. - const PHASE_ENCODED_ROUND_ID: &str = "0x100000000000000000000000000000042"; - const BLOCK_NUMBER: u64 = 987_654; - const STARTED_AT: u64 = 1_699_999_000; - vec![ - Felt::from_hex_unchecked(PHASE_ENCODED_ROUND_ID), - Felt::from(answer), - Felt::from(BLOCK_NUMBER), - Felt::from(STARTED_AT), - Felt::from(updated_at), - ] -} - /// The two fields the oracle reads sit at positions two and five of the flat five-felt `Round`, so /// this pins the layout the decoder assumes. #[test] diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs index a5ef0c14283..029739e0064 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs @@ -1,8 +1,5 @@ //! One Chainlink feed read: two view calls through the batcher, and the guards their answer passes. -// [Temporary comment] `pub` with no caller yet: the client (A9) calls `read_feed` through the -// feed accessors and narrows them to `pub(super)`. - use apollo_batcher_types::batcher_types::CallContractInput; use apollo_batcher_types::communication::SharedBatcherClient; use apollo_l1_gas_price_config::config::{ @@ -42,7 +39,7 @@ pub(super) const DECIMALS_ENTRY_POINT: &str = "decimals"; /// Everything one feed read needs: the feed's address, the bounds and pair its answer is judged /// against, and the freshness window that answer must fall in. #[derive(Clone, Copy, Debug)] -pub struct FeedRead { +pub(super) struct FeedRead { feed_address: ContractAddress, bounds: RateBounds, freshness: FreshnessWindow, @@ -51,7 +48,7 @@ pub struct FeedRead { /// The reads a `ChainlinkOracleConfig` describes, judged against the bounds `bounds_config` holds. /// One method per pair Chainlink quotes, so a read cannot be requested for the derived pair, which /// has no feed. -pub trait ChainlinkFeeds { +pub(super) trait ChainlinkFeeds { fn eth_usd_feed(&self, bounds_config: &RateBoundsConfig) -> FeedRead; fn strk_usd_feed(&self, bounds_config: &RateBoundsConfig) -> FeedRead; } @@ -75,7 +72,7 @@ impl ChainlinkFeeds for ChainlinkOracleConfig { } /// The feed's answer, rescaled to `RATE_DECIMALS` and checked against the feed's bounds. -pub async fn read_feed( +pub(super) async fn read_feed( batcher_client: &SharedBatcherClient, feed: FeedRead, block_timestamp: u64, diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs index 966e580aea0..1c23e76e09e 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs @@ -14,43 +14,26 @@ use crate::chainlink_oracle::feed_math::{MAX_CONTRACT_CALL_ERROR_BYTES, TRUNCATI use crate::chainlink_oracle::test_utils::{ batcher_client_from_responses, decimals_retdata, - feed_responses, + fresh_updated_at, + stale_updated_at, + strk_usd_responses, + test_config, FeedFixture, FeedResponses, FEED_DECIMALS, + STRK_USD_ANSWER, + TIMESTAMP, }; use crate::metrics::CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT; -/// The block timestamp every reading below is dated against, and every freshness bound measured -/// against. -const TIMESTAMP: u64 = 1_700_000_000; -/// $0.03 per STRK at `FEED_DECIMALS`. -const STRK_USD_ANSWER: u128 = 3_000_000; - -fn test_config() -> ChainlinkOracleConfig { - ChainlinkOracleConfig::default() -} - fn strk_usd_feed() -> FeedRead { test_config().strk_usd_feed(&RateBoundsConfig::default()) } -fn fresh_updated_at() -> u64 { - TIMESTAMP -} - -fn stale_updated_at() -> u64 { - TIMESTAMP - test_config().freshness.max_staleness_seconds - 1 -} - fn future_updated_at() -> u64 { TIMESTAMP + test_config().freshness.max_future_updated_at_seconds + 1 } -fn strk_usd_responses(strk_usd: FeedFixture) -> FeedResponses { - feed_responses(test_config().strk_usd_feed_address, strk_usd) -} - /// The STRK/USD feed with a valid `decimals` reply but no round, so exactly one of the two calls a /// read issues fails. fn strk_usd_responses_without_round_data() -> FeedResponses { diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs index abdfb9acfc7..0f029d6f742 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs @@ -1,8 +1,273 @@ -//! Reading the Chainlink price feeds on Starknet. +//! Reading Chainlink's price feeds on Starknet: the oracle client consensus calls, and the feed +//! reads behind it. -pub mod feed_math; -pub mod feed_read; +use std::fmt::{Debug, Formatter, Result as FormatterResult}; +use std::marker::PhantomData; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use apollo_batcher_types::communication::SharedBatcherClient; +use apollo_l1_gas_price_config::config::{ChainlinkOracleConfig, RateBoundsConfig}; +use apollo_l1_gas_price_types::errors::ExchangeRateOracleClientError; +use apollo_l1_gas_price_types::{ + EthToFri, + ExchangeRate, + ExchangeRateOracleClientTrait, + RateKind, + StrkToUsd, +}; +use apollo_metrics::metrics::set_unix_now_seconds; +use async_trait::async_trait; +use futures::future::try_join; +use futures::FutureExt; +use tokio::time::Instant; +use tokio_util::task::AbortOnDropHandle; +use tracing::{debug, info, instrument, warn}; + +use crate::chainlink_oracle::feed_math::{derive_eth_to_fri_rate, RateResult}; +use crate::chainlink_oracle::feed_read::{read_feed, ChainlinkFeeds}; +use crate::metrics::{ + register_chainlink_guard_metrics, + ExchangeRateOracleMetrics, + ETH_TO_STRK_ORACLE_METRICS, + STRK_TO_USD_ORACLE_METRICS, +}; +use crate::rate_bounds::check_rate_bounds; + +pub(crate) mod feed_math; +mod feed_read; -// [Temporary comment] A harness for a single test file so far; A9's `test.rs` shares it. +#[cfg(test)] +mod test; #[cfg(test)] mod test_utils; + +// A read in flight, resolving to a rate already dated by the block timestamp it was issued for. +type RateQuery = AbortOnDropHandle>; + +// A rate that passed every guard, and the block timestamp it was read for. +#[derive(Clone, Copy)] +struct ValidRead { + rate: ExchangeRate, + block_timestamp: u64, +} + +#[derive(Default)] +struct OracleState { + // The newest read that passed every guard, served to callers until a newer one replaces it. + last_valid_read: Option, + // The query in flight. A single slot bounds this client to one query at a time. + query: Option, + // When the last query was spawned, on the local monotonic clock, which the refresh cadence is + // measured from. Local because the cadence is this node's own scheduling, so a block timestamp + // arriving from the network cannot steer it. + last_attempt_instant: Option, +} + +/// The Chainlink read behind a `RateKind`. Separate from `RateKind` because +/// `ExchangeRateOracleMetrics` lives in this crate, which the types crate cannot name. +#[async_trait] +pub trait ChainlinkRate: RateKind { + /// The pair's metrics bundle, shared with the HTTP client for the same pair so each keeps one + /// set of Prometheus series across a migration between the two sources. + fn metrics() -> ExchangeRateOracleMetrics; + + async fn query_rate( + batcher_client: &SharedBatcherClient, + config: &ChainlinkOracleConfig, + bounds_config: &RateBoundsConfig, + block_timestamp: u64, + ) -> RateResult; +} + +/// Reads Chainlink's on-chain Starknet price feeds through the sequencer's own batcher. +/// +/// Consensus calls `fetch_rate` on every proposal build and validate, so the call must not block on +/// the batcher: the feed is read by a background query spawned at most once per +/// `sampling_interval_seconds`, and every caller is served the last valid read. +/// +/// Reads are not deterministic across nodes: `call_contract` executes against the batcher's latest +/// committed block rather than state pinned to the queried timestamp, so two nodes can read +/// different rounds for the same block timestamp. Chainlink's deviation threshold is far inside the +/// `l1_gas_price_margin_percent` validators compare within, so this is not expected to reject +/// proposals. +// [Temporary comment] Constructed only by tests; B4 builds it for a feed whose configured oracle +// source is Chainlink. +#[derive(Clone)] +pub struct ChainlinkOracleClient { + config: ChainlinkOracleConfig, + bounds_config: RateBoundsConfig, + batcher_client: SharedBatcherClient, + state: Arc>, + metrics: ExchangeRateOracleMetrics, + _kind: PhantomData, +} + +// Manual impl: the trait requires `Debug` but `SharedBatcherClient` does not provide it. +impl Debug for ChainlinkOracleClient { + fn fmt(&self, formatter: &mut Formatter<'_>) -> FormatterResult { + formatter + .debug_struct("ChainlinkOracleClient") + .field("pair", &Kind::PAIR) + .field("config", &self.config) + .field("bounds_config", &self.bounds_config) + .finish_non_exhaustive() + } +} + +impl ChainlinkOracleClient { + pub fn new( + config: ChainlinkOracleConfig, + bounds_config: RateBoundsConfig, + batcher_client: SharedBatcherClient, + ) -> Self { + let pair = Kind::PAIR; + info!("Creating ChainlinkOracleClient for {pair:?} with: {config:?} {bounds_config:?}"); + let metrics = Kind::metrics(); + metrics.register(); + register_chainlink_guard_metrics(); + Self { + config, + bounds_config, + batcher_client, + state: Arc::new(Mutex::new(OracleState::default())), + metrics, + _kind: PhantomData, + } + } + + // `block_timestamp` is what every freshness guard inside the query is measured against, and + // what the resulting read is dated by. + fn spawn_query(&self, block_timestamp: u64) -> RateQuery { + let batcher_client = self.batcher_client.clone(); + let config = self.config.clone(); + let bounds_config = self.bounds_config.clone(); + let metrics = self.metrics; + let pair = Kind::PAIR; + AbortOnDropHandle::new(tokio::spawn(async move { + let result = + Kind::query_rate(&batcher_client, &config, &bounds_config, block_timestamp).await; + match &result { + Ok(rate) => { + metrics.success_count.increment(1); + set_unix_now_seconds(metrics.last_success_timestamp); + metrics.rate.set_lossy(*rate); + debug!( + "Resolved {pair:?} query for block timestamp {block_timestamp} to {rate}" + ); + } + Err(error) => { + metrics.error_count.increment(1); + warn!("Failed {pair:?} query for block timestamp {block_timestamp}: {error:?}"); + } + } + result.map(|rate| ValidRead { rate, block_timestamp }) + })) + } + + // Moves a finished query's success into `state` as the last valid read. Called on every + // `fetch_rate`, so that a query which resolved after the last caller that could have observed + // it is harvested rather than dropped together with the round trip that produced it. + fn harvest_finished_query(&self, state: &mut OracleState) { + if !state.query.as_ref().is_some_and(|query| query.is_finished()) { + return; + } + let joined = state + .query + .take() + .expect("Query must be present if it reported being finished") + .now_or_never() + .expect("Finished query must resolve immediately"); + let result = joined.unwrap_or_else(|error| { + self.metrics.error_count.increment(1); + warn!("Query failed to join its handle: {error:?}"); + Err(ExchangeRateOracleClientError::JoinError(error.to_string())) + }); + match result { + Ok(valid_read) => { + debug!( + "Harvested a rate of {} for block timestamp {}", + valid_read.rate, valid_read.block_timestamp + ); + state.last_valid_read = Some(valid_read); + } + // Nothing is held for a failure: the next query waits for the sampling interval like + // any other refresh. + Err(error) => debug!("Harvested query failure: {error:?}"), + } + } +} + +#[async_trait] +impl ExchangeRateOracleClientTrait for ChainlinkOracleClient { + #[instrument(skip(self))] + async fn fetch_rate( + &self, + block_timestamp: u64, + ) -> Result { + // Held for the whole function: harvesting the finished query, deciding whether to spawn the + // next one and serving this caller are one critical section, so no caller can observe a + // query that was taken but whose outcome is not stored yet. + let mut state = self.state.lock().unwrap(); + self.harvest_finished_query(&mut state); + + let sampling_interval = Duration::from_secs(self.config.sampling_interval_seconds); + let is_refresh_due = state + .last_attempt_instant + .is_none_or(|last_attempt| last_attempt.elapsed() >= sampling_interval); + if state.query.is_none() && is_refresh_due { + state.query = Some(self.spawn_query(block_timestamp)); + state.last_attempt_instant = Some(Instant::now()); + } + + // A caller whose own read has not resolved is served the read the client already holds, + // including the caller that spawned the read in flight. + match state.last_valid_read { + Some(valid_read) => Ok(valid_read.rate), + None => Err(ExchangeRateOracleClientError::QueryNotReadyError(block_timestamp)), + } + } +} + +#[async_trait] +impl ChainlinkRate for StrkToUsd { + fn metrics() -> ExchangeRateOracleMetrics { + STRK_TO_USD_ORACLE_METRICS + } + + async fn query_rate( + batcher_client: &SharedBatcherClient, + config: &ChainlinkOracleConfig, + bounds_config: &RateBoundsConfig, + block_timestamp: u64, + ) -> RateResult { + read_feed(batcher_client, config.strk_usd_feed(bounds_config), block_timestamp).await + } +} + +#[async_trait] +impl ChainlinkRate for EthToFri { + fn metrics() -> ExchangeRateOracleMetrics { + ETH_TO_STRK_ORACLE_METRICS + } + + async fn query_rate( + batcher_client: &SharedBatcherClient, + config: &ChainlinkOracleConfig, + bounds_config: &RateBoundsConfig, + block_timestamp: u64, + ) -> RateResult { + // The two legs are separate `call_contract` calls, which exposes no block pinning, so they + // may straddle a block boundary. A one-block skew is orders of magnitude below the + // staleness bound both legs must independently pass. + let (eth_to_usd_rate, strk_to_usd_rate) = try_join( + read_feed(batcher_client, config.eth_usd_feed(bounds_config), block_timestamp), + read_feed(batcher_client, config.strk_usd_feed(bounds_config), block_timestamp), + ) + .await?; + + let eth_to_fri_rate = derive_eth_to_fri_rate(eth_to_usd_rate, strk_to_usd_rate)?; + check_rate_bounds(eth_to_fri_rate, bounds_config.eth_to_fri_bounds())?; + Ok(eth_to_fri_rate) + } +} diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/test.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/test.rs new file mode 100644 index 00000000000..5f0d97f2dae --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/test.rs @@ -0,0 +1,262 @@ +use std::sync::atomic::Ordering; + +use apollo_l1_gas_price_types::CurrencyPair; +use assert_matches::assert_matches; +use rstest::rstest; + +use super::*; +use crate::chainlink_oracle::test_utils::{ + batcher_client_failing_after, + batcher_client_from_responses, + counting_batcher_client, + eth_and_strk_responses, + fresh_updated_at, + stale_updated_at, + strk_usd_responses, + test_config, + FeedFixture, + FeedResponses, + ETH_TO_FRI_RATE, + ETH_USD_ANSWER, + STRK_TO_USD_RATE, + STRK_USD_ANSWER, + TIMESTAMP, +}; + +const MAX_POLL_ATTEMPTS: usize = 1000; +/// `decimals` and `latest_round_data`, per feed. +const CALLS_PER_FEED_PER_QUERY: usize = 2; + +fn sampling_interval_seconds() -> u64 { + test_config().sampling_interval_seconds +} + +fn make_client(responses: FeedResponses) -> ChainlinkOracleClient { + client_with_batcher(batcher_client_from_responses(responses)) +} + +fn client_with_batcher( + batcher_client: SharedBatcherClient, +) -> ChainlinkOracleClient { + ChainlinkOracleClient::new(test_config(), RateBoundsConfig::default(), batcher_client) +} + +/// Polls until the spawned background query resolves, mirroring how consensus retries across +/// proposals. +async fn resolve_rate( + client: &dyn ExchangeRateOracleClientTrait, + block_timestamp: u64, +) -> Result { + for _ in 0..MAX_POLL_ATTEMPTS { + match client.fetch_rate(block_timestamp).await { + Err(ExchangeRateOracleClientError::QueryNotReadyError(_)) => { + tokio::task::yield_now().await; + } + resolved => return resolved, + } + } + panic!("Query did not resolve within {MAX_POLL_ATTEMPTS} attempts"); +} + +fn last_valid_read(client: &ChainlinkOracleClient) -> Option { + client.state.lock().unwrap().last_valid_read +} + +fn last_attempt_instant( + client: &ChainlinkOracleClient, +) -> Option { + client.state.lock().unwrap().last_attempt_instant +} + +fn is_query_in_flight(client: &ChainlinkOracleClient) -> bool { + client.state.lock().unwrap().query.is_some() +} + +/// Waits for the spawned query to finish without calling `fetch_rate`, which would harvest it. This +/// is the state a query is left in when it resolves after the last caller that could have observed +/// it. +async fn wait_for_query_to_finish(client: &ChainlinkOracleClient) { + for _ in 0..MAX_POLL_ATTEMPTS { + let is_finished = { + let state = client.state.lock().unwrap(); + state.query.as_ref().is_some_and(|query| query.is_finished()) + }; + if is_finished { + return; + } + tokio::task::yield_now().await; + } + panic!("Query did not finish within {MAX_POLL_ATTEMPTS} attempts"); +} + +#[tokio::test] +async fn strk_to_usd_rescales_feed_answer_to_eighteen_decimals() { + let client = make_client::(strk_usd_responses(FeedFixture::new( + STRK_USD_ANSWER, + fresh_updated_at(), + ))); + assert_eq!(resolve_rate(&client, TIMESTAMP).await.unwrap(), STRK_TO_USD_RATE); +} + +#[tokio::test] +async fn eth_to_fri_divides_the_two_usd_legs() { + let updated_at = fresh_updated_at(); + let client = make_client::(eth_and_strk_responses( + FeedFixture::new(ETH_USD_ANSWER, updated_at), + FeedFixture::new(STRK_USD_ANSWER, updated_at), + )); + assert_eq!(resolve_rate(&client, TIMESTAMP).await.unwrap(), ETH_TO_FRI_RATE); +} + +/// Each leg is read and checked on its own, so one fresh and one stale leg cannot manufacture a +/// rate. The read is driven through `query_rate`, since the client holds successes only. +#[rstest] +#[case::stale_eth_leg(true, false, CurrencyPair::EthUsd)] +#[case::stale_strk_leg(false, true, CurrencyPair::StrkUsd)] +#[tokio::test] +async fn eth_to_fri_rejects_when_either_leg_is_stale( + #[case] is_eth_leg_stale: bool, + #[case] is_strk_leg_stale: bool, + #[case] expected_pair: CurrencyPair, +) { + let updated_at = + |is_stale: bool| if is_stale { stale_updated_at() } else { fresh_updated_at() }; + let batcher_client = batcher_client_from_responses(eth_and_strk_responses( + FeedFixture::new(ETH_USD_ANSWER, updated_at(is_eth_leg_stale)), + FeedFixture::new(STRK_USD_ANSWER, updated_at(is_strk_leg_stale)), + )); + + assert_matches!( + EthToFri::query_rate( + &batcher_client, + &test_config(), + &RateBoundsConfig::default(), + TIMESTAMP + ) + .await, + Err(ExchangeRateOracleClientError::StaleFeedError { pair_name, .. }) + if pair_name == expected_pair.pair_name() + ); +} + +#[tokio::test] +async fn first_call_spawns_a_query_and_later_calls_are_served_without_requerying() { + let (batcher_client, num_batcher_calls) = counting_batcher_client(strk_usd_responses( + FeedFixture::new(STRK_USD_ANSWER, fresh_updated_at()), + )); + let client = client_with_batcher::(batcher_client); + + // The batcher round trip must never block the proposal path. + assert_matches!( + client.fetch_rate(TIMESTAMP).await, + Err(ExchangeRateOracleClientError::QueryNotReadyError(_)) + ); + + let rate = resolve_rate(&client, TIMESTAMP).await.unwrap(); + assert_eq!(num_batcher_calls.load(Ordering::SeqCst), CALLS_PER_FEED_PER_QUERY); + + const NUM_LATER_CALLS: usize = 10; + for _ in 0..NUM_LATER_CALLS { + assert_eq!(client.fetch_rate(TIMESTAMP).await.unwrap(), rate); + } + assert_eq!(num_batcher_calls.load(Ordering::SeqCst), CALLS_PER_FEED_PER_QUERY); +} + +/// `decimals` and `latest_round_data` are read once per sampling interval, however many proposals +/// that interval spans. The call that spawns the refresh must not block on it, so it is served the +/// rate the client already holds. +#[rstest] +#[case::one_second_short(1, false)] +#[case::at_the_interval(0, true)] +#[tokio::test(start_paused = true)] +async fn a_healthy_feed_is_requeried_once_the_sampling_interval_elapses( + #[case] seconds_short_of_the_interval: u64, + #[case] is_requeried: bool, +) { + let (batcher_client, num_batcher_calls) = counting_batcher_client(strk_usd_responses( + FeedFixture::new(STRK_USD_ANSWER, fresh_updated_at()), + )); + let client = client_with_batcher::(batcher_client); + let rate = resolve_rate(&client, TIMESTAMP).await.unwrap(); + assert_eq!(num_batcher_calls.load(Ordering::SeqCst), CALLS_PER_FEED_PER_QUERY); + + let elapsed_seconds = sampling_interval_seconds() - seconds_short_of_the_interval; + tokio::time::advance(Duration::from_secs(elapsed_seconds)).await; + assert_eq!(client.fetch_rate(TIMESTAMP + elapsed_seconds).await.unwrap(), rate); + + assert_eq!(is_query_in_flight(&client), is_requeried); + if is_requeried { + wait_for_query_to_finish(&client).await; + assert_eq!(num_batcher_calls.load(Ordering::SeqCst), 2 * CALLS_PER_FEED_PER_QUERY); + } else { + assert_eq!(num_batcher_calls.load(Ordering::SeqCst), CALLS_PER_FEED_PER_QUERY); + } +} + +/// One query per client at a time: a refresh that comes due while a query is still in flight does +/// not start a second one. +#[tokio::test(start_paused = true)] +async fn no_second_query_starts_while_one_is_in_flight() { + let (batcher_client, num_batcher_calls) = counting_batcher_client(strk_usd_responses( + FeedFixture::new(STRK_USD_ANSWER, fresh_updated_at()), + )); + let client = client_with_batcher::(batcher_client); + // A query that never resolves, so the slot is still occupied when the refresh comes due. + let spawn_instant = Instant::now(); + { + let mut state = client.state.lock().unwrap(); + state.query = Some(AbortOnDropHandle::new(tokio::spawn(std::future::pending()))); + state.last_attempt_instant = Some(spawn_instant); + } + + tokio::time::advance(Duration::from_secs(sampling_interval_seconds())).await; + assert_matches!( + client.fetch_rate(TIMESTAMP).await, + Err(ExchangeRateOracleClientError::QueryNotReadyError(_)) + ); + + assert_eq!(num_batcher_calls.load(Ordering::SeqCst), 0); + // A second spawn would have overwritten it. + assert_eq!(last_attempt_instant(&client), Some(spawn_instant)); +} + +/// A query can resolve after the last call that could have observed it. That success is still +/// recorded, so the round trip that produced it is not wasted. +#[tokio::test] +async fn a_success_that_resolves_after_its_last_caller_is_not_lost() { + // Only the first query is served, so the rate below can come from no later one. + let client = client_with_batcher::(batcher_client_failing_after( + strk_usd_responses(FeedFixture::new(STRK_USD_ANSWER, fresh_updated_at())), + CALLS_PER_FEED_PER_QUERY, + )); + assert_matches!( + client.fetch_rate(TIMESTAMP).await, + Err(ExchangeRateOracleClientError::QueryNotReadyError(_)) + ); + wait_for_query_to_finish(&client).await; + assert!(last_valid_read(&client).is_none()); + + assert_eq!(client.fetch_rate(TIMESTAMP).await.unwrap(), STRK_TO_USD_RATE); +} + +/// A harvested read is dated by the timestamp its own query was issued for, not by the timestamp of +/// the call that harvests it. +#[tokio::test] +async fn a_harvested_success_is_dated_by_its_attempt_timestamp() { + let client = make_client::(strk_usd_responses(FeedFixture::new( + STRK_USD_ANSWER, + fresh_updated_at(), + ))); + assert_matches!( + client.fetch_rate(TIMESTAMP).await, + Err(ExchangeRateOracleClientError::QueryNotReadyError(_)) + ); + wait_for_query_to_finish(&client).await; + + let later_timestamp = TIMESTAMP + sampling_interval_seconds(); + assert_eq!(client.fetch_rate(later_timestamp).await.unwrap(), STRK_TO_USD_RATE); + assert_eq!( + last_valid_read(&client).expect("The harvested read must be held").block_timestamp, + TIMESTAMP + ); +} diff --git a/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs index f925d5d1a8f..d63a880b8bc 100644 --- a/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs @@ -1,6 +1,7 @@ -//! The batcher mock the Chainlink oracle tests read their feeds through. +//! The feed fixtures the Chainlink oracle tests read, and the batcher mocks that serve them. use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use apollo_batcher_types::batcher_types::CallContractOutput; @@ -10,28 +11,56 @@ use apollo_batcher_types::communication::{ SharedBatcherClient, }; use apollo_batcher_types::errors::BatcherError; +use apollo_l1_gas_price_config::config::ChainlinkOracleConfig; +use apollo_l1_gas_price_types::ExchangeRate; use starknet_api::core::ContractAddress; use starknet_types_core::felt::Felt; +use crate::chainlink_oracle::feed_math::RATE_SCALE; use crate::chainlink_oracle::feed_read::{DECIMALS_ENTRY_POINT, LATEST_ROUND_DATA_ENTRY_POINT}; /// The scale the Chainlink feeds report at today. pub(super) const FEED_DECIMALS: u32 = 8; +/// The block timestamp every fixture below is dated against, and every freshness bound measured +/// against. +pub(super) const TIMESTAMP: u64 = 1_700_000_000; + +/// $3000 per ETH at `FEED_DECIMALS`. +pub(super) const ETH_USD_ANSWER: u128 = 300_000_000_000; +/// $0.03 per STRK at `FEED_DECIMALS`. +pub(super) const STRK_USD_ANSWER: u128 = 3_000_000; + +/// $0.03 per STRK at `RATE_DECIMALS`, which `STRK_USD_ANSWER` rescales to. +pub(super) const STRK_TO_USD_RATE: ExchangeRate = 30_000_000_000_000_000; +/// 100,000 STRK per ETH at `RATE_DECIMALS`, which the two feed answers derive to. +pub(super) const ETH_TO_FRI_RATE: ExchangeRate = 100_000 * RATE_SCALE; + /// The mocked reply per feed address and entry point. A call with no entry here fails. pub(super) type FeedResponses = HashMap<(ContractAddress, String), Vec>; -/// One feed's mocked `decimals` and `latest_round_data` replies. +pub(super) fn test_config() -> ChainlinkOracleConfig { + ChainlinkOracleConfig::default() +} + +pub(super) fn fresh_updated_at() -> u64 { + TIMESTAMP +} + +pub(super) fn stale_updated_at() -> u64 { + TIMESTAMP - test_config().freshness.max_staleness_seconds - 1 +} + +/// One feed's mocked `decimals` and `latest_round_data` replies, reported at `FEED_DECIMALS`. #[derive(Clone, Copy)] pub(super) struct FeedFixture { answer: u128, updated_at: u64, - feed_decimals: u32, } impl FeedFixture { pub(super) fn new(answer: u128, updated_at: u64) -> Self { - Self { answer, updated_at, feed_decimals: FEED_DECIMALS } + Self { answer, updated_at } } } @@ -56,7 +85,7 @@ pub(super) fn round_retdata(answer: u128, updated_at: u64) -> Vec { pub(super) fn feed_responses(feed_address: ContractAddress, feed: FeedFixture) -> FeedResponses { HashMap::from([ - ((feed_address, DECIMALS_ENTRY_POINT.to_string()), decimals_retdata(feed.feed_decimals)), + ((feed_address, DECIMALS_ENTRY_POINT.to_string()), decimals_retdata(FEED_DECIMALS)), ( (feed_address, LATEST_ROUND_DATA_ENTRY_POINT.to_string()), round_retdata(feed.answer, feed.updated_at), @@ -64,13 +93,58 @@ pub(super) fn feed_responses(feed_address: ContractAddress, feed: FeedFixture) - ]) } +pub(super) fn strk_usd_responses(strk_usd: FeedFixture) -> FeedResponses { + feed_responses(test_config().strk_usd_feed_address, strk_usd) +} + +pub(super) fn eth_and_strk_responses(eth_usd: FeedFixture, strk_usd: FeedFixture) -> FeedResponses { + let mut responses = feed_responses(test_config().eth_usd_feed_address, eth_usd); + responses.extend(strk_usd_responses(strk_usd)); + responses +} + pub(super) fn batcher_client_from_responses(responses: FeedResponses) -> SharedBatcherClient { + counting_batcher_client(responses).0 +} + +/// A batcher client alongside the number of calls made through it. +pub(super) fn counting_batcher_client( + responses: FeedResponses, +) -> (SharedBatcherClient, Arc) { + let num_calls = Arc::new(AtomicUsize::new(0)); + let num_calls_in_mock = num_calls.clone(); let mut batcher_client = MockBatcherClient::new(); batcher_client.expect_call_contract().returning(move |input| { - responses - .get(&(input.contract_address, input.entry_point.clone())) - .map(|retdata| CallContractOutput { retdata: retdata.clone() }) - .ok_or(BatcherClientError::BatcherError(BatcherError::InternalError)) + num_calls_in_mock.fetch_add(1, Ordering::SeqCst); + reply(&responses, input.contract_address, &input.entry_point) + }); + (Arc::new(batcher_client), num_calls) +} + +/// Serves `responses` for the first `num_served_calls` calls and fails every call after that, which +/// lets a test hold a successful read followed by a failing one. +pub(super) fn batcher_client_failing_after( + responses: FeedResponses, + num_served_calls: usize, +) -> SharedBatcherClient { + let num_calls = AtomicUsize::new(0); + let mut batcher_client = MockBatcherClient::new(); + batcher_client.expect_call_contract().returning(move |input| { + if num_calls.fetch_add(1, Ordering::SeqCst) >= num_served_calls { + return Err(BatcherClientError::BatcherError(BatcherError::InternalError)); + } + reply(&responses, input.contract_address, &input.entry_point) }); Arc::new(batcher_client) } + +fn reply( + responses: &FeedResponses, + contract_address: ContractAddress, + entry_point: &str, +) -> Result { + responses + .get(&(contract_address, entry_point.to_string())) + .map(|retdata| CallContractOutput { retdata: retdata.clone() }) + .ok_or(BatcherClientError::BatcherError(BatcherError::InternalError)) +} diff --git a/crates/apollo_l1_gas_price/src/lib.rs b/crates/apollo_l1_gas_price/src/lib.rs index 6797fcc86e6..04f492e9448 100644 --- a/crates/apollo_l1_gas_price/src/lib.rs +++ b/crates/apollo_l1_gas_price/src/lib.rs @@ -3,11 +3,12 @@ //! # Price Selection and Fallback Strategy //! //! Each block proposal needs two independent inputs: -//! - **L1 gas prices (WEI)** — mean `base_fee_per_gas` and `blob_fee` over the last N Ethereum +//! - **L1 gas prices (WEI)**: mean `base_fee_per_gas` and `blob_fee` over the last N Ethereum //! blocks, maintained by [`l1_gas_price_provider`]. -//! - **ETH→STRK rate** — current market rate from one of several oracle endpoints, queried by -//! [`exchange_rate_oracle`]. Multiple URLs are tried in round-robin; if the in-flight query for -//! the current time bucket hasn't resolved yet, the previous bucket's cached rate is used. +//! - **ETH→STRK rate**: current market rate, from either of two oracle sources. +//! [`exchange_rate_oracle`] queries HTTP endpoints, trying multiple URLs in round-robin; +//! [`chainlink_oracle`] reads Chainlink's on-chain Starknet feeds through the batcher. Both query +//! in the background and serve the rate they already hold, so no proposal waits on a round trip. //! //! When combining these in `apollo_consensus_orchestrator`, the fallback chain is: //! @@ -28,6 +29,8 @@ //! unusable precisely when it is already degraded. The configured minimums and the default //! ETH→STRK rate are safe floors the operator has verified are always economically viable. +// [Temporary comment] The source each feed reads from becomes configurable in B3 and is built in +// B4. Until then only the HTTP client is constructed. pub mod chainlink_oracle; pub mod communication; pub mod exchange_rate_oracle; diff --git a/crates/apollo_l1_gas_price/src/metrics.rs b/crates/apollo_l1_gas_price/src/metrics.rs index 25730e3633d..75cf9db11f0 100644 --- a/crates/apollo_l1_gas_price/src/metrics.rs +++ b/crates/apollo_l1_gas_price/src/metrics.rs @@ -116,8 +116,7 @@ pub(crate) fn register_provider_metrics() { } /// Registers one counter series per `currency_pair`. Runs once per process. -// [Temporary comment] `pub` until the Chainlink client (A9) calls it. -pub fn register_chainlink_guard_metrics() { +pub(crate) fn register_chainlink_guard_metrics() { static CHAINLINK_GUARD_METRICS_REGISTRATION: Once = Once::new(); CHAINLINK_GUARD_METRICS_REGISTRATION.call_once(|| { CHAINLINK_ORACLE_STALE_FEED_COUNT.register(); diff --git a/crates/apollo_l1_gas_price_config/src/config.rs b/crates/apollo_l1_gas_price_config/src/config.rs index 69e7d381fad..6296ae8532f 100644 --- a/crates/apollo_l1_gas_price_config/src/config.rs +++ b/crates/apollo_l1_gas_price_config/src/config.rs @@ -333,7 +333,8 @@ impl SerializeConfig for FreshnessWindow { } /// Configuration for reading Chainlink's on-chain Starknet price feeds through the batcher. -// [Temporary comment] A9 wires the source in; B2 nests this under `L1GasPriceProviderConfig`. +// [Temporary comment] B3 selects the source per feed and B4 builds the client; B2 nests this under +// `L1GasPriceProviderConfig`. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Validate)] pub struct ChainlinkOracleConfig { /// Quotes USD per ETH. From 91061a36d1ded07cc39923e45ffdb545f204b5a0 Mon Sep 17 00:00:00 2001 From: Asaf Merschon Date: Sun, 16 Aug 2026 19:37:59 +0300 Subject: [PATCH 8/8] apollo_consensus_orchestrator,apollo_l1_gas_price: bound the per-block eth to fri rate change The Chainlink oracle guards each reading with absolute sanity bounds, but those are deliberately wide (the STRK/USD band spans five decades) because their job is wrong-feed detection. A manipulated but plausible feed value passes them. A bound relative to the previously accepted rate is what catches that, and #14944 names it as a precondition before any feed is switched to the on-chain oracle. The bound lives in the orchestrator, not in the Chainlink client. A bound anchored to the client's own last read is node-local history: two validators with different read histories would accept and reject different proposals. Anchoring instead on the rate implied by the previous block's recorded wei and fri prices (calculate_eth_to_fri_rate) makes the bound a function of the block header and of config alone, so every validator derives the same band and maps a given oracle reading to the same rate. It also covers both oracle sources, HTTP and Chainlink, which a bound inside the Chainlink client would not. A fresh rate outside the band is clamped to the band's edge rather than rejected, following the shape of the per-block L2 gas price clamp: clamping bounds per-block damage and forces a manipulation to be sustained across many blocks to move the price far. max_eth_to_fri_rate_change_ppt defaults to 50 (5% per block). The ETH and STRK feeds update on deviation thresholds of roughly 0.5% and 1%, so the ratio steps by at most ~1.5% between consecutive blocks and a normal feed update never trips the bound. On the other side, one manipulated reading moves the rate by at most 5%, and doubling it requires holding the feed for about 15 consecutive blocks (~40 seconds at mainnet block times), long enough for the oracle staleness and guard alerts to fire. A genuine sharp move is tracked within a few blocks. When there is no previous block (startup, or the path that falls back to the minimal config values) there is no anchor, so the oracle rate is used as is. That path is explicit. Clamping increments consensus_eth_to_fri_rate_clamped and logs a warning. --- .../src/metrics.rs | 2 + .../src/utils.rs | 64 +++++++ .../src/utils_test.rs | 161 +++++++++++++++++- .../src/config.rs | 20 +++ .../app_configs/consensus_manager_config.json | 1 + .../replacer_consensus_manager_config.json | 1 + crates/apollo_l1_gas_price/src/rate_bounds.rs | 11 +- .../apollo_node/resources/config_schema.json | 5 + 8 files changed, 259 insertions(+), 6 deletions(-) diff --git a/crates/apollo_consensus_orchestrator/src/metrics.rs b/crates/apollo_consensus_orchestrator/src/metrics.rs index 0eddeb3ff51..5d1829d3529 100644 --- a/crates/apollo_consensus_orchestrator/src/metrics.rs +++ b/crates/apollo_consensus_orchestrator/src/metrics.rs @@ -18,6 +18,7 @@ define_metrics!( MetricGauge { CONSENSUS_L2_GAS_PRICE_AT_MINIMUM, "consensus_l2_gas_price_at_minimum", "1 when the accepted L2 gas price is clamped at the configured minimum (min_l2_gas_price_per_height, or the versioned-constants min_gas_price fallback), else 0" }, MetricCounter { CONSENSUS_L1_GAS_PRICE_PROVIDER_ERROR, "consensus_l1_gas_price_provider_error", "Number of times the context got an error when querying the L1 gas price provider", init=0}, MetricCounter { CONSENSUS_RETROSPECTIVE_BLOCK_HASH_MISMATCH, "consensus_retrospective_block_hash_mismatch", "Number of times the retrospective block hashes of the state sync and the batcher mismatched", init=0}, + MetricCounter { CONSENSUS_ETH_TO_FRI_RATE_CLAMPED, "consensus_eth_to_fri_rate_clamped", "Number of times the eth to fri rate from the oracle was clamped to the per-block change bound around the rate implied by the previous block", init=0}, // Cende metrics @@ -101,6 +102,7 @@ pub(crate) fn register_metrics() { CONSENSUS_L2_GAS_PRICE_AT_MINIMUM.register(); CONSENSUS_L1_GAS_PRICE_PROVIDER_ERROR.register(); CONSENSUS_RETROSPECTIVE_BLOCK_HASH_MISMATCH.register(); + CONSENSUS_ETH_TO_FRI_RATE_CLAMPED.register(); CENDE_LAST_PREPARED_BLOB_BLOCK_NUMBER.register(); CENDE_PREPARE_BLOB_FOR_NEXT_HEIGHT_LATENCY.register(); CENDE_WRITE_PREV_HEIGHT_BLOB_LATENCY.register(); diff --git a/crates/apollo_consensus_orchestrator/src/utils.rs b/crates/apollo_consensus_orchestrator/src/utils.rs index 63e39ea5578..10b23cdd661 100644 --- a/crates/apollo_consensus_orchestrator/src/utils.rs +++ b/crates/apollo_consensus_orchestrator/src/utils.rs @@ -16,6 +16,7 @@ use apollo_state_sync_types::errors::StateSyncError; use apollo_time::time::{Clock, DateTime}; // TODO(Gilad): Define in consensus, either pass to blockifier as config or keep the dup. use blockifier::abi::constants::STORED_BLOCK_HASH_BUFFER; +use ethnum::U256; use futures::channel::mpsc; use futures::SinkExt; use num_rational::Ratio; @@ -35,10 +36,13 @@ use starknet_api::StarknetApiError; use tracing::{info, warn}; use crate::metrics::{ + CONSENSUS_ETH_TO_FRI_RATE_CLAMPED, CONSENSUS_L1_GAS_PRICE_PROVIDER_ERROR, CONSENSUS_RETROSPECTIVE_BLOCK_HASH_MISMATCH, }; +const PARTS_PER_THOUSAND: u128 = 1000; + pub(crate) struct StreamSender { pub proposal_sender: mpsc::Sender, } @@ -79,6 +83,7 @@ pub(crate) struct GasPriceParams { pub override_l1_gas_price_fri: Option, pub override_l1_data_gas_price_fri: Option, pub override_eth_to_fri_rate: Option, + pub max_eth_to_fri_rate_change_ppt: u128, } #[derive(Clone, Debug)] @@ -161,6 +166,11 @@ pub(crate) async fn get_l1_prices_in_fri_and_wei_and_conversion_rate( "raw eth_to_fri_rate (from oracle): {eth_to_fri_rate}, raw l1 gas price wei (from \ provider): {price_info:?}" ); + let eth_to_fri_rate = bound_eth_to_fri_rate_change( + eth_to_fri_rate, + previous_proposal_init, + gas_price_params.max_eth_to_fri_rate_change_ppt, + ); apply_fee_transformations(&mut price_info, gas_price_params); let prices_in_wei = L1PricesInWei { l1_gas_price: price_info.base_fee_per_gas, @@ -483,7 +493,61 @@ pub(crate) fn make_gas_price_params(config: &ContextDynamicConfig) -> GasPricePa override_l1_gas_price_fri: config.override_l1_gas_price_fri.map(GasPrice), override_l1_data_gas_price_fri: config.override_l1_data_gas_price_fri.map(GasPrice), override_eth_to_fri_rate: config.override_eth_to_fri_rate, + max_eth_to_fri_rate_change_ppt: config.max_eth_to_fri_rate_change_ppt, + } +} + +/// Clamp a freshly fetched eth to fri rate into a band of `max_change_ppt` parts per thousand +/// around the rate implied by the previous block, and return the clamped value. +/// +/// The band's center is recomputed from the previous block's recorded wei and fri prices, so it is +/// a function of the block header and of config alone: every validator derives the same center and +/// maps a given oracle reading to the same rate. Clamping rather than rejecting bounds the effect +/// of a single manipulated reading, and forces an attacker to hold the feed across many blocks to +/// move the rate far. +fn bound_eth_to_fri_rate_change( + oracle_eth_to_fri_rate: u128, + previous_proposal_init: Option<&PreviousProposalInitInfo>, + max_change_ppt: u128, +) -> u128 { + // With no previous block (startup, or the path that falls back to the minimal config values) + // there is no anchor to bound against, so the oracle rate is used as is. + let Some(previous_proposal_init) = previous_proposal_init else { + return oracle_eth_to_fri_rate; + }; + let previous_eth_to_fri_rate = match calculate_eth_to_fri_rate(previous_proposal_init) { + Ok(previous_eth_to_fri_rate) => previous_eth_to_fri_rate, + Err(error) => { + warn!( + "Cannot bound the eth to fri rate change, the previous block info {:?} implies no \ + rate: {:?}", + previous_proposal_init, error + ); + return oracle_eth_to_fri_rate; + } + }; + + // Multiply before dividing to avoid the precision loss of dividing first. U256 because the + // product of two u128 values overflows u128. The division truncates, which makes the band + // marginally tighter, never looser. + let previous_rate_u256 = U256::from(previous_eth_to_fri_rate); + let max_change = + (previous_rate_u256 * U256::from(max_change_ppt)) / U256::from(PARTS_PER_THOUSAND); + // The rate should not realistically approach u128::MAX, bound to avoid theoretical overflow. + let max_rate = u128::try_from(previous_rate_u256 + max_change).unwrap_or(u128::MAX); + let min_rate = + previous_eth_to_fri_rate.saturating_sub(u128::try_from(max_change).unwrap_or(u128::MAX)); + + let bounded_eth_to_fri_rate = oracle_eth_to_fri_rate.clamp(min_rate, max_rate); + if bounded_eth_to_fri_rate != oracle_eth_to_fri_rate { + warn!( + "Eth to fri rate {oracle_eth_to_fri_rate} from the oracle is more than \ + {max_change_ppt} ppt away from the rate implied by the previous block \ + ({previous_eth_to_fri_rate}), clamping it to {bounded_eth_to_fri_rate}." + ); + CONSENSUS_ETH_TO_FRI_RATE_CLAMPED.increment(1); } + bounded_eth_to_fri_rate } fn calculate_eth_to_fri_rate( diff --git a/crates/apollo_consensus_orchestrator/src/utils_test.rs b/crates/apollo_consensus_orchestrator/src/utils_test.rs index 08aba6c152a..df49375bf31 100644 --- a/crates/apollo_consensus_orchestrator/src/utils_test.rs +++ b/crates/apollo_consensus_orchestrator/src/utils_test.rs @@ -1,19 +1,37 @@ +use std::sync::Arc; + use apollo_batcher_types::communication::BatcherClientError; use apollo_batcher_types::errors::BatcherError; +use apollo_consensus_orchestrator_config::config::ContextDynamicConfig; +use apollo_l1_gas_price_types::{MockL1GasPriceProviderClient, PriceInfo}; use apollo_protobuf::consensus::ProposalInit; use apollo_state_sync_types::communication::StateSyncClientError; use apollo_state_sync_types::errors::StateSyncError; use assert_matches::assert_matches; use blockifier::abi::constants::STORED_BLOCK_HASH_BUFFER; -use starknet_api::block::{BlockHash, BlockHashAndNumber, BlockNumber}; +use metrics_exporter_prometheus::PrometheusBuilder; +use starknet_api::block::{ + BlockHash, + BlockHashAndNumber, + BlockNumber, + GasPrice, + TEMP_ETH_BLOB_GAS_FEE_IN_WEI, + TEMP_ETH_GAS_FEE_IN_WEI, +}; use starknet_types_core::felt::Felt; use crate::build_proposal::ProposalBuildArguments; +use crate::metrics::{register_metrics, CONSENSUS_ETH_TO_FRI_RATE_CLAMPED}; use crate::test_utils::create_proposal_build_arguments; use crate::utils::{ get_l1_prices_in_fri_and_wei, + get_l1_prices_in_fri_and_wei_and_conversion_rate, + make_gas_price_params, retrospective_block_hash, wait_for_retrospective_block_hash, + L1PricesInFri, + L1PricesInWei, + PreviousProposalInitInfo, RetrospectiveBlockHashError, }; @@ -22,6 +40,17 @@ const RETRO_BLOCK_NUMBER: BlockNumber = BlockNumber(0); const MUST_HAVE_BLOCK_HASH_FOR: BlockNumber = BlockNumber(1); const RETRO_BLOCK_HASH: BlockHash = BlockHash(Felt::from_hex_unchecked("0x1234567890abcdef")); +const PROPOSAL_TIMESTAMP: u64 = 1_700_000_000; +// A gas price of one gwei keeps the previous block's implied rate exactly equal to the rate it was +// built with, for the rates used in these tests. +const PREVIOUS_L1_GAS_PRICE_WEI: GasPrice = GasPrice(u128::pow(10, 9)); +const PREVIOUS_ETH_TO_FRI_RATE: u128 = 2 * u128::pow(10, 18); +const MAX_ETH_TO_FRI_RATE_CHANGE_PPT: u128 = 50; +const MAX_ETH_TO_FRI_RATE_CHANGE: u128 = + PREVIOUS_ETH_TO_FRI_RATE * MAX_ETH_TO_FRI_RATE_CHANGE_PPT / 1000; +const ETH_TO_FRI_RATE_CEILING: u128 = PREVIOUS_ETH_TO_FRI_RATE + MAX_ETH_TO_FRI_RATE_CHANGE; +const ETH_TO_FRI_RATE_FLOOR: u128 = PREVIOUS_ETH_TO_FRI_RATE - MAX_ETH_TO_FRI_RATE_CHANGE; + async fn get_proposal_init(args: &ProposalBuildArguments) -> ProposalInit { let timestamp = args.deps.clock.unix_now(); let (l1_prices_fri, l1_prices_wei) = get_l1_prices_in_fri_and_wei( @@ -331,3 +360,133 @@ async fn wait_for_retrospective_block_hash_batcher_ready_after_a_while() { Some(BlockHashAndNumber { number: RETRO_BLOCK_NUMBER, hash: RETRO_BLOCK_HASH }) ); } + +/// Builds the previous block's recorded prices such that they imply `eth_to_fri_rate`. +fn previous_proposal_init_with_rate(eth_to_fri_rate: u128) -> PreviousProposalInitInfo { + let l1_prices_wei = L1PricesInWei { + l1_gas_price: PREVIOUS_L1_GAS_PRICE_WEI, + l1_data_gas_price: PREVIOUS_L1_GAS_PRICE_WEI, + }; + let l1_prices_fri = L1PricesInFri::convert_from_wei(&l1_prices_wei, eth_to_fri_rate) + .expect("Test prices should be convertible to fri."); + PreviousProposalInitInfo { timestamp: PROPOSAL_TIMESTAMP, l1_prices_wei, l1_prices_fri } +} + +/// Fetches the eth to fri rate through the full oracle path, with a freshly built provider and a +/// freshly built config, both independent of any other instance. +async fn fetch_eth_to_fri_rate( + oracle_eth_to_fri_rate: u128, + previous_proposal_init: Option<&PreviousProposalInitInfo>, +) -> u128 { + let mut l1_gas_price_provider = MockL1GasPriceProviderClient::new(); + l1_gas_price_provider.expect_get_rate().return_const(Ok(oracle_eth_to_fri_rate)); + l1_gas_price_provider.expect_get_price_info().return_const(Ok(PriceInfo { + base_fee_per_gas: GasPrice(TEMP_ETH_GAS_FEE_IN_WEI), + blob_fee: GasPrice(TEMP_ETH_BLOB_GAS_FEE_IN_WEI), + })); + let dynamic_config = ContextDynamicConfig { + max_eth_to_fri_rate_change_ppt: MAX_ETH_TO_FRI_RATE_CHANGE_PPT, + ..Default::default() + }; + + let (_l1_prices_fri, _l1_prices_wei, eth_to_fri_rate) = + get_l1_prices_in_fri_and_wei_and_conversion_rate( + Arc::new(l1_gas_price_provider), + PROPOSAL_TIMESTAMP, + previous_proposal_init, + &make_gas_price_params(&dynamic_config), + ) + .await; + eth_to_fri_rate +} + +#[tokio::test] +async fn eth_to_fri_rate_inside_bound_is_not_clamped() { + let previous_proposal_init = previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE); + let oracle_eth_to_fri_rate = PREVIOUS_ETH_TO_FRI_RATE + MAX_ETH_TO_FRI_RATE_CHANGE / 2; + assert_eq!( + fetch_eth_to_fri_rate(oracle_eth_to_fri_rate, Some(&previous_proposal_init)).await, + oracle_eth_to_fri_rate + ); +} + +#[tokio::test] +async fn eth_to_fri_rate_above_bound_is_clamped_to_ceiling() { + let previous_proposal_init = previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE); + assert_eq!( + fetch_eth_to_fri_rate(PREVIOUS_ETH_TO_FRI_RATE * 3, Some(&previous_proposal_init)).await, + ETH_TO_FRI_RATE_CEILING + ); +} + +#[tokio::test] +async fn eth_to_fri_rate_below_bound_is_clamped_to_floor() { + let previous_proposal_init = previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE); + assert_eq!( + fetch_eth_to_fri_rate(PREVIOUS_ETH_TO_FRI_RATE / 3, Some(&previous_proposal_init)).await, + ETH_TO_FRI_RATE_FLOOR + ); +} + +#[tokio::test] +async fn eth_to_fri_rate_exactly_at_bound_is_not_clamped() { + let previous_proposal_init = previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE); + assert_eq!( + fetch_eth_to_fri_rate(ETH_TO_FRI_RATE_CEILING, Some(&previous_proposal_init)).await, + ETH_TO_FRI_RATE_CEILING + ); + assert_eq!( + fetch_eth_to_fri_rate(ETH_TO_FRI_RATE_FLOOR, Some(&previous_proposal_init)).await, + ETH_TO_FRI_RATE_FLOOR + ); +} + +#[tokio::test] +async fn eth_to_fri_rate_is_not_clamped_without_a_previous_block() { + let oracle_eth_to_fri_rate = PREVIOUS_ETH_TO_FRI_RATE * 100; + assert_eq!(fetch_eth_to_fri_rate(oracle_eth_to_fri_rate, None).await, oracle_eth_to_fri_rate); +} + +#[tokio::test] +async fn eth_to_fri_rate_clamp_metric_increments_only_when_clamping() { + let recorder = PrometheusBuilder::new().build_recorder(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + register_metrics(); + + let previous_proposal_init = previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE); + fetch_eth_to_fri_rate(PREVIOUS_ETH_TO_FRI_RATE, Some(&previous_proposal_init)).await; + CONSENSUS_ETH_TO_FRI_RATE_CLAMPED.assert_eq(&recorder.handle().render(), 0); + + fetch_eth_to_fri_rate(PREVIOUS_ETH_TO_FRI_RATE * 3, Some(&previous_proposal_init)).await; + CONSENSUS_ETH_TO_FRI_RATE_CLAMPED.assert_eq(&recorder.handle().render(), 1); +} + +/// Two instances built independently of each other, given the same previous block and the same +/// oracle rate, must agree: the bound reads only the previous block and the config, so neither the +/// instance nor the number of oracle calls it has made can change the outcome. +#[tokio::test] +async fn eth_to_fri_rate_bound_is_deterministic_across_instances() { + let oracle_eth_to_fri_rate = PREVIOUS_ETH_TO_FRI_RATE * 7; + + let first_instance_rate = fetch_eth_to_fri_rate( + oracle_eth_to_fri_rate, + Some(&previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE)), + ) + .await; + + // The second instance reaches the same previous block after a different number of oracle + // fetches. + fetch_eth_to_fri_rate( + PREVIOUS_ETH_TO_FRI_RATE / 5, + Some(&previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE * 4)), + ) + .await; + let second_instance_rate = fetch_eth_to_fri_rate( + oracle_eth_to_fri_rate, + Some(&previous_proposal_init_with_rate(PREVIOUS_ETH_TO_FRI_RATE)), + ) + .await; + + assert_eq!(first_instance_rate, second_instance_rate); + assert_eq!(first_instance_rate, ETH_TO_FRI_RATE_CEILING); +} diff --git a/crates/apollo_consensus_orchestrator_config/src/config.rs b/crates/apollo_consensus_orchestrator_config/src/config.rs index 1b5d63ec2c1..5b3d9e4e3e5 100644 --- a/crates/apollo_consensus_orchestrator_config/src/config.rs +++ b/crates/apollo_consensus_orchestrator_config/src/config.rs @@ -85,6 +85,13 @@ const ETH_FACTOR: u128 = u128::pow(10, 18); // Default SNIP-35 target USD cost per L2 gas unit: $0.88 per 1e9 L2 gas = 880_000_000 atto-USD. pub const DEFAULT_SNIP35_TARGET_ATTO_USD_PER_L2_GAS: u128 = 880_000_000; +// Default per-block bound on the Eth-to-Fri rate change: 5%. Wide enough that a single feed +// update never trips it (the ETH and STRK feeds update on deviation thresholds of about 0.5% and +// 1%, so their ratio steps by at most ~1.5% between blocks), tight enough that one manipulated +// reading moves the rate by at most 5% and doubling it requires holding the feed for ~15 +// consecutive blocks. +pub const DEFAULT_MAX_ETH_TO_FRI_RATE_CHANGE_PPT: u128 = 50; + // This matches the min_gas_price in orchestrator_versioned_constants_0_14_1.json (0x1dcd65000). const MIN_ALLOWED_GAS_PRICE: u128 = 8_000_000_000; @@ -290,6 +297,10 @@ pub struct ContextDynamicConfig { /// SNIP-35 target USD cost per L2 gas unit, in atto-USD ($0.88 per 1e9 L2 gas = 880_000_000 /// atto-USD). pub snip35_target_atto_usd_per_l2_gas: u128, + /// Maximum change, in parts per thousand, of the Eth-to-Fri rate relative to the rate implied + /// by the previous block. A fresh oracle rate outside this band is clamped to the band's edge. + /// The bound does not apply when there is no previous block. + pub max_eth_to_fri_rate_change_ppt: u128, /// If given, will override the L2 gas price. pub override_l2_gas_price_fri: Option, /// If given, will override the L1 gas price in FRI. @@ -363,6 +374,14 @@ impl SerializeConfig for ContextDynamicConfig { 880_000_000 atto-USD).", ParamPrivacyInput::Public, ), + ser_param( + "max_eth_to_fri_rate_change_ppt", + &self.max_eth_to_fri_rate_change_ppt, + "Maximum change, in parts per thousand, of the Eth-to-Fri rate relative to the \ + rate implied by the previous block. A fresh oracle rate outside this band is \ + clamped to the band's edge.", + ParamPrivacyInput::Public, + ), ser_param( "compare_retrospective_block_hash", &self.compare_retrospective_block_hash, @@ -427,6 +446,7 @@ impl Default for ContextDynamicConfig { l1_data_gas_price_multiplier_ppt: 135, l1_gas_tip_wei: GWEI_FACTOR, snip35_target_atto_usd_per_l2_gas: DEFAULT_SNIP35_TARGET_ATTO_USD_PER_L2_GAS, + max_eth_to_fri_rate_change_ppt: DEFAULT_MAX_ETH_TO_FRI_RATE_CHANGE_PPT, override_l2_gas_price_fri: None, override_l1_gas_price_fri: None, override_l1_data_gas_price_fri: None, diff --git a/crates/apollo_deployments/resources/app_configs/consensus_manager_config.json b/crates/apollo_deployments/resources/app_configs/consensus_manager_config.json index 16d336e489b..5ab7c235597 100644 --- a/crates/apollo_deployments/resources/app_configs/consensus_manager_config.json +++ b/crates/apollo_deployments/resources/app_configs/consensus_manager_config.json @@ -40,6 +40,7 @@ "consensus_manager_config.context_config.static_config.l1_da_mode": true, "consensus_manager_config.context_config.dynamic_config.l1_data_gas_price_multiplier_ppt": 135, "consensus_manager_config.context_config.dynamic_config.l1_gas_tip_wei": 1000000000, + "consensus_manager_config.context_config.dynamic_config.max_eth_to_fri_rate_change_ppt": 50, "consensus_manager_config.context_config.dynamic_config.max_l1_data_gas_price_wei": 1000000000000, "consensus_manager_config.context_config.dynamic_config.max_l1_gas_price_wei": 1000000000000, "consensus_manager_config.context_config.dynamic_config.min_l1_data_gas_price_wei": 1, diff --git a/crates/apollo_deployments/resources/app_configs/replacer_consensus_manager_config.json b/crates/apollo_deployments/resources/app_configs/replacer_consensus_manager_config.json index 10e371e1759..1a320d65800 100644 --- a/crates/apollo_deployments/resources/app_configs/replacer_consensus_manager_config.json +++ b/crates/apollo_deployments/resources/app_configs/replacer_consensus_manager_config.json @@ -36,6 +36,7 @@ "consensus_manager_config.context_config.dynamic_config.compare_retrospective_block_hash": "$$$_CONSENSUS_MANAGER_CONFIG-CONTEXT_CONFIG-DYNAMIC_CONFIG-COMPARE_RETROSPECTIVE_BLOCK_HASH_$$$", "consensus_manager_config.context_config.dynamic_config.l1_data_gas_price_multiplier_ppt": 135, "consensus_manager_config.context_config.dynamic_config.l1_gas_tip_wei": 1000000000, + "consensus_manager_config.context_config.dynamic_config.max_eth_to_fri_rate_change_ppt": 50, "consensus_manager_config.context_config.dynamic_config.max_l1_data_gas_price_wei": 1000000000000, "consensus_manager_config.context_config.dynamic_config.max_l1_gas_price_wei": 1000000000000, "consensus_manager_config.context_config.dynamic_config.min_l1_data_gas_price_wei": 1, diff --git a/crates/apollo_l1_gas_price/src/rate_bounds.rs b/crates/apollo_l1_gas_price/src/rate_bounds.rs index 642d5f01a33..6267247c88a 100644 --- a/crates/apollo_l1_gas_price/src/rate_bounds.rs +++ b/crates/apollo_l1_gas_price/src/rate_bounds.rs @@ -11,11 +11,12 @@ use crate::metrics::CHAINLINK_ORACLE_RATE_OUT_OF_BOUNDS_COUNT; #[path = "rate_bounds_test.rs"] mod rate_bounds_test; -// TODO(Asaf): bound the rate's change against the previous block's implied rate. The absolute -// bounds below are wide enough to pass a manipulated but plausible answer, the STRK/USD pair alone -// accepting anything from $0.0001 to $10, which only a bound relative to the last accepted rate -// catches. It must be anchored to the block header rather than to node-local history, so that every -// validator accepts and rejects the same values. +// The bounds below are absolute: they are wide enough to pass a manipulated but plausible answer, +// the STRK/USD pair alone accepting anything from $0.0001 to $10, and only catch a wrong feed. The +// bound relative to the previous block's implied rate lives in +// `apollo_consensus_orchestrator::utils`, where the previous block is in hand, so that it is +// anchored to the block header rather than to this client's own read history and every validator +// accepts and rejects the same values. /// Absolute bounds are the only defense against a feed wired to the wrong asset or a /// plausible-but-poisoned answer: consensus checks that validators agree with each other, never /// that the agreed value is sane, and every node reads the same chain state. diff --git a/crates/apollo_node/resources/config_schema.json b/crates/apollo_node/resources/config_schema.json index 75cf8c28b1e..8e56636c62f 100644 --- a/crates/apollo_node/resources/config_schema.json +++ b/crates/apollo_node/resources/config_schema.json @@ -2684,6 +2684,11 @@ "privacy": "Public", "value": 1000000000 }, + "consensus_manager_config.context_config.dynamic_config.max_eth_to_fri_rate_change_ppt": { + "description": "Maximum change, in parts per thousand, of the Eth-to-Fri rate relative to the rate implied by the previous block. A fresh oracle rate outside this band is clamped to the band's edge.", + "privacy": "Public", + "value": 50 + }, "consensus_manager_config.context_config.dynamic_config.max_l1_data_gas_price_wei": { "description": "The maximum L1 data gas price in wei.", "privacy": "Public",