diff --git a/Makefile b/Makefile index ecfdabe9df..d2795245ad 100644 --- a/Makefile +++ b/Makefile @@ -274,7 +274,10 @@ check: @echo -e "$(PASS)All code quality checks passed!$(RESET)" .PHONY: fmt -fmt: setup-cairo +fmt: + @if [ -z "$(NO_CAIRO_SETUP)" ]; then \ + $(MAKE) --silent setup-cairo; \ + fi @echo -e "$(DIM)Running code formatters...$(RESET)" @echo -e "$(INFO)Running taplo formatter...$(RESET)" @npm install diff --git a/madara/Cargo.lock b/madara/Cargo.lock index 3ad301b02d..41f5bd63a7 100644 --- a/madara/Cargo.lock +++ b/madara/Cargo.lock @@ -6739,6 +6739,7 @@ dependencies = [ "rstest 0.18.2", "serde", "serde_json", + "serde_yaml", "siphasher", "smallvec", "starknet-types-core", @@ -6750,6 +6751,7 @@ dependencies = [ "tracing-core", "tracing-opentelemetry", "tracing-subscriber", + "url", ] [[package]] diff --git a/madara/crates/client/block_production/src/executor/thread.rs b/madara/crates/client/block_production/src/executor/thread.rs index ef834721c0..bda1de954a 100644 --- a/madara/crates/client/block_production/src/executor/thread.rs +++ b/madara/crates/client/block_production/src/executor/thread.rs @@ -226,6 +226,7 @@ impl ExecutorThread { &exec_ctx, state.state_adaptor, |block_n| self.wait_for_hash_of_block_min_10(block_n), + None, // Use backend's chain_config (normal execution) )?; Ok(ExecutorStateExecuting { diff --git a/madara/crates/client/block_production/src/lib.rs b/madara/crates/client/block_production/src/lib.rs index 686b58c180..1b9349fc4c 100644 --- a/madara/crates/client/block_production/src/lib.rs +++ b/madara/crates/client/block_production/src/lib.rs @@ -87,6 +87,7 @@ use mc_exec::LayeredStateAdapter; use mc_mempool::Mempool; use mc_settlement_client::SettlementClient; use mp_block::TransactionWithReceipt; +use mp_chain_config::RuntimeExecutionConfig; use mp_convert::{Felt, ToFelt}; use mp_receipt::from_blockifier_execution_info; use mp_state_update::{ClassUpdateItem, DeclaredClassCompiledClass, TransactionStateUpdate}; @@ -339,11 +340,12 @@ impl BlockProductionTask { &self, preconfirmed_tx: &PreconfirmedExecutedTransaction, state_view: &MadaraStateView, + no_charge_fee: bool, ) -> anyhow::Result { // Convert PreconfirmedExecutedTransaction to ValidatedTransaction // Use the actual charge_fee value from configuration (charge_fee = !no_charge_fee) let mut validated_tx = preconfirmed_tx.to_validated(); - validated_tx.charge_fee = !self.no_charge_fee; + validated_tx.charge_fee = !no_charge_fee; // If declared_class is missing and transaction is Declare, fetch it from state_view // NOTE: For declare transactions in the preconfirmed block, declared_class MUST be stored @@ -460,6 +462,8 @@ impl BlockProductionTask { async fn reexecute_preconfirmed_block( &self, preconfirmed_view: &MadaraPreconfirmedBlockView, + saved_chain_config: Option<&Arc>, + saved_no_charge_fee: bool, ) -> anyhow::Result { // Get all executed transactions let executed_txs: Vec<_> = preconfirmed_view.borrow_content().executed_transactions().cloned().collect(); @@ -468,9 +472,12 @@ impl BlockProductionTask { let parent_state_view = preconfirmed_view.state_view_on_parent(); // Convert transactions to blockifier format + // Note: saved_no_charge_fee is passed here to ensure re-execution uses the saved value let blockifier_txs: Vec = executed_txs .iter() - .map(|preconfirmed_tx| self.prepare_preconfirmed_tx_for_reexecution(preconfirmed_tx, &parent_state_view)) + .map(|preconfirmed_tx| { + self.prepare_preconfirmed_tx_for_reexecution(preconfirmed_tx, &parent_state_view, saved_no_charge_fee) + }) .collect::, _>>() .context("Converting preconfirmed transactions to blockifier format")?; @@ -490,11 +497,17 @@ impl BlockProductionTask { LayeredStateAdapter::new(self.backend.clone()).context("Creating LayeredStateAdapter for re-execution")?; // Create TransactionExecutor with block_n-10 handling - let mut executor = - crate::util::create_executor_with_block_n_min_10(&self.backend, &exec_ctx, state_adapter, |block_n| { - Self::wait_for_hash_of_block_min_10(&self.backend, block_n) - }) - .context("Creating TransactionExecutor for re-execution")?; + // Use saved configs if available, otherwise use current backend configs + let custom_chain_config = saved_chain_config; + + let mut executor = crate::util::create_executor_with_block_n_min_10( + &self.backend, + &exec_ctx, + state_adapter, + |block_n| Self::wait_for_hash_of_block_min_10(&self.backend, block_n), + custom_chain_config, // Use saved chain_config if available (re-execution) + ) + .context("Creating TransactionExecutor for re-execution")?; // Execute all transactions let execution_results = executor.execute_txs(&blockifier_txs, /* execution_deadline */ None); @@ -541,6 +554,29 @@ impl BlockProductionTask { Ok(block_exec_summary) } + /// Saves the current runtime execution config to the database. + /// This ensures the config is persisted for future restarts. + fn save_current_runtime_exec_config(&self) -> anyhow::Result<()> { + let current_chain_config = self.backend.chain_config(); + let current_exec_constants = current_chain_config + .exec_constants_by_protocol_version(current_chain_config.latest_protocol_version) + .context("Failed to resolve execution constants for latest protocol version")?; + + let runtime_config = RuntimeExecutionConfig::from_arc_chain_config( + current_chain_config, + current_exec_constants, + self.no_charge_fee, + ) + .context("Failed to create runtime execution config")?; + + self.backend + .write_access() + .write_runtime_exec_config(&runtime_config) + .context("Saving runtime execution config")?; + + Ok(()) + } + /// Closes the last preconfirmed block stored in the database (if any). /// /// This function is called when Madara restarts and finds a preconfirmed block in the database. @@ -548,7 +584,7 @@ impl BlockProductionTask { /// /// # Process /// - /// 1. Checks if a preconfirmed block exists, returns early if not + /// 1. Checks if a preconfirmed block exists, returns early if not (but still saves runtime exec config) /// 2. Re-executes all transactions in the block using `reexecute_preconfirmed_block()` to obtain: /// - `bouncer_weights`: Required for block finalization /// - `state_diff`: Required for block closing @@ -556,6 +592,7 @@ impl BlockProductionTask { /// 4. Removes consumed L1 to L2 message nonces from the database /// 5. Saves bouncer weights to the database /// 6. Closes the preconfirmed block with the regenerated state_diff + /// 7. Updates runtime exec config with current values after re-execution /// /// # Why Re-execution is Necessary /// @@ -567,8 +604,9 @@ impl BlockProductionTask { /// # Important Notes /// /// - The re-execution uses the exact header values from the preconfirmed block (timestamp, gas_prices, etc.) - /// - The `charge_fee` flag is determined by `self.no_charge_fee` configuration + /// - The `charge_fee` flag is determined by the saved `no_charge_fee` value (not current value) /// - L1 handler transactions preserve their `paid_fee_on_l1` value (stored during `append_batch`) + /// - Runtime exec config is always saved (even when no preconfirmed block exists) to ensure persistence /// /// # TODO (mohit, 13/11/2025) /// @@ -576,6 +614,9 @@ impl BlockProductionTask { /// - Handle cases where version constants or bouncer weights have changed async fn close_preconfirmed_block_if_exists(&mut self) -> anyhow::Result<()> { if !self.backend.has_preconfirmed_block() { + // Even if there's no preconfirmed block, save the current runtime exec config + // This ensures the config is persisted for future restarts + self.save_current_runtime_exec_config()?; return Ok(()); } @@ -586,15 +627,36 @@ impl BlockProductionTask { let block_number = preconfirmed_view.block_number(); let n_txs = preconfirmed_view.num_executed_transactions(); - tracing::info!( + tracing::debug!( "Re-executing {} transaction(s) in preconfirmed block #{} to obtain bouncer_weights and state_diff", n_txs, block_number ); + // Load saved runtime execution config + let saved_config = self.backend.get_runtime_exec_config().context("Getting runtime execution config")?; + + // Extract saved values for re-execution without modifying self + let (saved_chain_config, saved_no_charge_fee) = if let Some(config) = saved_config { + // Log warning if saved config differs from current config (for debugging) + if config.chain_config.chain_id != self.backend.chain_config().chain_id { + tracing::warn!( + "Saved chain_id ({}) differs from current chain_id ({})", + config.chain_config.chain_id, + self.backend.chain_config().chain_id + ); + } + + (Some(Arc::new(config.chain_config)), config.no_charge_fee) + } else { + tracing::warn!("No saved runtime execution config found, using current configs (backward compatibility)"); + (None, self.no_charge_fee) + }; + // Re-execute transactions to get BlockExecutionSummary + // Use saved_no_charge_fee for re-execution without modifying self.no_charge_fee let block_exec_summary = self - .reexecute_preconfirmed_block(&preconfirmed_view) + .reexecute_preconfirmed_block(&preconfirmed_view, saved_chain_config.as_ref(), saved_no_charge_fee) .await .context("Re-executing preconfirmed block to get execution summary")?; @@ -617,6 +679,12 @@ impl BlockProductionTask { .await .context("Closing preconfirmed block on startup")?; + // Update runtime exec config with current configs after re-execution is complete + // This ensures that if we restart again before starting the next block, we have the current configs + // Note: Use self.no_charge_fee (current value) not saved_no_charge_fee (saved value) + self.save_current_runtime_exec_config() + .context("Updating runtime execution config after restart re-execution")?; + tracing::info!("✅ Closed preconfirmed block #{} with {} transactions on startup", block_number, n_txs); Ok(()) @@ -641,9 +709,16 @@ impl BlockProductionTask { ) } + // Check if pre-confirmed block exists (it shouldn't at this point) + if self.backend.has_preconfirmed_block() { + tracing::warn!("Unexpected pre-confirmed block exists when starting new block"); + } + + // Create new preconfirmed block let backend = self.backend.clone(); global_spawn_rayon_task(move || { - backend.write_access().new_preconfirmed(PreconfirmedBlock::new(exec_ctx.into_header())) + let write_access = backend.write_access(); + write_access.new_preconfirmed(PreconfirmedBlock::new(exec_ctx.into_header())) }) .await?; @@ -1210,6 +1285,8 @@ pub(crate) mod tests { // | PHASE 1: Close the block and note down its state. | // -------------------------------------------------------------- + tracing::info!("PHASE 1: Close the block and note down its state."); + // Step 1: Create a block normally with transactions in the original backend assert!(original_devnet_setup.mempool.is_empty().await); @@ -1316,6 +1393,7 @@ pub(crate) mod tests { // We'll add them in the same order using the same helper functions // All transactions will be in a single block // This ensures they're executed in the same context (clean genesis state) + tracing::info!("PHASE 2: Re-execute the block and note down its state."); assert!(restart_devnet_setup.mempool.is_empty().await); // Create the same transactions using the helper function @@ -1631,4 +1709,141 @@ pub(crate) mod tests { } ); } + + /// Test that re-execution uses the saved `no_charge_fee` value, not the current value. + /// + /// This test verifies the critical behavior that when a node restarts with a pre-confirmed block, + /// re-execution must use the exact same `no_charge_fee` value that was used during original execution, + /// even if the node's current configuration has changed. This ensures transaction receipts remain + /// consistent between original execution and re-execution. + /// + /// # Test Flow + /// + /// 1. **Initial execution**: Start with `no_charge_fee = true`, execute a transaction, stop before closing + /// - Transaction is executed without charging fees + /// - Runtime exec config is saved with `no_charge_fee = true` + /// + /// 2. **Restart**: Restart with `no_charge_fee = false` (different value) + /// - Node's current config is `no_charge_fee = false` + /// - But saved config has `no_charge_fee = true` + /// + /// 3. **Re-execution**: When closing the pre-confirmed block, re-execution uses saved value + /// - Re-execution uses `saved_no_charge_fee = true` (from saved config) + /// - NOT `restart_no_charge_fee = false` (from current config) + /// - This ensures receipts match between original and re-execution + /// + /// 4. **Post re-execution**: After re-execution completes, config is updated with current value + /// - Config is updated to `no_charge_fee = false` for future blocks + /// - This ensures next block uses the current configuration + /// + /// # Why This Matters + /// + /// If re-execution used the current `no_charge_fee` value instead of the saved one: + /// - Transaction receipts would differ between original execution and re-execution + /// - State diffs would be inconsistent + /// - Block validation would fail + #[rstest::rstest] + #[timeout(Duration::from_secs(100))] + #[tokio::test] + async fn test_reexecution_uses_saved_no_charge_fee_value( + #[future] + #[from(devnet_setup)] + original_devnet_setup: DevnetSetup, + ) { + let original_devnet_setup = original_devnet_setup.await; + + // Phase 1: Initial execution with no_charge_fee = true + let initial_no_charge_fee = true; + assert!(original_devnet_setup.mempool.is_empty().await); + + // Create a transaction validator that matches our no_charge_fee setting. + // This ensures transactions are validated with charge_fee = !no_charge_fee. + // Without this, transactions would be validated with charge_fee = true (default), + // causing a mismatch between validation and execution. + let tx_validator_with_no_fee = Arc::new(TransactionValidator::new( + Arc::clone(&original_devnet_setup.mempool) as _, + Arc::clone(&original_devnet_setup.backend), + TransactionValidatorConfig { disable_validation: false, disable_fee: initial_no_charge_fee }, + )); + + sign_and_add_invoke_tx( + &original_devnet_setup.contracts.0[0], + &original_devnet_setup.contracts.0[1], + &original_devnet_setup.backend, + &tx_validator_with_no_fee, + Felt::ZERO, + ) + .await; + + assert!(!original_devnet_setup.mempool.is_empty().await); + + // Start block production task with no_charge_fee = true. + // This will execute the transaction and add it to the pre-confirmed block. + let mut block_production_task = BlockProductionTask::new( + original_devnet_setup.backend.clone(), + original_devnet_setup.mempool.clone(), + original_devnet_setup.metrics.clone(), + Arc::new(original_devnet_setup.l1_client.clone()), + initial_no_charge_fee, + ); + + let mut notifications = block_production_task.subscribe_state_notifications(); + let restart_task = + AbortOnDrop::spawn( + async move { block_production_task.run(ServiceContext::new_for_testing()).await.unwrap() }, + ); + + // Wait for transaction to be executed and added to pre-confirmed block + assert_eq!(notifications.recv().await.unwrap(), BlockProductionStateNotification::BatchExecuted); + + // Verify pre-confirmed block exists with our transaction + assert!(original_devnet_setup.backend.has_preconfirmed_block()); + let preconfirmed_view = original_devnet_setup.backend.block_view_on_preconfirmed().unwrap(); + assert_eq!(preconfirmed_view.num_executed_transactions(), 1); + + // Stop the task before it closes the block. + // This simulates a node crash/restart scenario where a pre-confirmed block exists. + drop(restart_task); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Phase 2: Restart with different no_charge_fee value + // This simulates a configuration change between shutdown and restart. + let restart_no_charge_fee = false; + let restart_block_production_task = BlockProductionTask::new( + original_devnet_setup.backend.clone(), // Same backend = same database + original_devnet_setup.mempool.clone(), + original_devnet_setup.metrics.clone(), + Arc::new(original_devnet_setup.l1_client.clone()), + restart_no_charge_fee, // Current config: no_charge_fee = false + ); + + // Start the block production task. + // This will call setup_initial_state() which calls close_preconfirmed_block_if_exists(). + // During re-execution, it will use saved_no_charge_fee = true (from saved config), + // NOT restart_no_charge_fee = false (from current config). + let _restart_task = AbortOnDrop::spawn(async move { + restart_block_production_task.run(ServiceContext::new_for_testing()).await.unwrap() + }); + + // Give time for setup_initial_state to complete and close the pre-confirmed block + tokio::time::sleep(Duration::from_secs(1)).await; + + // Phase 3: Verify block was closed successfully + assert!(!original_devnet_setup.backend.has_preconfirmed_block()); + assert_eq!(original_devnet_setup.backend.latest_confirmed_block_n(), Some(1)); + + // Phase 4: Verify config was updated with CURRENT value after re-execution + // After re-execution completes, the config is updated to the current value. + // This ensures that the next block will use the current configuration. + let updated_config = original_devnet_setup + .backend + .get_runtime_exec_config() + .expect("Should be able to read runtime exec config") + .expect("Runtime exec config should exist after closing"); + + assert_eq!( + updated_config.no_charge_fee, restart_no_charge_fee, + "Config should be updated with current value after re-execution completes" + ); + } } diff --git a/madara/crates/client/block_production/src/util.rs b/madara/crates/client/block_production/src/util.rs index 5cbac06296..c388c52e9f 100644 --- a/madara/crates/client/block_production/src/util.rs +++ b/madara/crates/client/block_production/src/util.rs @@ -4,7 +4,7 @@ use blockifier::{ transaction::transaction_execution::Transaction, }; use mc_db::MadaraBackend; -use mc_exec::{LayeredStateAdapter, MadaraBackendExecutionExt}; +use mc_exec::LayeredStateAdapter; use mp_block::header::{BlockTimestamp, GasPrices, PreconfirmedHeader}; use mp_chain_config::{L1DataAvailabilityMode, StarknetVersion}; use mp_class::ConvertedClass; @@ -166,6 +166,23 @@ impl BlockExecutionContext { use_kzg_da: self.l1_da_mode == L1DataAvailabilityMode::Blob, }) } + + /// Create a BlockContext from this execution context with the given chain config and exec constants. + /// This is a helper function that encapsulates the BlockContext creation logic. + pub fn to_block_context( + &self, + chain_config: &Arc, + exec_constants: &blockifier::blockifier_versioned_constants::VersionedConstants, + ) -> anyhow::Result { + use blockifier::context::BlockContext; + let block_info = self.to_blockifier()?; + Ok(BlockContext::new( + block_info, + chain_config.blockifier_chain_info(), + exec_constants.clone(), + chain_config.bouncer_config.clone(), + )) + } } pub(crate) fn create_execution_context( @@ -204,17 +221,39 @@ pub(crate) fn create_execution_context( /// and sets up the block_n-10 state diff entry if available. /// /// This is a helper function to avoid code duplication between normal block production -/// and re-execution scenarios. +/// and re-execution scenarios. It reuses `backend.new_executor_for_block_production()` +/// but allows using a custom chain_config (e.g., saved config for re-execution). pub(crate) fn create_executor_with_block_n_min_10( backend: &Arc, exec_ctx: &BlockExecutionContext, state_adaptor: LayeredStateAdapter, get_block_n_min_10_hash: impl FnOnce(u64) -> anyhow::Result>, + custom_chain_config: Option<&Arc>, ) -> anyhow::Result> { + use mc_exec::MadaraBackendExecutionExt; + + // Use backend.new_executor_for_block_production() to create executor (reuses existing logic) + let block_info = exec_ctx.to_blockifier()?; let mut executor = backend - .new_executor_for_block_production(state_adaptor, exec_ctx.to_blockifier()?) + .clone() + .new_executor_for_block_production(state_adaptor, block_info.clone()) .context("Creating TransactionExecutor")?; + // If custom_chain_config is provided, override BlockContext to use it instead of backend's config + // This is needed for re-execution scenarios where we want to use saved config + if let Some(chain_config) = custom_chain_config { + // Get exec_constants from custom chain_config + let exec_constants = chain_config + .exec_constants_by_protocol_version(exec_ctx.protocol_version) + .context("Failed to resolve execution constants for protocol version")?; + + // Use to_block_context helper to create BlockContext (reuses existing logic) + let block_context = exec_ctx.to_block_context(chain_config, &exec_constants)?; + executor.block_context = Arc::new(block_context); + // Override concurrency config as well + executor.config.concurrency_config = chain_config.block_production_concurrency.blockifier_config(); + } + // Prepare the block_n-10 state diff entry on the 0x1 contract if let Some((block_n_min_10, block_hash_n_min_10)) = get_block_n_min_10_hash(exec_ctx.block_number)? { let contract_address = 1u64.into(); diff --git a/madara/crates/client/db/Cargo.toml b/madara/crates/client/db/Cargo.toml index 7040cb958d..32c6ac53c7 100644 --- a/madara/crates/client/db/Cargo.toml +++ b/madara/crates/client/db/Cargo.toml @@ -76,10 +76,12 @@ opentelemetry-stdout = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["rt-tokio", "logs"] } reqwest = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } tracing = { workspace = true } tracing-core = { workspace = true, default-features = false } tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } +url = { workspace = true } [dev-dependencies] diff --git a/madara/crates/client/db/src/lib.rs b/madara/crates/client/db/src/lib.rs index dd75a07dfc..db651d59e9 100644 --- a/madara/crates/client/db/src/lib.rs +++ b/madara/crates/client/db/src/lib.rs @@ -495,6 +495,11 @@ impl MadaraBackend { pub fn chain_config(&self) -> &Arc { &self.chain_config } + + /// Get the runtime execution configuration from the database. + pub fn get_runtime_exec_config(&self) -> Result> { + self.db.get_runtime_exec_config(&self.chain_config) + } } /// Structure holding exclusive access to write the blocks and the tip of the chain. @@ -623,6 +628,16 @@ impl MadaraBackendWriter { self.replace_chain_tip(ChainTip::on_confirmed_block_n_or_empty(self.inner.latest_confirmed_block_n())) } + /// Write the runtime execution configuration to the database. + pub fn write_runtime_exec_config(&self, config: &mp_chain_config::RuntimeExecutionConfig) -> Result<()> { + self.inner.db.write_runtime_exec_config(config) + } + + /// Clear the runtime execution configuration from the database. + pub fn clear_runtime_exec_config(&self) -> Result<()> { + self.inner.db.clear_runtime_exec_config() + } + /// Start a new preconfirmed block on top of the latest confirmed block. Deletes and replaces the current preconfirmed block if present. /// Warning: Caller is responsible for ensuring the block_number is the one following the current confirmed block. pub fn new_preconfirmed(&self, block: PreconfirmedBlock) -> Result<()> { diff --git a/madara/crates/client/db/src/rocksdb/meta.rs b/madara/crates/client/db/src/rocksdb/meta.rs index 8a5c4ecdfa..a60add2e5c 100644 --- a/madara/crates/client/db/src/rocksdb/meta.rs +++ b/madara/crates/client/db/src/rocksdb/meta.rs @@ -5,6 +5,7 @@ use crate::{ storage::{DevnetPredeployedKeys, StorageChainTip, StoredChainInfo}, }; use mp_block::header::PreconfirmedHeader; +use mp_chain_config::{ChainConfig, RuntimeExecutionConfig, RuntimeExecutionConfigSerializable}; use rocksdb::{IteratorMode, ReadOptions}; pub const META_COLUMN: Column = Column::new("meta").set_point_lookup(); @@ -16,6 +17,7 @@ const META_CONFIRMED_ON_L1_TIP_KEY: &[u8] = b"CONFIRMED_ON_L1_TIP"; const META_CHAIN_TIP_KEY: &[u8] = b"CHAIN_TIP"; const META_CHAIN_INFO_KEY: &[u8] = b"CHAIN_INFO"; const META_LATEST_APPLIED_TRIE_UPDATE: &[u8] = b"LATEST_APPLIED_TRIE_UPDATE"; +const META_RUNTIME_EXEC_CONFIG_KEY: &[u8] = b"RUNTIME_EXEC_CONFIG"; const META_SNAP_SYNC_LATEST_BLOCK: &[u8] = b"SNAP_SYNC_LATEST_BLOCK"; #[derive(serde::Deserialize, serde::Serialize)] @@ -233,6 +235,40 @@ impl RocksDBStorageInner { Ok(()) } + /// Write the runtime execution configuration to the database. + #[tracing::instrument(skip(self, config))] + pub(super) fn write_runtime_exec_config(&self, config: &RuntimeExecutionConfig) -> Result<()> { + let serializable = config.to_serializable()?; + self.db.put_cf_opt( + &self.get_column(META_COLUMN), + META_RUNTIME_EXEC_CONFIG_KEY, + super::serialize(&serializable)?, + &self.writeopts, + )?; + Ok(()) + } + + /// Get the runtime execution configuration from the database. + /// Note: This requires a backend_chain_config to reconstruct the full ChainConfig. + #[tracing::instrument(skip(self))] + pub(super) fn get_runtime_exec_config( + &self, + backend_chain_config: &ChainConfig, + ) -> Result> { + let Some(res) = self.db.get_pinned_cf(&self.get_column(META_COLUMN), META_RUNTIME_EXEC_CONFIG_KEY)? else { + return Ok(None); + }; + let serializable: RuntimeExecutionConfigSerializable = super::deserialize(&res)?; + Ok(Some(RuntimeExecutionConfig::from_serializable(serializable, backend_chain_config)?)) + } + + /// Clear the runtime execution configuration from the database. + #[tracing::instrument(skip(self))] + pub(super) fn clear_runtime_exec_config(&self) -> Result<()> { + self.db.delete_cf_opt(&self.get_column(META_COLUMN), META_RUNTIME_EXEC_CONFIG_KEY, &self.writeopts)?; + Ok(()) + } + /// Get the latest block number where snap sync computed the trie. /// Returns None if snap sync was never used. #[tracing::instrument(skip(self))] diff --git a/madara/crates/client/db/src/rocksdb/mod.rs b/madara/crates/client/db/src/rocksdb/mod.rs index ee1d5ce160..e28a9710e6 100644 --- a/madara/crates/client/db/src/rocksdb/mod.rs +++ b/madara/crates/client/db/src/rocksdb/mod.rs @@ -38,7 +38,7 @@ mod events_bloom_filter; mod iter_pinned; mod l1_to_l2_messages; mod mempool; -mod meta; +pub mod meta; mod metrics; mod options; mod rocksdb_snapshot; @@ -320,6 +320,12 @@ impl MadaraStorageRead for RocksDBStorage { fn get_latest_applied_trie_update(&self) -> Result> { self.inner.get_latest_applied_trie_update().context("Getting latest applied trie update info from db") } + fn get_runtime_exec_config( + &self, + backend_chain_config: &mp_chain_config::ChainConfig, + ) -> Result> { + self.inner.get_runtime_exec_config(backend_chain_config).context("Getting runtime execution config from db") + } fn get_snap_sync_latest_block(&self) -> Result> { self.inner.get_snap_sync_latest_block().context("Getting snap sync latest block from db") } @@ -456,6 +462,14 @@ impl MadaraStorageWrite for RocksDBStorage { tracing::debug!("Write latest applied trie update block_n={block_n:?}"); self.inner.write_latest_applied_trie_update(block_n).context("Writing latest applied trie update block_n") } + fn write_runtime_exec_config(&self, config: &mp_chain_config::RuntimeExecutionConfig) -> Result<()> { + tracing::debug!("Writing runtime execution config"); + self.inner.write_runtime_exec_config(config).context("Writing runtime execution config") + } + fn clear_runtime_exec_config(&self) -> Result<()> { + tracing::debug!("Clearing runtime execution config"); + self.inner.clear_runtime_exec_config().context("Clearing runtime execution config") + } fn write_snap_sync_latest_block(&self, block_n: &Option) -> Result<()> { tracing::debug!("Write snap sync latest block block_n={block_n:?}"); self.inner.write_snap_sync_latest_block(block_n).context("Writing snap sync latest block") diff --git a/madara/crates/client/db/src/storage.rs b/madara/crates/client/db/src/storage.rs index 3e02ff9282..6898fed7da 100644 --- a/madara/crates/client/db/src/storage.rs +++ b/madara/crates/client/db/src/storage.rs @@ -130,6 +130,10 @@ pub trait MadaraStorageRead: Send + Sync + 'static { fn get_l1_messaging_sync_tip(&self) -> Result>; fn get_stored_chain_info(&self) -> Result>; fn get_latest_applied_trie_update(&self) -> Result>; + fn get_runtime_exec_config( + &self, + backend_chain_config: &mp_chain_config::ChainConfig, + ) -> Result>; fn get_snap_sync_latest_block(&self) -> Result>; // L1 to L2 messages @@ -166,6 +170,8 @@ pub trait MadaraStorageWrite: Send + Sync + 'static { fn write_devnet_predeployed_keys(&self, devnet_keys: &DevnetPredeployedKeys) -> Result<()>; fn write_chain_info(&self, info: &StoredChainInfo) -> Result<()>; fn write_latest_applied_trie_update(&self, block_n: &Option) -> Result<()>; + fn write_runtime_exec_config(&self, config: &mp_chain_config::RuntimeExecutionConfig) -> Result<()>; + fn clear_runtime_exec_config(&self) -> Result<()>; fn write_snap_sync_latest_block(&self, block_n: &Option) -> Result<()>; fn remove_mempool_transactions(&self, tx_hashes: impl IntoIterator) -> Result<()>; diff --git a/madara/crates/primitives/chain_config/src/lib.rs b/madara/crates/primitives/chain_config/src/lib.rs index 0042b0395c..ec026a2a1d 100644 --- a/madara/crates/primitives/chain_config/src/lib.rs +++ b/madara/crates/primitives/chain_config/src/lib.rs @@ -1,9 +1,11 @@ mod chain_config; mod l1_da_mode; mod rpc_version; +mod runtime_exec_config; mod starknet_version; pub use chain_config::*; pub use l1_da_mode::*; pub use rpc_version::*; +pub use runtime_exec_config::*; pub use starknet_version::*; diff --git a/madara/crates/primitives/chain_config/src/runtime_exec_config.rs b/madara/crates/primitives/chain_config/src/runtime_exec_config.rs new file mode 100644 index 0000000000..6df7d1bf3a --- /dev/null +++ b/madara/crates/primitives/chain_config/src/runtime_exec_config.rs @@ -0,0 +1,382 @@ +//! Runtime execution configuration for block production. +//! +//! This module provides types and serialization logic for persisting runtime execution +//! configuration when starting a new block. This ensures that re-execution on restart +//! uses the same configs that were used during original execution. +//! +//! ## Version Handling +//! +//! This module handles ChainConfig versioning automatically through round-trip serialization. +//! When a ChainConfig is saved, its version is tracked and stored. On deserialization: +//! +//! - **Same version, different values**: The saved config values are used for re-execution +//! - **Different versions**: The saved config (with its original version) is deserialized +//! using the appropriate version handler from `ChainConfigVersioned`. This ensures that +//! pre-confirmed blocks are always closed using the exact config that was used during +//! original execution, even if the node restarts with a different ChainConfig version. +//! +//! ### Example Scenarios +//! +//! 1. **Node stops with v1 config, restarts with v1 config**: Uses saved v1 config ✓ +//! 2. **Node stops with v1 config, restarts with v2 config**: Uses saved v1 config for +//! re-execution, then saves v2 config for next block ✓ +//! 3. **Same version, different values**: Uses saved values for re-execution ✓ +//! +//! ### Maintenance Notes +//! +//! When adding a new ChainConfig version: +//! - Add the new variant to `ChainConfigVersioned` enum +//! - Implement `TryFrom for ChainConfig` +//! - Update `TryFrom for ChainConfig` to handle the new variant +//! - No changes needed in this module - version handling is automatic! + +use crate::{ChainConfig, ChainConfigVersioned, ChainVersionedConstants, StarknetVersion}; +use anyhow::{Context, Result}; +use blockifier::blockifier_versioned_constants::VersionedConstants; +use mp_utils::crypto::ZeroingPrivateKey; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Runtime execution configuration saved when starting a new block. +/// This ensures that re-execution on restart uses the same configs that were used during original execution. +#[derive(Debug)] +pub struct RuntimeExecutionConfig { + /// Complete chain configuration used during execution + pub chain_config: ChainConfig, + /// The actual execution constants resolved for the protocol_version used + pub exec_constants: VersionedConstants, + /// Fee charging flag (not part of ChainConfig but affects execution) + pub no_charge_fee: bool, +} + +/// Serializable representation of RuntimeExecutionConfig for database storage. +/// This is used internally for serialization since ChainConfig and VersionedConstants +/// don't implement Serialize/Deserialize directly. +#[derive(Serialize, Deserialize)] +pub struct RuntimeExecutionConfigSerializable { + /// Config version used when this was serialized (for version tracking and migration) + config_version: u32, + /// Chain config stored as YAML string (using ChainConfig's deserialization format) + /// Note: Field name says "json" for historical reasons, but it's actually YAML + chain_config_json: String, + /// Protocol version string - we'll reconstruct VersionedConstants from backend's versioned_constants map + protocol_version: String, + /// Fee charging flag + no_charge_fee: bool, +} + +impl RuntimeExecutionConfig { + /// Copy ChainConfig using round-trip serialization. + /// This ensures version-aware copying that automatically handles all ChainConfig versions. + /// When new ChainConfig versions are added, this function continues to work without changes. + fn copy_chain_config_via_serialization(config: &ChainConfig) -> Result { + use serde_yaml; + + // Serialize ChainConfig to versioned YAML format + let yaml_str = Self::chain_config_to_versioned_yaml(config)?; + + // Deserialize back using ChainConfigVersioned (handles all versions automatically) + let versioned: ChainConfigVersioned = + serde_yaml::from_str(&yaml_str).context("Failed to deserialize ChainConfig from YAML during copy")?; + + // Convert to canonical ChainConfig + ChainConfig::try_from(versioned) + .context("Failed to convert versioned ChainConfig to canonical ChainConfig during copy") + } + + /// Convert ChainConfig to versioned YAML string. + /// This is used both for copying and for database serialization. + fn chain_config_to_versioned_yaml(config: &ChainConfig) -> Result { + use serde_yaml; + + // Manually construct a YAML Value that matches ChainConfigVersioned format + // This allows us to use ChainConfig's deserialization logic when reading back + let mut yaml_value = serde_yaml::Mapping::new(); + + // Set config_version tag (required for ChainConfigVersioned deserialization) + // Use dynamic version from the config instead of hardcoding + yaml_value.insert( + serde_yaml::Value::String("config_version".to_string()), + serde_yaml::Value::String(config.config_version.to_string()), + ); + + // Add all ChainConfig fields + yaml_value.insert( + serde_yaml::Value::String("chain_name".to_string()), + serde_yaml::Value::String(config.chain_name.clone()), + ); + yaml_value.insert( + serde_yaml::Value::String("chain_id".to_string()), + serde_yaml::Value::String(config.chain_id.to_string()), + ); + yaml_value.insert( + serde_yaml::Value::String("l1_da_mode".to_string()), + serde_yaml::Value::String(format!("{:?}", config.l1_da_mode)), + ); + yaml_value.insert( + serde_yaml::Value::String("settlement_chain_kind".to_string()), + serde_yaml::Value::String(format!("{:?}", config.settlement_chain_kind)), + ); + yaml_value.insert( + serde_yaml::Value::String("feeder_gateway_url".to_string()), + serde_yaml::Value::String(config.feeder_gateway_url.as_str().to_string()), + ); + yaml_value.insert( + serde_yaml::Value::String("gateway_url".to_string()), + serde_yaml::Value::String(config.gateway_url.as_str().to_string()), + ); + yaml_value.insert( + serde_yaml::Value::String("native_fee_token_address".to_string()), + serde_yaml::Value::String(config.native_fee_token_address.to_string()), + ); + yaml_value.insert( + serde_yaml::Value::String("parent_fee_token_address".to_string()), + serde_yaml::Value::String(config.parent_fee_token_address.to_string()), + ); + yaml_value.insert( + serde_yaml::Value::String("latest_protocol_version".to_string()), + serde_yaml::Value::String(config.latest_protocol_version.to_string()), + ); + // Serialize block_time as a string (e.g., "30s") since deserialize_duration expects a string + let block_time_str = if config.block_time.as_secs_f64().fract() == 0.0 { + format!("{}s", config.block_time.as_secs()) + } else { + format!("{}ms", config.block_time.as_millis()) + }; + yaml_value + .insert(serde_yaml::Value::String("block_time".to_string()), serde_yaml::Value::String(block_time_str)); + yaml_value.insert( + serde_yaml::Value::String("no_empty_blocks".to_string()), + serde_yaml::Value::Bool(config.no_empty_blocks), + ); + yaml_value.insert( + serde_yaml::Value::String("bouncer_config".to_string()), + serde_yaml::to_value(&config.bouncer_config).context("Failed to serialize bouncer_config")?, + ); + yaml_value.insert( + serde_yaml::Value::String("sequencer_address".to_string()), + serde_yaml::Value::String(config.sequencer_address.to_string()), + ); + yaml_value.insert( + serde_yaml::Value::String("eth_core_contract_address".to_string()), + serde_yaml::Value::String(config.eth_core_contract_address.clone()), + ); + yaml_value.insert( + serde_yaml::Value::String("eth_gps_statement_verifier".to_string()), + serde_yaml::Value::String(config.eth_gps_statement_verifier.clone()), + ); + yaml_value.insert( + serde_yaml::Value::String("mempool_mode".to_string()), + serde_yaml::Value::String(format!("{:?}", config.mempool_mode)), + ); + yaml_value.insert( + serde_yaml::Value::String("mempool_min_tip_bump".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(config.mempool_min_tip_bump)), + ); + yaml_value.insert( + serde_yaml::Value::String("mempool_max_transactions".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(config.mempool_max_transactions)), + ); + if let Some(max_declare) = config.mempool_max_declare_transactions { + yaml_value.insert( + serde_yaml::Value::String("mempool_max_declare_transactions".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(max_declare)), + ); + } + if let Some(ttl) = config.mempool_ttl { + // Serialize mempool_ttl as a string (e.g., "3600s") since deserialize_optional_duration expects a string + let mempool_ttl_str = if ttl.as_secs_f64().fract() == 0.0 { + format!("{}s", ttl.as_secs()) + } else { + format!("{}ms", ttl.as_millis()) + }; + yaml_value.insert( + serde_yaml::Value::String("mempool_ttl".to_string()), + serde_yaml::Value::String(mempool_ttl_str), + ); + } + yaml_value.insert( + serde_yaml::Value::String("l2_gas_price".to_string()), + serde_yaml::to_value(&config.l2_gas_price).context("Failed to serialize l2_gas_price")?, + ); + yaml_value.insert( + serde_yaml::Value::String("block_production_concurrency".to_string()), + serde_yaml::to_value(&config.block_production_concurrency) + .context("Failed to serialize block_production_concurrency")?, + ); + // Serialize l1_messages_replay_max_duration as a string (e.g., "259200s") since deserialize_duration expects a string + let l1_messages_replay_max_duration_str = if config.l1_messages_replay_max_duration.as_secs_f64().fract() == 0.0 + { + format!("{}s", config.l1_messages_replay_max_duration.as_secs()) + } else { + format!("{}ms", config.l1_messages_replay_max_duration.as_millis()) + }; + yaml_value.insert( + serde_yaml::Value::String("l1_messages_replay_max_duration".to_string()), + serde_yaml::Value::String(l1_messages_replay_max_duration_str), + ); + + // Serialize to YAML string + serde_yaml::to_string(&serde_yaml::Value::Mapping(yaml_value)) + .context("Failed to serialize ChainConfig to YAML") + } + + /// Create RuntimeExecutionConfig from Arc using version-aware copying. + /// This uses round-trip serialization to ensure all ChainConfig versions are handled automatically. + /// When new ChainConfig versions are added, this function continues to work without changes. + pub fn from_arc_chain_config( + chain_config: &Arc, + exec_constants: VersionedConstants, + no_charge_fee: bool, + ) -> Result { + // Copy ChainConfig using round-trip serialization (version-aware) + let mut chain_config_copy = Self::copy_chain_config_via_serialization(chain_config) + .context("Failed to copy ChainConfig via serialization")?; + + // Copy versioned_constants manually (it's not part of the serialized config) + let mut versioned_constants_copy = ChainVersionedConstants::default(); + for (k, v) in &chain_config.versioned_constants.0 { + versioned_constants_copy.add(*k, v.clone()); + } + chain_config_copy.versioned_constants = versioned_constants_copy; + + // Use default private_key since it's not serialized and ZeroingPrivateKey doesn't implement Clone + // This is safe because: + // 1. The copied ChainConfig is only used for re-execution (which doesn't require private_key) + // 2. When closing the block, the backend's CURRENT chain_config is used for computing block hash and commitments + // 3. When blocks are signed (on-demand via gateway API), the backend's CURRENT chain_config.private_key is used + // Therefore, using default() here is acceptable since private_key is never accessed from the copied config + + Ok(Self { chain_config: chain_config_copy, exec_constants, no_charge_fee }) + } + + /// Convert to serializable form for database storage. + /// The config version is automatically detected and stored for version tracking. + pub fn to_serializable(&self) -> Result { + // Convert ChainConfig to versioned YAML string using the shared helper + let chain_config_yaml = Self::chain_config_to_versioned_yaml(&self.chain_config)?; + + // Store protocol version instead of VersionedConstants since it doesn't implement Serialize + // We'll reconstruct VersionedConstants from the backend's versioned_constants map on load + let protocol_version_str = self.chain_config.latest_protocol_version.to_string(); + + Ok(RuntimeExecutionConfigSerializable { + config_version: self.chain_config.config_version, + chain_config_json: chain_config_yaml, // Actually YAML, but keeping field name for compatibility + protocol_version: protocol_version_str, + no_charge_fee: self.no_charge_fee, + }) + } + + /// Reconstruct from serializable form. + pub fn from_serializable( + serializable: RuntimeExecutionConfigSerializable, + backend_chain_config: &ChainConfig, + ) -> Result { + use serde_yaml; + + // Log version comparison for debugging + if serializable.config_version != backend_chain_config.config_version { + tracing::warn!( + "RuntimeExecutionConfig version mismatch: saved version {} != current backend version {}. \ + Using saved config for re-execution to ensure consistency.", + serializable.config_version, + backend_chain_config.config_version + ); + } + + // Deserialize ChainConfig from YAML using its built-in deserialization logic + // This reuses ChainConfig's deserialization code and handles all versions automatically + let versioned: ChainConfigVersioned = serde_yaml::from_str(&serializable.chain_config_json) + .context("Failed to deserialize ChainConfig from YAML")?; + + let mut chain_config = ChainConfig::try_from(versioned) + .context("Failed to convert versioned ChainConfig to canonical ChainConfig")?; + + // Merge versioned_constants from backend (they're not stored in the serialized config) + // This ensures we have all the versioned constants available + chain_config.versioned_constants = { + let mut vc = ChainVersionedConstants::default(); + for (k, v) in &backend_chain_config.versioned_constants.0 { + vc.add(*k, v.clone()); + } + vc + }; + + // Use default private_key since it's not serialized and ZeroingPrivateKey doesn't implement Clone + // This is safe because: + // 1. The reconstructed ChainConfig is only used for re-execution (which doesn't require private_key) + // 2. When closing the block (close_preconfirmed_block_with_state_diff -> close_preconfirmed -> write_new_confirmed_inner), + // the backend's CURRENT chain_config is used (self.inner.chain_config) for computing block hash and commitments, + // not the reconstructed one. Blocks are saved with consensus_signatures: vec![] (not signed at closing time). + // 3. When blocks are signed (on-demand via gateway API handle_get_signature), + // the backend's CURRENT chain_config.private_key is used, not the reconstructed one + // Therefore, using default() here is acceptable since private_key is never accessed from the reconstructed config + chain_config.private_key = ZeroingPrivateKey::default(); + + // Reconstruct VersionedConstants from protocol version using backend's versioned_constants map + let protocol_version: StarknetVersion = + serializable.protocol_version.parse().context("Failed to parse protocol version")?; + let exec_constants = backend_chain_config.versioned_constants.0.get(&protocol_version).cloned().context( + format!("Failed to find VersionedConstants for protocol version {}", serializable.protocol_version), + )?; + + Ok(Self { chain_config, exec_constants, no_charge_fee: serializable.no_charge_fee }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{path::Path, sync::Arc}; + + /// Load mainnet config for testing (same as chain_config.rs tests) + fn load_mainnet_config() -> ChainConfig { + // Change to project root to find configs/presets/mainnet.yaml + // Save current directory and restore it after loading + let original_dir = std::env::current_dir().expect("Failed to get current directory"); + std::env::set_current_dir("../../../../").expect("Failed to change directory"); + let config = + ChainConfig::from_yaml(Path::new("configs/presets/mainnet.yaml")).expect("Failed to load mainnet config"); + std::env::set_current_dir(original_dir).expect("Failed to restore directory"); + config + } + + /// Test that round-trip serialization preserves all config values. + /// + /// This is the core test that verifies the serialization mechanism works correctly. + /// It ensures that when we save and load a RuntimeExecutionConfig, all values are preserved. + #[test] + fn test_round_trip_serialization_preserves_config() { + let config = Arc::new(load_mainnet_config()); + let exec_constants = config + .versioned_constants + .0 + .get(&config.latest_protocol_version) + .cloned() + .expect("Mainnet config should have versioned constants"); + let no_charge_fee = false; + + // Create RuntimeExecutionConfig from mainnet config + let runtime_config = + RuntimeExecutionConfig::from_arc_chain_config(&config, exec_constants.clone(), no_charge_fee) + .expect("Failed to create RuntimeExecutionConfig"); + + // Serialize to database format + let serializable = runtime_config.to_serializable().expect("Failed to serialize RuntimeExecutionConfig"); + + // Verify version is stored correctly + assert_eq!(serializable.config_version, config.config_version); + + // Deserialize back + let deserialized = RuntimeExecutionConfig::from_serializable(serializable, &config) + .expect("Failed to deserialize RuntimeExecutionConfig"); + + // Verify critical fields are preserved + assert_eq!(deserialized.chain_config.config_version, runtime_config.chain_config.config_version); + assert_eq!(deserialized.chain_config.chain_name, runtime_config.chain_config.chain_name); + assert_eq!(deserialized.chain_config.chain_id, runtime_config.chain_config.chain_id); + assert_eq!(deserialized.chain_config.block_time, runtime_config.chain_config.block_time); + assert_eq!(deserialized.no_charge_fee, runtime_config.no_charge_fee); + } +}