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 from = sender_addr().await?.to_string();
128            let deploy = lotus_exec_retrying_transient(&[
129                "evm",
130                "deploy",
131                "--from",
132                &from,
133                "--hex",
134                HEX_IN_CONTAINER,
135            ])
136            .await?;
137            let f4 = deploy
138                .lines()
139                .find_map(|l| l.trim().strip_prefix("f4 Address: "))
140                .with_context(|| format!("no `f4 Address:` in deploy output:\n{deploy}"))?;
141            let f4 = Address::from_str(f4.trim()).context("parsing the deployed f4 address")?;
142            eprintln!("deployed NestedGas at {f4}");
143            anyhow::Ok(Deployed {
144                eth: EthAddress::from_filecoin_address(&f4)?,
145                f4,
146            })
147        })
148        .await
149}
150
151/// An `f4` sender funded well enough to afford the gas limits under test. Lotus rejects
152/// estimation from an unfunded or non-`f4` sender, so both properties are required.
153///
154/// Created in Lotus's keystore rather than Forest's: estimation only needs the address to
155/// exist on chain, while submitting messages and deploying the contract (both run on Lotus)
156/// need whoever signs to hold the key.
157async fn sender_addr() -> anyhow::Result<&'static Address> {
158    static SENDER: OnceCell<Address> = OnceCell::const_new();
159    SENDER
160        .get_or_try_init(|| async {
161            let addr = lotus_exec(&["wallet", "new", "delegated"])?;
162            let msg = send_from(
163                &FOREST_TEST_PRELOADED_ADDRESS,
164                &addr,
165                SENDER_FUND_AMT,
166                Backend::Local,
167            )?;
168            eprintln!("funding sender {addr} with {SENDER_FUND_AMT}, msg: {msg}");
169            let balance = poll_until_funded(&addr, Backend::Local).await?;
170            eprintln!("sender {addr} funded balance: {balance}");
171            let sender = Address::from_str(&addr).context("parsing the sender address")?;
172            poll_until_actor_on("lotus", sender, lotus_client).await?;
173            Ok(sender)
174        })
175        .await
176}
177
178async fn estimate(
179    client: &Client,
180    calldata: Vec<u8>,
181    block: BlockNumberOrHash,
182) -> anyhow::Result<u64> {
183    let (sender, deployed) = tokio::try_join!(sender_addr(), contract())?;
184    let msg = EthCallMessage {
185        from: Some(EthAddress::from_filecoin_address(sender)?),
186        to: Some(deployed.eth),
187        data: Some(EthBytes(calldata)),
188        ..Default::default()
189    };
190    let gas = client
191        .call(EthEstimateGas::request((msg, Some(block)))?)
192        .await?;
193    Ok(gas.0)
194}
195
196/// A height both nodes have already executed. `Latest` is resolved per node, so at an epoch
197/// boundary or under slight sync skew the two could pick different tipsets; pinning both to the
198/// lower of their heads makes the cross-node comparison deterministic.
199async fn common_block_number(a: &Client, b: &Client) -> anyhow::Result<i64> {
200    let (head_a, head_b) = tokio::try_join!(
201        async { anyhow::Ok(a.call(EthBlockNumber::request(())?).await?) },
202        async { anyhow::Ok(b.call(EthBlockNumber::request(())?).await?) },
203    )?;
204    Ok(head_a.0.min(head_b.0) as i64)
205}
206
207/// Deploy + fund, build both node clients, and pin a block height both have executed. Sampling the
208/// height only after the deploy/fund guarantees the pinned tipset already contains the contract and
209/// sender on both nodes (the funding poll also lets both catch up to the deploy).
210async fn pinned_common_block() -> anyhow::Result<(Client, Client, i64)> {
211    tokio::try_join!(contract(), sender_addr())?;
212    let (forest_c, lotus_c) = (forest_client()?, lotus_client()?);
213    let block = common_block_number(&forest_c, &lotus_c).await?;
214    Ok((forest_c, lotus_c, block))
215}
216
217/// Forest and Lotus must return the same estimate.
218async fn estimate_agrees(depth: u64) -> anyhow::Result<()> {
219    let (forest_c, lotus_c, block) = pinned_common_block().await?;
220    let (forest, lotus) = tokio::try_join!(
221        async {
222            estimate(
223                &forest_c,
224                recurse_calldata(depth),
225                BlockNumberOrHash::from_block_number(block),
226            )
227            .await
228            .context("EthEstimateGas on forest")
229        },
230        async {
231            estimate(
232                &lotus_c,
233                recurse_calldata(depth),
234                BlockNumberOrHash::from_block_number(block),
235            )
236            .await
237            .context("EthEstimateGas on lotus")
238        },
239    )?;
240    eprintln!("depth={depth} block={block} forest={forest} lotus={lotus}");
241    ensure!(
242        forest == lotus,
243        "eth_estimateGas disagrees at recursion depth {depth} (block {block}): forest={forest} lotus={lotus}"
244    );
245    Ok(())
246}
247
248/// The estimate Forest returns must actually be enough to land the transaction.
249async fn estimate_is_sufficient_on_chain() -> anyhow::Result<()> {
250    let forest = forest_client()?;
251    // No cross-node comparison here, so `Latest` is fine: the estimate must reflect the same
252    // fresh state the following `lotus send` executes against.
253    let estimate = estimate(
254        &forest,
255        recurse_calldata(NESTED_DEPTH),
256        BlockNumberOrHash::PredefinedBlock(Predefined::Latest),
257    )
258    .await?;
259    let sender = sender_addr().await?.to_string();
260    let target = contract().await?.f4.to_string();
261    let params = hex::encode(recurse_calldata(NESTED_DEPTH));
262    let gas_limit = estimate.to_string();
263    // `lotus send` infers `InvokeContract` and CBOR-wraps the params when the sender is an
264    // eth account, and rejects an explicit `--method`, so pass the bare calldata. Retry the
265    // submit while Lotus's mpool briefly lags the freshly funded sender.
266    let out = lotus_exec_retrying_transient(&[
267        "send",
268        "--from",
269        &sender,
270        "--params-hex",
271        &params,
272        "--gas-limit",
273        &gas_limit,
274        &target,
275        "0",
276    ])
277    .await?;
278    let cid = out
279        .lines()
280        .last()
281        .context("no cid from `lotus send`")?
282        .trim();
283    eprintln!("submitted at forest's estimate {estimate}: {cid}");
284
285    let lookup = poll_until_message_executed(&forest, Cid::from_str(cid)?).await?;
286    let exit = lookup.receipt.exit_code();
287    ensure!(
288        exit.is_success(),
289        "a transaction submitted at forest's own eth_estimateGas value ({estimate}) failed \
290         on chain with exit code {exit}; the estimate is not a usable gas limit"
291    );
292    Ok(())
293}
294
295/// A failure that raising the gas limit cannot be shown to fix must be reported, not searched
296/// around. This is the companion of [`estimate_agrees`]: it pins the branch that decides whether
297/// a failed probe means "needs more gas" or "is simply broken".
298async fn estimate_reports_a_non_gas_failure() -> anyhow::Result<()> {
299    let (forest_c, lotus_c, block) = pinned_common_block().await?;
300    for (node, client) in [("forest", &forest_c), ("lotus", &lotus_c)] {
301        let err = match estimate(
302            client,
303            selector(REQUIRES_HIGH_GAS_SIGNATURE),
304            BlockNumberOrHash::from_block_number(block),
305        )
306        .await
307        {
308            Ok(gas) => anyhow::bail!(
309                "{node} returned an estimate ({gas}) for a message that reverts at that limit; \
310                 a non-gas failure must be reported, not answered with a gas value"
311            ),
312            Err(e) => e,
313        };
314        let Some(ClientError::Call(obj)) = err.downcast_ref::<ClientError>() else {
315            anyhow::bail!("{node} returned a non-JSON-RPC error, cannot check parity: {err:?}");
316        };
317        eprintln!(
318            "{node} rejected the call: code={} has_data={} msg={}",
319            obj.code(),
320            obj.data().is_some(),
321            obj.message()
322        );
323        // Cross-node parity: both name the branch ("gas search failed") and the decoded revert reason.
324        ensure!(
325            obj.message().contains(GAS_SEARCH_FAILURE),
326            "{node} rejected the call before the gas search, so this no longer exercises the \
327             branch it is meant to pin (expected `{GAS_SEARCH_FAILURE}`): {}",
328            obj.message()
329        );
330        ensure!(
331            obj.message().contains(REVERT_REASON),
332            "{node} rejected the call without naming the revert reason `{REVERT_REASON}`: {}",
333            obj.message()
334        );
335
336        // Forest returns eth-standard `execution reverted` (code 3) + data, matching current Lotus.
337        // The devnet's Lotus image predates that refactor (generic code, no data), so code/data
338        // parity is pinned on Forest alone.
339        if node == "forest" {
340            ensure!(
341                obj.code() == EXECUTION_REVERTED_CODE,
342                "forest rejected with code {}, expected execution-reverted {EXECUTION_REVERTED_CODE}: {}",
343                obj.code(),
344                obj.message()
345            );
346            ensure!(
347                obj.data().is_some(),
348                "forest rejected without revert data; eth clients cannot ABI-decode the reason: {}",
349                obj.message()
350            );
351        }
352    }
353    Ok(())
354}