Skip to main content

forest/dev/subcommands/devnet_cmd/
eth_skip_sender.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4//! Skip-sender `eth_call` and `eth_estimateGas` tests on the docker devnet
5//! (`scripts/devnet`). These cases need a private chain: deploy a contract, fund
6//! an address, submit a transaction, or assert Forest state after a skip-call.
7//!
8//! Tests deploy `SimpleCoin`, `ContractA` / `ContractB`, `NestedGas`, or `Errors`
9//! as needed. They cover estimate-then-submit from an unfunded `from` (including
10//! nested `recurse`), estimate parity with a funded placeholder, `msg.sender`
11//! identity via `sendCoin`, skip-call state isolation, a historical `eth_call`,
12//! cross-contract callbacks, and the skip-sender success/error matrix (CREATE,
13//! `gasPrice`, `FromNil`, `FromEOA`, value, revert data, out-of-gas).
14
15use crate::dev::subcommands::tests_cmd::helpers::*;
16use crate::rpc::Client;
17use crate::rpc::eth::errors::{EXECUTION_REVERTED_CODE, OUT_OF_GAS_CODE};
18use crate::rpc::eth::{
19    BlockNumberOrHash, EthBigInt, Predefined,
20    types::{EthAddress, EthBytes, EthCallMessage},
21};
22use crate::rpc::prelude::*;
23use crate::shim::address::Address;
24use crate::shim::econ::TokenAmount;
25use crate::shim::state_tree::ActorState;
26use crate::utils::encoding::{hex, keccak_256};
27use anyhow::{Context as _, ensure};
28use cid::Cid;
29use jsonrpsee::core::ClientError;
30use libtest_mimic::{Arguments, Failed, Trial};
31use std::io::Write as _;
32use std::str::FromStr as _;
33use tempfile::NamedTempFile;
34use tokio::sync::OnceCell;
35
36const SIMPLE_COIN_HEX: &str = include_str!("contracts/simple_coin/simple_coin.hex");
37const CONTRACT_A_HEX: &str = include_str!("contracts/contract_a/contract_a.hex");
38const CONTRACT_B_HEX: &str = include_str!("contracts/contract_b/contract_b.hex");
39const NESTED_GAS_HEX: &str = include_str!("contracts/nested_gas/nested_gas.hex");
40const ERRORS_HEX: &str = include_str!("contracts/errors/errors.hex");
41
42const SEND_COIN_SIGNATURE: &str = "sendCoin(address,uint256)";
43const SET_CONTRACT_B_SIGNATURE: &str = "setContractB(address)";
44const GET_BALANCE_SIGNATURE: &str = "getBalance(address)";
45const CALL_B_AND_READ_BACK: &str = "callBAndReadBack()";
46const CALL_B_AND_DOUBLE: &str = "callBAndDouble()";
47const RECURSE_SIGNATURE: &str = "recurse(uint256)";
48const FAIL_DIV_ZERO: &str = "failDivZero()";
49const FAIL_ASSERT: &str = "failAssert()";
50const FAIL_REVERT_REASON: &str = "failRevertReason()";
51const FAIL_REVERT_EMPTY: &str = "failRevertEmpty()";
52const FAIL_CUSTOM: &str = "failCustom()";
53
54const NESTED_DEPTH: u64 = 100;
55const DEPLOYER_FUND_AMT: &str = "10 FIL";
56const ROUND_TRIP_FUND_AMT: &str = "1 FIL";
57const RECURSIVE_FUND_AMT: &str = "10 FIL";
58const EOA_FUND_AMT: &str = "10 FIL";
59const PLACEHOLDER_FUND_AMT: &str = "2 FIL";
60const ESTIMATE_PARITY: f64 = 0.10;
61const GAS_PRICE: u64 = 1_000_000_000;
62const MIN_ESTIMATE_GAS: u64 = 21_000;
63const MAX_ESTIMATE_GAS: u64 = 10_000_000_000;
64/// ABI `Panic(uint256)` payload for Solidity assert (`0x01`) and division by zero (`0x12`).
65const PANIC_ASSERT: &str =
66    "4e487b710000000000000000000000000000000000000000000000000000000000000001";
67const PANIC_DIV_ZERO: &str =
68    "4e487b710000000000000000000000000000000000000000000000000000000000000012";
69/// `JUMPDEST PUSH1 0x00 JUMP` CREATE payload; constructor loops until `BLOCK_GAS_LIMIT`.
70const OOG_INITCODE: &str = "5b600056";
71
72/// Skip-sender integration tests that need a private chain with a miner
73#[derive(Debug, clap::Args)]
74pub struct EthSkipSenderTestCommand {}
75
76impl EthSkipSenderTestCommand {
77    pub async fn run(self) -> anyhow::Result<()> {
78        let args = Arguments {
79            test_threads: Some(1),
80            ..Default::default()
81        };
82        libtest_mimic::run(&args, tests()).exit();
83    }
84}
85
86fn tests() -> Vec<Trial> {
87    fn trial(name: &'static str, body: fn() -> anyhow::Result<()>) -> Trial {
88        Trial::test(name, move || {
89            body().map_err(|e| Failed::from(format!("{e:?}")))
90        })
91    }
92
93    vec![
94        trial("round_trip_from_unfunded", || {
95            block_on(round_trip_from_unfunded())
96        }),
97        trial("parity_with_existing_sender", || {
98            block_on(parity_with_existing_sender())
99        }),
100        trial("round_trip_recursive", || block_on(round_trip_recursive())),
101        trial("call_sender_identity", || block_on(call_sender_identity())),
102        trial("skip_sender_state_isolation", || {
103            block_on(skip_sender_state_isolation())
104        }),
105        trial("skip_sender_historical_call", || {
106            block_on(skip_sender_historical_call())
107        }),
108        trial("cross_contract_from_contract", || {
109            block_on(cross_contract_from_contract())
110        }),
111        trial("cross_contract_from_missing", || {
112            block_on(cross_contract_from_missing())
113        }),
114        trial("cross_contract_from_eoa", || {
115            block_on(cross_contract_from_eoa())
116        }),
117        trial("cross_contract_double_callback", || {
118            block_on(cross_contract_double_callback())
119        }),
120        trial("call_skip_sender", || block_on(call_skip_sender())),
121        trial("estimate_gas_skip_sender", || {
122            block_on(estimate_gas_skip_sender())
123        }),
124        trial("funded_placeholder_sender", || {
125            block_on(funded_placeholder_sender())
126        }),
127    ]
128}
129
130fn selector(signature: &str) -> Vec<u8> {
131    keccak_256(signature.as_bytes())
132        .get(..4)
133        .expect("keccak256 is 32 bytes")
134        .to_vec()
135}
136
137fn abi_address_word(addr: EthAddress) -> Vec<u8> {
138    let mut word = vec![0u8; 12];
139    word.extend_from_slice(addr.0.as_bytes());
140    word
141}
142
143fn calldata(sig: &str, extra: &[u8]) -> Vec<u8> {
144    let mut out = selector(sig);
145    out.extend_from_slice(extra);
146    out
147}
148
149fn send_coin_calldata(to: EthAddress, amount: u64) -> Vec<u8> {
150    let mut extra = abi_address_word(to);
151    extra.extend_from_slice(&ethereum_types::U256::from(amount).to_big_endian());
152    calldata(SEND_COIN_SIGNATURE, &extra)
153}
154
155fn set_contract_b_calldata(addr: EthAddress) -> Vec<u8> {
156    calldata(SET_CONTRACT_B_SIGNATURE, &abi_address_word(addr))
157}
158
159fn recurse_calldata(depth: u64) -> Vec<u8> {
160    calldata(
161        RECURSE_SIGNATURE,
162        &ethereum_types::U256::from(depth).to_big_endian(),
163    )
164}
165
166fn get_balance_calldata(addr: EthAddress) -> Vec<u8> {
167    calldata(GET_BALANCE_SIGNATURE, &abi_address_word(addr))
168}
169
170fn simple_coin_initcode() -> anyhow::Result<EthBytes> {
171    Ok(EthBytes(
172        hex::decode(SIMPLE_COIN_HEX.trim()).context("decoding SimpleCoin initcode")?,
173    ))
174}
175
176fn oog_create(from: Option<EthAddress>) -> anyhow::Result<EthCallMessage> {
177    Ok(EthCallMessage {
178        from,
179        to: None,
180        data: Some(EthBytes(
181            hex::decode(OOG_INITCODE).context("decoding OOG initcode")?,
182        )),
183        ..Default::default()
184    })
185}
186
187/// Missing eth address: `0xdeadbeef` then zeros, last byte `seed`.
188fn non_existent(seed: u8) -> anyhow::Result<EthAddress> {
189    EthAddress::from_str(&format!("0xdeadbeef{:030}{seed:02x}", 0))
190        .context("parsing missing eth address")
191}
192
193fn latest() -> BlockNumberOrHash {
194    BlockNumberOrHash::PredefinedBlock(Predefined::Latest)
195}
196
197/// Deployed EVM actor: `eth` for JSON-RPC, `f4` for `lotus send` / `StateGetActor`.
198#[derive(Clone, Copy)]
199struct Deployed {
200    eth: EthAddress,
201    f4: Address,
202}
203
204/// Lotus `wallet new` string (`t4…`) plus parsed Filecoin and ETH forms.
205struct Wallet {
206    cli: String,
207    f4: Address,
208    eth: EthAddress,
209}
210
211/// Dedicated delegated wallet used to deploy and to credit `SimpleCoin`.
212/// Not the genesis/miner key: that wallet races with the miner on nonce.
213async fn deployer() -> anyhow::Result<&'static Address> {
214    static DEPLOYER: OnceCell<Address> = OnceCell::const_new();
215    DEPLOYER
216        .get_or_try_init(|| async {
217            let addr = lotus_exec(&["wallet", "new", "delegated"])?;
218            fund_on_chain(&addr, DEPLOYER_FUND_AMT).await
219        })
220        .await
221}
222
223async fn deploy_hex(label: &str, bytecode: &str, container_path: &str) -> anyhow::Result<Deployed> {
224    let deployer = deployer().await?;
225    let mut hex_file =
226        NamedTempFile::new_in(std::env::temp_dir()).context("staging the contract bytecode")?;
227    hex_file.write_all(bytecode.trim().as_bytes())?;
228    hex_file.flush()?;
229    docker(&[
230        "cp",
231        &hex_file.path().to_string_lossy(),
232        &format!("lotus:{container_path}"),
233    ])?;
234
235    let from = deployer.to_string();
236    let deploy =
237        lotus_exec_retrying_transient(&["evm", "deploy", "--from", &from, "--hex", container_path])
238            .await?;
239    let f4 = deploy
240        .lines()
241        .find_map(|l| l.trim().strip_prefix("f4 Address: "))
242        .with_context(|| format!("no `f4 Address:` in {label} deploy output:\n{deploy}"))?;
243    let f4 = Address::from_str(f4.trim()).context("parsing the deployed f4 address")?;
244    eprintln!("deployed {label} at {f4}");
245    poll_until_actor(f4).await?;
246    Ok(Deployed {
247        eth: EthAddress::from_filecoin_address(&f4)?,
248        f4,
249    })
250}
251
252async fn simple_coin() -> anyhow::Result<&'static Deployed> {
253    static CONTRACT: OnceCell<Deployed> = OnceCell::const_new();
254    CONTRACT
255        .get_or_try_init(|| deploy_hex("SimpleCoin", SIMPLE_COIN_HEX, "/tmp/simple_coin.hex"))
256        .await
257}
258
259async fn contract_b() -> anyhow::Result<&'static Deployed> {
260    static CONTRACT: OnceCell<Deployed> = OnceCell::const_new();
261    CONTRACT
262        .get_or_try_init(|| deploy_hex("ContractB", CONTRACT_B_HEX, "/tmp/contract_b.hex"))
263        .await
264}
265
266async fn nested_gas() -> anyhow::Result<&'static Deployed> {
267    static CONTRACT: OnceCell<Deployed> = OnceCell::const_new();
268    CONTRACT
269        .get_or_try_init(|| deploy_hex("NestedGas", NESTED_GAS_HEX, "/tmp/nested_gas_skip.hex"))
270        .await
271}
272
273async fn errors_contract() -> anyhow::Result<&'static Deployed> {
274    static CONTRACT: OnceCell<Deployed> = OnceCell::const_new();
275    CONTRACT
276        .get_or_try_init(|| deploy_hex("Errors", ERRORS_HEX, "/tmp/errors.hex"))
277        .await
278}
279
280/// Shared senders and contracts for the skip-sender call/estimate tables.
281struct TableEnv {
282    coin: EthAddress,
283    errors: EthAddress,
284    eoa: EthAddress,
285    eoa2: EthAddress,
286}
287
288async fn table_env() -> anyhow::Result<&'static TableEnv> {
289    static ENV: OnceCell<TableEnv> = OnceCell::const_new();
290    ENV.get_or_try_init(|| async {
291        let coin = simple_coin().await?;
292        let errors = errors_contract().await?;
293        let eoa = new_funded(EOA_FUND_AMT).await?;
294        let eoa2 = new_unfunded().await?;
295        Ok(TableEnv {
296            coin: coin.eth,
297            errors: errors.eth,
298            eoa: eoa.eth,
299            eoa2: eoa2.eth,
300        })
301    })
302    .await
303}
304
305/// `ContractA` with `setContractB` already mined, so callbacks see `storedValue`.
306async fn linked_contracts() -> anyhow::Result<&'static (Deployed, Deployed)> {
307    static LINKED: OnceCell<(Deployed, Deployed)> = OnceCell::const_new();
308    LINKED
309        .get_or_try_init(|| async {
310            let b = contract_b().await?;
311            let a = deploy_hex("ContractA", CONTRACT_A_HEX, "/tmp/contract_a.hex").await?;
312            invoke(&a.f4, &set_contract_b_calldata(b.eth)).await?;
313            Ok((a, *b))
314        })
315        .await
316}
317
318async fn poll_until_actor(addr: Address) -> anyhow::Result<ActorState> {
319    poll_until_actor_on("forest", addr, forest_client).await
320}
321
322/// The miner only talks to Lotus, so `lotus send` / `lotus evm deploy --from`
323/// fail with `actor not found` until Lotus's state has the Forest-funded sender.
324async fn poll_until_lotus_actor(addr: Address) -> anyhow::Result<ActorState> {
325    poll_until_actor_on("lotus", addr, lotus_client).await
326}
327
328/// Fund `cli_addr` (Lotus `t4…` form) from the harness wallet, then wait until
329/// both Forest and Lotus see the actor. Lotus visibility is required before any
330/// `lotus --from`. Pass the Lotus string into `forest-wallet`; it rejects Forest
331/// `Address::to_string()` (`f4…`) while `CurrentNetwork` stays Mainnet.
332async fn fund_on_chain(cli_addr: &str, amount: &str) -> anyhow::Result<Address> {
333    let addr = Address::from_str(cli_addr).context("parsing funded delegated address")?;
334    let msg = send_from(
335        &FOREST_TEST_PRELOADED_ADDRESS,
336        cli_addr,
337        amount,
338        Backend::Local,
339    )?;
340    eprintln!("funding {cli_addr} with {amount}, msg: {msg}");
341    let balance = poll_until_funded(cli_addr, Backend::Local).await?;
342    eprintln!("{cli_addr} funded on forest, balance: {balance}");
343    poll_until_lotus_actor(addr).await?;
344    Ok(addr)
345}
346
347async fn wait_for_cid(forest: &Client, cid: Cid) -> anyhow::Result<()> {
348    let lookup = poll_until_message_executed(forest, cid).await?;
349    let exit = lookup.receipt.exit_code();
350    ensure!(
351        exit.is_success(),
352        "message {cid} failed on chain with exit code {exit}"
353    );
354    Ok(())
355}
356
357async fn lotus_send(
358    from: &Address,
359    to: &Address,
360    calldata: &[u8],
361    gas_limit: Option<u64>,
362) -> anyhow::Result<Cid> {
363    let forest = forest_client()?;
364    let from_s = from.to_string();
365    let to_s = to.to_string();
366    let params = hex::encode(calldata);
367    let gas = gas_limit.map(|g| g.to_string());
368    let mut args = vec![
369        "send",
370        "--from",
371        from_s.as_str(),
372        "--params-hex",
373        params.as_str(),
374    ];
375    if let Some(gas) = gas.as_deref() {
376        args.extend(["--gas-limit", gas]);
377    }
378    args.extend([to_s.as_str(), "0"]);
379    let out = lotus_exec_retrying_transient(&args).await?;
380    let cid = Cid::from_str(
381        out.lines()
382            .last()
383            .context("no cid from `lotus send`")?
384            .trim(),
385    )?;
386    if let Some(limit) = gas_limit {
387        eprintln!("submitted at estimate {limit}: {cid}");
388        wait_for_cid(&forest, cid)
389            .await
390            .with_context(|| format!("transaction submitted at eth_estimateGas {limit} failed"))?;
391    } else {
392        wait_for_cid(&forest, cid).await?;
393    }
394    Ok(cid)
395}
396
397async fn invoke(to: &Address, calldata: &[u8]) -> anyhow::Result<Cid> {
398    lotus_send(deployer().await?, to, calldata, None).await
399}
400
401async fn submit_at_gas_limit(
402    from: &Address,
403    to: &Address,
404    calldata: &[u8],
405    gas_limit: u64,
406) -> anyhow::Result<()> {
407    lotus_send(from, to, calldata, Some(gas_limit)).await?;
408    Ok(())
409}
410
411async fn eth_call_msg(
412    client: &Client,
413    msg: EthCallMessage,
414    block: BlockNumberOrHash,
415) -> anyhow::Result<EthBytes> {
416    Ok(client.call(EthCall::request((msg, block))?).await?)
417}
418
419async fn estimate_msg(client: &Client, msg: EthCallMessage) -> anyhow::Result<u64> {
420    Ok(client
421        .call(EthEstimateGas::request((msg, Some(latest())))?)
422        .await?
423        .0)
424}
425
426fn rpc_call_err(err: &anyhow::Error) -> Option<&jsonrpsee::types::ErrorObjectOwned> {
427    match err.downcast_ref::<ClientError>() {
428        Some(ClientError::Call(obj)) => Some(obj),
429        _ => None,
430    }
431}
432
433fn rpc_data(obj: &jsonrpsee::types::ErrorObjectOwned) -> Option<String> {
434    let raw = obj.data()?;
435    serde_json::from_str::<String>(raw.get())
436        .ok()
437        .or_else(|| Some(raw.get().trim_matches('"').to_string()))
438}
439
440#[derive(Clone)]
441enum Expect {
442    Success,
443    SuccessGas,
444    ErrContains(&'static str),
445    Reverted {
446        msg: &'static str,
447        data_contains: Option<String>,
448        data_eq: Option<&'static str>,
449    },
450    ErrCode {
451        code: i32,
452        contains: &'static str,
453    },
454}
455
456struct SkipSenderCase {
457    name: &'static str,
458    msg: EthCallMessage,
459    call: Option<Expect>,
460    estimate: Option<Expect>,
461}
462
463impl SkipSenderCase {
464    fn success(name: &'static str, msg: EthCallMessage) -> Self {
465        Self {
466            name,
467            msg,
468            call: Some(Expect::Success),
469            estimate: Some(Expect::SuccessGas),
470        }
471    }
472
473    fn err_both(name: &'static str, msg: EthCallMessage, needle: &'static str) -> Self {
474        Self {
475            name,
476            msg,
477            call: Some(Expect::ErrContains(needle)),
478            estimate: Some(Expect::ErrContains(needle)),
479        }
480    }
481
482    fn revert_both(name: &'static str, msg: EthCallMessage, expect: Expect) -> Self {
483        Self {
484            name,
485            msg,
486            call: Some(expect.clone()),
487            estimate: Some(expect),
488        }
489    }
490
491    fn call_only(name: &'static str, msg: EthCallMessage, expect: Expect) -> Self {
492        Self {
493            name,
494            msg,
495            call: Some(expect),
496            estimate: None,
497        }
498    }
499}
500
501fn fil(whole: u64) -> EthBigInt {
502    EthBigInt::from(TokenAmount::from_whole(whole))
503}
504
505fn skip_sender_cases(env: &TableEnv) -> anyhow::Result<Vec<SkipSenderCase>> {
506    let initcode = simple_coin_initcode()?;
507    let missing = non_existent(0x01)?;
508    let gas_price = Some(EthBigInt::from(GAS_PRICE));
509    let custom = hex::encode(selector("CustomError()"));
510    let revert_empty = Expect::Reverted {
511        msg: "none",
512        data_contains: None,
513        data_eq: Some("0x"),
514    };
515    let oog_err = Expect::ErrCode {
516        code: EXECUTION_REVERTED_CODE,
517        contains: "SysErrOutOfGas",
518    };
519
520    let transfer = |from: Option<EthAddress>, to: Option<EthAddress>| EthCallMessage {
521        from,
522        to,
523        ..Default::default()
524    };
525    let errors_from_eoa = |sig: &'static str| EthCallMessage {
526        from: Some(env.eoa),
527        to: Some(env.errors),
528        data: Some(EthBytes(selector(sig))),
529        ..Default::default()
530    };
531
532    Ok(vec![
533        SkipSenderCase::err_both(
534            "CreateFromContract",
535            EthCallMessage {
536                from: Some(env.coin),
537                to: None,
538                data: Some(initcode.clone()),
539                ..Default::default()
540            },
541            "disallowed caller",
542        ),
543        SkipSenderCase::success(
544            "CreateFromNonExistent",
545            EthCallMessage {
546                from: Some(missing),
547                to: None,
548                data: Some(initcode),
549                ..Default::default()
550            },
551        ),
552        SkipSenderCase::revert_both(
553            "OutOfGasFromNonExistent",
554            oog_create(Some(missing))?,
555            oog_err.clone(),
556        ),
557        SkipSenderCase {
558            name: "OutOfGasFromEoa",
559            msg: oog_create(Some(env.eoa))?,
560            call: Some(oog_err),
561            estimate: Some(Expect::ErrCode {
562                code: OUT_OF_GAS_CODE,
563                contains: "call ran out of gas",
564            }),
565        },
566        SkipSenderCase::success("FromContract", transfer(Some(env.coin), Some(env.eoa))),
567        SkipSenderCase::success(
568            "FromContractWithGasPrice",
569            EthCallMessage {
570                from: Some(env.coin),
571                to: Some(env.eoa),
572                gas_price,
573                ..Default::default()
574            },
575        ),
576        SkipSenderCase::success(
577            "FromContractToSelf",
578            EthCallMessage {
579                from: Some(env.coin),
580                to: Some(env.coin),
581                data: Some(EthBytes(get_balance_calldata(env.coin))),
582                ..Default::default()
583            },
584        ),
585        SkipSenderCase::err_both(
586            "FromContractWithValue",
587            EthCallMessage {
588                from: Some(env.coin),
589                to: Some(env.eoa),
590                value: Some(fil(1)),
591                ..Default::default()
592            },
593            "insufficient",
594        ),
595        SkipSenderCase::success("FromNonExistent", transfer(Some(missing), Some(env.eoa))),
596        SkipSenderCase::success(
597            "FromNonExistentWithGasPrice",
598            EthCallMessage {
599                from: Some(missing),
600                to: Some(env.eoa),
601                gas_price,
602                ..Default::default()
603            },
604        ),
605        SkipSenderCase::revert_both(
606            "FromNonExistentToContractWithData",
607            EthCallMessage {
608                from: Some(missing),
609                to: Some(env.errors),
610                data: Some(EthBytes(selector(FAIL_REVERT_EMPTY))),
611                ..Default::default()
612            },
613            revert_empty.clone(),
614        ),
615        SkipSenderCase::call_only(
616            "FromNonExistentWithValue",
617            EthCallMessage {
618                from: Some(missing),
619                to: Some(env.eoa),
620                value: Some(fil(1)),
621                ..Default::default()
622            },
623            Expect::ErrContains("insufficient"),
624        ),
625        SkipSenderCase::success("FromEOA", transfer(Some(env.eoa), Some(env.eoa2))),
626        SkipSenderCase::call_only("FromNil", transfer(None, Some(env.eoa)), Expect::Success),
627        SkipSenderCase::call_only(
628            "ValueOverBalance",
629            EthCallMessage {
630                from: Some(env.eoa),
631                to: Some(missing),
632                value: Some(fil(11)),
633                ..Default::default()
634            },
635            Expect::ErrContains("insufficient"),
636        ),
637        SkipSenderCase::revert_both(
638            "RevertDivideByZero",
639            errors_from_eoa(FAIL_DIV_ZERO),
640            Expect::Reverted {
641                msg: "DivideByZero",
642                data_contains: Some(PANIC_DIV_ZERO.to_string()),
643                data_eq: None,
644            },
645        ),
646        SkipSenderCase::revert_both(
647            "RevertAssert",
648            errors_from_eoa(FAIL_ASSERT),
649            Expect::Reverted {
650                msg: "Assert",
651                data_contains: Some(PANIC_ASSERT.to_string()),
652                data_eq: None,
653            },
654        ),
655        SkipSenderCase::revert_both(
656            "RevertWithReason",
657            errors_from_eoa(FAIL_REVERT_REASON),
658            Expect::Reverted {
659                msg: "my reason",
660                data_contains: None,
661                data_eq: None,
662            },
663        ),
664        SkipSenderCase::revert_both(
665            "RevertEmpty",
666            errors_from_eoa(FAIL_REVERT_EMPTY),
667            revert_empty,
668        ),
669        SkipSenderCase::revert_both(
670            "RevertCustomError",
671            errors_from_eoa(FAIL_CUSTOM),
672            Expect::Reverted {
673                msg: "",
674                data_contains: Some(custom),
675                data_eq: None,
676            },
677        ),
678    ])
679}
680
681fn assert_expect(
682    label: &str,
683    result: Result<u64, anyhow::Error>,
684    expect: &Expect,
685) -> anyhow::Result<()> {
686    match expect {
687        Expect::Success => {
688            result.with_context(|| format!("{label}: expected success"))?;
689            Ok(())
690        }
691        Expect::SuccessGas => {
692            let gas = result.with_context(|| format!("{label}: expected a gas estimate"))?;
693            ensure!(
694                gas >= MIN_ESTIMATE_GAS,
695                "{label}: estimate {gas} is below the 21_000 transfer floor"
696            );
697            ensure!(
698                gas < MAX_ESTIMATE_GAS,
699                "{label}: estimate {gas} looks like an overflow"
700            );
701            Ok(())
702        }
703        Expect::ErrContains(needle) => {
704            let err = result.err().with_context(|| {
705                format!("{label}: expected an error containing `{needle}`, but the call succeeded")
706            })?;
707            let text = match rpc_call_err(&err) {
708                Some(obj) => {
709                    let mut s = obj.message().to_string();
710                    if let Some(data) = rpc_data(obj) {
711                        s.push(' ');
712                        s.push_str(&data);
713                    }
714                    s
715                }
716                None => err.to_string(),
717            };
718            ensure!(
719                text.to_ascii_lowercase()
720                    .contains(&needle.to_ascii_lowercase()),
721                "{label}: error `{text}` does not contain `{needle}`"
722            );
723            Ok(())
724        }
725        Expect::Reverted {
726            msg,
727            data_contains,
728            data_eq,
729        } => {
730            let err = result.err().with_context(|| {
731                format!("{label}: expected execution reverted, but the call succeeded")
732            })?;
733            let obj = rpc_call_err(&err)
734                .with_context(|| format!("{label}: expected a JSON-RPC error, got {err:#}"))?;
735            ensure!(
736                obj.code() == EXECUTION_REVERTED_CODE,
737                "{label}: expected execution-reverted code {EXECUTION_REVERTED_CODE}, got {}: {}",
738                obj.code(),
739                obj.message()
740            );
741            if !msg.is_empty() {
742                ensure!(
743                    obj.message().contains(msg),
744                    "{label}: revert message `{}` does not contain `{msg}`",
745                    obj.message()
746                );
747            }
748            let data = rpc_data(obj).unwrap_or_default();
749            if let Some(want) = data_eq {
750                ensure!(data == *want, "{label}: revert data `{data}` != `{want}`");
751            }
752            if let Some(want) = data_contains {
753                ensure!(
754                    data.contains(want),
755                    "{label}: revert data `{data}` does not contain `{want}`"
756                );
757            }
758            Ok(())
759        }
760        Expect::ErrCode { code, contains } => {
761            let err = result.err().with_context(|| {
762                format!("{label}: expected error code {code}, but the call succeeded")
763            })?;
764            let obj = rpc_call_err(&err)
765                .with_context(|| format!("{label}: expected a JSON-RPC error, got {err:#}"))?;
766            ensure!(
767                obj.code() == *code,
768                "{label}: expected error code {code}, got {}: {}",
769                obj.code(),
770                obj.message()
771            );
772            ensure!(
773                obj.message().contains(contains),
774                "{label}: error `{}` does not contain `{contains}`",
775                obj.message()
776            );
777            Ok(())
778        }
779    }
780}
781
782async fn call_skip_sender() -> anyhow::Result<()> {
783    let forest = forest_client()?;
784    let env = table_env().await?;
785    for case in skip_sender_cases(env)? {
786        let Some(expect) = case.call else {
787            continue;
788        };
789        let label = format!("eth_call {}", case.name);
790        let result = eth_call_msg(&forest, case.msg, latest())
791            .await
792            .map(|_| 0u64);
793        assert_expect(&label, result, &expect)?;
794    }
795    Ok(())
796}
797
798async fn estimate_gas_skip_sender() -> anyhow::Result<()> {
799    let forest = forest_client()?;
800    let env = table_env().await?;
801    for case in skip_sender_cases(env)? {
802        let Some(expect) = case.estimate else {
803            continue;
804        };
805        let label = format!("eth_estimateGas {}", case.name);
806        assert_expect(&label, estimate_msg(&forest, case.msg).await, &expect)?;
807    }
808    Ok(())
809}
810
811async fn funded_placeholder_sender() -> anyhow::Result<()> {
812    let forest = forest_client()?;
813    let from = new_funded(PLACEHOLDER_FUND_AMT).await?;
814    let to = new_funded(ROUND_TRIP_FUND_AMT).await?;
815    eth_call_msg(
816        &forest,
817        EthCallMessage {
818            from: Some(from.eth),
819            to: Some(to.eth),
820            value: Some(fil(1)),
821            ..Default::default()
822        },
823        latest(),
824    )
825    .await
826    .context("value-bearing eth_call from a funded placeholder")?;
827    Ok(())
828}
829
830async fn estimate_gas(
831    client: &Client,
832    from: EthAddress,
833    to: EthAddress,
834    data: Vec<u8>,
835) -> anyhow::Result<u64> {
836    estimate_msg(
837        client,
838        EthCallMessage {
839            from: Some(from),
840            to: Some(to),
841            data: Some(EthBytes(data)),
842            ..Default::default()
843        },
844    )
845    .await
846}
847
848async fn eth_call(
849    client: &Client,
850    from: EthAddress,
851    to: EthAddress,
852    data: Vec<u8>,
853    block: BlockNumberOrHash,
854) -> anyhow::Result<EthBytes> {
855    eth_call_msg(
856        client,
857        EthCallMessage {
858            from: Some(from),
859            to: Some(to),
860            data: (!data.is_empty()).then_some(EthBytes(data)),
861            ..Default::default()
862        },
863        block,
864    )
865    .await
866}
867
868fn within_parity(skip: u64, funded: u64) -> bool {
869    let denom = funded.max(1) as f64;
870    (skip as f64 - funded as f64).abs() / denom <= ESTIMATE_PARITY
871}
872
873async fn new_unfunded() -> anyhow::Result<Wallet> {
874    let cli = lotus_exec(&["wallet", "new", "delegated"])?;
875    let f4 = Address::from_str(&cli).context("parsing unfunded delegated address")?;
876    let eth = EthAddress::from_filecoin_address(&f4)?;
877    Ok(Wallet { cli, f4, eth })
878}
879
880async fn new_funded(amount: &str) -> anyhow::Result<Wallet> {
881    let wallet = new_unfunded().await?;
882    fund_on_chain(&wallet.cli, amount).await?;
883    Ok(wallet)
884}
885
886async fn round_trip_from_unfunded() -> anyhow::Result<()> {
887    let forest = forest_client()?;
888    let coin = simple_coin().await?;
889    let recipient = non_existent(0x01)?;
890    let calldata = send_coin_calldata(recipient, 0);
891
892    let from = new_unfunded().await?;
893    let gas = estimate_gas(&forest, from.eth, coin.eth, calldata.clone())
894        .await
895        .context("eth_estimateGas from unfunded sender")?;
896    eprintln!("skip-sender estimate {gas} from {}", from.cli);
897
898    ensure!(
899        get_actor(&forest, from.f4).await?.is_none(),
900        "ephemeral placeholder for {} leaked onto chain during estimate",
901        from.f4
902    );
903
904    fund_on_chain(&from.cli, ROUND_TRIP_FUND_AMT).await?;
905    let actor = poll_until_actor(from.f4).await?;
906    ensure!(
907        actor.sequence == 0,
908        "pre-submit nonce of {} is {}, expected 0 (placeholder must not have incremented it)",
909        from.f4,
910        actor.sequence
911    );
912
913    submit_at_gas_limit(&from.f4, &coin.f4, &calldata, gas).await?;
914    let after = get_actor(&forest, from.f4)
915        .await?
916        .with_context(|| format!("actor {} missing after successful submit", from.f4))?;
917    ensure!(
918        after.sequence == 1,
919        "successful tx must use nonce 0; on-chain nonce is now {}",
920        after.sequence
921    );
922    Ok(())
923}
924
925async fn parity_with_existing_sender() -> anyhow::Result<()> {
926    let forest = forest_client()?;
927    let coin = simple_coin().await?;
928    let calldata = send_coin_calldata(non_existent(0x01)?, 0);
929
930    let skip = estimate_gas(&forest, non_existent(0x42)?, coin.eth, calldata.clone())
931        .await
932        .context("eth_estimateGas from missing from")?;
933    let placeholder = new_funded(ROUND_TRIP_FUND_AMT).await?;
934    let funded = estimate_gas(&forest, placeholder.eth, coin.eth, calldata)
935        .await
936        .context("eth_estimateGas from funded placeholder")?;
937    eprintln!("parity skip={skip} funded={funded}");
938    ensure!(
939        within_parity(skip, funded),
940        "skip-sender estimate {skip} vs funded-placeholder {funded} exceeds 10%"
941    );
942    Ok(())
943}
944
945async fn round_trip_recursive() -> anyhow::Result<()> {
946    let forest = forest_client()?;
947    let nested = nested_gas().await?;
948    let calldata = recurse_calldata(NESTED_DEPTH);
949
950    let from = new_unfunded().await?;
951    let gas = estimate_gas(&forest, from.eth, nested.eth, calldata.clone())
952        .await
953        .context("skip-sender eth_estimateGas recurse(100)")?;
954
955    let placeholder = new_funded(RECURSIVE_FUND_AMT).await?;
956    let funded = estimate_gas(&forest, placeholder.eth, nested.eth, calldata.clone())
957        .await
958        .context("funded-placeholder eth_estimateGas recurse(100)")?;
959    eprintln!("recursive skip={gas} funded={funded}");
960    ensure!(
961        within_parity(gas, funded),
962        "recursive skip-sender estimate {gas} vs funded-placeholder {funded} exceeds 10%"
963    );
964
965    fund_on_chain(&from.cli, RECURSIVE_FUND_AMT).await?;
966    submit_at_gas_limit(&from.f4, &nested.f4, &calldata, gas).await
967}
968
969async fn call_sender_identity() -> anyhow::Result<()> {
970    let forest = forest_client()?;
971    let coin = simple_coin().await?;
972    let sender_contract = contract_b().await?;
973    let with_coins = non_existent(0x21)?;
974    let without_coins = non_existent(0x22)?;
975    let recipient = non_existent(0x01)?;
976
977    for to in [sender_contract.eth, with_coins] {
978        invoke(&coin.f4, &send_coin_calldata(to, 100)).await?;
979    }
980
981    let spend = send_coin_calldata(recipient, 10);
982    for (label, from, want) in [
983        ("contract from", sender_contract.eth, 1u8),
984        ("credited missing from", with_coins, 1),
985        ("uncounted missing from", without_coins, 0),
986    ] {
987        let ret = eth_call(&forest, from, coin.eth, spend.clone(), latest())
988            .await
989            .with_context(|| format!("eth_call sendCoin from {label}"))?;
990        ensure!(
991            ret.0.len() == 32,
992            "{label}: sendCoin return must be a 32-byte ABI bool, got {} bytes",
993            ret.0.len()
994        );
995        ensure!(
996            ret.0.last() == Some(&want),
997            "{label}: callee must observe the requested from as msg.sender (want {want}, got {ret:?})"
998        );
999    }
1000    Ok(())
1001}
1002
1003async fn skip_sender_state_isolation() -> anyhow::Result<()> {
1004    let forest = forest_client()?;
1005    let to = EthAddress::from_filecoin_address(deployer().await?)?;
1006    let from = non_existent(0x03)?;
1007
1008    let first = eth_call(&forest, from, to, Vec::new(), latest()).await?;
1009    let second = eth_call(&forest, from, to, Vec::new(), latest()).await?;
1010    ensure!(
1011        first == second,
1012        "repeated skip-sender eth_call results must match"
1013    );
1014
1015    let fil = from.to_filecoin_address()?;
1016    ensure!(
1017        get_actor(&forest, fil).await?.is_none(),
1018        "skip-sender eth_call must not persist an actor for {fil}"
1019    );
1020
1021    let mut futs = Vec::with_capacity(8);
1022    for _ in 0..8 {
1023        futs.push(async move {
1024            let client = forest_client()?;
1025            eth_call(&client, from, to, Vec::new(), latest()).await
1026        });
1027    }
1028    let concurrent = futures::future::try_join_all(futs).await?;
1029    for (i, got) in concurrent.iter().enumerate() {
1030        ensure!(
1031            *got == first,
1032            "concurrent skip-sender eth_call {i} diverged from the first result"
1033        );
1034    }
1035    Ok(())
1036}
1037
1038async fn skip_sender_historical_call() -> anyhow::Result<()> {
1039    let forest = forest_client()?;
1040    let to = EthAddress::from_filecoin_address(deployer().await?)?;
1041    let from = non_existent(0x03)?;
1042    let head = forest
1043        .call(EthBlockNumber::request(())?)
1044        .await
1045        .map_err(|e| anyhow::anyhow!("{e:#}"))?;
1046    ensure!(
1047        head.0 > 2,
1048        "devnet head {} is too low for a head-2 historical eth_call",
1049        head.0
1050    );
1051    let hist = BlockNumberOrHash::from_block_number((head.0 - 2) as i64);
1052    eth_call(&forest, from, to, Vec::new(), hist)
1053        .await
1054        .context("historical skip-sender eth_call at head-2")?;
1055    Ok(())
1056}
1057
1058async fn assert_call_b(
1059    from: EthAddress,
1060    sig: &str,
1061    expected: u8,
1062    label: &str,
1063) -> anyhow::Result<()> {
1064    let forest = forest_client()?;
1065    let (a, _) = linked_contracts().await?;
1066    assert_abi_u256(
1067        eth_call(&forest, from, a.eth, selector(sig), latest())
1068            .await
1069            .with_context(|| label.to_string())?,
1070        expected,
1071        label,
1072    )
1073}
1074
1075async fn cross_contract_from_contract() -> anyhow::Result<()> {
1076    let (_, b) = linked_contracts().await?;
1077    assert_call_b(
1078        b.eth,
1079        CALL_B_AND_READ_BACK,
1080        42,
1081        "cross-contract callback from contract from",
1082    )
1083    .await
1084}
1085
1086async fn cross_contract_from_missing() -> anyhow::Result<()> {
1087    assert_call_b(
1088        non_existent(0x10)?,
1089        CALL_B_AND_READ_BACK,
1090        42,
1091        "cross-contract callback from missing from",
1092    )
1093    .await
1094}
1095
1096async fn cross_contract_from_eoa() -> anyhow::Result<()> {
1097    let from = new_funded(ROUND_TRIP_FUND_AMT).await?;
1098    assert_call_b(
1099        from.eth,
1100        CALL_B_AND_READ_BACK,
1101        42,
1102        "cross-contract callback from EOA from",
1103    )
1104    .await
1105}
1106
1107async fn cross_contract_double_callback() -> anyhow::Result<()> {
1108    assert_call_b(
1109        non_existent(0x11)?,
1110        CALL_B_AND_DOUBLE,
1111        84,
1112        "cross-contract double callback from missing from",
1113    )
1114    .await
1115}
1116
1117fn assert_abi_u256(ret: EthBytes, expected: u8, label: &str) -> anyhow::Result<()> {
1118    ensure!(
1119        ret.0.len() == 32,
1120        "{label}: expected 32-byte ABI uint256, got {} bytes",
1121        ret.0.len()
1122    );
1123    ensure!(
1124        ret.0.last() == Some(&expected),
1125        "{label}: expected {expected}, got {ret:?}"
1126    );
1127    Ok(())
1128}