Skip to main content

forest/dev/subcommands/devnet_cmd/
eth_gas.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4//! `eth_estimateGas` parity tests against the Lotus node on the docker devnet.
5//!
6//! [EIP-150] caps a `CALL` at 63/64 of remaining gas, so a nested call chain needs a far higher
7//! gas *limit* than the gas it *uses*. Estimating from gas used alone therefore under-shoots,
8//! and the estimate has to be probed and raised until it succeeds.
9//!
10//! [EIP-150]: https://github.com/ethereum/EIPs/blob/15f61ed0fda82ec86d8d6a872f6b874816f03d96/EIPS/eip-150.md#L32-L33
11
12use crate::dev::subcommands::tests_cmd::helpers::*;
13use crate::rpc::Client;
14use crate::rpc::eth::errors::EXECUTION_REVERTED_CODE;
15use crate::rpc::eth::{
16    BlockNumberOrHash, Predefined,
17    types::{EthAddress, EthBytes, EthCallMessage},
18};
19use crate::rpc::prelude::*;
20use crate::shim::address::Address;
21use crate::utils::encoding::{hex, keccak_256};
22use anyhow::{Context as _, ensure};
23use cid::Cid;
24use jsonrpsee::core::ClientError;
25use libtest_mimic::{Arguments, Failed, Trial};
26use std::io::Write as _;
27use std::str::FromStr as _;
28use tempfile::NamedTempFile;
29use tokio::sync::OnceCell;
30
31/// `NestedGas`, whose `recurse(uint256)` calls itself that many times.
32/// Regenerate with `contracts/compile.sh` after editing the source.
33const NESTED_GAS_HEX: &str = include_str!("contracts/nested_gas/nested_gas.hex");
34const RECURSE_SIGNATURE: &str = "recurse(uint256)";
35/// Reverts explicitly unless given a large gas limit, so estimating it fails for a reason no
36/// amount of extra gas can be shown to fix.
37const REQUIRES_HIGH_GAS_SIGNATURE: &str = "requiresHighGasLimit()";
38/// The `require` string in [`REQUIRES_HIGH_GAS_SIGNATURE`].
39const REVERT_REASON: &str = "gas limit too low";
40/// Both implementations prefix this branch's error with it. Asserting on it pins *which* rejection
41/// happened: a message that failed earlier, during plain gas estimation, would never carry it.
42const GAS_SEARCH_FAILURE: &str = "gas search failed";
43const HEX_IN_CONTAINER: &str = "/tmp/nested_gas.hex";
44
45/// Shallow enough that the 63/64 penalty stays inside any estimator's safety margin, so both
46/// nodes must agree. Guards against a failure that is really "the two disagree about gas".
47const CONTROL_DEPTH: u64 = 0;
48/// Deep enough that the penalty is ~1.9x, well clear of the crossover measured around 40-60.
49const NESTED_DEPTH: u64 = 100;
50/// The nested call needs a gas limit in the hundreds of millions, and a sender that cannot
51/// afford it makes the estimate saturate at the block gas limit instead of converging.
52const SENDER_FUND_AMT: &str = "10 FIL";
53
54/// `eth_estimateGas` parity tests
55#[derive(Debug, clap::Args)]
56pub struct EthGasTestCommand {}
57
58impl EthGasTestCommand {
59    pub async fn run(self) -> anyhow::Result<()> {
60        let args = Arguments {
61            test_threads: Some(1),
62            ..Default::default()
63        };
64        libtest_mimic::run(&args, tests()).exit();
65    }
66}
67
68fn tests() -> Vec<Trial> {
69    fn trial(name: &'static str, body: fn() -> anyhow::Result<()>) -> Trial {
70        Trial::test(name, move || {
71            body().map_err(|e| Failed::from(format!("{e:?}")))
72        })
73    }
74
75    vec![
76        trial("eth_estimate_gas_agrees_without_nesting", || {
77            block_on(estimate_agrees(CONTROL_DEPTH))
78        }),
79        trial("eth_estimate_gas_agrees_with_nesting", || {
80            block_on(estimate_agrees(NESTED_DEPTH))
81        }),
82        trial("eth_estimate_gas_is_sufficient_on_chain", || {
83            block_on(estimate_is_sufficient_on_chain())
84        }),
85        trial("eth_estimate_gas_reports_a_non_gas_failure", || {
86            block_on(estimate_reports_a_non_gas_failure())
87        }),
88    ]
89}
90
91/// The 4-byte Ethereum function selector: first 4 bytes of `keccak256(signature)`.
92fn selector(signature: &str) -> Vec<u8> {
93    keccak_256(signature.as_bytes())
94        .get(..4)
95        .expect("keccak256 is 32 bytes")
96        .to_vec()
97}
98
99/// ABI calldata for `recurse(uint256)`: the selector followed by `depth` as a 32-byte word.
100fn recurse_calldata(depth: u64) -> Vec<u8> {
101    let mut out = selector(RECURSE_SIGNATURE);
102    out.extend_from_slice(&ethereum_types::U256::from(depth).to_big_endian());
103    out
104}
105
106/// Deployed `NestedGas` addresses: `eth` for the JSON-RPC calls, `f4` as the `lotus send` target.
107struct Deployed {
108    eth: EthAddress,
109    f4: Address,
110}
111
112/// Deploys `NestedGas` once per process.
113async fn contract() -> anyhow::Result<&'static Deployed> {
114    static CONTRACT: OnceCell<Deployed> = OnceCell::const_new();
115    CONTRACT
116        .get_or_try_init(|| async {
117            let mut hex_file = NamedTempFile::new_in(std::env::temp_dir())
118                .context("staging the contract bytecode")?;
119            hex_file.write_all(NESTED_GAS_HEX.trim().as_bytes())?;
120            hex_file.flush()?;
121            docker(&[
122                "cp",
123                &hex_file.path().to_string_lossy(),
124                &format!("lotus:{HEX_IN_CONTAINER}"),
125            ])?;
126
127            let deploy = lotus_exec(&["evm", "deploy", "--hex", HEX_IN_CONTAINER])?;
128            let f4 = deploy
129                .lines()
130                .find_map(|l| l.trim().strip_prefix("f4 Address: "))
131                .with_context(|| format!("no `f4 Address:` in deploy output:\n{deploy}"))?;
132            let f4 = Address::from_str(f4.trim()).context("parsing the deployed f4 address")?;
133            eprintln!("deployed NestedGas at {f4}");
134            anyhow::Ok(Deployed {
135                eth: EthAddress::from_filecoin_address(&f4)?,
136                f4,
137            })
138        })
139        .await
140}
141
142/// An `f4` sender funded well enough to afford the gas limits under test. Lotus rejects
143/// estimation from an unfunded or non-`f4` sender, so both properties are required.
144///
145/// Created in Lotus's keystore rather than Forest's: estimation only needs the address to
146/// exist on chain, while submitting needs whoever signs to hold the key.
147async fn sender_addr() -> anyhow::Result<&'static Address> {
148    static SENDER: OnceCell<Address> = OnceCell::const_new();
149    SENDER
150        .get_or_try_init(|| async {
151            let addr = lotus_exec(&["wallet", "new", "delegated"])?;
152            let msg = send_from(
153                &FOREST_TEST_PRELOADED_ADDRESS,
154                &addr,
155                SENDER_FUND_AMT,
156                Backend::Local,
157            )?;
158            eprintln!("funding sender {addr} with {SENDER_FUND_AMT}, msg: {msg}");
159            let balance = poll_until_funded(&addr, Backend::Local).await?;
160            eprintln!("sender {addr} funded balance: {balance}");
161            Address::from_str(&addr).context("parsing the sender address")
162        })
163        .await
164}
165
166async fn estimate(
167    client: &Client,
168    calldata: Vec<u8>,
169    block: BlockNumberOrHash,
170) -> anyhow::Result<u64> {
171    let (sender, deployed) = tokio::try_join!(sender_addr(), contract())?;
172    let msg = EthCallMessage {
173        from: Some(EthAddress::from_filecoin_address(sender)?),
174        to: Some(deployed.eth),
175        data: Some(EthBytes(calldata)),
176        ..Default::default()
177    };
178    let gas = client
179        .call(EthEstimateGas::request((msg, Some(block)))?)
180        .await?;
181    Ok(gas.0)
182}
183
184/// A height both nodes have already executed. `Latest` is resolved per node, so at an epoch
185/// boundary or under slight sync skew the two could pick different tipsets; pinning both to the
186/// lower of their heads makes the cross-node comparison deterministic.
187async fn common_block_number(a: &Client, b: &Client) -> anyhow::Result<i64> {
188    let (head_a, head_b) = tokio::try_join!(
189        async { anyhow::Ok(a.call(EthBlockNumber::request(())?).await?) },
190        async { anyhow::Ok(b.call(EthBlockNumber::request(())?).await?) },
191    )?;
192    Ok(head_a.0.min(head_b.0) as i64)
193}
194
195/// Deploy + fund, build both node clients, and pin a block height both have executed. Sampling the
196/// height only after the deploy/fund guarantees the pinned tipset already contains the contract and
197/// sender on both nodes (the funding poll also lets both catch up to the deploy).
198async fn pinned_common_block() -> anyhow::Result<(Client, Client, i64)> {
199    tokio::try_join!(contract(), sender_addr())?;
200    let (forest_c, lotus_c) = (forest_client()?, lotus_client()?);
201    let block = common_block_number(&forest_c, &lotus_c).await?;
202    Ok((forest_c, lotus_c, block))
203}
204
205/// Forest and Lotus must return the same estimate.
206async fn estimate_agrees(depth: u64) -> anyhow::Result<()> {
207    let (forest_c, lotus_c, block) = pinned_common_block().await?;
208    let (forest, lotus) = tokio::try_join!(
209        async {
210            estimate(
211                &forest_c,
212                recurse_calldata(depth),
213                BlockNumberOrHash::from_block_number(block),
214            )
215            .await
216            .context("EthEstimateGas on forest")
217        },
218        async {
219            estimate(
220                &lotus_c,
221                recurse_calldata(depth),
222                BlockNumberOrHash::from_block_number(block),
223            )
224            .await
225            .context("EthEstimateGas on lotus")
226        },
227    )?;
228    eprintln!("depth={depth} block={block} forest={forest} lotus={lotus}");
229    ensure!(
230        forest == lotus,
231        "eth_estimateGas disagrees at recursion depth {depth} (block {block}): forest={forest} lotus={lotus}"
232    );
233    Ok(())
234}
235
236/// The estimate Forest returns must actually be enough to land the transaction.
237async fn estimate_is_sufficient_on_chain() -> anyhow::Result<()> {
238    let forest = forest_client()?;
239    // No cross-node comparison here, so `Latest` is fine: the estimate must reflect the same
240    // fresh state the following `lotus send` executes against.
241    let estimate = estimate(
242        &forest,
243        recurse_calldata(NESTED_DEPTH),
244        BlockNumberOrHash::PredefinedBlock(Predefined::Latest),
245    )
246    .await?;
247    let sender = sender_addr().await?.to_string();
248    let target = contract().await?.f4.to_string();
249    let params = hex::encode(recurse_calldata(NESTED_DEPTH));
250    let gas_limit = estimate.to_string();
251    // `lotus send` infers `InvokeContract` and CBOR-wraps the params when the sender is an
252    // eth account, and rejects an explicit `--method`, so pass the bare calldata. Retry the
253    // submit while Lotus's mpool briefly lags the freshly funded sender.
254    let out = lotus_exec_retrying_mpool(&[
255        "send",
256        "--from",
257        &sender,
258        "--params-hex",
259        &params,
260        "--gas-limit",
261        &gas_limit,
262        &target,
263        "0",
264    ])
265    .await?;
266    let cid = out
267        .lines()
268        .last()
269        .context("no cid from `lotus send`")?
270        .trim();
271    eprintln!("submitted at forest's estimate {estimate}: {cid}");
272
273    let lookup = forest
274        .call(
275            StateWaitMsg::request((Cid::from_str(cid)?, 0, 800, true))?.with_timeout(POLL_TIMEOUT),
276        )
277        .await?;
278    let exit = lookup.receipt.exit_code();
279    ensure!(
280        exit.is_success(),
281        "a transaction submitted at forest's own eth_estimateGas value ({estimate}) failed \
282         on chain with exit code {exit}; the estimate is not a usable gas limit"
283    );
284    Ok(())
285}
286
287/// A failure that raising the gas limit cannot be shown to fix must be reported, not searched
288/// around. This is the companion of [`estimate_agrees`]: it pins the branch that decides whether
289/// a failed probe means "needs more gas" or "is simply broken".
290async fn estimate_reports_a_non_gas_failure() -> anyhow::Result<()> {
291    let (forest_c, lotus_c, block) = pinned_common_block().await?;
292    for (node, client) in [("forest", &forest_c), ("lotus", &lotus_c)] {
293        let err = match estimate(
294            client,
295            selector(REQUIRES_HIGH_GAS_SIGNATURE),
296            BlockNumberOrHash::from_block_number(block),
297        )
298        .await
299        {
300            Ok(gas) => anyhow::bail!(
301                "{node} returned an estimate ({gas}) for a message that reverts at that limit; \
302                 a non-gas failure must be reported, not answered with a gas value"
303            ),
304            Err(e) => e,
305        };
306        let Some(ClientError::Call(obj)) = err.downcast_ref::<ClientError>() else {
307            anyhow::bail!("{node} returned a non-JSON-RPC error, cannot check parity: {err:?}");
308        };
309        eprintln!(
310            "{node} rejected the call: code={} has_data={} msg={}",
311            obj.code(),
312            obj.data().is_some(),
313            obj.message()
314        );
315        // Cross-node parity: both name the branch ("gas search failed") and the decoded revert reason.
316        ensure!(
317            obj.message().contains(GAS_SEARCH_FAILURE),
318            "{node} rejected the call before the gas search, so this no longer exercises the \
319             branch it is meant to pin (expected `{GAS_SEARCH_FAILURE}`): {}",
320            obj.message()
321        );
322        ensure!(
323            obj.message().contains(REVERT_REASON),
324            "{node} rejected the call without naming the revert reason `{REVERT_REASON}`: {}",
325            obj.message()
326        );
327
328        // Forest returns eth-standard `execution reverted` (code 3) + data, matching current Lotus.
329        // The devnet's Lotus image predates that refactor (generic code, no data), so code/data
330        // parity is pinned on Forest alone.
331        if node == "forest" {
332            ensure!(
333                obj.code() == EXECUTION_REVERTED_CODE,
334                "forest rejected with code {}, expected execution-reverted {EXECUTION_REVERTED_CODE}: {}",
335                obj.code(),
336                obj.message()
337            );
338            ensure!(
339                obj.data().is_some(),
340                "forest rejected without revert data; eth clients cannot ABI-decode the reason: {}",
341                obj.message()
342            );
343        }
344    }
345    Ok(())
346}