forest-filecoin 0.38.0

Rust Filecoin implementation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
// Copyright 2019-2026 ChainSafe Systems
// SPDX-License-Identifier: Apache-2.0, MIT

//! `eth_estimateGas` parity tests against the Lotus node on the docker devnet, plus Forest-only
//! tests of a caller-supplied `gas` cap.
//!
//! [EIP-150] caps a `CALL` at 63/64 of remaining gas, so a nested call chain needs a far higher
//! gas *limit* than the gas it *uses*. Estimating from gas used alone therefore under-shoots,
//! and the estimate has to be probed and raised until it succeeds.
//!
//! [EIP-150]: https://github.com/ethereum/EIPs/blob/15f61ed0fda82ec86d8d6a872f6b874816f03d96/EIPS/eip-150.md#L32-L33

use crate::dev::subcommands::tests_cmd::helpers::*;
use crate::rpc::Client;
use crate::rpc::eth::errors::{
    EXECUTION_REVERTED_CODE, INVALID_INPUT_CODE, TRANSACTION_REJECTED_CODE,
};
use crate::rpc::eth::{
    BlockNumberOrHash, EthUint64, Predefined,
    types::{EthAddress, EthBytes, EthCallMessage},
};
use crate::rpc::prelude::*;
use crate::shim::address::Address;
use crate::shim::econ::BLOCK_GAS_LIMIT;
use crate::utils::encoding::keccak_256;
use anyhow::{Context as _, ensure};
use libtest_mimic::{Arguments, Failed, Trial};
use std::str::FromStr as _;
use tokio::sync::OnceCell;

/// `NestedGas`, whose `recurse(uint256)` calls itself that many times.
/// Regenerate with `contracts/compile.sh` after editing the source.
const NESTED_GAS_HEX: &str = include_str!("contracts/nested_gas/nested_gas.hex");
const RECURSE_SIGNATURE: &str = "recurse(uint256)";
/// Reverts explicitly unless given a large gas limit, so estimating it without a `gas` cap fails
/// for a reason no amount of extra gas can be shown to fix.
const REQUIRES_HIGH_GAS_SIGNATURE: &str = "requiresHighGasLimit()";
/// The `require` string in [`REQUIRES_HIGH_GAS_SIGNATURE`].
const REVERT_REASON: &str = "gas limit too low";

/// Shallow enough that the 63/64 penalty stays inside any estimator's safety margin, so both
/// nodes must agree. Guards against a failure that is really "the two disagree about gas".
const CONTROL_DEPTH: u64 = 0;
/// Deep enough that the penalty is ~1.9x, well clear of the crossover measured around 40-60.
const NESTED_DEPTH: u64 = 100;
/// The nested call needs a gas limit in the hundreds of millions, and a sender that cannot
/// afford it makes the estimate saturate at the block gas limit instead of converging.
const SENDER_FUND_AMT: &str = "10 FIL";

/// `eth_estimateGas` parity and gas cap tests
#[derive(Debug, clap::Args)]
pub struct EthGasTestCommand {}

impl EthGasTestCommand {
    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<Trial> {
    fn trial(name: &'static str, body: fn() -> anyhow::Result<()>) -> Trial {
        Trial::test(name, move || {
            body().map_err(|e| Failed::from(format!("{e:?}")))
        })
    }

    vec![
        trial("eth_estimate_gas_agrees_without_nesting", || {
            block_on(estimate_agrees(CONTROL_DEPTH))
        }),
        trial("eth_estimate_gas_agrees_with_nesting", || {
            block_on(estimate_agrees(NESTED_DEPTH))
        }),
        trial("eth_estimate_gas_is_sufficient_on_chain", || {
            block_on(estimate_is_sufficient_on_chain())
        }),
        trial("eth_estimate_gas_reports_a_non_gas_failure", || {
            block_on(estimate_reports_a_non_gas_failure())
        }),
        trial("eth_estimate_gas_honors_gas_cap", || {
            block_on(estimate_honors_gas_cap())
        }),
        trial(
            "eth_estimate_gas_under_cap_searches_past_gas_dependent_revert",
            || block_on(estimate_under_cap_searches_past_gas_dependent_revert()),
        ),
    ]
}

/// The 4-byte Ethereum function selector: first 4 bytes of `keccak256(signature)`.
fn selector(signature: &str) -> Vec<u8> {
    keccak_256(signature.as_bytes())
        .get(..4)
        .expect("keccak256 is 32 bytes")
        .to_vec()
}

/// ABI calldata for `recurse(uint256)`: the selector followed by `depth` as a 32-byte word.
fn recurse_calldata(depth: u64) -> Vec<u8> {
    let mut out = selector(RECURSE_SIGNATURE);
    out.extend_from_slice(&ethereum_types::U256::from(depth).to_big_endian());
    out
}

/// Deploys `NestedGas` once per process.
async fn contract() -> anyhow::Result<&'static EthAddress> {
    static CONTRACT: OnceCell<EthAddress> = OnceCell::const_new();
    CONTRACT
        .get_or_try_init(|| async {
            let from = sender().await?;
            let deploy = forest_evm_deploy_hex(from, NESTED_GAS_HEX)?;
            let f4 = parse_f4_from_evm_deploy(&deploy)?;
            eprintln!("deployed NestedGas at {f4}");
            poll_until_actor_on("forest", f4, forest_client).await?;
            poll_until_actor_on("lotus", f4, lotus_client).await?;
            poll_until_next_epoch().await?;
            EthAddress::from_filecoin_address(&f4)
        })
        .await
}

/// Funded delegated sender. Lotus rejects estimates from an unfunded or non-`f4` address.
async fn sender() -> anyhow::Result<&'static str> {
    static SENDER: OnceCell<String> = OnceCell::const_new();
    Ok(SENDER
        .get_or_try_init(|| async {
            let addr = lotus_exec(&["wallet", "new", "delegated"])?;
            let msg = send_from(
                &FOREST_TEST_PRELOADED_ADDRESS,
                &addr,
                SENDER_FUND_AMT,
                Backend::Local,
            )?;
            eprintln!("funding sender {addr} with {SENDER_FUND_AMT}, msg: {msg}");
            let balance = poll_until_funded(&addr, Backend::Local).await?;
            eprintln!("sender {addr} funded balance: {balance}");
            let parsed = Address::from_str(&addr).context("parsing the sender address")?;
            poll_until_actor_on("lotus", parsed, lotus_client).await?;
            import_lotus_wallet_into_forest(&addr)?;
            anyhow::Ok(addr)
        })
        .await?
        .as_str())
}

/// A call from the funded sender to the deployed contract.
async fn call_message(calldata: Vec<u8>, gas: Option<u64>) -> anyhow::Result<EthCallMessage> {
    let (from, to) = tokio::try_join!(sender(), contract())?;
    let from = Address::from_str(from).context("parsing the sender address")?;
    Ok(EthCallMessage {
        from: Some(EthAddress::from_filecoin_address(&from)?),
        to: Some(*to),
        data: Some(EthBytes(calldata)),
        gas: gas.map(EthUint64),
        ..Default::default()
    })
}

async fn estimate(
    client: &Client,
    calldata: Vec<u8>,
    block: BlockNumberOrHash,
    gas: Option<u64>,
) -> anyhow::Result<u64> {
    let msg = call_message(calldata, gas).await?;
    let gas = client
        .call(EthEstimateGas::request((msg, Some(block)))?)
        .await?;
    Ok(gas.0)
}

/// Estimating under `cap` must fail with exactly this JSON-RPC error.
async fn expect_estimate_error(
    client: &Client,
    calldata: Vec<u8>,
    block: BlockNumberOrHash,
    cap: u64,
    code: i32,
    expected: &str,
) -> anyhow::Result<()> {
    let err = match estimate(client, calldata, block, Some(cap)).await {
        Ok(gas) => anyhow::bail!("returned {gas} for a call that does not fit in a cap of {cap}"),
        Err(e) => e,
    };
    let obj = rpc_call_err(&err)
        .with_context(|| format!("expected a JSON-RPC error for a cap of {cap}: {err:?}"))?;
    ensure!(
        obj.code() == code && obj.message() == expected,
        "expected code {code} `{expected}` for a cap of {cap}, got code {} `{}`",
        obj.code(),
        obj.message()
    );
    Ok(())
}

/// A height both nodes have already executed. `Latest` is resolved per node, so at an epoch
/// boundary or under slight sync skew the two could pick different tipsets; pinning both to the
/// lower of their heads makes the cross-node comparison deterministic.
async fn common_block_number(a: &Client, b: &Client) -> anyhow::Result<i64> {
    let (head_a, head_b) = tokio::try_join!(
        async { anyhow::Ok(a.call(EthBlockNumber::request(())?).await?) },
        async { anyhow::Ok(b.call(EthBlockNumber::request(())?).await?) },
    )?;
    Ok(head_a.0.min(head_b.0) as i64)
}

async fn poll_until_next_epoch() -> anyhow::Result<()> {
    let current_epoch = common_block_number(&forest_client()?, &lotus_client()?).await?;
    poll("both nodes one epoch past deploy", || async {
        Ok(
            (common_block_number(&forest_client()?, &lotus_client()?).await? > current_epoch)
                .then_some(()),
        )
    })
    .await
}

/// Deploy + fund, build both node clients, and pin a block height both have executed. Sampling the
/// height only after the deploy/fund guarantees the pinned tipset already contains the contract and
/// sender on both nodes (the funding poll also lets both catch up to the deploy).
async fn pinned_common_block() -> anyhow::Result<(Client, Client, i64)> {
    contract().await?;
    let (forest_c, lotus_c) = (forest_client()?, lotus_client()?);
    let block = common_block_number(&forest_c, &lotus_c).await?;
    Ok((forest_c, lotus_c, block))
}

/// Forest and Lotus must return the same estimate.
async fn estimate_agrees(depth: u64) -> anyhow::Result<()> {
    let (forest_c, lotus_c, block) = pinned_common_block().await?;
    let (forest, lotus) = tokio::try_join!(
        async {
            estimate(
                &forest_c,
                recurse_calldata(depth),
                BlockNumberOrHash::from_block_number(block),
                None,
            )
            .await
            .context("EthEstimateGas on forest")
        },
        async {
            estimate(
                &lotus_c,
                recurse_calldata(depth),
                BlockNumberOrHash::from_block_number(block),
                None,
            )
            .await
            .context("EthEstimateGas on lotus")
        },
    )?;
    eprintln!("depth={depth} block={block} forest={forest} lotus={lotus}");
    ensure!(
        forest == lotus,
        "eth_estimateGas disagrees at recursion depth {depth} (block {block}): forest={forest} lotus={lotus}"
    );
    Ok(())
}

/// The estimate Forest returns must actually be enough to land the transaction.
async fn estimate_is_sufficient_on_chain() -> anyhow::Result<()> {
    let forest = forest_client()?;
    // No cross-node comparison here, so `Latest` is fine: the estimate must reflect the same
    // fresh state the following `forest-wallet send` executes against.
    let estimate = estimate(
        &forest,
        recurse_calldata(NESTED_DEPTH),
        BlockNumberOrHash::PredefinedBlock(Predefined::Latest),
        None,
    )
    .await?;
    let from = sender().await?;
    // `forest-wallet send` infers `InvokeContract` and CBOR-wraps the params when the sender is an
    // eth account, and rejects an explicit `--method`, so pass the bare calldata.
    let cid = wallet_send_calldata(
        from,
        contract().await?,
        &recurse_calldata(NESTED_DEPTH),
        estimate,
    )
    .await
    .with_context(|| {
        format!(
            "a transaction submitted at forest's own eth_estimateGas value ({estimate}) failed \
             on chain; the estimate is not a usable gas limit"
        )
    })?;
    eprintln!("submitted at forest's estimate {estimate}: {cid}");
    Ok(())
}

/// A failure that raising the gas limit cannot be shown to fix must be reported, not searched
/// around. This is the companion of [`estimate_agrees`]: it pins the branch that decides whether
/// a failed probe means "needs more gas" or "is simply broken".
async fn estimate_reports_a_non_gas_failure() -> anyhow::Result<()> {
    let (forest_c, lotus_c, block) = pinned_common_block().await?;
    for (node, client) in [("forest", &forest_c), ("lotus", &lotus_c)] {
        let err = match estimate(
            client,
            selector(REQUIRES_HIGH_GAS_SIGNATURE),
            BlockNumberOrHash::from_block_number(block),
            None,
        )
        .await
        {
            Ok(gas) => anyhow::bail!(
                "{node} returned an estimate ({gas}) for a message that reverts at that limit; \
                 a non-gas failure must be reported, not answered with a gas value"
            ),
            Err(e) => e,
        };
        let obj = rpc_call_err(&err).with_context(|| {
            format!("{node} returned a non-JSON-RPC error, cannot check parity: {err:?}")
        })?;
        eprintln!(
            "{node} rejected the call: code={} has_data={} msg={}",
            obj.code(),
            obj.data().is_some(),
            obj.message()
        );
        ensure!(
            obj.message().contains(REVERT_REASON),
            "{node} rejected the call without naming the revert reason `{REVERT_REASON}`: {}",
            obj.message()
        );

        // Forest returns eth-standard `execution reverted` (code 3) + data, matching current Lotus.
        // The devnet's Lotus image predates that refactor (generic code, no data), so code/data
        // parity is pinned on Forest alone.
        if node == "forest" {
            ensure!(
                obj.code() == EXECUTION_REVERTED_CODE,
                "forest rejected with code {}, expected execution-reverted {EXECUTION_REVERTED_CODE}: {}",
                obj.code(),
                obj.message()
            );
            ensure!(
                obj.data().is_some(),
                "forest rejected without revert data; eth clients cannot ABI-decode the reason: {}",
                obj.message()
            );
        }
    }
    Ok(())
}

/// A caller-supplied `gas` bounds the estimate.
async fn estimate_honors_gas_cap() -> anyhow::Result<()> {
    let (forest, _, block) = pinned_common_block().await?;
    let block = BlockNumberOrHash::from_block_number(block);
    let calldata = recurse_calldata(NESTED_DEPTH);
    let uncapped = estimate(&forest, calldata.clone(), block.clone(), None).await?;

    let estimate_with_spare_cap =
        estimate(&forest, calldata.clone(), block.clone(), Some(uncapped * 2)).await?;
    ensure!(
        estimate_with_spare_cap == uncapped,
        "a cap above the need changed the estimate: capped={estimate_with_spare_cap} uncapped={uncapped}"
    );

    // Just under the estimate still covers the need, so the result is clamped to the cap and must work.
    let tight = uncapped - 1;
    let estimate_with_tight_cap =
        estimate(&forest, calldata.clone(), block.clone(), Some(tight)).await?;
    ensure!(
        estimate_with_tight_cap <= tight,
        "the estimate {estimate_with_tight_cap} exceeds the cap {tight}"
    );
    let call = call_message(calldata.clone(), Some(estimate_with_tight_cap)).await?;
    forest
        .call(EthCall::request((call, block.clone()))?)
        .await
        .with_context(|| {
            format!("eth_call at the capped estimate {estimate_with_tight_cap} failed")
        })?;

    // Half the estimate is short of what the nesting needs.
    let half = uncapped / 2;
    expect_estimate_error(
        &forest,
        calldata.clone(),
        block.clone(),
        half,
        TRANSACTION_REJECTED_CODE,
        &format!("out of gas: gas required exceeds: {half}"),
    )
    .await?;
    // Below the inclusion cost, preflight rejects the message before it runs.
    expect_estimate_error(
        &forest,
        calldata,
        block,
        21_000,
        INVALID_INPUT_CODE,
        "gas required exceeds allowance (21000)",
    )
    .await
}

/// Under a cap, a revert that more gas fixes is searched past instead of reported. Forest only: Lotus reports it.
async fn estimate_under_cap_searches_past_gas_dependent_revert() -> anyhow::Result<()> {
    let (forest, _, block) = pinned_common_block().await?;
    let block = BlockNumberOrHash::from_block_number(block);
    // The `gasleft()` bound in `requiresHighGasLimit()`.
    let threshold: u64 = 50_000_000;
    let calldata = selector(REQUIRES_HIGH_GAS_SIGNATURE);

    let gas = estimate(
        &forest,
        calldata.clone(),
        block.clone(),
        Some(BLOCK_GAS_LIMIT),
    )
    .await?;
    ensure!(
        gas > threshold,
        "expected an estimate above {threshold}, got {gas}"
    );
    let call = call_message(calldata.clone(), Some(gas)).await?;
    forest
        .call(EthCall::request((call, block.clone()))?)
        .await
        .with_context(|| format!("eth_call at the estimate {gas} failed"))?;

    let cap = threshold / 2;
    expect_estimate_error(
        &forest,
        calldata,
        block,
        cap,
        TRANSACTION_REJECTED_CODE,
        &format!("out of gas: gas required exceeds: {cap}"),
    )
    .await
}