diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index 44ddfd0f657..a0a06721796 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -511,6 +511,7 @@ jobs: forest-dev tests wallet forest-dev devnet eth-gas forest-dev devnet eth-skip-sender + forest-dev devnet state-call timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} - name: Dump docker logs if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index c53f397e085..b4238d4cdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,11 +37,14 @@ - [#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)). + ### Removed ### 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. ## Forest v0.38.0 "Ayanant" diff --git a/docs/docs/users/reference/cli.md b/docs/docs/users/reference/cli.md index 2f9a02a4aea..7689af6527d 100644 --- a/docs/docs/users/reference/cli.md +++ b/docs/docs/users/reference/cli.md @@ -2929,6 +2929,7 @@ Usage: forest-dev devnet Commands: eth-gas `eth_estimateGas` parity and gas cap 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) Options: @@ -2956,3 +2957,14 @@ Usage: forest-dev devnet eth-skip-sender Options: -h, --help Print help ``` + +### `forest-dev devnet state-call` + +``` +`Filecoin.StateCall` gas limit parity tests + +Usage: forest-dev devnet state-call + +Options: + -h, --help Print help +``` diff --git a/docs/docs/users/reference/cli.sh b/docs/docs/users/reference/cli.sh index f94d0af2a3e..5435f7a29dc 100755 --- a/docs/docs/users/reference/cli.sh +++ b/docs/docs/users/reference/cli.sh @@ -218,3 +218,4 @@ generate_markdown_section "forest-dev" "tests mpool" 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" diff --git a/docs/docs/users/reference/env_variables.md b/docs/docs/users/reference/env_variables.md index 5062a243ff1..eab228b3e6f 100644 --- a/docs/docs/users/reference/env_variables.md +++ b/docs/docs/users/reference/env_variables.md @@ -22,6 +22,7 @@ process. | `FOREST_RPC_MAX_CONNECTIONS` | positive integer | 1000 | 42 | Maximum number of allowed connections for the RPC server | | `FOREST_RPC_MAX_BATCH_LEN` | positive integer | 100 | 500 | Maximum number of entries in a single JSON-RPC batch request. Larger batches are rejected with error `-32010`. `0` or invalid values fall back to the default | | `FOREST_RPC_REPLAY_CONCURRENCY` | positive integer | half the available CPUs | 4 | Maximum concurrent tipset replays triggered by RPC methods (`StateReplay`, `trace_*`, `debug_trace*`). Each replay is a full VM execution of a tipset; bounding them keeps replay-heavy clients from starving the node | +| `FOREST_STATE_CALL_GAS_LIMIT` | positive integer | 10000000000 (the block gas limit) | 20000000000 | Caps the `GasLimit` of `Filecoin.StateCall` messages: a message above the cap is rejected, and one with `GasLimit` `0` runs at the cap | | `FOREST_RPC_COMPRESS_MIN_BODY_SIZE` | integer in `[-1, 65535]` (bytes) | -1 (disabled) | 2048 (or `-1` to disable) | Disabled by default. When set to a non-negative value, gzip-compresses responses whose body is at least that many bytes; smaller responses are sent uncompressed. Values above 65535 are clamped to 65535. Set to a negative value (e.g. `-1`) to disable compression entirely | | `FOREST_MAX_CONCURRENT_REQUEST_RESPONSE_STREAMS_PER_PEER` | positive integer | 10 | 10 | the maximum concurrent streams per peer for request-response-based p2p protocols | | `FOREST_BLOCK_DELAY_SECS` | positive integer | Depends on the network | 30 | Duration of each tipset epoch | diff --git a/scripts/devnet/README.md b/scripts/devnet/README.md index f36cd6f97f9..d3c185b9040 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` and `forest-dev devnet eth-skip-sender`. +`forest-dev devnet eth-gas`, `forest-dev devnet eth-skip-sender` and +`forest-dev devnet state-call`. -The `devnet` suites (unlike `tests`) need both nodes reachable: `eth-gas` -compares Forest against Lotus, and `eth-skip-sender` deploys contracts and -submits transactions, so they run pre-flight checks and fail early if the -devnet or harness environment is missing. +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 +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 10009bd94cb..c39aad7a91f 100755 --- a/scripts/devnet/run_integration_tests.sh +++ b/scripts/devnet/run_integration_tests.sh @@ -22,3 +22,4 @@ forest-dev tests mpool forest-dev tests wallet forest-dev devnet eth-gas forest-dev devnet eth-skip-sender +forest-dev devnet state-call diff --git a/scripts/tests/api_compare/.env b/scripts/tests/api_compare/.env index ec46de60aa2..2aca87a4e64 100644 --- a/scripts/tests/api_compare/.env +++ b/scripts/tests/api_compare/.env @@ -1,6 +1,6 @@ # Note: this should be a `fat` image so that it contains the pre-downloaded filecoin proof parameters FOREST_IMAGE=ghcr.io/chainsafe/forest:edge-fat -LOTUS_IMAGE=filecoin/lotus-all-in-one:v1.37.0-calibnet +LOTUS_IMAGE=filecoin/lotus-all-in-one:v1.38.0-calibnet FIL_PROOFS_PARAMETER_CACHE=/var/tmp/filecoin-proof-parameters LOTUS_RPC_PORT=1234 LOTUS_VIA_GATEWAY_RPC_PORT=4568 diff --git a/src/dev/subcommands/devnet_cmd.rs b/src/dev/subcommands/devnet_cmd.rs index e369691a3e0..bf237aa8fb2 100644 --- a/src/dev/subcommands/devnet_cmd.rs +++ b/src/dev/subcommands/devnet_cmd.rs @@ -8,6 +8,7 @@ mod eth_gas; mod eth_skip_sender; +mod state_call; use crate::dev::subcommands::tests_cmd::helpers::{docker, forest_client, lotus_client}; use crate::rpc::prelude::*; @@ -18,6 +19,7 @@ use anyhow::{Context as _, ensure}; pub enum DevnetCommand { EthGas(eth_gas::EthGasTestCommand), EthSkipSender(eth_skip_sender::EthSkipSenderTestCommand), + StateCall(state_call::StateCallTestCommand), } impl DevnetCommand { @@ -26,6 +28,7 @@ impl DevnetCommand { match self { Self::EthGas(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_gas.rs b/src/dev/subcommands/devnet_cmd/eth_gas.rs index 8ee66968c21..ecc21076276 100644 --- a/src/dev/subcommands/devnet_cmd/eth_gas.rs +++ b/src/dev/subcommands/devnet_cmd/eth_gas.rs @@ -23,11 +23,10 @@ use crate::rpc::eth::{ use crate::rpc::prelude::*; use crate::rpc::types::ApiTipsetKey; use crate::shim::econ::{BLOCK_GAS_LIMIT, TokenAmount}; -use crate::utils::encoding::keccak_256; use anyhow::{Context as _, bail, ensure}; use jsonrpsee::types::ErrorObjectOwned; use jsonrpsee::types::error::INVALID_PARAMS_CODE; -use libtest_mimic::{Arguments, Failed, Trial}; +use libtest_mimic::{Arguments, Trial}; use tokio::sync::OnceCell; /// `NestedGas`, whose `recurse(uint256)` calls itself that many times. @@ -46,8 +45,6 @@ const ALWAYS_REVERTS_REASON: &str = "always reverts"; 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; -/// Pays for the on-chain submit and lets the priced estimates afford the nested call. -const SENDER_FUND_AMT: &str = "10 FIL"; /// Enough for the sender to exist, far too little to pay for the nested call's gas. const POOR_SENDER_FUND_AMT: &str = "1 nanoFIL"; @@ -66,12 +63,6 @@ impl EthGasTestCommand { } fn tests() -> Vec { - 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)) @@ -111,14 +102,6 @@ fn tests() -> Vec { ] } -/// The 4-byte Ethereum function selector: first 4 bytes of `keccak256(signature)`. -fn selector(signature: &str) -> Vec { - 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 { let mut out = selector(RECURSE_SIGNATURE); @@ -131,25 +114,9 @@ async fn contract() -> anyhow::Result<&'static EthAddress> { static CONTRACT: OnceCell = OnceCell::const_new(); CONTRACT .get_or_try_init(|| async { - let deploy = forest_evm_deploy_hex(&sender().await?.cli, 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?; + let contract = deploy_contract("NestedGas", NESTED_GAS_HEX).await?; poll_until_next_epoch().await?; - EthAddress::from_filecoin_address(&f4) - }) - .await -} - -/// The funded sender, created once per process and known to both nodes and both wallets. -async fn sender() -> anyhow::Result<&'static Wallet> { - static SENDER: OnceCell = OnceCell::const_new(); - SENDER - .get_or_try_init(|| async { - let wallet = new_funded(SENDER_FUND_AMT).await?; - import_lotus_wallet_into_forest(&wallet.cli)?; - Ok(wallet) + Ok(contract) }) .await } diff --git a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs index a26c28a8d30..09e382647c7 100644 --- a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs +++ b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs @@ -27,9 +27,9 @@ use crate::rpc::prelude::*; use crate::shim::address::Address; use crate::shim::econ::TokenAmount; use crate::shim::state_tree::ActorState; -use crate::utils::encoding::{hex, keccak_256}; +use crate::utils::encoding::hex; use anyhow::{Context as _, ensure}; -use libtest_mimic::{Arguments, Failed, Trial}; +use libtest_mimic::{Arguments, Trial}; use std::str::FromStr as _; use tokio::sync::OnceCell; @@ -52,7 +52,6 @@ const FAIL_REVERT_EMPTY: &str = "failRevertEmpty()"; const FAIL_CUSTOM: &str = "failCustom()"; const NESTED_DEPTH: u64 = 100; -const DEPLOYER_FUND_AMT: &str = "10 FIL"; const ROUND_TRIP_FUND_AMT: &str = "1 FIL"; const RECURSIVE_FUND_AMT: &str = "10 FIL"; const EOA_FUND_AMT: &str = "10 FIL"; @@ -84,12 +83,6 @@ impl EthSkipSenderTestCommand { } fn tests() -> Vec { - fn trial(name: &'static str, body: fn() -> anyhow::Result<()>) -> Trial { - Trial::test(name, move || { - body().map_err(|e| Failed::from(format!("{e:?}"))) - }) - } - vec![ trial("evm_deploy_and_call", || block_on(evm_deploy_and_call())), trial("round_trip_from_unfunded", || { @@ -134,25 +127,6 @@ fn tests() -> Vec { ] } -fn selector(signature: &str) -> Vec { - keccak_256(signature.as_bytes()) - .get(..4) - .expect("keccak256 is 32 bytes") - .to_vec() -} - -fn abi_address_word(addr: EthAddress) -> Vec { - let mut word = vec![0u8; 12]; - word.extend_from_slice(addr.0.as_bytes()); - word -} - -fn calldata(sig: &str, extra: &[u8]) -> Vec { - let mut out = selector(sig); - out.extend_from_slice(extra); - out -} - fn send_coin_calldata(to: EthAddress, amount: u64) -> Vec { let mut extra = abi_address_word(to); extra.extend_from_slice(ðereum_types::U256::from(amount).to_big_endian()); @@ -201,41 +175,16 @@ fn latest() -> BlockNumberOrHash { BlockNumberOrHash::PredefinedBlock(Predefined::Latest) } -/// Dedicated delegated wallet used to deploy and to credit `SimpleCoin`. -/// Not the genesis/miner key: that wallet races with the miner on nonce. -async fn deployer() -> anyhow::Result<&'static Address> { - static DEPLOYER: OnceCell
= OnceCell::const_new(); - DEPLOYER - .get_or_try_init(|| async { - let addr = lotus_exec(&["wallet", "new", "delegated"])?; - let funded = fund_on_chain(&addr, DEPLOYER_FUND_AMT).await?; - import_lotus_wallet_into_forest(&addr)?; - Ok(funded) - }) - .await -} - -async fn deploy_hex(label: &str, bytecode: &str) -> anyhow::Result { - let deployer = deployer().await?; - let from = deployer.to_string(); - let deploy = forest_evm_deploy_hex(&from, bytecode)?; - let f4 = parse_f4_from_evm_deploy(&deploy)?; - eprintln!("deployed {label} at {f4}"); - poll_until_actor(f4).await?; - poll_until_actor_on("lotus", f4, lotus_client).await?; - EthAddress::from_filecoin_address(&f4) -} - async fn simple_coin() -> anyhow::Result<&'static EthAddress> { static CONTRACT: OnceCell = OnceCell::const_new(); CONTRACT - .get_or_try_init(|| deploy_hex("SimpleCoin", SIMPLE_COIN_HEX)) + .get_or_try_init(|| deploy_contract("SimpleCoin", SIMPLE_COIN_HEX)) .await } async fn evm_deploy_and_call() -> anyhow::Result<()> { let coin = simple_coin().await?; - let from = EthAddress::from_filecoin_address(deployer().await?)?; + let from = sender().await?.eth; let from_hex = hex::encode_prefixed(from.0.as_bytes()); let to_hex = hex::encode_prefixed(coin.0.as_bytes()); let data = hex::encode_prefixed(get_balance_calldata(from)); @@ -260,21 +209,21 @@ async fn evm_deploy_and_call() -> anyhow::Result<()> { async fn contract_b() -> anyhow::Result<&'static EthAddress> { static CONTRACT: OnceCell = OnceCell::const_new(); CONTRACT - .get_or_try_init(|| deploy_hex("ContractB", CONTRACT_B_HEX)) + .get_or_try_init(|| deploy_contract("ContractB", CONTRACT_B_HEX)) .await } async fn nested_gas() -> anyhow::Result<&'static EthAddress> { static CONTRACT: OnceCell = OnceCell::const_new(); CONTRACT - .get_or_try_init(|| deploy_hex("NestedGas", NESTED_GAS_HEX)) + .get_or_try_init(|| deploy_contract("NestedGas", NESTED_GAS_HEX)) .await } async fn errors_contract() -> anyhow::Result<&'static EthAddress> { static CONTRACT: OnceCell = OnceCell::const_new(); CONTRACT - .get_or_try_init(|| deploy_hex("Errors", ERRORS_HEX)) + .get_or_try_init(|| deploy_contract("Errors", ERRORS_HEX)) .await } @@ -309,7 +258,7 @@ async fn linked_contracts() -> anyhow::Result<&'static (EthAddress, EthAddress)> LINKED .get_or_try_init(|| async { let b = contract_b().await?; - let a = deploy_hex("ContractA", CONTRACT_A_HEX).await?; + let a = deploy_contract("ContractA", CONTRACT_A_HEX).await?; invoke(a, &set_contract_b_calldata(*b)).await?; Ok((a, *b)) }) @@ -321,9 +270,9 @@ async fn poll_until_actor(addr: Address) -> anyhow::Result { } async fn invoke(to: EthAddress, calldata: &[u8]) -> anyhow::Result<()> { - let from = deployer().await?.to_string(); + let from = &sender().await?.cli; let to_fil = to.to_filecoin_address()?.to_string(); - forest_evm_invoke(&from, &to_fil, &hex::encode(calldata))?; + forest_evm_invoke(from, &to_fil, &hex::encode(calldata))?; Ok(()) } @@ -1021,7 +970,7 @@ async fn call_sender_identity() -> anyhow::Result<()> { async fn skip_sender_state_isolation() -> anyhow::Result<()> { let forest = forest_client()?; - let to = EthAddress::from_filecoin_address(deployer().await?)?; + let to = sender().await?.eth; let from = non_existent(0x03)?; let first = eth_call(&forest, from, to, Vec::new(), latest()).await?; @@ -1056,7 +1005,7 @@ async fn skip_sender_state_isolation() -> anyhow::Result<()> { async fn skip_sender_historical_call() -> anyhow::Result<()> { let forest = forest_client()?; - let to = EthAddress::from_filecoin_address(deployer().await?)?; + let to = sender().await?.eth; let from = non_existent(0x03)?; let head = forest .call(EthBlockNumber::request(())?) diff --git a/src/dev/subcommands/devnet_cmd/state_call.rs b/src/dev/subcommands/devnet_cmd/state_call.rs new file mode 100644 index 00000000000..1e8630f0fee --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/state_call.rs @@ -0,0 +1,271 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! `Filecoin.StateCall` gas limit parity tests against the Lotus node on the docker devnet. Every +//! call runs on both nodes, which must agree. + +use crate::dev::subcommands::tests_cmd::helpers::*; +use crate::rpc::eth::types::{EthAddress, EthBytes, EthCallMessage}; +use crate::rpc::prelude::*; +use crate::rpc::state::ApiInvocResult; +use crate::rpc::types::ApiTipsetKey; +use crate::rpc::{Client, Request}; +use crate::shim::econ::{BLOCK_GAS_LIMIT, TokenAmount}; +use crate::shim::executor::Receipt; +use crate::shim::fvm_shared_latest::error::ExitCode; +use crate::shim::message::Message; +use anyhow::{Context as _, bail, ensure}; +use jsonrpsee::types::ErrorObjectOwned; +use libtest_mimic::{Arguments, Trial}; +use tokio::sync::OnceCell; + +/// `SimpleCoin`, whose `getBalance(address)` reads one storage slot. +const SIMPLE_COIN_HEX: &str = include_str!("contracts/simple_coin/simple_coin.hex"); +const GET_BALANCE_SIGNATURE: &str = "getBalance(address)"; + +/// `Filecoin.StateCall` gas limit parity tests +#[derive(Debug, clap::Args)] +pub struct StateCallTestCommand {} + +impl StateCallTestCommand { + 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("state_call_runs_at_the_message_gas_limit", || { + block_on(state_call_runs_at_the_message_gas_limit()) + }), + trial("state_call_charges_no_fees", || { + block_on(state_call_charges_no_fees()) + }), + ] +} + +/// `SimpleCoin`, deployed once per process. +async fn contract() -> anyhow::Result<&'static EthAddress> { + static CONTRACT: OnceCell = OnceCell::const_new(); + CONTRACT + .get_or_try_init(|| deploy_contract("SimpleCoin", SIMPLE_COIN_HEX)) + .await +} + +/// `getBalance(sender)` on `SimpleCoin` from the sender, with the given gas limit. +async fn get_balance_message(gas_limit: u64) -> anyhow::Result { + let (from, to) = tokio::try_join!(sender(), contract())?; + let msg = Message::try_from(EthCallMessage { + from: Some(from.eth), + to: Some(*to), + data: Some(EthBytes(calldata( + GET_BALANCE_SIGNATURE, + &abi_address_word(from.eth), + ))), + ..Default::default() + })?; + Ok(msg.into_builder().gas_limit(gas_limit).build()) +} + +fn receipt_of(result: &ApiInvocResult) -> anyhow::Result<&Receipt> { + result.msg_rct.as_ref().context("no receipt in the result") +} + +/// A tipset both nodes have executed, so the two simulations start from the same state. +async fn common_tipset(forest: &Client, lotus: &Client) -> anyhow::Result { + let (forest_head, lotus_head) = tokio::try_join!( + async { anyhow::Ok(forest.call(ChainHead::request(())?).await?) }, + async { anyhow::Ok(lotus.call(ChainHead::request(())?).await?) }, + )?; + let epoch = forest_head.epoch().min(lotus_head.epoch()); + let tipset = forest + .call(ChainGetTipSetByHeight::request(( + epoch, + ApiTipsetKey(None), + ))?) + .await?; + Ok(tipset.key().into()) +} + +/// Both nodes and a tipset both have executed, so every call starts from the same state. +struct Devnet { + forest: Client, + lotus: Client, + tipset: ApiTipsetKey, +} + +impl Devnet { + /// Deploys the contract first, so the pinned tipset already has it on both nodes. + async fn pinned() -> anyhow::Result { + contract().await?; + let (forest, lotus) = (forest_client()?, lotus_client()?); + let tipset = common_tipset(&forest, &lotus).await?; + Ok(Self { + forest, + lotus, + tipset, + }) + } + + fn request(&self, msg: Message) -> anyhow::Result> { + Ok(StateCall::request((msg, self.tipset.clone()))?) + } + + /// Runs `msg` on both nodes, which must return the same result with `exit_code`. + async fn expect(&self, msg: Message, exit_code: ExitCode) -> anyhow::Result { + let request = self.request(msg)?; + let (forest, lotus) = tokio::try_join!( + async { anyhow::Ok(self.forest.call(request.clone()).await?) }, + async { anyhow::Ok(self.lotus.call(request.clone()).await?) }, + )?; + for (node, result) in [("forest", &forest), ("lotus", &lotus)] { + let exit = receipt_of(result).with_context(|| node)?.exit_code(); + ensure!( + exit == exit_code, + "{node}: expected exit code {exit_code}, got {exit} ({})", + result.error + ); + } + ensure!( + forest.msg == lotus.msg, + "echoed messages differ: forest {:?}, lotus {:?}", + forest.msg, + lotus.msg + ); + ensure!( + forest.msg_cid == lotus.msg_cid, + "message CIDs differ: forest {}, lotus {}", + forest.msg_cid, + lotus.msg_cid + ); + ensure!( + forest.msg_rct == lotus.msg_rct, + "receipts differ: forest {:?}, lotus {:?}", + forest.msg_rct, + lotus.msg_rct + ); + ensure!( + forest.error == lotus.error, + "errors differ: forest `{}`, lotus `{}`", + forest.error, + lotus.error + ); + ensure!( + forest.gas_cost == lotus.gas_cost, + "gas costs differ: forest {:?}, lotus {:?}", + forest.gas_cost, + lotus.gas_cost + ); + ensure!( + forest.execution_trace == lotus.execution_trace, + "execution traces differ" + ); + Ok(forest) + } + + /// Sends `msg` to both nodes, which must both answer with a JSON-RPC error. + async fn expect_rejected( + &self, + msg: Message, + ) -> anyhow::Result<(ErrorObjectOwned, ErrorObjectOwned)> { + let request = self.request(msg)?; + tokio::try_join!( + rejection("forest", &self.forest, request.clone()), + rejection("lotus", &self.lotus, request) + ) + } +} + +async fn rejection( + node: &str, + client: &Client, + request: Request, +) -> anyhow::Result { + let err = match client.call(request).await { + Ok(result) => bail!("{node} ran a message it must reject: {result:?}"), + Err(e) => anyhow::Error::from(e), + }; + rpc_call_err(&err) + .cloned() + .with_context(|| format!("{node}: expected a JSON-RPC error, got {err:?}")) +} + +/// Both nodes run the message at its own gas limit, treat `0` as the block gas limit, and reject a +/// limit above it before running anything. +async fn state_call_runs_at_the_message_gas_limit() -> anyhow::Result<()> { + let devnet = Devnet::pinned().await?; + let ok = ExitCode::OK; + + // Gas 0 means the cap, the block gas limit by default. + let result = devnet.expect(get_balance_message(0).await?, ok).await?; + ensure!( + result.msg.gas_limit() == BLOCK_GAS_LIMIT, + "expected gas 0 to run at the block gas limit, got {}", + result.msg.gas_limit() + ); + let gas_used = receipt_of(&result)?.gas_used(); + ensure!( + gas_used > 1, + "expected getBalance to use gas, got {gas_used}" + ); + eprintln!("getBalance used {gas_used} gas"); + + // Any other limit is the one the message runs at: enough gas succeeds, one unit short runs out. + let limits = [ + ("exactly the gas used", gas_used, ok), + ("one unit short", gas_used - 1, ExitCode::SYS_OUT_OF_GAS), + ("the block gas limit", BLOCK_GAS_LIMIT, ok), + ]; + for (label, limit, exit_code) in limits { + let result = devnet + .expect(get_balance_message(limit).await?, exit_code) + .await + .with_context(|| format!("at {label} ({limit})"))?; + ensure!( + result.msg.gas_limit() == limit, + "{label}: expected the message to run at {limit}, got {}", + result.msg.gas_limit() + ); + } + + // Above the cap is rejected before anything runs, with Lotus's text. + let expected = format!( + "StateCall gas limit {} exceeds configured cap {BLOCK_GAS_LIMIT}", + BLOCK_GAS_LIMIT + 1 + ); + let (forest, lotus) = devnet + .expect_rejected(get_balance_message(BLOCK_GAS_LIMIT + 1).await?) + .await?; + for (node, err) in [("forest", &forest), ("lotus", &lotus)] { + ensure!( + err.message().contains(&expected), + "{node}: expected `{expected}`, got `{}`", + err.message() + ); + } + Ok(()) +} + +/// A realistic limit changes nothing about fees: the call is applied without checking or charging +/// them, so fees the sender could never pay still run and come back echoed as given. +async fn state_call_charges_no_fees() -> anyhow::Result<()> { + let devnet = Devnet::pinned().await?; + let unpayable = TokenAmount::from_whole(1_000_000u64); + let priced = get_balance_message(0) + .await? + .into_builder() + .gas_fee_cap(unpayable.clone()) + .gas_premium(unpayable.clone()) + .build(); + let result = devnet.expect(priced, ExitCode::OK).await?; + ensure!( + result.msg.gas_fee_cap() == &unpayable && result.msg.gas_premium() == &unpayable, + "expected the fees echoed as given, got {:?}", + result.msg + ); + Ok(()) +} diff --git a/src/dev/subcommands/tests_cmd/helpers.rs b/src/dev/subcommands/tests_cmd/helpers.rs index 8525d5a09d5..8bceea601c6 100644 --- a/src/dev/subcommands/tests_cmd/helpers.rs +++ b/src/dev/subcommands/tests_cmd/helpers.rs @@ -11,6 +11,7 @@ use std::time::Duration; use anyhow::{Context as _, bail, ensure}; use cid::Cid; use jsonrpsee::core::ClientError; +use libtest_mimic::{Failed, Trial}; use serde_json::{Value, json}; use tempfile::NamedTempFile; use tokio::sync::OnceCell; @@ -23,7 +24,7 @@ use crate::shim::address::Address; use crate::shim::clock::ChainEpoch; use crate::shim::state_tree::ActorState; use crate::state_manager::FAILED_TO_LOAD_MESSAGE; -use crate::utils::encoding::hex; +use crate::utils::encoding::{hex, keccak_256}; /// Funded preloaded address from env `FOREST_TEST_PRELOADED_ADDRESS` (`forest_wallet_init` in `scripts/tests/harness.sh`). pub static FOREST_TEST_PRELOADED_ADDRESS: LazyLock = LazyLock::new(|| { @@ -40,6 +41,8 @@ pub const FIL_AMT: &str = "500 atto FIL"; pub const FIL_ZERO: &str = "0 FIL"; /// Amount to seed a freshly-created delegated wallet. pub const DELEGATE_FUND_AMT: &str = "30 micro FIL"; +/// Funds [`sender`]: pays for an on-chain submit and lets priced estimates afford a nested call. +const SENDER_FUND_AMT: &str = "10 FIL"; /// Maximum time to wait for a polled condition before failing the test. pub const POLL_TIMEOUT: Duration = Duration::from_secs(600); @@ -319,6 +322,19 @@ pub async fn fund_on_chain(cli_addr: &str, amount: &str) -> anyhow::Result anyhow::Result<&'static Wallet> { + static SENDER: OnceCell = OnceCell::const_new(); + SENDER + .get_or_try_init(|| async { + let wallet = new_funded(SENDER_FUND_AMT).await?; + import_lotus_wallet_into_forest(&wallet.cli)?; + Ok(wallet) + }) + .await +} + /// Delegated signer: create once on local, fund locally, mirror to remote /// for tests that query or sign. pub async fn funded_delegated_addr() -> &'static str { @@ -573,6 +589,38 @@ pub fn parse_f4_from_evm_deploy(out: &str) -> anyhow::Result
{ Address::from_str(f4.trim()).context("parsing the deployed f4 address") } +/// Deploys `bytecode_hex` from [`sender`] and waits until both nodes have the contract. +pub async fn deploy_contract(name: &str, bytecode_hex: &str) -> anyhow::Result { + let deploy = forest_evm_deploy_hex(&sender().await?.cli, bytecode_hex)?; + let f4 = parse_f4_from_evm_deploy(&deploy)?; + eprintln!("deployed {name} at {f4}"); + poll_until_actor_on("forest", f4, forest_client).await?; + poll_until_actor_on("lotus", f4, lotus_client).await?; + EthAddress::from_filecoin_address(&f4) +} + +/// The 4-byte Ethereum function selector: first 4 bytes of `keccak256(signature)`. +pub fn selector(signature: &str) -> Vec { + keccak_256(signature.as_bytes()) + .get(..4) + .expect("keccak256 is 32 bytes") + .to_vec() +} + +/// An address as a 32-byte ABI word: 12 zero bytes, then the 20 address bytes. +pub fn abi_address_word(addr: EthAddress) -> Vec { + let mut word = vec![0u8; 12]; + word.extend_from_slice(addr.0.as_bytes()); + word +} + +/// ABI calldata: the selector of `signature`, then the already encoded `args`. +pub fn calldata(signature: &str, args: &[u8]) -> Vec { + let mut out = selector(signature); + out.extend_from_slice(args); + out +} + /// Next nonce for an address pub fn mpool_nonce(address: &str) -> anyhow::Result { let out = forest_cli(&["mpool", "nonce", address])?; @@ -620,6 +668,13 @@ pub async fn filecoin_to_eth(address: &str) -> anyhow::Result { .with_context(|| format!("expected string ETH address, got {result}")) } +/// A trial that runs `body` and reports a failure with its full error chain. +pub fn trial(name: &'static str, body: fn() -> anyhow::Result<()>) -> Trial { + Trial::test(name, move || { + body().map_err(|e| Failed::from(format!("{e:?}"))) + }) +} + pub fn block_on(future: F) -> F::Output where F::Output: Send + 'static, diff --git a/src/dev/subcommands/tests_cmd/wallet.rs b/src/dev/subcommands/tests_cmd/wallet.rs index 6b23b2b9b14..c7631323991 100644 --- a/src/dev/subcommands/tests_cmd/wallet.rs +++ b/src/dev/subcommands/tests_cmd/wallet.rs @@ -3,7 +3,7 @@ use super::helpers::*; use crate::rpc::eth::types::EthAddress; -use crate::utils::encoding::{hex, keccak_256}; +use crate::utils::encoding::hex; use libtest_mimic::{Arguments, Trial}; use std::str::FromStr as _; @@ -251,13 +251,8 @@ async fn send_params_hex_invokes_contract() { let from_eth = EthAddress::from_filecoin_address(&crate::shim::address::Address::from_str(from).unwrap()) .unwrap(); - let mut calldata = keccak_256(b"getBalance(address)") - .get(..4) - .expect("keccak256 is 32 bytes") - .to_vec(); - calldata.extend_from_slice(&[0u8; 12]); - calldata.extend_from_slice(from_eth.0.as_bytes()); - let params = hex::encode(&calldata); + let data = calldata("getBalance(address)", &abi_address_word(from_eth)); + let params = hex::encode(&data); let out = wallet_send( Backend::Local, diff --git a/src/shim/executor.rs b/src/shim/executor.rs index 0ad50d2bf4e..87c6340faf3 100644 --- a/src/shim/executor.rs +++ b/src/shim/executor.rs @@ -35,8 +35,20 @@ pub enum ApplyRet { } impl ApplyRet { + /// The failure of a message that did not succeed: the failure info from + /// the FVM, or `unknown error` when it gave none, followed by the exit code. + /// pub fn failure_info(&self) -> Option { - delegate_apply_ret!(self => |r| r.failure_info.as_ref().map(|failure| format!("{failure} (RetCode={})", self.exit_code()))) + let exit_code = self.exit_code(); + if exit_code.is_success() { + return None; + } + let info = + delegate_apply_ret!(self => |r| r.failure_info.as_ref().map(ToString::to_string)); + Some(format!( + "{} (RetCode={exit_code})", + info.unwrap_or_else(|| "unknown error".into()) + )) } pub fn miner_tip(&self) -> TokenAmount { diff --git a/src/state_manager/message_simulation.rs b/src/state_manager/message_simulation.rs index a12b25dec46..30560109cca 100644 --- a/src/state_manager/message_simulation.rs +++ b/src/state_manager/message_simulation.rs @@ -6,9 +6,13 @@ use super::*; use crate::interpreter::{ExecutionContext, IMPLICIT_MESSAGE_GAS_LIMIT, VM, VMTrace}; use crate::rpc::state::{ApiInvocResult, MessageGasCost}; use crate::shim::address::Protocol; +use crate::shim::econ::BLOCK_GAS_LIMIT; use crate::shim::executor::ApplyRet; use crate::shim::message::{METHOD_SEND, Message}; use crate::state_migration::run_state_migrations; +use crate::utils::misc::env::env_or_default_logged; +use std::num::NonZeroU64; +use std::sync::LazyLock; use std::time::Duration; use tracing::instrument; @@ -20,6 +24,7 @@ impl StateManager { msg: &Message, tipset: Option, ) -> Result { + let gas_limit = state_call_gas_limit(msg.gas_limit(), state_call_gas_cap())?; let chain_config = self.chain_config(); let tipset = if let Some(ts) = tipset { @@ -111,12 +116,11 @@ impl StateManager { let from_actor = state .get_actor(&msg.from())? .ok_or_else(|| anyhow::anyhow!("actor not found"))?; - // Implicit messages need a special gas limit. let msg = msg .clone() .into_builder() .sequence(from_actor.sequence) - .gas_limit(IMPLICIT_MESSAGE_GAS_LIMIT as u64) + .gas_limit(gas_limit) .build(); let (apply_ret, duration) = vm.apply_implicit_message(&msg)?; @@ -133,8 +137,14 @@ impl StateManager { }) } - /// runs the given message and returns its result without any persisted - /// changes. + /// Runs the given message and returns its result without any persisted changes. + /// + /// The message runs at its own gas limit; `0` selects the `StateCall` gas cap + /// (`FOREST_STATE_CALL_GAS_LIMIT`, the block gas limit by default). + /// + /// # Errors + /// + /// The message's gas limit is above the cap. pub async fn call( &self, message: Arc, @@ -271,6 +281,27 @@ impl StateManager { } } +/// The most gas a `StateCall` simulation may run with: `FOREST_STATE_CALL_GAS_LIMIT`, or the block +/// gas limit when it is unset or not a positive integer. +fn state_call_gas_cap() -> u64 { + static CAP: LazyLock = LazyLock::new(|| { + env_or_default_logged("FOREST_STATE_CALL_GAS_LIMIT", nonzero!(BLOCK_GAS_LIMIT)) + }); + CAP.get() +} + +/// Returns the gas limit a `StateCall` simulation runs with: `requested`, or `cap` when +/// `requested` is `0`. +fn state_call_gas_limit(requested: u64, cap: u64) -> Result { + match requested { + 0 => Ok(cap), + limit if limit > cap => Err(Error::Other(format!( + "StateCall gas limit {limit} exceeds configured cap {cap}" + ))), + limit => Ok(limit), + } +} + #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum SenderApply { Explicit, @@ -328,3 +359,23 @@ fn placeholder_send(to: Address) -> Message { .gas_limit(IMPLICIT_MESSAGE_GAS_LIMIT as u64) .build() } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::zero_selects_the_cap(0, 10, Ok(10))] + #[case::below_the_cap(1, 10, Ok(1))] + #[case::at_the_cap(10, 10, Ok(10))] + #[case::above_the_cap(11, 10, Err("StateCall gas limit 11 exceeds configured cap 10"))] + fn state_call_gas_limit_respects_the_cap( + #[case] requested: u64, + #[case] cap: u64, + #[case] expected: Result, + ) { + let actual = state_call_gas_limit(requested, cap).map_err(|e| e.to_string()); + assert_eq!(actual, expected.map_err(str::to_string)); + } +} diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index 492ffaddf09..dff5b2df8da 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -35,7 +35,7 @@ use crate::shim::sector::SectorSize; use crate::shim::{ address::{Address, Protocol}, crypto::Signature, - econ::TokenAmount, + econ::{BLOCK_GAS_LIMIT, TokenAmount}, message::{METHOD_SEND, Message}, state_tree::StateTree, }; @@ -1350,6 +1350,20 @@ fn state_tests_with_tipset( tipset.epoch(), ))?), RpcTest::identity(StateCall::request((msg.clone(), tipset.key().into()))?), + // A zero gas limit selects the StateCall gas cap; above the block gas limit both + // nodes reject the message. + RpcTest::identity(StateCall::request(( + msg.clone().into_builder().gas_limit(0).build(), + tipset.key().into(), + ))?), + RpcTest::identity(StateCall::request(( + msg.clone() + .into_builder() + .gas_limit(BLOCK_GAS_LIMIT + 1) + .build(), + tipset.key().into(), + ))?) + .policy_on_rejected(PolicyOnRejected::PassWithQuasiIdenticalError), ]); } } diff --git a/src/tool/subcommands/api_cmd/test_snapshots.txt b/src/tool/subcommands/api_cmd/test_snapshots.txt index 034c8f7adc1..16ed819d2ee 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots.txt @@ -267,7 +267,7 @@ filecoin_power_onepochtickend_statedecodeparams_1754479441718920.rpcsnap.json.zs filecoin_reward_thisepochreward_statedecodeparams_1754479441718972.rpcsnap.json.zst filecoin_session_1741781372985818.rpcsnap.json.zst filecoin_stateaccountkey_1741783761556416.rpcsnap.json.zst -filecoin_statecall_1778013726767345.rpcsnap.json.zst +filecoin_statecall_1791980867010848.rpcsnap.json.zst filecoin_statecirculatingsupply_1737546950391069.rpcsnap.json.zst filecoin_statecompute_1778013740986027.rpcsnap.json.zst filecoin_statedealprovidercollateralbounds_1737546933164491.rpcsnap.json.zst