use crate::dev::subcommands::tests_cmd::helpers::*;
use crate::rpc::Client;
use crate::rpc::eth::errors::EXECUTION_REVERTED_CODE;
use crate::rpc::eth::{
BlockNumberOrHash, Predefined,
types::{EthAddress, EthBytes, EthCallMessage},
};
use crate::rpc::prelude::*;
use crate::shim::address::Address;
use crate::utils::encoding::{hex, keccak_256};
use anyhow::{Context as _, ensure};
use cid::Cid;
use jsonrpsee::core::ClientError;
use libtest_mimic::{Arguments, Failed, Trial};
use std::io::Write as _;
use std::str::FromStr as _;
use tempfile::NamedTempFile;
use tokio::sync::OnceCell;
const NESTED_GAS_HEX: &str = include_str!("contracts/nested_gas/nested_gas.hex");
const RECURSE_SIGNATURE: &str = "recurse(uint256)";
const REQUIRES_HIGH_GAS_SIGNATURE: &str = "requiresHighGasLimit()";
const REVERT_REASON: &str = "gas limit too low";
const GAS_SEARCH_FAILURE: &str = "gas search failed";
const HEX_IN_CONTAINER: &str = "/tmp/nested_gas.hex";
const CONTROL_DEPTH: u64 = 0;
const NESTED_DEPTH: u64 = 100;
const SENDER_FUND_AMT: &str = "10 FIL";
#[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())
}),
]
}
fn selector(signature: &str) -> Vec<u8> {
keccak_256(signature.as_bytes())
.get(..4)
.expect("keccak256 is 32 bytes")
.to_vec()
}
fn recurse_calldata(depth: u64) -> Vec<u8> {
let mut out = selector(RECURSE_SIGNATURE);
out.extend_from_slice(ðereum_types::U256::from(depth).to_big_endian());
out
}
struct Deployed {
eth: EthAddress,
f4: Address,
}
async fn contract() -> anyhow::Result<&'static Deployed> {
static CONTRACT: OnceCell<Deployed> = OnceCell::const_new();
CONTRACT
.get_or_try_init(|| async {
let mut hex_file = NamedTempFile::new_in(std::env::temp_dir())
.context("staging the contract bytecode")?;
hex_file.write_all(NESTED_GAS_HEX.trim().as_bytes())?;
hex_file.flush()?;
docker(&[
"cp",
&hex_file.path().to_string_lossy(),
&format!("lotus:{HEX_IN_CONTAINER}"),
])?;
let deploy = lotus_exec(&["evm", "deploy", "--hex", HEX_IN_CONTAINER])?;
let f4 = deploy
.lines()
.find_map(|l| l.trim().strip_prefix("f4 Address: "))
.with_context(|| format!("no `f4 Address:` in deploy output:\n{deploy}"))?;
let f4 = Address::from_str(f4.trim()).context("parsing the deployed f4 address")?;
eprintln!("deployed NestedGas at {f4}");
anyhow::Ok(Deployed {
eth: EthAddress::from_filecoin_address(&f4)?,
f4,
})
})
.await
}
async fn sender_addr() -> anyhow::Result<&'static Address> {
static SENDER: OnceCell<Address> = OnceCell::const_new();
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}");
Address::from_str(&addr).context("parsing the sender address")
})
.await
}
async fn estimate(
client: &Client,
calldata: Vec<u8>,
block: BlockNumberOrHash,
) -> anyhow::Result<u64> {
let (sender, deployed) = tokio::try_join!(sender_addr(), contract())?;
let msg = EthCallMessage {
from: Some(EthAddress::from_filecoin_address(sender)?),
to: Some(deployed.eth),
data: Some(EthBytes(calldata)),
..Default::default()
};
let gas = client
.call(EthEstimateGas::request((msg, Some(block)))?)
.await?;
Ok(gas.0)
}
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 pinned_common_block() -> anyhow::Result<(Client, Client, i64)> {
tokio::try_join!(contract(), sender_addr())?;
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))
}
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),
)
.await
.context("EthEstimateGas on forest")
},
async {
estimate(
&lotus_c,
recurse_calldata(depth),
BlockNumberOrHash::from_block_number(block),
)
.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(())
}
async fn estimate_is_sufficient_on_chain() -> anyhow::Result<()> {
let forest = forest_client()?;
let estimate = estimate(
&forest,
recurse_calldata(NESTED_DEPTH),
BlockNumberOrHash::PredefinedBlock(Predefined::Latest),
)
.await?;
let sender = sender_addr().await?.to_string();
let target = contract().await?.f4.to_string();
let params = hex::encode(recurse_calldata(NESTED_DEPTH));
let gas_limit = estimate.to_string();
let out = lotus_exec_retrying_mpool(&[
"send",
"--from",
&sender,
"--params-hex",
¶ms,
"--gas-limit",
&gas_limit,
&target,
"0",
])
.await?;
let cid = out
.lines()
.last()
.context("no cid from `lotus send`")?
.trim();
eprintln!("submitted at forest's estimate {estimate}: {cid}");
let lookup = forest
.call(
StateWaitMsg::request((Cid::from_str(cid)?, 0, 800, true))?.with_timeout(POLL_TIMEOUT),
)
.await?;
let exit = lookup.receipt.exit_code();
ensure!(
exit.is_success(),
"a transaction submitted at forest's own eth_estimateGas value ({estimate}) failed \
on chain with exit code {exit}; the estimate is not a usable gas limit"
);
Ok(())
}
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),
)
.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 Some(ClientError::Call(obj)) = err.downcast_ref::<ClientError>() else {
anyhow::bail!("{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(GAS_SEARCH_FAILURE),
"{node} rejected the call before the gas search, so this no longer exercises the \
branch it is meant to pin (expected `{GAS_SEARCH_FAILURE}`): {}",
obj.message()
);
ensure!(
obj.message().contains(REVERT_REASON),
"{node} rejected the call without naming the revert reason `{REVERT_REASON}`: {}",
obj.message()
);
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(())
}