diff --git a/.changelog/anvil-monad-rollback-profile.md b/.changelog/anvil-monad-rollback-profile.md new file mode 100644 index 0000000000000..f636e6c5aa801 --- /dev/null +++ b/.changelog/anvil-monad-rollback-profile.md @@ -0,0 +1,5 @@ +--- +anvil: patch +--- + +Rewind inferred Monad hardfork and fee rules with `anvil_rollback` and reorgs. diff --git a/crates/anvil/src/eth/api.rs b/crates/anvil/src/eth/api.rs index e832c72b18254..43063af015f09 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -1694,6 +1694,7 @@ impl EthApi { /// Handler for RPC call: `anvil_rollback` pub async fn anvil_rollback(&self, depth: Option) -> Result<()> { node_info!("anvil_rollback"); + let _lifecycle = self.lifecycle_lock.write().await; let depth = depth.unwrap_or(1); // Check reorg depth doesn't exceed current chain height @@ -1705,8 +1706,11 @@ impl EthApi { ))?; // Get the common ancestor block - let common_block = - self.backend.get_block(common_height).ok_or(BlockchainError::BlockNotFound)?; + let common_block = self + .backend + .rollback_block(common_height) + .await? + .ok_or(BlockchainError::BlockNotFound)?; self.backend.rollback(common_block).await?; Ok(()) @@ -1876,8 +1880,8 @@ impl EthApi { trace!(target: "rpc::api", "executing eth request"); // Fork reset and RPC URL replacement take the write lock internally after their fallible // remote staging work. Memory reset takes it before staging because it snapshots live - // state. Identity and snapshot methods also lock internally to keep direct API callers - // safe without recursively acquiring this fair RwLock. + // state. Identity, snapshot, and chain-rewind methods also lock internally to keep direct + // API callers safe without recursively acquiring this fair RwLock. let _lifecycle = if matches!( &request, EthRequest::Reset(_) @@ -1886,6 +1890,8 @@ impl EthApi { | EthRequest::AnvilMetadata(_) | EthRequest::EvmSnapshot(_) | EthRequest::EvmRevert(_) + | EthRequest::Reorg(_) + | EthRequest::Rollback(_) ) { None } else { @@ -4026,6 +4032,7 @@ impl EthApi { /// Handler for RPC call: `anvil_reorg` pub async fn anvil_reorg(&self, options: ReorgOptions) -> Result<()> { node_info!("anvil_reorg"); + let _lifecycle = self.lifecycle_lock.write().await; let depth = options.depth; let tx_block_pairs = options.tx_block_pairs; @@ -4038,8 +4045,11 @@ impl EthApi { ))?; // Get the common ancestor block - let common_block = - self.backend.get_block(common_height).ok_or(BlockchainError::BlockNotFound)?; + let common_block = self + .backend + .rollback_block(common_height) + .await? + .ok_or(BlockchainError::BlockNotFound)?; // Convert the transaction requests to pool transactions if they exist, otherwise use empty // hashmap @@ -5063,13 +5073,17 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn set_rpc_url_installs_context_equivalent_identity_with_new_instance() { - let genesis_timestamp = 1_700_000_000u64; + let timestamp = 1_700_000_000u64; let (_origin_api, origin_handle) = - spawn(NodeConfig::test().with_genesis_timestamp(Some(genesis_timestamp))).await; - let (api, _handle) = - spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + spawn(NodeConfig::test().with_genesis_timestamp(Some(timestamp))).await; + let (api, _handle) = spawn( + NodeConfig::test() + .with_genesis_timestamp(Some(timestamp)) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())), + ) + .await; let (target_api, target_handle) = - spawn(NodeConfig::test().with_genesis_timestamp(Some(genesis_timestamp))).await; + spawn(NodeConfig::test().with_genesis_timestamp(Some(timestamp))).await; let target_url = target_handle.http_endpoint(); let fork = api.backend.get_fork().unwrap(); let identity_before = fork.config.read().endpoint_identity; diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index f173f4288c794..71c13f81e76fd 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -76,11 +76,10 @@ use alloy_evm::{ use alloy_evm::{RecoveredTx, block::BlockExecutionError}; #[cfg(feature = "monad")] use alloy_monad_evm::{MonadContext, MonadEvm, MonadEvmFactory}; -#[cfg(feature = "monad")] -use alloy_network::BlockResponse; use alloy_network::{ - AnyHeader, AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, AnyTxEnvelope, AnyTxType, Network, - NetworkTransactionBuilder, ReceiptResponse, UnknownTxEnvelope, UnknownTypedTransaction, + AnyHeader, AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, AnyTxEnvelope, AnyTxType, + BlockResponse, Network, NetworkTransactionBuilder, ReceiptResponse, UnknownTxEnvelope, + UnknownTypedTransaction, }; #[cfg(feature = "optimism")] use alloy_op_evm::{OpEvmContext, OpEvmFactory, OpTx}; @@ -510,6 +509,15 @@ struct PreparedMonadExecution { hardfork: MonadHardfork, } +/// Monad execution state staged while rewinding to a retained canonical block. +#[cfg(feature = "monad")] +struct MonadRollbackProfile { + hardfork: FoundryHardfork, + fees: FeeManager, + block_blob_excess_gas_and_price: Option, + publish_inferred_hardfork: bool, +} + #[cfg(feature = "monad")] fn exact_monad_context(context: MonadReplayContext) -> MonadExecutionContext<'static> { MonadExecutionContext::Exact(Box::new(context)) @@ -2170,6 +2178,30 @@ impl Backend { self.get_block_with_hash(id).map(|(block, _)| block) } + /// Returns a rollback ancestor, including the remote base of a Monad transaction-hash fork. + pub async fn rollback_block(&self, number: u64) -> Result, BlockchainError> { + if let Some(block) = self.get_block(number) { + return Ok(Some(block)); + } + + let Some(fork) = self.get_fork().filter(|fork| { + self.is_monad() + && fork.transaction_hash().is_some() + && fork.predates_fork_inclusive(number) + }) else { + return Ok(None); + }; + let Some(block) = fork.block_by_number(number).await? else { + return Ok(None); + }; + let header = Header::try_from(block.header().inner.clone()) + .map_err(|err| BlockchainError::Internal(err.to_string()))?; + Ok(Some(Block { + header: FoundryHeader::new(header, self.is_tempo()), + body: Default::default(), + })) + } + pub fn get_block_by_hash(&self, hash: B256) -> Option { self.blockchain.get_block_by_hash(&hash) } @@ -7100,13 +7132,100 @@ where } } + /// Stages the Monad protocol profile and next-block fee state for a retained block. + #[cfg(feature = "monad")] + async fn prepare_monad_rollback_profile( + &self, + common_block: &Block, + ) -> Option { + if !self.is_monad() { + return None; + } + + let explicit_hardfork = self.node_config.read().await.hardfork; + let block_hash = common_block.header.hash_slow(); + let stored_hardfork = self + .blockchain + .storage + .read() + .monad_block_replay_profiles + .get(&block_hash) + .map(|profile| profile.hardfork); + let fork = self.fork.read().clone(); + let endpoint_hardfork = fork.as_ref().and_then(|fork| { + let config = fork.config.read(); + if config.block_number != common_block.header.number() + || config.block_hash != block_hash + { + return None; + } + match config.endpoint_identity.hardfork { + Some(FoundryHardfork::Monad(hardfork)) => Some(hardfork), + _ => None, + } + }); + let source_chain_id = self.protocol_chain_id(); + let scheduled_hardfork = MonadHardfork::from_chain_and_timestamp( + source_chain_id, + common_block.header.timestamp(), + ); + let hardfork = explicit_hardfork.unwrap_or_else(|| { + FoundryHardfork::Monad( + stored_hardfork + .or(endpoint_hardfork) + .or(scheduled_hardfork) + .unwrap_or_else(|| self.monad_hardfork()), + ) + }); + let spec_id = SpecId::from(hardfork); + let timestamp = common_block.header.timestamp(); + let blob_params = get_blob_params(source_chain_id, timestamp); + let block_base_fee = common_block.header.base_fee_per_gas.unwrap_or_default(); + + let fees = self.fees.detached(); + fees.set_execution_rules(spec_id, self.networks.base_fee_params(timestamp), None); + fees.set_blob_params(blob_params); + // The fee manager stores values for the next block. Seed it with the retained block while + // deriving those values so the zero-base-fee sentinel and Osaka blob target are applied to + // the correct parent. + fees.set_base_fee(block_base_fee); + let next_block_base_fee = fees.get_next_block_base_fee_per_gas( + common_block.header.gas_used, + common_block.header.gas_limit, + block_base_fee, + ); + let next_block_excess_blob_gas = fees.get_next_block_blob_excess_gas( + common_block.header.excess_blob_gas.unwrap_or_default(), + common_block.header.blob_gas_used.unwrap_or_default(), + ); + fees.set_base_fee(next_block_base_fee); + fees.set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( + next_block_excess_blob_gas, + blob_params.update_fraction as u64, + )); + + Some(MonadRollbackProfile { + hardfork, + fees, + block_blob_excess_gas_and_price: common_block.header.excess_blob_gas.map(|excess| { + BlobExcessGasAndPrice::new(excess, blob_params.update_fraction as u64) + }), + publish_inferred_hardfork: explicit_hardfork.is_none(), + }) + } + /// Rollback the chain to a common height. /// /// The state of the chain is rewound using `rewind` to the common block, including the db, /// storage, and env. pub async fn rollback(&self, common_block: Block) -> Result<(), BlockchainError> { + #[cfg(feature = "monad")] + let _mining_guard = if self.is_monad() { Some(self.mining.lock().await) } else { None }; let hash = common_block.header.hash_slow(); + #[cfg(feature = "monad")] + let monad_profile = self.prepare_monad_rollback_profile(&common_block).await; + // Get the database at the common block let common_state = { let return_state_or_throw_err = @@ -7153,6 +7272,12 @@ where env.block_env.gas_limit = common_block.header.gas_limit(); env.block_env.difficulty = common_block.header.difficulty(); env.block_env.prevrandao = common_block.header.mix_hash(); + #[cfg(feature = "monad")] + if let Some(profile) = &monad_profile { + env.cfg_env.set_spec_and_mainnet_gas_params(profile.hardfork.into()); + env.block_env.basefee = common_block.header.base_fee_per_gas.unwrap_or_default(); + env.block_env.blob_excess_gas_and_price = profile.block_blob_excess_gas_and_price; + } self.time.reset(env.block_env.timestamp.saturating_to()); // drop any pending next-block prevrandao override so it does not leak into a block @@ -7193,6 +7318,17 @@ where } } + #[cfg(feature = "monad")] + if let Some(profile) = monad_profile { + self.fees.replace_from(&profile.fees); + if profile.publish_inferred_hardfork { + *self.hardfork.write() = profile.hardfork; + if let Some(fork) = self.fork.read().clone() { + fork.config.write().hardfork = Some(profile.hardfork); + } + } + } + Ok(()) } diff --git a/crates/anvil/tests/it/monad.rs b/crates/anvil/tests/it/monad.rs index ba50ceb9eb10c..009d7a6be4e58 100644 --- a/crates/anvil/tests/it/monad.rs +++ b/crates/anvil/tests/it/monad.rs @@ -2,7 +2,11 @@ use alloy_consensus::{ SidecarBuilder, SignableTransaction, SimpleCoder, Transaction, TxEip1559, TxLegacy, transaction::TxEip7702, }; -use alloy_eips::eip2718::{Decodable2718, Encodable2718}; +use alloy_eips::{ + calc_next_block_base_fee, + eip1559::BaseFeeParams, + eip2718::{Decodable2718, Encodable2718}, +}; use alloy_network::{ ReceiptResponse, TransactionBuilder, TransactionBuilder4844, TransactionResponse, TxSignerSync, }; @@ -38,7 +42,10 @@ use anvil_core::{ eth::transaction::PendingTransaction, types::{ReorgOptions, TransactionData}, }; -use foundry_evm::hardfork::{FoundryHardfork, MonadHardfork}; +use foundry_evm::{ + hardfork::{FoundryHardfork, MonadHardfork}, + utils::get_blob_params, +}; use foundry_evm_networks::NetworkConfigs; use foundry_primitives::FoundryTxEnvelope; use foundry_test_utils::rpc::spawn_canonical_monad_system_rpc; @@ -55,6 +62,7 @@ use monad_revm::{ }, }, }; +use revm::primitives::hardfork::SpecId; use std::sync::Arc; const STAKING_ADDRESS: Address = address!("0x0000000000000000000000000000000000001000"); const RESERVE_BALANCE_ADDRESS: Address = address!("0x0000000000000000000000000000000000001001"); @@ -1221,6 +1229,108 @@ async fn monad_fork_transaction_hash_preserves_hardfork_on_chain_id_collision() assert!(handle.http_provider().call(reserve_balance_call()).await.unwrap().is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_rollback_restores_inferred_profile() { + let (_origin, endpoint, transaction_hash) = monad_rollback_boundary_origin().await; + let config = NodeConfig::test_monad() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(api.backend.spec_id(), SpecId::OSAKA); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); + + let replay_block_number = provider.get_block_number().await.unwrap(); + let parent = provider + .get_block_by_number(BlockNumberOrTag::Number(replay_block_number - 1)) + .await + .unwrap() + .unwrap(); + api.anvil_rollback(Some(1)).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadEight"); + assert_eq!(api.backend.spec_id(), SpecId::PRAGUE); + assert!(provider.call(reserve_balance_call()).await.unwrap().is_empty()); + assert_eq!( + api.backend.blob_params(), + get_blob_params(MONAD_TESTNET_CHAIN_ID, parent.header.timestamp) + ); + assert_eq!( + api.backend.evm_env().read().block_env.basefee, + parent.header.base_fee_per_gas.unwrap() + ); + assert_eq!( + api.backend.base_fee(), + calc_next_block_base_fee( + parent.header.gas_used, + parent.header.gas_limit, + parent.header.base_fee_per_gas.unwrap(), + BaseFeeParams::ethereum(), + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_reorg_restores_inferred_profile() { + let (_origin, endpoint, transaction_hash) = monad_rollback_boundary_origin().await; + let config = NodeConfig::test_monad() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + api.anvil_reorg(ReorgOptions { depth: 1, tx_block_pairs: Vec::new() }).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadEight"); + assert_eq!(api.backend.spec_id(), SpecId::PRAGUE); + assert!(provider.call(reserve_balance_call()).await.unwrap().is_empty()); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!( + api.backend.blob_params(), + get_blob_params(MONAD_TESTNET_CHAIN_ID, block.header.timestamp) + ); + assert_eq!( + api.backend.base_fee(), + calc_next_block_base_fee( + block.header.gas_used, + block.header.gas_limit, + block.header.base_fee_per_gas.unwrap(), + BaseFeeParams::ethereum(), + ) + ); + let state = api.serialized_state(false).await.unwrap(); + assert_eq!( + state.monad_block_replay_profiles[&block.header.hash].hardfork, + MonadHardfork::MonadEight + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_rollback_preserves_explicit_hardfork() { + let (_origin, endpoint, transaction_hash) = monad_rollback_boundary_origin().await; + let config = NodeConfig::test_monad() + .with_hardfork(Some(MonadHardfork::MonadNine.into())) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.anvil_rollback(Some(1)).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(api.backend.spec_id(), SpecId::OSAKA); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); +} + #[tokio::test(flavor = "multi_thread")] async fn monad_mining_tracks_eip7702_authorities() { let (api, handle) = spawn(monad_nine_config()).await; @@ -2134,6 +2244,36 @@ async fn assert_monad_reset_to_memory( assert_eq!(local_result.is_empty(), local_hardfork == MonadHardfork::MonadEight); } +async fn monad_rollback_boundary_origin() -> (NodeHandle, String, B256) { + let activation = MonadHardfork::MonadNine.testnet_activation_timestamp().unwrap(); + let config = monad_eight_config() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some(activation - 2)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + + api.evm_set_next_block_timestamp(activation - 1).unwrap(); + api.mine_one().await.unwrap(); + api.evm_set_next_block_timestamp(activation).unwrap(); + let receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let endpoint = handle.http_endpoint(); + (handle, endpoint, receipt.transaction_hash) +} + async fn monad_boundary_origin() -> (NodeHandle, String) { let activation = MonadHardfork::MonadNine.testnet_activation_timestamp().unwrap(); let config = monad_nine_config()