diff --git a/.config/nextest.toml b/.config/nextest.toml index c1c8c4fa14c1f..1557b3a41246b 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -46,6 +46,12 @@ slow-timeout = { period = "60s", terminate-after = 3 } filter = "test(/fork_chains::/)" success-output = "final" +# The network read tests report an unreachable endpoint or an unusable block by skipping instead +# of failing, so keep their output on success to show which networks a run actually exercised. +[[profile.flaky.overrides]] +filter = "test(/read_networks::/)" +success-output = "final" + # Profile for the on-chain deploy+verify tests (used by the nightly test-deploy-verify workflow). # Requires _RPC_URL plus TESTNET_DEPLOYER_PRIVATE_KEY to do anything. # Run with: cargo nextest run --profile deploy-verify diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index 6483f1564c929..e7481f495ccc1 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -39,6 +39,7 @@ extern crate foundry_test_utils; mod erc20; mod keychain; +mod read_networks; mod remote_trace; mod selectors; mod tempo; diff --git a/crates/cast/tests/cli/read_networks.rs b/crates/cast/tests/cli/read_networks.rs new file mode 100644 index 0000000000000..a8bee5d420844 --- /dev/null +++ b/crates/cast/tests/cli/read_networks.rs @@ -0,0 +1,257 @@ +//! Read-path `cast` coverage across public networks. +//! +//! The read commands are the ones every other workflow is built on, and each of them decodes a +//! chain's responses through Foundry's network types. A chain that serves a shape those types do +//! not model breaks the command for that whole chain rather than for one transaction, which is +//! the class of breakage this module is here to catch. +//! +//! Every test is named `flaky_` so the default nextest profile skips it and the nightly `flaky` +//! profile runs it with retries. These endpoints are free, unauthenticated and rate limited, so +//! an outage must not fail a normal CI run. + +use foundry_test_utils::TestCommand; + +/// How far behind the tip to start looking. +/// +/// The newest block is not always fully served yet, and a chain that reorgs its tip would leave +/// the transaction unfetchable. +const TIP_CONFIRMATIONS: u64 = 3; + +/// How many blocks back to look for one holding a usable transaction. +/// +/// Chains with sub-second blocks routinely produce empty blocks, and Tempo produces long runs of +/// them, so a single block is not enough to test against. +const BLOCK_SCAN_DEPTH: u64 = 24; + +/// A public endpoint to exercise the read commands against. +struct Network { + name: &'static str, + rpc_url: &'static str, + /// Pinned so that an endpoint quietly repointed at another chain fails instead of passing. + chain_id: u64, +} + +/// Runs the read commands against `network` and cross-checks what they return. +/// +/// Returns without asserting when the endpoint is unreachable, so an endpoint having a bad day +/// reports a skip rather than a failure. Once a response has been served, failing to decode it is +/// a failure: that is the case being guarded. +#[expect(clippy::disallowed_macros, reason = "skips have to be visible in the nightly test log")] +fn assert_read_commands_work(cmd: &mut TestCommand, network: &Network) { + let Some(chain_id) = decimal_output(cmd, &["chain-id", "--rpc-url", network.rpc_url]) else { + eprintln!("skipping {}: endpoint unreachable", network.name); + return; + }; + assert_eq!( + chain_id, network.chain_id, + "{}: endpoint served chain id {chain_id}, expected {}", + network.name, network.chain_id + ); + + let block_number = decimal_output(cmd, &["block-number", "--rpc-url", network.rpc_url]) + .unwrap_or_else(|| panic!("{}: `cast block-number` returned no number", network.name)); + + // Both of these are chain-wide decode canaries: `--full` decodes every transaction body in + // the block, which is where an unmodelled envelope takes out the command for the whole chain. + let scan_from = block_number.saturating_sub(TIP_CONFIRMATIONS); + let header = block(cmd, network, scan_from, false) + .unwrap_or_else(|| panic!("{}: could not decode block {scan_from}", network.name)); + assert_eq!( + hex_field(&header, "number"), + Some(scan_from), + "{}: block {scan_from} reports a different number", + network.name + ); + block(cmd, network, scan_from, true).unwrap_or_else(|| { + panic!("{}: could not decode block {scan_from} with full transactions", network.name) + }); + + for command in [["gas-price"], ["base-fee"]] { + assert!( + decimal_output(cmd, &[command[0], "--rpc-url", network.rpc_url]).is_some(), + "{}: `cast {}` returned no number", + network.name, + command[0] + ); + } + + let found = find_transaction(cmd, network, scan_from).unwrap_or_else(|err| panic!("{err}")); + let Some((tx_block, tx_hash)) = found else { + eprintln!( + "skipping {} transaction checks: no transaction in the {BLOCK_SCAN_DEPTH} blocks \ + before {scan_from}", + network.name + ); + return; + }; + + let tx = json_output(cmd, &["tx", &tx_hash, "--rpc-url", network.rpc_url]) + .unwrap_or_else(|| panic!("{}: could not decode transaction {tx_hash}", network.name)); + assert_eq!( + tx.get("hash").and_then(|hash| hash.as_str()), + Some(tx_hash.as_str()), + "{}: `cast tx` returned a different transaction", + network.name + ); + + // The receipt decodes through a separate path from the transaction, so agreeing on the block + // is a real cross-check rather than a restatement. + let receipt = json_output(cmd, &["receipt", &tx_hash, "--rpc-url", network.rpc_url]) + .unwrap_or_else(|| panic!("{}: could not decode the receipt for {tx_hash}", network.name)); + assert_eq!( + hex_field(&receipt, "blockNumber"), + Some(tx_block), + "{}: receipt for {tx_hash} disagrees with the block it was found in", + network.name + ); + + // The sender is present for calls and contract creations alike, so the account reads below + // stay unconditional; `to` is null for a creation and would skip them. + let account = tx + .get("from") + .and_then(|from| from.as_str()) + .unwrap_or_else(|| panic!("{}: transaction {tx_hash} has no sender", network.name)); + + // State reads at the block the transaction landed in, which is also an archive-depth probe: + // an endpoint that ignores the block tag still answers, so this asserts shape, not history. + let at_block = tx_block.to_string(); + for command in ["balance", "nonce"] { + // Not parsed as a `u64`: balances routinely exceed it, and Monad's system account holds + // more than 1e28 wei. + let output = cmd + .cast_fuse() + .args([command, account, "--block", &at_block, "--rpc-url", network.rpc_url]) + .execute(); + let value = String::from_utf8_lossy(&output.stdout); + let value = value.trim(); + assert!( + output.status.success() + && !value.is_empty() + && value.bytes().all(|b| b.is_ascii_digit()), + "{}: `cast {command}` returned no number for {account}: {value:?}", + network.name + ); + } + for args in [ + vec!["code", account, "--block", &at_block, "--rpc-url", network.rpc_url], + vec!["storage", account, "0", "--block", &at_block, "--rpc-url", network.rpc_url], + ] { + let output = cmd.cast_fuse().args(&args).execute(); + assert!( + output.status.success(), + "{}: `cast {}` failed for {account}", + network.name, + args[0] + ); + assert!( + String::from_utf8_lossy(&output.stdout).trim().starts_with("0x"), + "{}: `cast {}` returned no hex for {account}", + network.name, + args[0] + ); + } +} + +/// Fetches a block, optionally with full transaction bodies. +fn block( + cmd: &mut TestCommand, + network: &Network, + number: u64, + full: bool, +) -> Option { + let number = number.to_string(); + let mut args = vec!["block", &number]; + if full { + args.push("--full"); + } + args.extend(["--rpc-url", network.rpc_url]); + json_output(cmd, &args) +} + +/// Walks back from `latest` looking for a block holding a transaction, returning its block number +/// and hash. +/// +/// Reachability is already established by the time this runs, so a block that will not decode is +/// the breakage this module guards and is reported as an error. `Ok(None)` is reserved for blocks +/// that decoded and genuinely held no transactions. +fn find_transaction( + cmd: &mut TestCommand, + network: &Network, + latest: u64, +) -> Result, String> { + for number in (latest.saturating_sub(BLOCK_SCAN_DEPTH)..=latest).rev() { + let Some(block) = block(cmd, network, number, false) else { + return Err(format!("{}: could not decode block {number}", network.name)); + }; + let transactions = block + .get("transactions") + .and_then(|txs| txs.as_array()) + .ok_or_else(|| format!("{}: block {number} has no transaction array", network.name))?; + let Some(first) = transactions.first() else { continue }; + let hash = first.as_str().ok_or_else(|| { + format!("{}: block {number} lists a transaction that is not a hash", network.name) + })?; + return Ok(Some((number, hash.to_string()))); + } + Ok(None) +} + +/// Runs a `cast` subcommand with `--json` and parses its output, returning `None` on any failure. +/// +/// `--json` wraps some results in the shell's envelope, so the payload is unwrapped from `data`. +fn json_output(cmd: &mut TestCommand, args: &[&str]) -> Option { + let output = cmd.cast_fuse().args(args).arg("--json").execute(); + if !output.status.success() { + return None; + } + let mut value: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + if let Some(data) = value.get_mut("data") { + value = data.take(); + } + Some(value) +} + +/// Runs a `cast` subcommand that prints a single decimal number small enough to be a `u64`. +fn decimal_output(cmd: &mut TestCommand, args: &[&str]) -> Option { + let output = cmd.cast_fuse().args(args).execute(); + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout).trim().parse().ok() +} + +/// Reads a hex-quantity field out of a JSON-RPC response. +fn hex_field(value: &serde_json::Value, field: &str) -> Option { + let raw = value.get(field)?.as_str()?; + u64::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() +} + +macro_rules! network_read_tests { + ($($test:ident => ($name:literal, $rpc_url:literal, $chain_id:literal),)*) => { + $( + casttest!($test, |_prj, cmd| { + assert_read_commands_work( + &mut cmd, + &Network { name: $name, rpc_url: $rpc_url, chain_id: $chain_id }, + ); + }); + )* + }; +} + +network_read_tests! { + flaky_read_ethereum => ("ethereum", "https://ethereum-rpc.publicnode.com", 1), + flaky_read_base => ("base", "https://mainnet.base.org", 8453), + flaky_read_polygon => ("polygon", "https://polygon-bor-rpc.publicnode.com", 137), + flaky_read_bsc => ("bsc", "https://bsc-dataseed.bnbchain.org", 56), + + // Every block opens with an internal transaction (`0x6a`) that the strict Ethereum envelope + // cannot decode, so `block --full` is the assertion that matters on the Nitro chains. + flaky_read_arbitrum => ("arbitrum", "https://arb1.arbitrum.io/rpc", 42161), + flaky_read_robinhood => ("robinhood", "https://rpc.mainnet.chain.robinhood.com", 4663), + + flaky_read_monad => ("monad", "https://rpc.monad.xyz", 143), + + // Produces long runs of empty blocks, which is what the block scan is sized for. + flaky_read_tempo => ("tempo", "https://rpc.mpp.tempo.xyz", 4217), +}