diff --git a/src/daemon/db_util.rs b/src/daemon/db_util.rs index 872667092b0d..6faee5a5b3bd 100644 --- a/src/daemon/db_util.rs +++ b/src/daemon/db_util.rs @@ -635,12 +635,7 @@ async fn process_ts( } }; // Store the block-logs bloom; idempotent if the loader already recomputed and stored it. - crate::rpc::eth::store_block_logs_bloom( - state_manager, - ts, - &executed.state_root, - &executed.executed_messages, - )?; + crate::rpc::eth::store_block_logs_bloom(state_manager, ts, &executed)?; delegated_messages.append( &mut state_manager diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 512f57290edb..c3d5e78552f3 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -4,6 +4,7 @@ mod bloom; pub(crate) mod errors; mod eth_tx; +pub mod events; pub mod filter; pub mod pubsub; pub(crate) mod pubsub_trait; @@ -16,6 +17,7 @@ use crate::utils::encoding::hex; pub use bloom::Bloom; pub(crate) use bloom::store_block_logs_bloom; use bloom::{EMPTY_BLOOM, FULL_BLOOM, accrue_eth_log, block_logs_bloom}; +use events::eth_log_from_event; pub use tipset_resolver::TipsetResolver; use tokio_util::sync::CancellationToken; @@ -77,7 +79,7 @@ use ahash::{HashMap, HashSet}; use anyhow::{Error, Result, anyhow, bail, ensure}; use enumflags2::{BitFlags, make_bitflags}; use filter::{ParsedFilter, ParsedFilterTipsets}; -use fvm_ipld_encoding::{CBOR, DAG_CBOR, IPLD_RAW, RawBytes}; +use fvm_ipld_encoding::{CBOR, DAG_CBOR, RawBytes}; use get_size2::GetSize; use ipld_core::ipld::Ipld; use nonzero_ext::nonzero; @@ -527,13 +529,9 @@ impl Block { let block_number = EthInt64(tipset.epoch()); let block_hash: EthHash = block_cid.into(); - let ExecutedTipset { - state_root, - executed_messages, - .. - } = state_manager.load_executed_tipset_for_rpc(&tipset).await?; - let has_transactions = !executed_messages.is_empty(); - let state_tree = state_manager.get_state_tree(&state_root)?; + let executed = state_manager.load_executed_tipset_for_rpc(&tipset).await?; + let has_transactions = !executed.executed_messages.is_empty(); + let state_tree = state_manager.get_state_tree(&executed.state_root)?; let mut full_transactions = vec![]; let mut gas_used = 0; @@ -542,7 +540,7 @@ impl Block { ExecutedMessage { message, receipt, .. }, - ) in executed_messages.iter().enumerate() + ) in executed.executed_messages.iter().enumerate() { let ti = EthUint64(i as u64); gas_used += receipt.gas_used(); @@ -570,8 +568,7 @@ impl Block { full_transactions.push(tx); } - let logs_bloom = - block_logs_bloom(state_manager, &tipset, &state_root, &executed_messages)?; + let logs_bloom = block_logs_bloom(state_manager, &tipset, &executed)?; Ok(Arc::new(Block { hash: block_hash, @@ -3232,80 +3229,6 @@ pub struct CollectedEvent { /// membership checks borrow the tipset key and stores each distinct key only once. pub type SeenEventPositions = HashMap>; -fn match_key(key: &str) -> Option { - match key.get(0..2) { - Some("t1") => Some(0), - Some("t2") => Some(1), - Some("t3") => Some(2), - Some("t4") => Some(3), - _ => None, - } -} - -fn eth_log_from_event(entries: &[EventEntry]) -> Option<(EthBytes, Vec)> { - let mut topics_found = [false; 4]; - let mut topics_found_count = 0; - let mut data_found = false; - let mut data: EthBytes = EthBytes::default(); - let mut topics: Vec = Vec::default(); - for entry in entries { - // Drop events with non-raw topics. Built-in actors emit CBOR, and anything else would be - // invalid anyway. - if entry.codec != IPLD_RAW { - return None; - } - // Check if the key is t1..t4 - if let Some(idx) = match_key(&entry.key) { - // Drop events with mis-sized topics. - let result: Result<[u8; EVM_WORD_LENGTH], _> = entry.value.0.as_slice().try_into(); - let bytes = if let Ok(value) = result { - value - } else { - tracing::warn!( - "got an EVM event topic with an invalid size (key: {}, size: {})", - entry.key, - entry.value.0.len() - ); - return None; - }; - // Drop events with duplicate topics. - if *topics_found.get(idx).expect("Infallible") { - tracing::warn!("got a duplicate EVM event topic (key: {})", entry.key); - return None; - } - *topics_found.get_mut(idx).expect("Infallible") = true; - topics_found_count += 1; - // Extend the topics array - if topics.len() <= idx { - topics.resize(idx + 1, EthHash::default()); - } - *topics.get_mut(idx).expect("Infallible") = bytes.into(); - } else if entry.key == "d" { - // Drop events with duplicate data fields. - if data_found { - tracing::warn!("got duplicate EVM event data"); - return None; - } - data_found = true; - data = EthBytes(entry.value.0.clone()); - } else { - // Skip entries we don't understand (makes it easier to extend things). - // But we warn for now because we don't expect them. - tracing::warn!("unexpected event entry (key: {})", entry.key); - } - } - // Drop events with skipped topics. - if topics.len() != topics_found_count { - tracing::warn!( - "EVM event topic length mismatch (expected: {}, actual: {})", - topics.len(), - topics_found_count - ); - return None; - } - Some((data, topics)) -} - pub(crate) fn eth_tx_hash_from_signed_message( message: &SignedMessage, eth_chain_id: EthChainIdType, @@ -4221,14 +4144,12 @@ async fn trace_filter( #[cfg(test)] mod test { use super::*; - use crate::rpc::eth::EventEntry; use crate::rpc::state::{ExecutionTrace, MessageTrace, ReturnTrace}; use crate::shim::{econ::TokenAmount, error::ExitCode}; use crate::{ db::MemoryDB, test_utils::{construct_bls_messages, construct_eth_messages, construct_messages}, }; - use fvm_shared4::event::Flags; use quickcheck::Arbitrary; use quickcheck_macros::quickcheck; use rstest::rstest; @@ -4598,262 +4519,6 @@ mod test { assert!(tx_hash.is_none()); } - #[test] - fn test_eth_log_from_event() { - // The value member of these event entries correspond to existing topics on Calibnet, - // but they could just as easily be vectors filled with random bytes. - - let entries = vec![ - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t2".into(), - codec: IPLD_RAW, - value: vec![ - 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, - 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, - ] - .into(), - }, - ]; - let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); - assert!(bytes.0.is_empty()); - assert_eq!(hashes.len(), 2); - - let entries = vec![ - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t2".into(), - codec: IPLD_RAW, - value: vec![ - 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, - 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t3".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t4".into(), - codec: IPLD_RAW, - value: vec![ - 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, - 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, - ] - .into(), - }, - ]; - let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); - assert!(bytes.0.is_empty()); - assert_eq!(hashes.len(), 4); - - let entries = vec![ - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, - 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, - ] - .into(), - }, - ]; - assert!(eth_log_from_event(&entries).is_none()); - - let entries = vec![ - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t3".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t4".into(), - codec: IPLD_RAW, - value: vec![ - 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, - 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t2".into(), - codec: IPLD_RAW, - value: vec![ - 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, - 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, - ] - .into(), - }, - ]; - let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); - assert!(bytes.0.is_empty()); - assert_eq!(hashes.len(), 4); - - let entries = vec![ - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t3".into(), - codec: IPLD_RAW, - value: vec![ - 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, - 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, - ] - .into(), - }, - ]; - assert!(eth_log_from_event(&entries).is_none()); - - let entries = vec![EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: DAG_CBOR, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, 81, - 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }]; - assert!(eth_log_from_event(&entries).is_none()); - - let entries = vec![EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, 81, - 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, 0, - ] - .into(), - }]; - assert!(eth_log_from_event(&entries).is_none()); - - let entries = vec![ - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "d".into(), - codec: IPLD_RAW, - value: vec![ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 190, - 25, 34, 116, 232, 27, 26, 248, - ] - .into(), - }, - ]; - let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); - assert_eq!(bytes.0.len(), 32); - assert_eq!(hashes.len(), 1); - - let entries = vec![ - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "t1".into(), - codec: IPLD_RAW, - value: vec![ - 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, - 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, 0, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "d".into(), - codec: IPLD_RAW, - value: vec![ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 190, - 25, 34, 116, 232, 27, 26, 248, - ] - .into(), - }, - EventEntry { - flags: (Flags::FLAG_INDEXED_ALL).bits(), - key: "d".into(), - codec: IPLD_RAW, - value: vec![ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 190, - 25, 34, 116, 232, 27, 26, 248, - ] - .into(), - }, - ]; - assert!(eth_log_from_event(&entries).is_none()); - } - #[test] fn test_from_bytes_valid() { let zero_bytes = [0u8; 32]; diff --git a/src/rpc/methods/eth/bloom.rs b/src/rpc/methods/eth/bloom.rs index 6e8c19dec6e9..9fb75e339301 100644 --- a/src/rpc/methods/eth/bloom.rs +++ b/src/rpc/methods/eth/bloom.rs @@ -8,8 +8,15 @@ //! served from the store when building Ethereum blocks. For tipsets never executed by //! this node (nor covered by index backfill), the block reports [`FULL_BLOOM`]. -use super::*; +use super::events::BlockLogs; +use super::types::{EthAddress, EthHash}; +use crate::blocks::Tipset; use crate::db::EthBlockBloomStore; +use crate::lotus_json::lotus_json_with_self; +use crate::state_manager::{ExecutedTipset, StateManager}; +use get_size2::GetSize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; /// Ethereum Bloom filter size in bits. /// Bloom filter is used in Ethereum to minimize the number of block queries. @@ -52,69 +59,18 @@ pub(super) fn accrue_eth_log(bloom: &mut Bloom, address: &EthAddress, topics: &[ bloom.accrue(address.0.as_bytes()); } -/// Computes the block logs bloom of a tipset directly from its executed messages, resolving -/// event emitters against the post-execution state root. -fn compute_block_logs_bloom( - state_manager: &StateManager, - state_root: &Cid, - executed_messages: &[ExecutedMessage], -) -> anyhow::Result { - let state_tree = state_manager.get_state_tree(state_root)?; - let mut resolved_eth_addrs = HashMap::default(); - let mut bloom = Bloom::default(); - for executed_message in executed_messages { - let Some(events) = &executed_message.events else { - continue; - }; - for event in events { - let emitter = event.emitter(); - let address = resolved_eth_addrs.entry(emitter).or_insert_with(|| { - state_tree - .resolve_to_deterministic_address( - state_manager.chain_store().db(), - FilecoinAddress::new_id(emitter), - ) - .ok() - .and_then(|addr| EthAddress::from_filecoin_address(&addr).ok()) - }); - let Some(address) = address else { - continue; - }; - let entries: Vec = event - .entries() - .into_iter() - .map(|entry| { - let (flags, key, codec, value) = entry.into_parts(); - EventEntry { - flags, - key, - codec, - value: value.into(), - } - }) - .collect(); - let Some((_data, topics)) = eth_log_from_event(&entries) else { - continue; - }; - accrue_eth_log(&mut bloom, address, &topics); - } - } - Ok(bloom) -} - /// Computes and stores the block logs bloom of an executed tipset so that serving it later /// is a plain read. Called when a tipset is executed and from index backfill. pub(crate) fn store_block_logs_bloom( state_manager: &StateManager, tipset: &Tipset, - state_root: &Cid, - executed_messages: &[ExecutedMessage], + executed: &ExecutedTipset, ) -> anyhow::Result<()> { let key = tipset.key().cid()?; if state_manager.db().read_bloom(&key)?.is_some() { return Ok(()); } - let bloom = compute_block_logs_bloom(state_manager, state_root, executed_messages)?; + let bloom = BlockLogs::collect(state_manager, tipset, executed)?.bloom(); state_manager .db() .write_bloom(&key, tipset.epoch(), &bloom.0.0) @@ -126,8 +82,7 @@ pub(crate) fn store_block_logs_bloom( pub(super) fn block_logs_bloom( state_manager: &StateManager, tipset: &Tipset, - state_root: &Cid, - executed_messages: &[ExecutedMessage], + executed: &ExecutedTipset, ) -> anyhow::Result { crate::def_is_env_truthy!(compute_bloom_on_miss, COMPUTE_BLOOM_ON_MISS_ENV); @@ -137,7 +92,7 @@ pub(super) fn block_logs_bloom( } if compute_bloom_on_miss() { - let bloom = compute_block_logs_bloom(state_manager, state_root, executed_messages)?; + let bloom = BlockLogs::collect(state_manager, tipset, executed)?.bloom(); state_manager .db() .write_bloom(&key, tipset.epoch(), &bloom.0.0)?; @@ -149,6 +104,7 @@ pub(super) fn block_logs_bloom( #[cfg(test)] mod tests { use super::*; + use crate::rpc::eth::{ADDRESS_LENGTH, EVM_WORD_LENGTH}; #[test] fn test_accrue_eth_log_and_block_bloom_decomposition() { diff --git a/src/rpc/methods/eth/events/logs.rs b/src/rpc/methods/eth/events/logs.rs new file mode 100644 index 000000000000..ef65df86f3cc --- /dev/null +++ b/src/rpc/methods/eth/events/logs.rs @@ -0,0 +1,517 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! The Ethereum view of a tipset's events. + +use super::TipsetEvents; +use crate::blocks::Tipset; +use crate::eth::EthChainId; +use crate::message::ChainMessage; +use crate::rpc::eth::bloom::{Bloom, accrue_eth_log}; +use crate::rpc::eth::types::{EthAddress, EthBytes, EthHash}; +use crate::rpc::eth::{EVM_WORD_LENGTH, EthLog, EthUint64, eth_tx_hash_from_signed_message}; +use crate::rpc::types::EventEntry; +use crate::state_manager::{ExecutedTipset, StateManager}; +use fvm_ipld_encoding::IPLD_RAW; + +/// The Ethereum logs of one tipset, grouped by message, in tipset order. +pub struct BlockLogs { + by_message: Vec>, +} + +impl BlockLogs { + /// Collects the logs of an executed tipset. + pub fn collect( + state_manager: &StateManager, + tipset: &Tipset, + executed: &ExecutedTipset, + ) -> anyhow::Result { + Self::from_events(&TipsetEvents::collect(state_manager, tipset, executed)) + } + + fn from_events(events: &TipsetEvents) -> anyhow::Result { + let block_hash: EthHash = events.tipset_key.cid()?.into(); + let block_number = EthUint64(events.height as u64); + let mut by_message = vec![Vec::new(); events.message_count]; + for message in &events.messages { + let Some(transaction_hash) = message.eth_tx_hash else { + continue; + }; + let logs = message + .events + .iter() + .filter_map(|event| { + let address = + EthAddress::from_filecoin_address(&event.emitter_address?).ok()?; + let (data, topics) = eth_log_from_event(&event.entries)?; + Some(EthLog { + address, + data, + topics, + removed: false, + log_index: event.event_idx.into(), + transaction_index: message.msg_idx.into(), + transaction_hash, + block_hash, + block_number, + }) + }) + .collect(); + if let Some(slot) = by_message.get_mut(message.msg_idx as usize) { + *slot = logs; + } + } + Ok(Self { by_message }) + } + + /// All logs of the tipset, in tipset order. + pub fn logs(&self) -> impl Iterator { + self.by_message.iter().flatten() + } + + /// The bloom of every log's address and topics. + pub fn bloom(&self) -> Bloom { + let mut bloom = Bloom::default(); + for log in self.logs() { + accrue_eth_log(&mut bloom, &log.address, &log.topics); + } + bloom + } +} + +/// The Ethereum transaction hash of a message, when one can be derived from it. +pub(super) fn eth_tx_hash(message: &ChainMessage, eth_chain_id: EthChainId) -> Option { + match message { + ChainMessage::Signed(signed) => eth_tx_hash_from_signed_message(signed, eth_chain_id).ok(), + ChainMessage::Unsigned(message) => Some(message.cid().into()), + } +} + +fn match_key(key: &str) -> Option { + match key.get(0..2) { + Some("t1") => Some(0), + Some("t2") => Some(1), + Some("t3") => Some(2), + Some("t4") => Some(3), + _ => None, + } +} + +/// The `(data, topics)` of an event in Ethereum form, or `None` when the event has none. +pub(crate) fn eth_log_from_event(entries: &[EventEntry]) -> Option<(EthBytes, Vec)> { + let mut topics_found = [false; 4]; + let mut topics_found_count = 0; + let mut data_found = false; + let mut data: EthBytes = EthBytes::default(); + let mut topics: Vec = Vec::default(); + for entry in entries { + // Drop events with non-raw topics. Built-in actors emit CBOR, and anything else would be + // invalid anyway. + if entry.codec != IPLD_RAW { + return None; + } + // Check if the key is t1..t4 + if let Some(idx) = match_key(&entry.key) { + // Drop events with mis-sized topics. + let result: Result<[u8; EVM_WORD_LENGTH], _> = entry.value.0.as_slice().try_into(); + let bytes = if let Ok(value) = result { + value + } else { + tracing::warn!( + "got an EVM event topic with an invalid size (key: {}, size: {})", + entry.key, + entry.value.0.len() + ); + return None; + }; + // Drop events with duplicate topics. + if *topics_found.get(idx).expect("Infallible") { + tracing::warn!("got a duplicate EVM event topic (key: {})", entry.key); + return None; + } + *topics_found.get_mut(idx).expect("Infallible") = true; + topics_found_count += 1; + // Extend the topics array + if topics.len() <= idx { + topics.resize(idx + 1, EthHash::default()); + } + *topics.get_mut(idx).expect("Infallible") = bytes.into(); + } else if entry.key == "d" { + // Drop events with duplicate data fields. + if data_found { + tracing::warn!("got duplicate EVM event data"); + return None; + } + data_found = true; + data = EthBytes(entry.value.0.clone()); + } else { + // Skip entries we don't understand (makes it easier to extend things). + // But we warn for now because we don't expect them. + tracing::warn!("unexpected event entry (key: {})", entry.key); + } + } + // Drop events with skipped topics. + if topics.len() != topics_found_count { + tracing::warn!( + "EVM event topic length mismatch (expected: {}, actual: {})", + topics.len(), + topics_found_count + ); + return None; + } + Some((data, topics)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blocks::TipsetKey; + use crate::rpc::eth::events::{MessageEvents, TipsetEvent}; + use crate::shim::address::Address; + use crate::utils::multihash::MultihashCode; + use cid::Cid; + use fvm_ipld_encoding::DAG_CBOR; + use fvm_shared4::event::Flags; + use itertools::Itertools as _; + use multihash_derive::MultihashDigest as _; + use nunny::vec as nonempty; + use std::str::FromStr as _; + + fn tipset_key(seed: u8) -> TipsetKey { + TipsetKey::from(nonempty![Cid::new_v1( + DAG_CBOR, + MultihashCode::Identity.digest(&[seed]) + )]) + } + + /// An event has an Ethereum form only when its emitter resolves to an eth address, its + /// message has an eth transaction hash and its entries are raw `t1..t4`/`d` (EMT-4, SHP-1, + /// SHP-7); every log carries the tipset's identity and its message position (NUM-5). + #[test] + fn from_events_keeps_only_events_with_an_eth_form() { + let contract = Address::from_str("t410f744ma4xsq3r3eczzktfj7goal67myzfkusna2hy").unwrap(); + let topic = EventEntry { + flags: Flags::FLAG_INDEXED_ALL.bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![7; EVM_WORD_LENGTH].into(), + }; + let cbor_topic = EventEntry { + codec: DAG_CBOR, + ..topic.clone() + }; + let tx_hash = EthHash(ethereum_types::H256::from_slice(&[1; EVM_WORD_LENGTH])); + let event = |event_idx, emitter_address, entries| TipsetEvent { + event_idx, + emitter_address, + entries, + }; + let events = TipsetEvents { + tipset_key: tipset_key(1), + height: 5, + message_count: 3, + messages: vec![ + // Message 0: an EVM log, then an unresolvable emitter, then a built-in actor's + // CBOR event. + MessageEvents { + msg_idx: 0, + eth_tx_hash: Some(tx_hash), + events: vec![ + event(0, Some(contract), vec![topic.clone()]), + event(1, None, vec![topic.clone()]), + event(2, Some(contract), vec![cbor_topic]), + ], + }, + // Message 2: an EVM log from a message without an eth transaction hash. + MessageEvents { + msg_idx: 2, + eth_tx_hash: None, + events: vec![event(3, Some(contract), vec![topic])], + }, + ], + }; + + let logs = BlockLogs::from_events(&events).unwrap(); + + assert_eq!( + logs.by_message.iter().map(Vec::len).collect_vec(), + [1, 0, 0] + ); + let log = logs.logs().exactly_one().ok().unwrap(); + assert_eq!( + log.address, + EthAddress::from_filecoin_address(&contract).unwrap() + ); + assert_eq!( + log.topics, + vec![EthHash(ethereum_types::H256([7; EVM_WORD_LENGTH]))] + ); + assert_eq!(log.log_index, EthUint64(0)); + assert_eq!(log.transaction_index, EthUint64(0)); + assert_eq!(log.transaction_hash, tx_hash); + assert_eq!(log.block_hash, events.tipset_key.cid().unwrap().into()); + assert_eq!(log.block_number, EthUint64(5)); + assert!(!log.removed); + + // The bloom is the fold of exactly those logs (BLM-2). + let mut expected = Bloom::default(); + accrue_eth_log(&mut expected, &log.address, &log.topics); + assert_eq!(logs.bloom(), expected); + } + + #[test] + fn test_eth_log_from_event() { + // The value member of these event entries correspond to existing topics on Calibnet, + // but they could just as easily be vectors filled with random bytes. + + let entries = vec![ + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t2".into(), + codec: IPLD_RAW, + value: vec![ + 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, + 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, + ] + .into(), + }, + ]; + let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); + assert!(bytes.0.is_empty()); + assert_eq!(hashes.len(), 2); + + let entries = vec![ + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t2".into(), + codec: IPLD_RAW, + value: vec![ + 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, + 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t3".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t4".into(), + codec: IPLD_RAW, + value: vec![ + 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, + 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, + ] + .into(), + }, + ]; + let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); + assert!(bytes.0.is_empty()); + assert_eq!(hashes.len(), 4); + + let entries = vec![ + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, + 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, + ] + .into(), + }, + ]; + assert!(eth_log_from_event(&entries).is_none()); + + let entries = vec![ + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t3".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t4".into(), + codec: IPLD_RAW, + value: vec![ + 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, + 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t2".into(), + codec: IPLD_RAW, + value: vec![ + 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, + 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, + ] + .into(), + }, + ]; + let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); + assert!(bytes.0.is_empty()); + assert_eq!(hashes.len(), 4); + + let entries = vec![ + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t3".into(), + codec: IPLD_RAW, + value: vec![ + 116, 4, 227, 209, 4, 234, 120, 65, 195, 217, 230, 253, 32, 173, 254, 153, 180, + 173, 88, 107, 192, 141, 143, 59, 211, 175, 239, 137, 76, 241, 132, 222, + ] + .into(), + }, + ]; + assert!(eth_log_from_event(&entries).is_none()); + + let entries = vec![EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: DAG_CBOR, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, 81, + 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }]; + assert!(eth_log_from_event(&entries).is_none()); + + let entries = vec![EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, 81, + 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, 0, + ] + .into(), + }]; + assert!(eth_log_from_event(&entries).is_none()); + + let entries = vec![ + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "d".into(), + codec: IPLD_RAW, + value: vec![ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 190, + 25, 34, 116, 232, 27, 26, 248, + ] + .into(), + }, + ]; + let (bytes, hashes) = eth_log_from_event(&entries).unwrap(); + assert_eq!(bytes.0.len(), 32); + assert_eq!(hashes.len(), 1); + + let entries = vec![ + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "t1".into(), + codec: IPLD_RAW, + value: vec![ + 226, 71, 32, 244, 92, 183, 79, 45, 85, 241, 222, 235, 182, 9, 143, 80, 241, 11, + 81, 29, 171, 138, 125, 71, 196, 129, 154, 8, 220, 208, 184, 149, 0, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "d".into(), + codec: IPLD_RAW, + value: vec![ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 190, + 25, 34, 116, 232, 27, 26, 248, + ] + .into(), + }, + EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "d".into(), + codec: IPLD_RAW, + value: vec![ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 190, + 25, 34, 116, 232, 27, 26, 248, + ] + .into(), + }, + ]; + assert!(eth_log_from_event(&entries).is_none()); + } +} diff --git a/src/rpc/methods/eth/events/mod.rs b/src/rpc/methods/eth/events/mod.rs new file mode 100644 index 000000000000..c117fdb3d5d7 --- /dev/null +++ b/src/rpc/methods/eth/events/mod.rs @@ -0,0 +1,252 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! The events of one executed tipset, collected once and served to every question about them. +//! +//! [`TipsetEvents`] is the chain-level artifact: every event of the tipset in message order, +//! numbered, with its emitter resolved to a deterministic address where the tipset's state +//! allows. [`BlockLogs`] is its Ethereum projection. Construction is the only step that performs +//! I/O; every view over the artifact is pure. + +mod logs; + +pub use logs::BlockLogs; +pub(super) use logs::eth_log_from_event; + +use crate::blocks::{Tipset, TipsetKey}; +use crate::chain::AtFinalityResolution; +use crate::db::DbImpl; +use crate::rpc::eth::types::EthHash; +use crate::rpc::types::EventEntry; +use crate::shim::address::Address; +use crate::shim::clock::ChainEpoch; +use crate::shim::executor::Entry; +use crate::shim::fvm_shared_latest::ActorID; +use crate::shim::state_tree::StateTree; +use crate::state_manager::{ExecutedTipset, StateManager}; +use ahash::HashMap; +use cid::Cid; +use std::cell::OnceCell; + +/// Every event of one executed tipset, numbered once and emitter-resolved once. +pub struct TipsetEvents { + tipset_key: TipsetKey, + height: ChainEpoch, + message_count: usize, + /// The messages that emitted events, in message order. + messages: Vec, +} + +/// The events of one message, with the identity all of them share. +struct MessageEvents { + msg_idx: u64, + eth_tx_hash: Option, + events: Vec, +} + +struct TipsetEvent { + /// Position among all events of the tipset, counting every event of every message. + event_idx: u64, + /// The emitter's deterministic address, when the tipset's state resolves one. + emitter_address: Option
, + entries: Vec, +} + +impl TipsetEvents { + /// Collects the events of an executed tipset. + pub fn collect( + state_manager: &StateManager, + tipset: &Tipset, + executed: &ExecutedTipset, + ) -> Self { + let eth_chain_id = state_manager.chain_config().eth_chain_id; + let mut resolver = EmitterResolver::new(state_manager, tipset, &executed.state_root); + let mut event_idx = 0u64; + let mut messages = Vec::new(); + for (msg_idx, executed_message) in executed.executed_messages.iter().enumerate() { + let Some(events) = executed_message + .events + .as_deref() + .filter(|events| !events.is_empty()) + else { + continue; + }; + let events = events + .iter() + .map(|event| { + let collected = TipsetEvent { + event_idx, + emitter_address: resolver.resolve(event.emitter()), + entries: event.entries().into_iter().map(event_entry).collect(), + }; + event_idx += 1; + collected + }) + .collect(); + messages.push(MessageEvents { + msg_idx: msg_idx as u64, + eth_tx_hash: logs::eth_tx_hash(&executed_message.message, eth_chain_id), + events, + }); + } + Self { + tipset_key: tipset.key().clone(), + height: tipset.epoch(), + message_count: executed.executed_messages.len(), + messages, + } + } +} + +fn event_entry(entry: Entry) -> EventEntry { + let (flags, key, codec, value) = entry.into_parts(); + EventEntry { + flags, + key, + codec, + value: value.into(), + } +} + +/// Resolves event emitters to deterministic addresses without awaiting: construction runs inside +/// the executed-tipset cache fill, where awaiting a state cache deadlocks. +struct EmitterResolver<'a> { + state_manager: &'a StateManager, + tipset: &'a Tipset, + state_root: &'a Cid, + post_state: OnceCell>>, + resolved: HashMap>, +} + +impl<'a> EmitterResolver<'a> { + fn new(state_manager: &'a StateManager, tipset: &'a Tipset, state_root: &'a Cid) -> Self { + Self { + state_manager, + tipset, + state_root, + post_state: OnceCell::new(), + resolved: HashMap::default(), + } + } + + fn resolve(&mut self, emitter: ActorID) -> Option
{ + if let Some(resolved) = self.resolved.get(&emitter) { + return *resolved; + } + let resolved = self.resolve_uncached(emitter); + self.resolved.insert(emitter, resolved); + resolved + } + + /// Global cache first, then the finality-deep state (cached globally when reorg-stable), + /// then the post-execution state, the only one holding an actor created in this tipset. + fn resolve_uncached(&self, emitter: ActorID) -> Option
{ + let cache = self.state_manager.id_to_deterministic_address_cache(); + if let Some(address) = cache.and_then(|cache| cache.get(&emitter)) { + return Some(address); + } + let id_address = Address::new_id(emitter); + match self + .state_manager + .chain_store() + .resolve_to_deterministic_address_at_finality(&id_address, self.tipset) + { + Ok(AtFinalityResolution::ReorgStable(address)) => { + if let Some(cache) = cache { + cache.insert(emitter, address); + } + return Some(address); + } + Ok(AtFinalityResolution::Unstable(address)) => return Some(address), + Err(_) => {} + } + let db = self.state_manager.db(); + self.post_state + .get_or_init(|| self.state_manager.get_state_tree(self.state_root).ok()) + .as_ref()? + .resolve_to_deterministic_address(db, id_address) + .ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::ChainMessage; + use crate::shim::executor::{Receipt, StampedEvent}; + use crate::shim::message::Message; + use crate::state_manager::ExecutedMessage; + use std::sync::Arc; + + /// Minimal `StateManager` over an in-memory store plus its (genesis) heaviest tipset. + pub(super) fn test_state_manager() -> (StateManager, Tipset) { + use crate::blocks::{CachingBlockHeader, RawBlockHeader}; + use crate::chain::ChainStore; + use crate::db::MemoryDB; + use crate::networks::ChainConfig; + + let db = Arc::new(MemoryDB::default()); + let genesis_header = CachingBlockHeader::new(RawBlockHeader { + miner_address: Address::new_id(0), + // A zero genesis timestamp is rejected by the beacon schedule. + timestamp: 7777, + ..Default::default() + }); + let chain_store = + ChainStore::new(db, Arc::new(ChainConfig::default()), genesis_header).unwrap(); + let tipset = chain_store.heaviest_tipset(); + let state_manager = StateManager::new(chain_store).unwrap(); + (state_manager, tipset) + } + + /// `transaction_index` and `log_index` derive from these positions: the message's index in + /// `executed_messages` and a gap-free count across the whole tipset (NUM-1, NUM-5). + #[test] + fn collect_numbers_events_by_message_and_across_the_tipset() { + let (state_manager, tipset) = test_state_manager(); + + let exec_msg = |n_events: usize| ExecutedMessage { + message: ChainMessage::Unsigned(Message::default().into()), + receipt: Receipt::empty_success(), + events: (n_events > 0).then(|| { + (0..n_events) + .map(|_| StampedEvent::new_indexed(1000, "t1")) + .collect() + }), + }; + // Message 1 emits nothing and must not shift the indices of later messages' events. + let events_per_msg = [2usize, 0, 3, 1]; + let executed = ExecutedTipset { + state_root: Cid::default(), + receipt_root: Cid::default(), + executed_messages: Arc::new(events_per_msg.iter().map(|&n| exec_msg(n)).collect()), + }; + + let events = TipsetEvents::collect(&state_manager, &tipset, &executed); + + let expected = events_per_msg + .iter() + .enumerate() + .flat_map(|(msg_idx, &n)| std::iter::repeat_n(msg_idx as u64, n)) + .enumerate() + .map(|(event_idx, msg_idx)| (msg_idx, event_idx as u64)); + itertools::assert_equal( + events.messages.iter().flat_map(|message| { + message + .events + .iter() + .map(|e| (message.msg_idx, e.event_idx)) + }), + expected, + ); + assert_eq!(events.message_count, events_per_msg.len()); + // The test genesis has no state tree, so no emitter resolves. + assert!( + events + .messages + .iter() + .flat_map(|message| &message.events) + .all(|event| event.emitter_address.is_none()) + ); + } +} diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index 732a0efe25fa..5c1027b55af2 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -308,8 +308,7 @@ impl StateManager { self } - /// Test-only view of the ID -> deterministic-address cache. - #[cfg(test)] + /// The ID -> deterministic-address cache, `None` when disabled. pub(crate) fn id_to_deterministic_address_cache(&self) -> Option<&IdToAddressCache> { self.id_to_deterministic_address_cache.as_ref() } diff --git a/src/state_manager/state_computation.rs b/src/state_manager/state_computation.rs index 65c2fe8647c0..823d5c28e665 100644 --- a/src/state_manager/state_computation.rs +++ b/src/state_manager/state_computation.rs @@ -301,14 +301,14 @@ impl StateManager { }); } + let executed = ExecutedTipset { + state_root, + receipt_root, + executed_messages: Arc::new(executed_messages), + }; // Store the block logs bloom whenever this tipset was executed here. if recomputed - && let Err(e) = crate::rpc::eth::store_block_logs_bloom( - self, - msg_ts, - &state_root, - &executed_messages, - ) + && let Err(e) = crate::rpc::eth::store_block_logs_bloom(self, msg_ts, &executed) { warn!( "failed to store block logs bloom for tipset {}: {e:#}", @@ -316,11 +316,7 @@ impl StateManager { ); } - Ok(ExecutedTipset { - state_root, - receipt_root, - executed_messages: Arc::new(executed_messages), - }) + Ok(executed) } /// Conceptually, a [`Tipset`] consists of _blocks_ which share an _epoch_.