From 6ca9f0c1934f05ca233dce87771d54b41dc3cfbe Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Thu, 15 Oct 2026 20:30:20 +0530 Subject: [PATCH 1/6] fix(rpc): fail eth log conversion when an event message cannot be read --- src/rpc/methods/eth.rs | 114 ++++++++++++++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 209a8de292e..682b60cb108 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -3354,22 +3354,30 @@ pub(crate) fn eth_tx_hash_from_signed_message( } } +/// Returns the Ethereum transaction hash of the message at `message_cid`. +/// +/// # Errors +/// Fails when the message can be read neither as a signed nor as an unsigned message. The error +/// names the CID and carries both read failures. fn eth_tx_hash_from_message_cid( blockstore: &DB, message_cid: &Cid, eth_chain_id: EthChainIdType, -) -> anyhow::Result> { - if let Ok(smsg) = crate::chain::message_from_cid(blockstore, message_cid) { - // This is an Eth Tx, Secp message, Or BLS message in the mpool - return Ok(Some(eth_tx_hash_from_signed_message(&smsg, eth_chain_id)?)); - } - let result: Result = crate::chain::message_from_cid(blockstore, message_cid); - if result.is_ok() { +) -> anyhow::Result { + let signed_err = + match crate::chain::message_from_cid::<_, SignedMessage>(blockstore, message_cid) { + // This is an Eth Tx, Secp message, Or BLS message in the mpool + Ok(smsg) => return eth_tx_hash_from_signed_message(&smsg, eth_chain_id), + Err(e) => e, + }; + // An unsigned/BLS message fails signed decoding, so always try the fallback. + match crate::chain::message_from_cid::<_, Message>(blockstore, message_cid) { // This is a BLS message - let hash: EthHash = (*message_cid).into(); - return Ok(Some(hash)); + Ok(_) => Ok((*message_cid).into()), + Err(unsigned_err) => Err(anyhow!( + "failed to load event message {message_cid}: {signed_err}; {unsigned_err}" + )), } - Ok(None) } fn eth_filter_logs_from_tipsets(events: &[CollectedEvent]) -> anyhow::Result> { @@ -3401,16 +3409,9 @@ fn eth_filter_logs_from_events( let transaction_hash = if let Some(h) = tx_hash_by_msg.get(&event.msg_cid) { *h } else { - match eth_tx_hash_from_message_cid(ctx.db(), &event.msg_cid, chain_id)? { - Some(h) => { - tx_hash_by_msg.insert(event.msg_cid, h); - h - } - None => { - tracing::debug!("Ignoring event"); - continue; - } - } + let h = eth_tx_hash_from_message_cid(ctx.db(), &event.msg_cid, chain_id)?; + tx_hash_by_msg.insert(event.msg_cid, h); + h }; let block_hash = if let Some(h) = block_hash_by_tipset.get(&event.tipset_key) { @@ -4858,13 +4859,78 @@ mod test { crate::chain::persist_objects(&blockstore, [&secp0, &bls0].into_iter()).unwrap(); let tx_hash = eth_tx_hash_from_message_cid(&blockstore, &secp0.cid(), 0).unwrap(); - assert!(tx_hash.is_some()); + assert_eq!(tx_hash, eth_tx_hash_from_signed_message(&secp0, 0).unwrap()); let tx_hash = eth_tx_hash_from_message_cid(&blockstore, &msg2.cid(), 0).unwrap(); - assert!(tx_hash.is_some()); + assert_eq!(tx_hash, EthHash::from(msg2.cid())); - let tx_hash = eth_tx_hash_from_message_cid(&blockstore, &secp1.cid(), 0).unwrap(); - assert!(tx_hash.is_none()); + let err = eth_tx_hash_from_message_cid(&blockstore, &secp1.cid(), 0).unwrap_err(); + let missing = secp1.cid(); + assert_eq!( + err.to_string(), + format!( + "failed to load event message {missing}: Invalid tipset: {missing}; Invalid tipset: {missing}" + ) + ); + } + + /// One unreadable event message fails the whole conversion instead of dropping the event; + /// readable ones still convert, through the unsigned fallback too. + #[tokio::test] + async fn test_eth_filter_logs_from_events_fails_on_unreadable_message() { + let (ctx, _network_rx) = + crate::rpc::RPCState::for_tests(crate::rpc::test_utils::chain_store()).unwrap(); + let (readable, _) = construct_bls_messages(); + let (_, missing) = construct_eth_messages(0); + crate::chain::persist_objects(ctx.db(), std::iter::once(&readable)).unwrap(); + let (readable, missing) = (readable.cid(), missing.cid()); + let tipset_key = ctx.chain_store().genesis_tipset().key().clone(); + let event = |msg_cid: Cid, codec: u64| CollectedEvent { + entries: vec![EventEntry { + flags: (Flags::FLAG_INDEXED_ALL).bits(), + key: "d".into(), + codec, + value: vec![1].into(), + }], + emitter_addr: FilecoinAddress::new_id(1), + event_idx: 0, + reverted: false, + height: 0, + tipset_key: tipset_key.clone(), + msg_idx: 0, + msg_cid, + }; + + // (case, events, expected log count; `None` expects the error naming the missing message) + let cases = [ + ("BLS fallback", vec![event(readable, IPLD_RAW)], Some(1)), + ("missing message", vec![event(missing, IPLD_RAW)], None), + ( + "readable then missing", + vec![event(readable, IPLD_RAW), event(missing, IPLD_RAW)], + None, + ), + ("empty", vec![], Some(0)), + ("non-Ethereum", vec![event(missing, DAG_CBOR)], Some(0)), + ]; + for (case, events, want_logs) in cases { + let result = eth_filter_logs_from_events(&ctx, &events); + let Some(want_logs) = want_logs else { + let err = result.expect_err(case).to_string(); + assert!( + err.contains(&format!("failed to load event message {missing}")), + "{case}: {err}" + ); + continue; + }; + let logs = result.unwrap_or_else(|e| panic!("{case}: {e:#}")); + assert_eq!(logs.len(), want_logs, "{case}"); + assert!( + logs.iter() + .all(|log| log.transaction_hash == EthHash::from(readable)), + "{case}: {logs:?}" + ); + } } #[test] From 32aae7469dbcca7e9b9826d77680d16638203cdc Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Thu, 15 Oct 2026 20:30:20 +0530 Subject: [PATCH 2/6] fix(rpc): report the hash and sender of a pending eth transaction --- src/rpc/methods/eth.rs | 107 +++++++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 21 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 682b60cb108..43117640f59 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -2681,21 +2681,36 @@ async fn get_eth_transaction_by_hash( // If not found, try to get it from the mempool let (pending, _) = ctx.mpool.pending(); - if let Some(smsg) = pending.iter().find(|item| item.cid() == message_cid) { - // We only return pending eth-account messages because we can't guarantee - // that the from/to addresses of other messages are conversable to 0x-style - // addresses. So we just ignore them. - // - // This should be "fine" as anyone using an "Ethereum-centric" block - // explorer shouldn't care about seeing pending messages from native - // accounts. - if let Ok(eth_tx) = EthTx::from_signed_message(ctx.chain_config().eth_chain_id, smsg) { - return Ok(Some(eth_tx.into())); - } - } + // We only return pending eth-account messages because we can't guarantee + // that the from/to addresses of other messages are conversable to 0x-style + // addresses. So we just ignore them. + // + // This should be "fine" as anyone using an "Ethereum-centric" block + // explorer shouldn't care about seeing pending messages from native + // accounts. + let eth_chain_id = ctx.chain_config().eth_chain_id; + let pending_tx = pending + .iter() + .find(|smsg| smsg.cid() == message_cid) + .and_then(|smsg| eth_tx_from_delegated_message(smsg, eth_chain_id).ok()); // Ethereum clients expect an empty response when the message was not found - Ok(None) + Ok(pending_tx) +} + +/// The API transaction of a delegated message, with its sender and hash; fails when it is not an +/// Ethereum transaction. +fn eth_tx_from_delegated_message( + smsg: &SignedMessage, + eth_chain_id: EthChainIdType, +) -> anyhow::Result { + let (from, tx) = eth_tx_from_signed_eth_message(smsg, eth_chain_id)?; + let hash = tx.eth_hash()?.into(); + Ok(ApiEthTx { + from, + hash, + ..tx.into() + }) } pub enum EthGetTransactionHashByCid {} @@ -4768,13 +4783,9 @@ mod test { RPCState::for_tests(cs).unwrap() } - // Regression test for the historical "EthSendRawTransaction returns the - // hash of the Filecoin message CID, not of the raw tx" bug: a delegated - // (eth account) transaction's own hash must never coincide with - // `EthHash::from(message_cid)` - the two are unrelated hashing schemes. - #[tokio::test] - async fn test_eth_send_raw_transaction_hash_is_not_message_cid() { - use crate::eth::{EthEip1559TxArgsBuilder, EthTx}; + /// A signed calibnet EIP-1559 transaction, as `eth_sendRawTransaction` receives it. + fn signed_calibnet_tx() -> EthTx { + use crate::eth::EthEip1559TxArgsBuilder; use std::str::FromStr as _; let mut tx_args = EthEip1559TxArgsBuilder::default() @@ -4801,7 +4812,12 @@ mod test { "7820796778417228639067439047870612492553874254089570360061550763595363987236", ) .unwrap(); - let tx = EthTx::from(tx_args); + EthTx::from(tx_args) + } + + #[tokio::test] + async fn test_eth_send_raw_transaction_hash_is_not_message_cid() { + let tx = signed_calibnet_tx(); let raw_tx = EthBytes(tx.rlp_signed_message().unwrap()); let expected_hash = "0x9f2e70d5737c6b798eccea14895893fb48091ab3c59d0fe95508dc7efdae2e5f"; @@ -4847,6 +4863,55 @@ mod test { ); } + /// A pending delegated message is found by its Ethereum hash and comes back with its hash and + /// sender. + #[tokio::test] + async fn test_eth_get_transaction_by_hash_finds_pending_tx() { + let tx = signed_calibnet_tx(); + let tx_hash: EthHash = tx.eth_hash().unwrap().into(); + let smsg = tx + .get_signed_message(crate::networks::calibnet::ETH_CHAIN_ID) + .unwrap(); + let (from, sequence) = (smsg.message().from(), smsg.message().sequence()); + let (ctx, _network_rx) = funded_calibnet_ctx(&from, sequence); + let cid = ctx.mpool.push(smsg).await.unwrap(); + let cancellation_token = CancellationToken::new(); + let lookup = |hash: EthHash| { + let ctx = ctx.clone(); + let cancellation_token = cancellation_token.clone(); + async move { + get_eth_transaction_by_hash(&ctx, &hash, None, &cancellation_token) + .await + .unwrap() + } + }; + + // (case, mapping): the mapping is what `eth_sendRawTransaction` on this node writes. + for (case, mapping) in [("mapping hit", Some(cid))] { + if let Some(cid) = mapping { + ctx.chain_store().put_mapping(tx_hash, cid, 0).unwrap(); + } + assert_eq!(ctx.chain_store().get_mapping(&tx_hash).unwrap(), mapping); + let found = lookup(tx_hash) + .await + .unwrap_or_else(|| panic!("{case}: the pending tx must be found by its hash")); + assert_eq!(found.hash, tx_hash, "{case}"); + assert_eq!( + found.from, + EthAddress::from_filecoin_address(&from).unwrap(), + "{case}" + ); + assert_eq!( + found.block_hash, + EthHash::default(), + "{case}: pending, not mined" + ); + } + + let unknown = lookup(EthHash::default()).await; + assert!(unknown.is_none(), "{unknown:?}"); + } + #[test] fn test_eth_tx_hash_from_message_cid() { let blockstore = Arc::new(MemoryDB::default()); From 134b6019e4b0d2bded86c4759d5b6c71aba224a7 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Thu, 15 Oct 2026 20:30:20 +0530 Subject: [PATCH 3/6] fix(rpc): find a pending eth transaction by its hash without a mapping --- src/rpc/methods/eth.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 43117640f59..3f05a898a6f 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -2649,7 +2649,8 @@ async fn get_eth_transaction_by_hash( limit: Option, cancellation_token: &CancellationToken, ) -> Result, ServerError> { - let message_cid = ctx.chain_store().get_mapping(tx_hash)?.unwrap_or_else(|| { + let mapping = ctx.chain_store().get_mapping(tx_hash)?; + let message_cid = mapping.unwrap_or_else(|| { tracing::debug!( "could not find transaction hash {} in Ethereum mapping", tx_hash @@ -2689,10 +2690,18 @@ async fn get_eth_transaction_by_hash( // explorer shouldn't care about seeing pending messages from native // accounts. let eth_chain_id = ctx.chain_config().eth_chain_id; - let pending_tx = pending - .iter() - .find(|smsg| smsg.cid() == message_cid) - .and_then(|smsg| eth_tx_from_delegated_message(smsg, eth_chain_id).ok()); + let pending_tx = match mapping { + Some(cid) => pending + .iter() + .find(|smsg| smsg.cid() == cid) + .and_then(|smsg| eth_tx_from_delegated_message(smsg, eth_chain_id).ok()), + // Without a mapping `tx_hash.to_cid()` never matches a delegated message's CID, so those + // are found by their Ethereum hash. + None => pending + .iter() + .filter_map(|smsg| eth_tx_from_delegated_message(smsg, eth_chain_id).ok()) + .find(|tx| tx.hash == *tx_hash), + }; // Ethereum clients expect an empty response when the message was not found Ok(pending_tx) @@ -4863,8 +4872,9 @@ mod test { ); } - /// A pending delegated message is found by its Ethereum hash and comes back with its hash and - /// sender. + /// A pending delegated message is found by its Ethereum hash with and without a tx-hash + /// mapping, and comes back with its hash and sender. A transaction gossiped from another node + /// has no mapping until it is mined. #[tokio::test] async fn test_eth_get_transaction_by_hash_finds_pending_tx() { let tx = signed_calibnet_tx(); @@ -4872,6 +4882,9 @@ mod test { let smsg = tx .get_signed_message(crate::networks::calibnet::ETH_CHAIN_ID) .unwrap(); + // The mapping miss falls back to the hash as a CID, which is never the message's CID. + assert_ne!(tx_hash.to_cid(), smsg.cid()); + let (from, sequence) = (smsg.message().from(), smsg.message().sequence()); let (ctx, _network_rx) = funded_calibnet_ctx(&from, sequence); let cid = ctx.mpool.push(smsg).await.unwrap(); @@ -4887,7 +4900,7 @@ mod test { }; // (case, mapping): the mapping is what `eth_sendRawTransaction` on this node writes. - for (case, mapping) in [("mapping hit", Some(cid))] { + for (case, mapping) in [("mapping miss", None), ("mapping hit", Some(cid))] { if let Some(cid) = mapping { ctx.chain_store().put_mapping(tx_hash, cid, 0).unwrap(); } From 5773deb6b300279cde817b36c3d7f18cad63489e Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Thu, 15 Oct 2026 20:30:20 +0530 Subject: [PATCH 4/6] test(devnet): look up a pending eth transaction submitted through Lotus --- .github/workflows/forest.yml | 1 + docs/docs/users/reference/cli.md | 12 ++ docs/docs/users/reference/cli.sh | 1 + scripts/devnet/README.md | 9 +- scripts/devnet/run_integration_tests.sh | 1 + src/dev/subcommands/devnet_cmd.rs | 3 + .../subcommands/devnet_cmd/eth_pending_tx.rs | 105 ++++++++++++++++++ src/dev/subcommands/tests_cmd/helpers.rs | 16 +-- 8 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 src/dev/subcommands/devnet_cmd/eth_pending_tx.rs diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index a0a06721796..6daac32f8a4 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -512,6 +512,7 @@ jobs: forest-dev devnet eth-gas forest-dev devnet eth-skip-sender forest-dev devnet state-call + forest-dev devnet eth-pending-tx timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} - name: Dump docker logs if: always() diff --git a/docs/docs/users/reference/cli.md b/docs/docs/users/reference/cli.md index 7689af6527d..40bfe0ea052 100644 --- a/docs/docs/users/reference/cli.md +++ b/docs/docs/users/reference/cli.md @@ -2928,6 +2928,7 @@ Usage: forest-dev devnet Commands: eth-gas `eth_estimateGas` parity and gas cap tests + eth-pending-tx `eth_getTransactionByHash` pending transaction tests eth-skip-sender Skip-sender integration tests that need a private chain with a miner state-call `Filecoin.StateCall` gas limit parity tests help Print this message or the help of the given subcommand(s) @@ -2968,3 +2969,14 @@ Usage: forest-dev devnet state-call Options: -h, --help Print help ``` + +### `forest-dev devnet eth-pending-tx` + +``` +`eth_getTransactionByHash` pending transaction tests + +Usage: forest-dev devnet eth-pending-tx + +Options: + -h, --help Print help +``` diff --git a/docs/docs/users/reference/cli.sh b/docs/docs/users/reference/cli.sh index 5435f7a29dc..41322712234 100755 --- a/docs/docs/users/reference/cli.sh +++ b/docs/docs/users/reference/cli.sh @@ -219,3 +219,4 @@ generate_markdown_section "forest-dev" "devnet" generate_markdown_section "forest-dev" "devnet eth-gas" generate_markdown_section "forest-dev" "devnet eth-skip-sender" generate_markdown_section "forest-dev" "devnet state-call" +generate_markdown_section "forest-dev" "devnet eth-pending-tx" diff --git a/scripts/devnet/README.md b/scripts/devnet/README.md index d3c185b9040..010dbc4dd6a 100644 --- a/scripts/devnet/README.md +++ b/scripts/devnet/README.md @@ -71,12 +71,13 @@ Under the hood this sources `test_harness.sh`, which reads the admin token and the funded genesis key from the running `forest` container, exports `FULLNODE_API_INFO` (Forest RPC on port 3456) and `FOREST_TEST_PRELOADED_ADDRESS`, then runs `forest-dev tests mpool`, `forest-dev tests wallet`, -`forest-dev devnet eth-gas`, `forest-dev devnet eth-skip-sender` and -`forest-dev devnet state-call`. +`forest-dev devnet eth-gas`, `forest-dev devnet eth-skip-sender`, +`forest-dev devnet state-call` and `forest-dev devnet eth-pending-tx`. The `devnet` suites (unlike `tests`) need both nodes reachable: `eth-gas` and -`state-call` compare Forest against Lotus, and `eth-skip-sender` deploys -contracts and submits transactions, so they run pre-flight checks and fail +`state-call` compare Forest against Lotus, `eth-skip-sender` deploys +contracts and submits transactions, and `eth-pending-tx` submits a transaction +through Lotus and looks it up on Forest, so they run pre-flight checks and fail early if the devnet or harness environment is missing. ## Local devnet development diff --git a/scripts/devnet/run_integration_tests.sh b/scripts/devnet/run_integration_tests.sh index c39aad7a91f..e8c6e8ad658 100755 --- a/scripts/devnet/run_integration_tests.sh +++ b/scripts/devnet/run_integration_tests.sh @@ -23,3 +23,4 @@ forest-dev tests wallet forest-dev devnet eth-gas forest-dev devnet eth-skip-sender forest-dev devnet state-call +forest-dev devnet eth-pending-tx diff --git a/src/dev/subcommands/devnet_cmd.rs b/src/dev/subcommands/devnet_cmd.rs index bf237aa8fb2..67760436a9b 100644 --- a/src/dev/subcommands/devnet_cmd.rs +++ b/src/dev/subcommands/devnet_cmd.rs @@ -7,6 +7,7 @@ //! that environment is missing, rather than letting a suite surface it as an opaque mid-run error. mod eth_gas; +mod eth_pending_tx; mod eth_skip_sender; mod state_call; @@ -18,6 +19,7 @@ use anyhow::{Context as _, ensure}; #[derive(Debug, clap::Subcommand)] pub enum DevnetCommand { EthGas(eth_gas::EthGasTestCommand), + EthPendingTx(eth_pending_tx::EthPendingTxTestCommand), EthSkipSender(eth_skip_sender::EthSkipSenderTestCommand), StateCall(state_call::StateCallTestCommand), } @@ -27,6 +29,7 @@ impl DevnetCommand { preflight().await.context("devnet pre-flight failed")?; match self { Self::EthGas(cmd) => cmd.run().await, + Self::EthPendingTx(cmd) => cmd.run().await, Self::EthSkipSender(cmd) => cmd.run().await, Self::StateCall(cmd) => cmd.run().await, } diff --git a/src/dev/subcommands/devnet_cmd/eth_pending_tx.rs b/src/dev/subcommands/devnet_cmd/eth_pending_tx.rs new file mode 100644 index 00000000000..af83b7b5aa8 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/eth_pending_tx.rs @@ -0,0 +1,105 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! `eth_getTransactionByHash` pending transaction tests on the docker devnet. + +use crate::dev::subcommands::tests_cmd::helpers::*; +use crate::rpc::eth::types::EthHash; +use crate::rpc::prelude::*; +use anyhow::{Context as _, ensure}; +use cid::Cid; +use libtest_mimic::{Arguments, Trial}; +use serde_json::{Value, json}; +use std::str::FromStr as _; + +/// `eth_getTransactionByHash` pending transaction tests +#[derive(Debug, clap::Args)] +pub struct EthPendingTxTestCommand {} + +impl EthPendingTxTestCommand { + pub async fn run(self) -> anyhow::Result<()> { + let args = Arguments { + test_threads: Some(1), + ..Default::default() + }; + libtest_mimic::run(&args, tests()).exit(); + } +} + +fn tests() -> Vec { + vec![trial("pending_tx_from_lotus_is_found_by_its_hash", || { + block_on(pending_tx_from_lotus_is_found_by_its_hash()) + })] +} + +/// A transaction submitted through Lotus, so without a hash mapping on Forest, is found by its +/// Ethereum hash while it is still pending. +async fn pending_tx_from_lotus_is_found_by_its_hash() -> anyhow::Result<()> { + let (forest, lotus) = (forest_client()?, lotus_client()?); + let sender = sender().await?; + + // A nonce gap keeps the transaction pending until the gap is filled, so the lookup cannot + // race the miner. + let gap_nonce = mpool_nonce(&sender.cli)? + 1; + // Lotus's mpool reads state at its own head, which trails the chain head the sender was + // funded on. + let gapped = poll("lotus to accept the gapped send", || async { + match lotus_send_to_self(&sender.cli, Some(gap_nonce)) { + Ok(cid) => Ok(Some(cid)), + Err(e) if is_unknown_actor_error(&e) => Ok(None), + Err(e) => Err(e), + } + }) + .await?; + poll_until_pending_nonce(&sender.cli, gap_nonce).await?; + let hash = lotus + .call(EthGetTransactionHashByCid::request((gapped,))?) + .await? + .context("lotus does not know the transaction's hash")?; + + let tx = forest + .call(EthGetTransactionByHash::request((hash,))?) + .await? + .context("forest does not return the pending transaction by its hash")?; + // Lotus's answer for the same transaction; its block fields are `null` while pending, so only + // the fields that identify the transaction are compared. + let lotus_tx = lotus + .call(EthGetTransactionByHash::request((hash,))?.map_ty::()) + .await?; + for (field, forest_value) in [ + ("hash", json!(tx.hash)), + ("from", json!(tx.from)), + ("nonce", json!(tx.nonce)), + ] { + ensure!( + lotus_tx.get(field) == Some(&forest_value), + "{field}: forest {forest_value}, lotus {lotus_tx}" + ); + } + ensure!( + tx.block_hash == EthHash::default(), + "reported as mined while pending: {tx:?}" + ); + + // Filling the gap gets both mined, so nothing stays pending after the trial. + lotus_send_to_self(&sender.cli, None)?; + poll_until_message_executed(&forest, gapped).await?; + Ok(()) +} + +/// Sends 0 FIL from `from` to itself through Lotus at `nonce`, or at the next nonce, and returns +/// the message CID. +fn lotus_send_to_self(from: &str, nonce: Option) -> anyhow::Result { + let nonce = nonce.map(|n| n.to_string()); + // `--force-send` skips the check that the nonce is the next one. + let mut args = vec!["--force-send", "send", "--from", from]; + if let Some(nonce) = &nonce { + args.extend(["--nonce", nonce]); + } + args.extend([from, "0"]); + let out = lotus_exec(&args)?; + // The CID follows the `Sending message ...` lines. + let cid = out.lines().last().context("empty `lotus send` output")?; + Cid::from_str(cid.trim()) + .with_context(|| format!("parsing the cid in `lotus send` output: {out}")) +} diff --git a/src/dev/subcommands/tests_cmd/helpers.rs b/src/dev/subcommands/tests_cmd/helpers.rs index 8bceea601c6..ef99337bb41 100644 --- a/src/dev/subcommands/tests_cmd/helpers.rs +++ b/src/dev/subcommands/tests_cmd/helpers.rs @@ -257,17 +257,19 @@ pub async fn get_actor(client: &Client, addr: Address) -> anyhow::Result Ok(actor), - Err(e) - if ["actor not found", "resolution lookup failed"] - .iter() - .any(|s| format!("{e:#}").contains(s)) => - { - Ok(None) - } + Err(e) if is_unknown_actor_error(&e) => Ok(None), Err(e) => Err(anyhow::anyhow!("{e:#}")), } } +/// Whether a node answered that it does not have the actor (yet). +pub fn is_unknown_actor_error(e: &impl std::fmt::Display) -> bool { + let text = format!("{e:#}"); + ["actor not found", "resolution lookup failed"] + .iter() + .any(|s| text.contains(s)) +} + /// Poll until `node`'s state has an actor at `addr` (the Lotus node trails Forest by a block on the /// forest-produced devnet, so a Forest-funded sender must be awaited before any `lotus --from`). pub async fn poll_until_actor_on( From 930ba7e7ece41b8386182498991b4178a4ff9888 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Thu, 15 Oct 2026 20:30:20 +0530 Subject: [PATCH 5/6] docs: changelog for the eth log and pending transaction fixes --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4238d4cdde..272b8cd645a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,8 +44,15 @@ ### Fixed - [#7733](https://github.com/ChainSafe/forest/pull/7733): Fixed `Filecoin.GetActorEventsRaw` ignoring the maximum filter height range. The height range is now correctly validated, and a filter without `fromHeight` returns no events. + - [#7754](https://github.com/ChainSafe/forest/pull/7754): `Filecoin.StateCall`, `Filecoin.StateReplay` and `Filecoin.StateCompute` report `unknown error (RetCode=)` for a failed message the FVM attaches no failure info to, such as one that runs out of gas, instead of an empty error. Matches Lotus. +- [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getLogs`, `eth_getFilterLogs`, `eth_getFilterChanges`, `eth_getTransactionReceipt`, `eth_getBlockReceipts` and their `Limited` variants return an error instead of partial logs when an event's message cannot be read. Matches Lotus v1.38.1 ([lotus#13926](https://github.com/filecoin-project/lotus/pull/13926)). + +- [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getTransactionByHash` and `eth_getTransactionByHashLimited` return a pending Ethereum transaction received from another node instead of `null` until it is mined. Matches Lotus v1.38.0 ([lotus#13677](https://github.com/filecoin-project/lotus/pull/13677)). + +- [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getTransactionByHash` and `eth_getTransactionByHashLimited` report the `hash` and `from` of a pending transaction instead of zeros. + ## Forest v0.38.0 "Ayanant" Mandatory release for mainnet node operators. It includes support for the NV29 _Solstice_ network upgrade for mainnet, which is set to activate at epoch `6470279` (2026-10-19T12:59:30Z). Note the breaking change below: JSON-RPC batch requests are now limited to 100 entries by default. There are also other changes and improvements; see below. From 371b18f0330d32789c3688d00bdb00db189cbf1e Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 16 Oct 2026 18:45:31 +0530 Subject: [PATCH 6/6] update the latest lotus version and changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 272b8cd645a..d48ff90f292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ - [#7702](https://github.com/ChainSafe/forest/issues/7702): `eth_estimateGas` matches Lotus v1.38.0 and reth. The estimate never exceeds the call's `gas` or, with `gasPrice` or `maxFeePerGas` set, the gas the sender can pay for after the value; without a price the sender's balance does not limit the gas. A revert that more gas fixes is searched past, and failures use the Ethereum codes: `-32000` when the cap is below the message inclusion cost or the sender affords no gas, `-32003` when the call runs out of gas within the cap or, with a price, the value exceeds the balance, `-32602` when both prices are set. See [lotus#13911](https://github.com/filecoin-project/lotus/pull/13911). -- [#7754](https://github.com/ChainSafe/forest/pull/7754): `Filecoin.StateCall` runs the message at its own `GasLimit` instead of an effectively unlimited one. A `GasLimit` of `0` selects the node's cap, the block gas limit unless the new `FOREST_STATE_CALL_GAS_LIMIT` is set, and a larger `GasLimit` is rejected. Matches Lotus v1.38.0 ([lotus#13910](https://github.com/filecoin-project/lotus/pull/13910)). +- [#7754](https://github.com/ChainSafe/forest/pull/7754): `Filecoin.StateCall` runs the message at its own `GasLimit` instead of an effectively unlimited one. A `GasLimit` of `0` selects the node's cap, the block gas limit unless the new `FOREST_STATE_CALL_GAS_LIMIT` is set, and a larger `GasLimit` is rejected ([lotus#13910](https://github.com/filecoin-project/lotus/pull/13910)). ### Removed @@ -47,9 +47,9 @@ - [#7754](https://github.com/ChainSafe/forest/pull/7754): `Filecoin.StateCall`, `Filecoin.StateReplay` and `Filecoin.StateCompute` report `unknown error (RetCode=)` for a failed message the FVM attaches no failure info to, such as one that runs out of gas, instead of an empty error. Matches Lotus. -- [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getLogs`, `eth_getFilterLogs`, `eth_getFilterChanges`, `eth_getTransactionReceipt`, `eth_getBlockReceipts` and their `Limited` variants return an error instead of partial logs when an event's message cannot be read. Matches Lotus v1.38.1 ([lotus#13926](https://github.com/filecoin-project/lotus/pull/13926)). +- [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getLogs`, `eth_getFilterLogs`, `eth_getFilterChanges`, `eth_getTransactionReceipt`, `eth_getBlockReceipts` and their `Limited` variants return an error instead of partial logs when an event's message cannot be read. Matches Lotus ([lotus#13926](https://github.com/filecoin-project/lotus/pull/13926)). -- [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getTransactionByHash` and `eth_getTransactionByHashLimited` return a pending Ethereum transaction received from another node instead of `null` until it is mined. Matches Lotus v1.38.0 ([lotus#13677](https://github.com/filecoin-project/lotus/pull/13677)). +- [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getTransactionByHash` and `eth_getTransactionByHashLimited` return a pending Ethereum transaction received from another node instead of `null` until it is mined. Matches Lotus ([lotus#13677](https://github.com/filecoin-project/lotus/pull/13677)). - [#7761](https://github.com/ChainSafe/forest/pull/7761): `eth_getTransactionByHash` and `eth_getTransactionByHashLimited` report the `hash` and `from` of a pending transaction instead of zeros.