diff --git a/crates/apollo_deployments/src/jsonnet.rs b/crates/apollo_deployments/src/jsonnet.rs index e1e77e9e6de..0641e1cd9e9 100644 --- a/crates/apollo_deployments/src/jsonnet.rs +++ b/crates/apollo_deployments/src/jsonnet.rs @@ -93,15 +93,48 @@ where assert!(!services.is_empty(), "build({layout}) produced no services"); for (service_name, config) in services { - serde_json::from_value::(config.clone()).unwrap_or_else(|error| { + let mut config = config.clone(); + fill_placeholder_component_urls(&mut config); + let node_config = + serde_json::from_value::(config).unwrap_or_else(|error| { + panic!( + "service {service_name} of layout {layout} does not deserialize into \ + SequencerNodeConfig: {error}" + ) + }); + // The build output must also satisfy the cross-component invariants (chain_id and the other + // formerly-pointer-resolved values agreeing across components, etc.). Without this, a + // jsonnet change that broke a pointer group would pass CI and only fail at prod boot. + node_config.validate_node_config().unwrap_or_else(|error| { panic!( - "service {service_name} of layout {layout} does not deserialize into \ - SequencerNodeConfig: {error}" + "service {service_name} of layout {layout} deserializes but fails \ + validate_node_config: {error}" ) }); } } +/// Rewrites every reactive component's `url` in a built config to a resolvable value. +/// +/// A component's `url` is a deploy-time placeholder: the build emits the in-cluster service DNS +/// name (e.g. `sequencer-core-service`) for remote components, which does not resolve outside the +/// cluster. `validate_node_config` runs the per-component url validator, which actually resolves +/// the address, so rewrite every present `url` to `localhost` (always resolvable, no network +/// needed) to let validation reach the cross-component invariants. url/port are placeholders anyway +/// — the infra-parity check (`without_url_port`) excludes them for the same reason. Active +/// components have no `url` key and are left untouched. +fn fill_placeholder_component_urls(config: &mut Value) { + let Some(components) = config.get_mut("components").and_then(Value::as_object_mut) else { + return; + }; + for component in components.values_mut() { + let Some(component) = component.as_object_mut() else { continue }; + if component.contains_key("url") { + component.insert("url".to_owned(), Value::String("localhost".to_owned())); + } + } +} + /// Clones a `components` map with `url` and `port` removed from each component object — the two /// fields the Rust config leaves as deploy-time placeholders, so they can't be compared against the /// jsonnet's baked-in real values. diff --git a/crates/apollo_node_config/src/config_test.rs b/crates/apollo_node_config/src/config_test.rs index 1c326333047..ed54c5106a7 100644 --- a/crates/apollo_node_config/src/config_test.rs +++ b/crates/apollo_node_config/src/config_test.rs @@ -1,13 +1,18 @@ use std::collections::BTreeSet; +use apollo_config::behavior_mode::BehaviorMode; use apollo_config::dumping::SerializeConfig; use apollo_config::ParamPath; use apollo_infra::component_client::RemoteClientConfig; use apollo_infra::component_server::{LocalServerConfig, RemoteServerConfig}; use apollo_infra_utils::dumping::serialize_to_file_test; +use apollo_reverts::RevertConfig; use apollo_state_sync_config::config::{StateSyncConfig, StateSyncStaticConfig}; use apollo_storage::{StorageConfig, StorageScope}; +use blockifier::blockifier::config::NativeClassesWhitelist; use rstest::rstest; +use starknet_api::contract_address; +use starknet_api::core::ChainId; use validator::Validate; use crate::component_config::ComponentConfig; @@ -290,7 +295,7 @@ fn config_manager_local_with_remote_enabled_is_rejected() { #[test] fn validation_only_with_tx_ingestion_disabled_succeeds() { - let config = SequencerNodeConfig { + let mut config = SequencerNodeConfig { validation_only: true, components: ComponentConfig { gateway: ReactiveComponentExecutionConfig::disabled(), @@ -306,5 +311,181 @@ fn validation_only_with_tx_ingestion_disabled_succeeds() { state_sync_config: Some(state_sync_config_with_full_archive()), ..Default::default() }; + // `SequencerNodeConfig::default()` does not have internally consistent pointer-group values + // (those are only reconciled by pointer resolution at load time), so normalize them before + // exercising the validation_only logic this test targets. + normalize_pointer_groups(&mut config); assert!(config.validate_node_config().is_ok()); } + +/// Overwrites every present target of each multi-target `CONFIG_POINTERS` group with a single, +/// consistent value, mirroring what pointer resolution does at load time. Lets a config assembled +/// directly from `SequencerNodeConfig::default()` satisfy the cross-component equality invariant. +fn normalize_pointer_groups(config: &mut SequencerNodeConfig) { + let chain_id = ChainId::Mainnet; + let eth_fee_token_address = contract_address!("0x1"); + let strk_fee_token_address = contract_address!("0x2"); + let max_cpu_time: u64 = 600; + + config.validation_only = false; + if let Some(sierra_compiler) = config.sierra_compiler_config.as_mut() { + sierra_compiler.max_cpu_time = max_cpu_time; + } + if let Some(batcher) = config.batcher_config.as_mut() { + let static_config = &mut batcher.static_config; + static_config.block_builder_config.chain_info.chain_id = chain_id.clone(); + static_config.storage.db_config.chain_id = chain_id.clone(); + let fee_token_addresses = + &mut static_config.block_builder_config.chain_info.fee_token_addresses; + fee_token_addresses.eth_fee_token_address = eth_fee_token_address; + fee_token_addresses.strk_fee_token_address = strk_fee_token_address; + static_config.contract_class_manager_config.native_compiler_config.max_cpu_time = + max_cpu_time; + static_config.pre_confirmed_cende_config.recorder_url = + "https://recorder_url".parse().unwrap(); + static_config.block_builder_config.versioned_constants_overrides = None; + static_config.validation_only = false; + batcher.dynamic_config.native_classes_whitelist = NativeClassesWhitelist::All; + } + if let Some(class_manager) = config.class_manager_config.as_mut() { + class_manager + .static_config + .class_storage_config + .class_hash_storage_config + .db_config + .chain_id = chain_id.clone(); + } + if let Some(consensus_manager) = config.consensus_manager_config.as_mut() { + consensus_manager + .consensus_manager_config + .static_config + .storage_config + .db_config + .chain_id = chain_id.clone(); + consensus_manager.context_config.static_config.chain_id = chain_id.clone(); + consensus_manager.network_config.chain_id = chain_id.clone(); + consensus_manager.context_config.static_config.behavior_mode = BehaviorMode::Starknet; + consensus_manager.cende_config.recorder_url = "https://recorder_url".parse().unwrap(); + consensus_manager.revert_config = RevertConfig::default(); + } + if let Some(gateway) = config.gateway_config.as_mut() { + gateway.static_config.chain_info.chain_id = chain_id.clone(); + let fee_token_addresses = &mut gateway.static_config.chain_info.fee_token_addresses; + fee_token_addresses.eth_fee_token_address = eth_fee_token_address; + fee_token_addresses.strk_fee_token_address = strk_fee_token_address; + gateway.static_config.contract_class_manager_config.native_compiler_config.max_cpu_time = + max_cpu_time; + gateway.static_config.stateful_tx_validator_config.validate_resource_bounds = true; + gateway.static_config.stateless_tx_validator_config.validate_resource_bounds = true; + gateway.static_config.stateful_tx_validator_config.versioned_constants_overrides = None; + gateway.dynamic_config.native_classes_whitelist = NativeClassesWhitelist::All; + } + if let Some(l1_events_scraper) = config.l1_events_scraper_config.as_mut() { + l1_events_scraper.chain_id = chain_id.clone(); + } + if let Some(l1_gas_price_scraper) = config.l1_gas_price_scraper_config.as_mut() { + l1_gas_price_scraper.chain_id = chain_id.clone(); + } + if let Some(mempool) = config.mempool_config.as_mut() { + mempool.static_config.recorder_url = "https://recorder_url".parse().unwrap(); + mempool.static_config.validate_resource_bounds = true; + mempool.static_config.behavior_mode = BehaviorMode::Starknet; + } + if let Some(mempool_p2p) = config.mempool_p2p_config.as_mut() { + mempool_p2p.network_config.chain_id = chain_id.clone(); + } + if let Some(state_sync) = config.state_sync_config.as_mut() { + let static_config = &mut state_sync.static_config; + static_config.storage_config.db_config.chain_id = chain_id.clone(); + if let Some(network_config) = static_config.network_config.as_mut() { + network_config.chain_id = chain_id.clone(); + } + static_config.rpc_config.chain_id = chain_id.clone(); + static_config.rpc_config.execution_config.eth_fee_contract_address = eth_fee_token_address; + static_config.rpc_config.execution_config.strk_fee_contract_address = + strk_fee_token_address; + static_config.revert_config = RevertConfig::default(); + } +} + +/// A config assembled directly from `SequencerNodeConfig::default()` is not internally consistent +/// on pointer-group values, so after normalizing those groups it validates `Ok`. This is the +/// "full" positive case: every component is present and every group agrees. +#[test] +fn pointer_groups_consistent_full_config_validates() { + let mut config = SequencerNodeConfig::default(); + normalize_pointer_groups(&mut config); + assert!( + config.validate_node_config().is_ok(), + "normalized full config should validate: {:?}", + config.validate_node_config() + ); +} + +/// Present-only guard: when only one owner of a pointer group is present (a partial/distributed +/// deployment), the equality check has nothing to compare against and validates `Ok`. +#[test] +fn pointer_groups_single_present_owner_validates() { + // Only `gateway_config` owns `native_classes_whitelist`/`validate_resource_bounds`; with the + // batcher and mempool absent there is a single present value, so the group is trivially equal. + let mut config = SequencerNodeConfig { + batcher_config: None, + mempool_config: None, + ..SequencerNodeConfig::default() + }; + // Disable the now-absent components so the per-component "set iff running locally" check + // passes. + config.components.batcher = ReactiveComponentExecutionConfig::disabled(); + config.components.mempool = ReactiveComponentExecutionConfig::disabled(); + normalize_pointer_groups(&mut config); + assert!( + config.validate_node_config().is_ok(), + "single-owner config should validate: {:?}", + config.validate_node_config() + ); +} + +/// Negative: a uniform shared field (`chain_id`) diverging between two present owners fails. +#[test] +fn pointer_group_chain_id_mismatch_fails() { + let mut config = SequencerNodeConfig::default(); + normalize_pointer_groups(&mut config); + // Diverge the gateway's chain_id from everyone else's. + config.gateway_config.as_mut().unwrap().static_config.chain_info.chain_id = ChainId::Sepolia; + let err = config.validate_node_config().unwrap_err(); + assert!(format!("{err:?}").contains("chain_id"), "Unexpected error: {err:?}"); +} + +/// Negative covering the fee-token name asymmetry: the batcher/gateway `eth_fee_token_address` and +/// the state_sync `eth_fee_contract_address` are the same logical value; diverging them fails. +#[test] +fn pointer_group_eth_fee_token_name_asymmetry_mismatch_fails() { + let mut config = SequencerNodeConfig::default(); + normalize_pointer_groups(&mut config); + // state_sync stores it under `eth_fee_contract_address`; diverge it from the gateway/batcher. + config + .state_sync_config + .as_mut() + .unwrap() + .static_config + .rpc_config + .execution_config + .eth_fee_contract_address = contract_address!("0xdead"); + let err = config.validate_node_config().unwrap_err(); + assert!(format!("{err:?}").contains("eth_fee_token_address"), "Unexpected error: {err:?}"); +} + +/// Negative: the node-level `validation_only` source disagreeing with the batcher's copy (its lone +/// pointer target, which actually drives batcher behavior) fails. Guards the source-vs-target +/// group. +#[test] +fn pointer_group_validation_only_mismatch_fails() { + let mut config = SequencerNodeConfig::default(); + normalize_pointer_groups(&mut config); + // Top-level `validation_only` is false (set by `normalize_pointer_groups`); diverge the + // batcher's copy. The top-level flag stays false, so `validate_validation_only_config` is a + // no-op and the equality group is what must catch this. + config.batcher_config.as_mut().unwrap().static_config.validation_only = true; + let err = config.validate_node_config().unwrap_err(); + assert!(format!("{err:?}").contains("validation_only"), "Unexpected error: {err:?}"); +} diff --git a/crates/apollo_node_config/src/node_config.rs b/crates/apollo_node_config/src/node_config.rs index 98d96f2a3d4..d6c95427d7e 100644 --- a/crates/apollo_node_config/src/node_config.rs +++ b/crates/apollo_node_config/src/node_config.rs @@ -603,6 +603,201 @@ impl SequencerNodeConfig { self.validate_validation_only_config()?; + self.validate_pointer_groups_equal()?; + + Ok(()) + } + + /// Asserts that the formerly-pointer-resolved values are equal across all present components. + /// + /// The `CONFIG_POINTERS` mechanism copies a single source value into many nested component + /// fields at load time. Once those values are baked into the config (e.g. by jsonnet `build()`) + /// nothing re-checks that the copies actually agree. These present-only equality asserts are a + /// defense-in-depth guard for hand-edited, test, or otherwise non-jsonnet-generated configs: + /// for each pointer group with more than one target, every present component must hold the same + /// value. Components that are absent (`None`) are skipped, so partial/distributed deployments + /// that own only a subset of a group still validate. Each group below mirrors one multi-target + /// entry of `CONFIG_POINTERS`, plus `validation_only`: a single-target pointer whose target + /// (the batcher's copy) is checked against the always-present top-level source field, since + /// the node reads the two independently. Two single-target pointers are intentionally not + /// checked here: `starknet_url` (its targets are typed `String` vs `Url` and cannot be + /// compared directly) and `validator_id` (a lone target with no independent source to + /// disagree with). + fn validate_pointer_groups_equal(&self) -> Result<(), ConfigError> { + let batcher = self.batcher_config.as_ref(); + let class_manager = self.class_manager_config.as_ref(); + let consensus_manager = self.consensus_manager_config.as_ref(); + let gateway = self.gateway_config.as_ref(); + let mempool = self.mempool_config.as_ref(); + let mempool_p2p = self.mempool_p2p_config.as_ref(); + let sierra_compiler = self.sierra_compiler_config.as_ref(); + let state_sync = self.state_sync_config.as_ref(); + let l1_events_scraper = self.l1_events_scraper_config.as_ref(); + let l1_gas_price_scraper = self.l1_gas_price_scraper_config.as_ref(); + + // `chain_id`: shared by every component that touches storage, networking, or chain context. + all_present_equal( + "chain_id", + &[ + batcher.map(|c| &c.static_config.block_builder_config.chain_info.chain_id), + batcher.map(|c| &c.static_config.storage.db_config.chain_id), + class_manager.map(|c| { + &c.static_config + .class_storage_config + .class_hash_storage_config + .db_config + .chain_id + }), + consensus_manager.map(|c| { + &c.consensus_manager_config.static_config.storage_config.db_config.chain_id + }), + consensus_manager.map(|c| &c.context_config.static_config.chain_id), + consensus_manager.map(|c| &c.network_config.chain_id), + gateway.map(|c| &c.static_config.chain_info.chain_id), + l1_events_scraper.map(|c| &c.chain_id), + l1_gas_price_scraper.map(|c| &c.chain_id), + mempool_p2p.map(|c| &c.network_config.chain_id), + state_sync.map(|c| &c.static_config.storage_config.db_config.chain_id), + state_sync.and_then(|c| { + c.static_config.network_config.as_ref().map(|network| &network.chain_id) + }), + state_sync.map(|c| &c.static_config.rpc_config.chain_id), + ], + )?; + + // `eth_fee_token_address`: note the name asymmetry — state_sync calls it + // `eth_fee_contract_address` but it is the same `ContractAddress` value. + all_present_equal( + "eth_fee_token_address", + &[ + batcher.map(|c| { + &c.static_config + .block_builder_config + .chain_info + .fee_token_addresses + .eth_fee_token_address + }), + gateway + .map(|c| &c.static_config.chain_info.fee_token_addresses.eth_fee_token_address), + state_sync + .map(|c| &c.static_config.rpc_config.execution_config.eth_fee_contract_address), + ], + )?; + + // `strk_fee_token_address`: same name asymmetry as `eth_fee_token_address`. + all_present_equal( + "strk_fee_token_address", + &[ + batcher.map(|c| { + &c.static_config + .block_builder_config + .chain_info + .fee_token_addresses + .strk_fee_token_address + }), + gateway.map(|c| { + &c.static_config.chain_info.fee_token_addresses.strk_fee_token_address + }), + state_sync.map(|c| { + &c.static_config.rpc_config.execution_config.strk_fee_contract_address + }), + ], + )?; + + // `recorder_url`: shared by the consensus cende client, the batcher pre-confirmed cende + // client, and the mempool. + all_present_equal( + "recorder_url", + &[ + consensus_manager.map(|c| &c.cende_config.recorder_url), + batcher.map(|c| &c.static_config.pre_confirmed_cende_config.recorder_url), + mempool.map(|c| &c.static_config.recorder_url), + ], + )?; + + // `native_classes_whitelist`: shared by the batcher and gateway dynamic configs. + all_present_equal( + "native_classes_whitelist", + &[ + batcher.map(|c| &c.dynamic_config.native_classes_whitelist), + gateway.map(|c| &c.dynamic_config.native_classes_whitelist), + ], + )?; + + // `validate_resource_bounds`: shared by the gateway stateful/stateless validators and the + // mempool. + all_present_equal( + "validate_resource_bounds", + &[ + gateway.map(|c| { + &c.static_config.stateful_tx_validator_config.validate_resource_bounds + }), + gateway.map(|c| { + &c.static_config.stateless_tx_validator_config.validate_resource_bounds + }), + mempool.map(|c| &c.static_config.validate_resource_bounds), + ], + )?; + + // `max_cpu_time`: the standalone sierra compiler and the batcher/gateway native compilers. + all_present_equal( + "max_cpu_time", + &[ + sierra_compiler.map(|c| &c.max_cpu_time), + batcher.map(|c| { + &c.static_config + .contract_class_manager_config + .native_compiler_config + .max_cpu_time + }), + gateway.map(|c| { + &c.static_config + .contract_class_manager_config + .native_compiler_config + .max_cpu_time + }), + ], + )?; + + // `behavior_mode`: shared by the consensus context and the mempool. + all_present_equal( + "behavior_mode", + &[ + consensus_manager.map(|c| &c.context_config.static_config.behavior_mode), + mempool.map(|c| &c.static_config.behavior_mode), + ], + )?; + + // `versioned_constants_overrides`: shared by the batcher block builder and the gateway + // stateful validator. Both sides are `Option`, compared by value. + all_present_equal( + "versioned_constants_overrides", + &[ + batcher + .map(|c| &c.static_config.block_builder_config.versioned_constants_overrides), + gateway.map(|c| { + &c.static_config.stateful_tx_validator_config.versioned_constants_overrides + }), + ], + )?; + + // `revert_config`: shared by state_sync and the consensus manager. + all_present_equal( + "revert_config", + &[ + state_sync.map(|c| &c.static_config.revert_config), + consensus_manager.map(|c| &c.revert_config), + ], + )?; + + // `validation_only`: the always-present top-level flag is the source; the batcher's copy + // (`batcher_config.static_config.validation_only`) is the lone target and is what actually + // drives batcher behavior. They are read independently, so assert they agree. + all_present_equal( + "validation_only", + &[Some(&self.validation_only), batcher.map(|c| &c.static_config.validation_only)], + )?; + Ok(()) } @@ -627,6 +822,31 @@ impl SequencerNodeConfig { } } +/// Asserts that all present values in `values` are equal, returning an error otherwise. +/// +/// Only the `Some` entries participate: absent components contribute nothing, so a config that +/// owns just a subset of a pointer group still validates. `label` names the pointer group (e.g. +/// `"chain_id"`) and is surfaced in the error message. Values are compared by reference, since +/// they are borrowed out of `&self`'s component `Option`s and cannot be moved. +fn all_present_equal( + label: &str, + values: &[Option<&T>], +) -> Result<(), ConfigError> { + let present_values: Vec<&T> = values.iter().filter_map(|value| *value).collect(); + let Some((first_value, rest_values)) = present_values.split_first() else { + return Ok(()); + }; + if let Some(mismatched_value) = rest_values.iter().find(|value| *value != first_value) { + return Err(ConfigError::ComponentConfigMismatch { + component_config_mismatch: format!( + "{label} values mismatch across components: {first_value:?} != \ + {mismatched_value:?}" + ), + }); + } + Ok(()) +} + /// The command line interface of this node. pub fn node_command() -> Command { Command::new("Sequencer")