diff --git a/.github/workflows/pr-main_l2.yaml b/.github/workflows/pr-main_l2.yaml index 9f81dec502f..26b28049964 100644 --- a/.github/workflows/pr-main_l2.yaml +++ b/.github/workflows/pr-main_l2.yaml @@ -45,6 +45,8 @@ jobs: - "fixtures/contracts/**" - "crates/blockchain/dev/**" - "crates/vm/levm/**" + - "crates/common/types/block_execution_witness.rs" + - "crates/networking/rpc/debug/execution_witness.rs" - ".github/workflows/pr-main_l2.yaml" - "cmd/ethrex/l2/**" non_docs: @@ -926,6 +928,122 @@ jobs: cargo test -p ethrex-test forced_inclusion --release --features l2 -- --nocapture --test-threads=1 killall ethrex -s SIGINT + replay-witness-test: + name: Replay Witness Test + runs-on: ubuntu-latest + needs: [detect-changes, build-docker] + if: ${{ needs.detect-changes.outputs.run_tests == 'true' && github.event_name != 'merge_group' }} + timeout-minutes: 30 + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Setup Rust Environment + uses: ./.github/actions/setup-rust + + - name: Download ethrex image artifact + uses: actions/download-artifact@v4 + with: + name: ethrex_image + path: /tmp + + - name: Load ethrex image + run: docker load --input /tmp/ethrex_image.tar + + - name: Start L1 dev node + run: | + cd crates/l2 + docker compose up --detach ethrex_l1 + + - name: Wait for L1 to be ready + run: | + MAX_TRIES=30 + for i in $(seq 1 $MAX_TRIES); do + if curl -sf -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' > /dev/null 2>&1; then + echo "L1 is ready" + exit 0 + fi + echo "Waiting for L1... (attempt $i)" + sleep 2 + done + echo "L1 failed to start" + docker logs ethrex_l1 + exit 1 + + - name: Install rex + run: | + curl -L https://github.com/lambdaclass/rex/releases/download/v8.0.0/rex-linux-x86_64 -o /tmp/rex + chmod +x /tmp/rex + sudo mv /tmp/rex /usr/local/bin/rex + + - name: Clone and build ethrex-replay + run: | + # Pin to a specific ethrex-replay commit to avoid unrelated replay + # regressions breaking ethrex CI. Update this SHA when replay is + # updated to match new ethrex API changes. + REPLAY_REF="c6bc19f" + git clone https://github.com/lambdaclass/ethrex-replay.git /tmp/ethrex-replay + cd /tmp/ethrex-replay + git checkout "$REPLAY_REF" + + # Override ethrex deps with the local checkout from the current PR. + # This ensures ethrex-replay always builds against the PR's code, + # regardless of what branch ethrex-replay's Cargo.toml points to. + cat >> Cargo.toml << PATCH + + [patch."https://github.com/lambdaclass/ethrex"] + ethrex-common = { path = "$GITHUB_WORKSPACE/crates/common" } + ethrex-config = { path = "$GITHUB_WORKSPACE/crates/common/config" } + ethrex-crypto = { path = "$GITHUB_WORKSPACE/crates/common/crypto" } + ethrex-rlp = { path = "$GITHUB_WORKSPACE/crates/common/rlp" } + ethrex-trie = { path = "$GITHUB_WORKSPACE/crates/common/trie" } + ethrex-storage = { path = "$GITHUB_WORKSPACE/crates/storage" } + ethrex-vm = { path = "$GITHUB_WORKSPACE/crates/vm" } + ethrex-levm = { path = "$GITHUB_WORKSPACE/crates/vm/levm" } + ethrex-blockchain = { path = "$GITHUB_WORKSPACE/crates/blockchain" } + ethrex-rpc = { path = "$GITHUB_WORKSPACE/crates/networking/rpc" } + ethrex-p2p = { path = "$GITHUB_WORKSPACE/crates/networking/p2p" } + ethrex-l2 = { path = "$GITHUB_WORKSPACE/crates/l2" } + ethrex-l2-common = { path = "$GITHUB_WORKSPACE/crates/l2/common" } + ethrex-l2-rpc = { path = "$GITHUB_WORKSPACE/crates/l2/networking/rpc" } + ethrex-storage-rollup = { path = "$GITHUB_WORKSPACE/crates/l2/storage" } + ethrex-sdk = { path = "$GITHUB_WORKSPACE/crates/l2/sdk" } + ethrex-prover = { path = "$GITHUB_WORKSPACE/crates/l2/prover" } + ethrex-guest-program = { path = "$GITHUB_WORKSPACE/crates/guest-program" } + PATCH + + cargo build --release + + - name: Send test transaction and run replay + run: | + # Use a pre-funded dev account (from l1.json genesis) + PRIVATE_KEY="0x850643a0224065ecce3882673c21f56bcf6eef86274cc21cadff15930b59fc8c" + TX_HASH=$(rex send 0x0000000000000000000000000000000000000001 \ + --value 1000000000000000000 \ + -k "$PRIVATE_KEY" \ + --rpc-url http://localhost:8545 2>&1 | head -1) + echo "Sent transaction: $TX_HASH" + + # Wait for receipt and extract block number + sleep 3 + BLOCK_NUMBER=$(curl -sf -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$TX_HASH\"],\"id\":1}" \ + | python3 -c "import sys,json; r=json.load(sys.stdin); print(int(r['result']['blockNumber'], 16))") + echo "Transaction included in block $BLOCK_NUMBER" + + # Run replay on the block containing the transaction + cd /tmp/ethrex-replay + RUST_LOG=info ./target/release/ethrex-replay block "$BLOCK_NUMBER" \ + --rpc-url http://localhost:8545 \ + --no-zkvm + + - name: Dump L1 logs on failure + if: ${{ failure() }} + run: docker logs ethrex_l1 2>&1 | tail -100 + # The purpose of this job is to add it as a required check in GitHub so that we don't have to add every individual job as a required check all-tests: # "Integration Test L2" is a required check, don't change the name @@ -939,9 +1057,10 @@ jobs: integration-test-tdx, uniswap-swap, integration-test-shared-bridge, + replay-witness-test, ] # Make sure this job runs even if the previous jobs failed or were skipped - if: ${{ needs.detect-changes.outputs.run_tests == 'true' && always() && needs.integration-test.result != 'skipped' && needs.state-diff-test.result != 'skipped' && needs.integration-test-tdx.result != 'skipped' && needs.uniswap-swap.result != 'skipped' && needs.integration-test-shared-bridge.result != 'skipped' }} + if: ${{ needs.detect-changes.outputs.run_tests == 'true' && always() && needs.integration-test.result != 'skipped' && needs.state-diff-test.result != 'skipped' && needs.integration-test-tdx.result != 'skipped' && needs.uniswap-swap.result != 'skipped' && needs.integration-test-shared-bridge.result != 'skipped' && needs.replay-witness-test.result != 'skipped' }} steps: - name: Check if any job failed run: | @@ -964,3 +1083,8 @@ jobs: echo "Job Integration test shared bridge failed" exit 1 fi + + if [ "${{ needs.replay-witness-test.result }}" != "success" ]; then + echo "Job Replay Witness Test failed" + exit 1 + fi diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index fb79f2e7e60..d94d8515d3d 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -1544,15 +1544,6 @@ impl Blockchain { block_headers_bytes.push(current_header.encode_to_vec()); } - // Create a list of all read/write addresses and storage slots - let mut keys = Vec::new(); - for (address, touched_storage_slots) in touched_account_storage_slots { - keys.push(address.as_bytes().to_vec()); - for slot in touched_storage_slots.iter() { - keys.push(slot.as_bytes().to_vec()); - } - } - // Get initial state trie root and embed the rest of the trie into it let nodes: BTreeMap = used_trie_nodes .into_iter() @@ -1573,12 +1564,9 @@ impl Blockchain { Trie::new_temp() }; let mut storage_trie_roots = BTreeMap::new(); - for key in &keys { - if key.len() != 20 { - continue; // not an address - } - let address = Address::from_slice(key); - let hashed_address = hash_address(&address); + for address in touched_account_storage_slots.keys() { + let hashed_address = hash_address(address); + let hashed_address_h256 = H256::from_slice(&hashed_address); let Some(encoded_account) = state_trie.get(&hashed_address)? else { continue; // empty account, doesn't have a storage trie }; @@ -1595,7 +1583,7 @@ impl Blockchain { "execution witness does not contain non-empty storage trie".to_string(), )); }; - storage_trie_roots.insert(address, (*node).clone()); + storage_trie_roots.insert(hashed_address_h256, (*node).clone()); } Ok(ExecutionWitness { @@ -1605,7 +1593,6 @@ impl Blockchain { chain_config: self.storage.get_chain_config(), state_trie_root, storage_trie_roots, - keys, }) } @@ -1786,15 +1773,6 @@ impl Blockchain { block_headers_bytes.push(current_header.encode_to_vec()); } - // Create a list of all read/write addresses and storage slots - let mut keys = Vec::new(); - for (address, touched_storage_slots) in touched_account_storage_slots { - keys.push(address.as_bytes().to_vec()); - for slot in touched_storage_slots.iter() { - keys.push(slot.as_bytes().to_vec()); - } - } - // Get initial state trie root and embed the rest of the trie into it let nodes: BTreeMap = used_trie_nodes .into_iter() @@ -1815,12 +1793,9 @@ impl Blockchain { Trie::new_temp() }; let mut storage_trie_roots = BTreeMap::new(); - for key in &keys { - if key.len() != 20 { - continue; // not an address - } - let address = Address::from_slice(key); - let hashed_address = hash_address(&address); + for address in touched_account_storage_slots.keys() { + let hashed_address = hash_address(address); + let hashed_address_h256 = H256::from_slice(&hashed_address); let Some(encoded_account) = state_trie.get(&hashed_address)? else { continue; // empty account, doesn't have a storage trie }; @@ -1837,7 +1812,7 @@ impl Blockchain { "execution witness does not contain non-empty storage trie".to_string(), )); }; - storage_trie_roots.insert(address, (*node).clone()); + storage_trie_roots.insert(hashed_address_h256, (*node).clone()); } Ok(ExecutionWitness { @@ -1847,7 +1822,6 @@ impl Blockchain { chain_config: self.storage.get_chain_config(), state_trie_root, storage_trie_roots, - keys, }) } diff --git a/crates/common/types/block_execution_witness.rs b/crates/common/types/block_execution_witness.rs index 28b4f1a913a..09be4a3d8d0 100644 --- a/crates/common/types/block_execution_witness.rs +++ b/crates/common/types/block_execution_witness.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use bytes::Bytes; -use crate::rkyv_utils::H160Wrapper; +use crate::rkyv_utils::H256Wrapper; use crate::serde_utils; use crate::types::{Block, Code, CodeMetadata}; use crate::{ @@ -42,17 +42,17 @@ pub struct GuestProgramState { pub first_block_number: u64, /// The chain configuration. pub chain_config: ChainConfig, - /// Map of storage root hashes to their corresponding storage tries. - pub storage_tries: BTreeMap, + /// Map of hashed addresses to their corresponding storage tries. + pub storage_tries: BTreeMap, /// Map of account addresses to their corresponding hashed addresses. /// This is a convenience map to avoid recomputing the hashed address /// multiple times during guest program execution. /// It is built on-demand during guest program execution, inside the zkVM. - pub account_hashes_by_address: BTreeMap>, - /// Map of account addresses to booleans, indicating whose account's storage tries were + pub account_hashes_by_address: BTreeMap, + /// Map of hashed addresses to booleans, indicating whose account's storage tries were /// verified. /// Verification is done by hashing the trie and comparing the root hash with the account's storage root. - pub verified_storage_roots: BTreeMap, + pub verified_storage_roots: BTreeMap, } /// Witness data produced by the client and consumed by the guest program @@ -76,13 +76,10 @@ pub struct ExecutionWitness { pub chain_config: ChainConfig, /// Root node embedded with the rest of the trie's nodes pub state_trie_root: Option, - /// Root nodes per account storage embedded with the rest of the trie's nodes - #[rkyv(with = MapKV)] - pub storage_trie_roots: BTreeMap, - /// Flattened map of account addresses and storage keys whose values - /// are needed for stateless execution. - #[rkyv(with = crate::rkyv_utils::VecVecWrapper)] - pub keys: Vec>, + /// Root nodes per account storage embedded with the rest of the trie's nodes, + /// keyed by the keccak256 hash of the account address. + #[rkyv(with = MapKV)] + pub storage_trie_roots: BTreeMap, } /// RPC-friendly representation of an execution witness. @@ -98,6 +95,7 @@ pub struct RpcExecutionWitness { )] pub state: Vec, #[serde( + default, serialize_with = "serde_utils::bytes::vec::serialize", deserialize_with = "serde_utils::bytes::vec::deserialize" )] @@ -127,7 +125,7 @@ impl TryFrom for RpcExecutionWitness { } Ok(Self { state: nodes.into_iter().map(Bytes::from).collect(), - keys: value.keys.into_iter().map(Bytes::from).collect(), + keys: Vec::new(), codes: value.codes.into_iter().map(Bytes::from).collect(), headers: value .block_headers_bytes @@ -199,11 +197,11 @@ impl TryFrom for GuestProgramState { state_trie.hash_no_commit(); let mut storage_tries = BTreeMap::new(); - for (address, storage_trie_root) in value.storage_trie_roots { + for (hashed_address, storage_trie_root) in value.storage_trie_roots { // hash storage trie nodes let storage_trie = Trie::new_temp_with_root(storage_trie_root.into()); storage_trie.hash_no_commit(); - storage_tries.insert(address, storage_trie); + storage_tries.insert(hashed_address, storage_trie); } // hash codes @@ -242,18 +240,18 @@ impl GuestProgramState { account_updates: &[AccountUpdate], ) -> Result<(), GuestProgramStateError> { for update in account_updates.iter() { - let hashed_address = self + let hashed_address = *self .account_hashes_by_address .entry(update.address) .or_insert_with(|| hash_address(&update.address)); if update.removed { // Remove account from trie - self.state_trie.remove(hashed_address)?; + self.state_trie.remove(hashed_address.as_bytes())?; } else { // Add or update AccountState in the trie // Fetch current state or create a new state to be inserted - let mut account_state = match self.state_trie.get(hashed_address)? { + let mut account_state = match self.state_trie.get(hashed_address.as_bytes())? { Some(encoded_state) => AccountState::decode(&encoded_state)?, None => AccountState::default(), }; @@ -271,7 +269,7 @@ impl GuestProgramState { } // Store the added storage in the account's storage trie and compute its new root if !update.added_storage.is_empty() { - let storage_trie = self.storage_tries.entry(update.address).or_default(); + let storage_trie = self.storage_tries.entry(hashed_address).or_default(); // Inserts must come before deletes, otherwise deletes might require extra nodes // Example: @@ -295,8 +293,10 @@ impl GuestProgramState { account_state.storage_root = storage_root; } - self.state_trie - .insert(hashed_address.clone(), account_state.encode_to_vec())?; + self.state_trie.insert( + hashed_address.as_bytes().to_vec(), + account_state.encode_to_vec(), + )?; } } Ok(()) @@ -362,12 +362,12 @@ impl GuestProgramState { &mut self, address: Address, ) -> Result, GuestProgramStateError> { - let hashed_address = self + let hashed_address = *self .account_hashes_by_address .entry(address) .or_insert_with(|| hash_address(&address)); - let Ok(Some(encoded_state)) = self.state_trie.get(hashed_address) else { + let Ok(Some(encoded_state)) = self.state_trie.get(hashed_address.as_bytes()) else { return Ok(None); }; let state = AccountState::decode(&encoded_state).map_err(|_| { @@ -500,16 +500,24 @@ impl GuestProgramState { &mut self, address: Address, ) -> Result, GuestProgramStateError> { - let is_storage_verified = *self.verified_storage_roots.get(&address).unwrap_or(&false); + let hashed_address = *self + .account_hashes_by_address + .entry(address) + .or_insert_with(|| hash_address(&address)); + + let is_storage_verified = *self + .verified_storage_roots + .get(&hashed_address) + .unwrap_or(&false); if is_storage_verified { - Ok(self.storage_tries.get(&address)) + Ok(self.storage_tries.get(&hashed_address)) } else { let Some(storage_root) = self.get_account_state(address)?.map(|a| a.storage_root) else { // empty account return Ok(None); }; - let storage_trie = match self.storage_tries.get(&address) { + let storage_trie = match self.storage_tries.get(&hashed_address) { None if storage_root == *EMPTY_TRIE_HASH => return Ok(None), Some(trie) if trie.hash_no_commit() == storage_root => trie, _ => { @@ -518,14 +526,14 @@ impl GuestProgramState { ))); } }; - self.verified_storage_roots.insert(address, true); + self.verified_storage_roots.insert(hashed_address, true); Ok(Some(storage_trie)) } } } -fn hash_address(address: &Address) -> Vec { - keccak_hash(address.to_fixed_bytes()).to_vec() +fn hash_address(address: &Address) -> H256 { + H256(keccak_hash(address.to_fixed_bytes())) } pub fn hash_key(key: &H256) -> Vec { diff --git a/crates/networking/rpc/debug/execution_witness.rs b/crates/networking/rpc/debug/execution_witness.rs index c7459416bf8..c55d4d7c924 100644 --- a/crates/networking/rpc/debug/execution_witness.rs +++ b/crates/networking/rpc/debug/execution_witness.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use bytes::Bytes; use ethrex_common::{ - Address, H256, + H256, types::{ AccountState, BlockHeader, ChainConfig, block_execution_witness::{ExecutionWitness, GuestProgramStateError, RpcExecutionWitness}, @@ -10,8 +10,7 @@ use ethrex_common::{ utils::keccak, }; use ethrex_rlp::{decode::RLPDecode, error::RLPDecodeError}; -use ethrex_storage::hash_address; -use ethrex_trie::{EMPTY_TRIE_HASH, Node, NodeRef, Trie}; +use ethrex_trie::{EMPTY_TRIE_HASH, Nibbles, Node, NodeRef, Trie}; use serde_json::Value; use tracing::debug; @@ -64,36 +63,28 @@ pub fn execution_witness_from_rpc_chain_config( None }; - // get all storage trie roots and embed the rest of the trie into it - let state_trie = if let Some(state_trie_root) = &state_trie_root { - Trie::new_temp_with_root(state_trie_root.clone().into()) - } else { - Trie::new_temp() - }; + // Walk the state trie to discover accounts and their storage roots, + // instead of relying on the keys field which is being removed from the RPC spec. let mut storage_trie_roots = BTreeMap::new(); - for key in &rpc_witness.keys { - if key.len() != 20 { - continue; // not an address - } - let address = Address::from_slice(key); - let hashed_address = hash_address(&address); - let Some(encoded_account) = state_trie.get(&hashed_address)? else { - continue; // empty account, doesn't have a storage trie - }; - let storage_root_hash = AccountState::decode(&encoded_account)?.storage_root; - if storage_root_hash == *EMPTY_TRIE_HASH { - continue; // empty storage trie - } - if !nodes.contains_key(&storage_root_hash) { - continue; // storage trie isn't relevant to this execution + if let Some(state_trie_root) = &state_trie_root { + let mut accounts = Vec::new(); + collect_accounts_from_node(state_trie_root, Nibbles::from_bytes(&[]), &mut accounts); + + for (hashed_address, storage_root_hash) in accounts { + if storage_root_hash == *EMPTY_TRIE_HASH { + continue; // empty storage trie + } + if !nodes.contains_key(&storage_root_hash) { + continue; // storage trie isn't relevant to this execution + } + let node = Trie::get_embedded_root(&nodes, storage_root_hash)?; + let NodeRef::Node(node, _) = node else { + return Err(GuestProgramStateError::Custom( + "execution witness does not contain non-empty storage trie".to_string(), + )); + }; + storage_trie_roots.insert(hashed_address, (*node).clone()); } - let node = Trie::get_embedded_root(&nodes, storage_root_hash)?; - let NodeRef::Node(node, _) = node else { - return Err(GuestProgramStateError::Custom( - "execution witness does not contain non-empty storage trie".to_string(), - )); - }; - storage_trie_roots.insert(address, (*node).clone()); } let witness = ExecutionWitness { @@ -107,12 +98,40 @@ pub fn execution_witness_from_rpc_chain_config( .collect(), state_trie_root, storage_trie_roots, - keys: rpc_witness.keys.into_iter().map(|b| b.to_vec()).collect(), }; Ok(witness) } +/// Recursively walks an embedded state trie node and collects +/// `(hashed_address, storage_root)` pairs from leaf nodes. +fn collect_accounts_from_node(node: &Node, path: Nibbles, accounts: &mut Vec<(H256, H256)>) { + match node { + Node::Branch(branch) => { + for (i, child) in branch.choices.iter().enumerate() { + if let NodeRef::Node(child_node, _) = child { + collect_accounts_from_node(child_node, path.append_new(i as u8), accounts); + } + } + } + Node::Extension(ext) => { + if let NodeRef::Node(child_node, _) = &ext.child { + collect_accounts_from_node(child_node, path.concat(&ext.prefix), accounts); + } + } + Node::Leaf(leaf) => { + let full_path = path.concat(&leaf.partial); + let path_bytes = full_path.to_bytes(); + if path_bytes.len() == 32 { + let hashed_address = H256::from_slice(&path_bytes); + if let Ok(account_state) = AccountState::decode(&leaf.value) { + accounts.push((hashed_address, account_state.storage_root)); + } + } + } + } +} + pub struct ExecutionWitnessRequest { pub from: BlockIdentifier, pub to: Option,