diff --git a/Cargo.lock b/Cargo.lock index f7ddefbf80d..f7ee19e3a22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1865,6 +1865,8 @@ dependencies = [ name = "apollo_l1_gas_price" version = "0.19.0-rc.2" dependencies = [ + "apollo_batcher_types", + "apollo_cairo_utils", "apollo_config", "apollo_infra", "apollo_infra_utils", @@ -1883,7 +1885,9 @@ dependencies = [ "reqwest 0.12.24", "rstest", "serde_json", + "starknet-types-core", "starknet_api", + "strum", "thiserror 1.0.69", "tokio", "tokio-util", @@ -1896,7 +1900,10 @@ name = "apollo_l1_gas_price_config" version = "0.19.0-rc.2" dependencies = [ "apollo_config", + "apollo_l1_gas_price_types", + "rstest", "serde", + "starknet-types-core", "starknet_api", "url", "validator", 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/Cargo.toml b/crates/apollo_l1_gas_price/Cargo.toml index f93eddaab59..fab153fa722 100644 --- a/crates/apollo_l1_gas_price/Cargo.toml +++ b/crates/apollo_l1_gas_price/Cargo.toml @@ -7,6 +7,8 @@ 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 apollo_infra_utils.workspace = true @@ -21,7 +23,9 @@ 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 tokio.workspace = true tokio-util = { workspace = true, features = ["rt"] } @@ -29,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 @@ -36,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 new file mode 100644 index 00000000000..8399bf4d30b --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math.rs @@ -0,0 +1,162 @@ +//! Decoding of Chainlink feed retdata, and the fixed-point arithmetic over the decoded answers. + +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`. +const RATE_DECIMALS: u32 = 18; +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); + +/// 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. +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(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(super) type RateResult = Result; + +pub(super) 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(super) 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(super) 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(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)) + .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(super) 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(super) struct ChainlinkRoundData { + /// The price the feed reports, at the feed's own `decimals()`. + pub(super) answer: u128, + /// Unix seconds at which the aggregator last wrote this round. + pub(super) 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..a5f8b3cf892 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_math_test.rs @@ -0,0 +1,174 @@ +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, +}; + +const UPDATED_AT: u64 = 1_700_000_000; + +/// 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/feed_read.rs b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs new file mode 100644 index 00000000000..029739e0064 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read.rs @@ -0,0 +1,153 @@ +//! One Chainlink feed read: two view calls through the batcher, and the guards their answer passes. + +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(super) 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(super) 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(super) 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..1c23e76e09e --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/feed_read_test.rs @@ -0,0 +1,227 @@ +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, + 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; + +fn strk_usd_feed() -> FeedRead { + test_config().strk_usd_feed(&RateBoundsConfig::default()) +} + +fn future_updated_at() -> u64 { + TIMESTAMP + test_config().freshness.max_future_updated_at_seconds + 1 +} + +/// 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 new file mode 100644 index 00000000000..0f029d6f742 --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/mod.rs @@ -0,0 +1,273 @@ +//! Reading Chainlink's price feeds on Starknet: the oracle client consensus calls, and the feed +//! reads behind it. + +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; + +#[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 new file mode 100644 index 00000000000..d63a880b8bc --- /dev/null +++ b/crates/apollo_l1_gas_price/src/chainlink_oracle/test_utils.rs @@ -0,0 +1,150 @@ +//! 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; +use apollo_batcher_types::communication::{ + BatcherClientError, + MockBatcherClient, + 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>; + +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, +} + +impl FeedFixture { + pub(super) fn new(answer: u128, updated_at: u64) -> Self { + Self { answer, updated_at } + } +} + +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_DECIMALS)), + ( + (feed_address, LATEST_ROUND_DATA_ENTRY_POINT.to_string()), + round_retdata(feed.answer, feed.updated_at), + ), + ]) +} + +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| { + 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 b4dc3cb4c6a..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,8 +29,12 @@ //! 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; pub mod l1_gas_price_provider; pub mod l1_gas_price_scraper; pub mod metrics; +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 7ba3800c8d6..75cf9db11f0 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,18 @@ 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. +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(); + 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/src/rate_bounds.rs b/crates/apollo_l1_gas_price/src/rate_bounds.rs new file mode 100644 index 00000000000..6267247c88a --- /dev/null +++ b/crates/apollo_l1_gas_price/src/rate_bounds.rs @@ -0,0 +1,40 @@ +//! The absolute sanity bounds every exchange rate must fall in, whichever source reports it. + +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; + +// 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. +pub(crate) 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/Cargo.toml b/crates/apollo_l1_gas_price_config/Cargo.toml index 01032dc5d24..76e84d18d66 100644 --- a/crates/apollo_l1_gas_price_config/Cargo.toml +++ b/crates/apollo_l1_gas_price_config/Cargo.toml @@ -11,7 +11,12 @@ workspace = true [dependencies] 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 + +[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..6296ae8532f 100644 --- a/crates/apollo_l1_gas_price_config/src/config.rs +++ b/crates/apollo_l1_gas_price_config/src/config.rs @@ -14,12 +14,14 @@ 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 starknet_api::core::{ChainId, ContractAddress}; +use starknet_types_core::felt::Felt; use url::Url; -use validator::Validate; +use validator::{Validate, ValidationError}; #[cfg(test)] #[path = "config_test.rs"] @@ -94,6 +96,329 @@ 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. +#[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] 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 + } +} + +/// 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] 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. + 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 b0d667c967d..33646e337c6 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::{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. @@ -14,3 +17,108 @@ 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()); +} + +#[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()); +} diff --git a/crates/apollo_l1_gas_price_types/src/errors.rs b/crates/apollo_l1_gas_price_types/src/errors.rs index ff74fed5451..e6e277480a4 100644 --- a/crates/apollo_l1_gas_price_types/src/errors.rs +++ b/crates/apollo_l1_gas_price_types/src/errors.rs @@ -49,6 +49,32 @@ pub enum ExchangeRateOracleClientError { AllUrlsFailedError(u64, usize), #[error("Invalid rate from oracle: {0}")] InvalidRateError(String), + #[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/lib.rs b/crates/apollo_l1_gas_price_types/src/lib.rs index d910436d323..9a35cbd34c3 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,33 @@ 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. +#[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 +155,29 @@ pub trait ExchangeRateOracleClientTrait: Send + Sync + Debug { ) -> Result; } +/// The rate an oracle client instance produces. +// [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; +} + +/// 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..6fa31f388df --- /dev/null +++ b/crates/apollo_l1_gas_price_types/src/test.rs @@ -0,0 +1,91 @@ +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. +#[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); +} + +/// 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" + ); +} 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",