Skip to content
← Mastering Foundry — Solidity testing discipline for engineers who already think in Rust
CLI & state-aware testing
Lesson 5 of 7·CONTENT30 min60 XP
Course
Mastering Foundry — Solidity testing discipline for engineers who already think in Rust
Lesson role
CONTENT
Sequence
5 / 7

Lesson 4 — cast — the EVM's curl + jq

Question

cast is Foundry's chain CLI — the EVM's curl + jq. Five verbs (call / send / storage / decode-calldata / 4byte) for talking to a chain. Read mainnet state, send a tx, read a storage slot directly, decode arbitrary calldata, reverse-look-up a 4-byte selector — the moments where Rust gives way to the shell.

Direct answers (Foundry 1.7.1). Five one-liners:

# read (no tx, no gas)
cast call --rpc-url https://ethereum.reth.rs/rpc \
  0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "totalSupply()(uint256)"
# write: signs, broadcasts, waits, prints the receipt (local anvil key)
cast send --rpc-url http://localhost:8545 --private-key <anvil-key> \
  0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503 --value 0.01ether
# encode calldata (selector + args)
cast calldata "transfer(address,uint256)" 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503 1000000
# decode calldata back to typed args
cast decode-calldata "transfer(address,uint256)" 0xa9059cbb00000000…
# selector → signature
cast 4byte 0xa9059cbb    # transfer(address,uint256)
  • cast abi-encode encodes the arguments only (no selector); cast calldata prepends the 4-byte selector. cast abi-decode decodes return data; cast decode-calldata decodes calldata — cast abi-decode on calldata prints Error: no data was decoded.
  • With no --rpc-url and no ETH_RPC_URL, cast talks to http://localhost:8545.

Principle (minimum model)

  • cast call <addr> "fn(args)" <args> = read. Calls a non-state-changing function and returns the result. No gas. Best for sanity-checking view / pure functions.
  • cast send <addr> "fn(args)" <args> --private-key <key> = write. State-changing, gas + nonce + signature. --rpc-url picks the network. Waits for the receipt and prints it (--async prints only the hash). --from alone does not impersonate: on anvil, first cast rpc anvil_impersonateAccount <addr>, then cast send --from <addr> --unlocked ….
  • cast storage <addr> <slot> = direct storage read. No public getter required; assumes you know the storage layout. For mapping slots compute the index with cast index.
  • cast decode-calldata "fn(args)" <calldata> = calldata-to-types. Take a tx's input, decode it using the function signature, see arguments printed by type. (cast abi-decode is the return-data counterpart.)
  • cast 4byte <selector> = selector-to-signature reverse lookup. 0xa9059cbb → transfer(address,uint256). Resolves unknown selectors via the openchain.xyz registry.
  • ethereum.reth.rs/rpc as an example mainnet RPC. An example public Reth node, used here for read-only calls. It is not cast's default — pass it via --rpc-url or ETH_RPC_URL; otherwise cast uses http://localhost:8545.
  • Tab completion (cast completions). zsh / bash / fish. Removes daily friction.

Worked example + steps

Lesson 4 — cast — the EVM's curl + jq

Goal

Concepts you'll grasp in this lesson:

  • cast is alloy::Provider exposed as a terminal command. Every cast subcommand that talks to a node maps to a method on alloy_provider::Provider (the ABI and hashing ones run locally — see the table below) — the same trait you call from Rust code in the fundamentals course's Provider lesson. cast call ↔ provider.call(...), cast block ↔ provider.get_block(...), cast send ↔ provider.send_transaction(...). The CLI is a thin shell wrapper around the same Rust code path. If you already wrote provider.call().await? in Rust, you're not learning a new mental model — you're learning a new keyboard shortcut. cast is alloy bindings + a shell prompt; the underlying RPC requests are identical.
  • cast separates what you're asking from which chain you're asking it of. Every command takes an optional --rpc-url <URL> flag that points at a node. With no flag, cast uses $ETH_RPC_URL from your environment, and if that is unset, http://localhost:8545. The same cast call against mainnet, sepolia, or a local anvil instance is a single flag change — the command itself is identical. The chain is a parameter, not a binding. This is the L1-engineer payoff: the same query reads from prod, staging, or a forked simulation with one substitution.
  • Read-only cast call and state-changing cast send are the two verbs you'll use 90% of the time. cast call runs view/pure functions or simulates a transaction without broadcasting — it returns the function's return value as raw bytes (or decoded if you pass a function signature). cast send actually broadcasts a transaction, requires a signer (e.g. --private-key), waits for it to be mined and prints the receipt (--async prints just the hash). The remaining commands (cast block, cast tx, cast logs, cast calldata, cast 4byte) are introspection and data-manipulation tools — useful, but the load-bearing pair is call and send. Most production debugging is cast call against a forked anvil; most production deployment is cast send against testnet then mainnet.
  • cast calldata / cast decode-calldata close the data-layer loop. When you need to construct calldata manually (for a multisig submission, for embedding in a Solidity script), cast calldata "transfer(address,uint256)" 0x... 1000 produces the exact bytes that would be sent on-chain — selector plus arguments. cast decode-calldata does the inverse — given calldata and a function signature, it pulls out the typed arguments. Their siblings cast abi-encode (arguments without the selector) and cast abi-decode (return data) cover the other half of the ABI. If you've ever debugged calldata by hand, cast decode-calldata is the tool that should have been in your shell aliases years ago.

Verification:

cast --version
cast call --rpc-url https://ethereum.reth.rs/rpc \
  0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "totalSupply()(uint256)"

…runs a read-only call against real mainnet via an example public Reth node and returns USDC's current total supply (a 17-digit raw integer in 6-decimal precision when this was written — about 47 billion USDC). After this lesson you'll know which cast subcommands map to which alloy methods, when to reach for cast call vs cast send, and how to assemble calldata by hand when you need to.

Specific changes:

  • No source-file edits. Lesson 4 is all CLI invocation. You'll run ~8 different cast commands against mainnet and (optionally) against a local anvil.
  • .env (optional) — you may want to set ETH_RPC_URL=https://ethereum.reth.rs/rpc to avoid passing --rpc-url on every command. The Lesson 5 lesson on anvil will demonstrate switching ETH_RPC_URL between mainnet and a forked anvil per terminal session.

Total: zero lines of Solidity. Lesson 4 is shell time. The pedagogical move is internalizing the alloy-method ↔ cast-subcommand mapping so the next time you reach for a Rust Provider, you reach for cast first.

Recap

After Lesson 3:

  • Invariant testing runs random call sequences against a Handler, checks invariant_* after each call.
  • Handlers wrap targets, bound inputs, track ghost variables (shadow specification).
  • Sequence shrinking drops the calls that aren't needed to reproduce a failure (arguments stay as generated).

Lesson 3 lived inside test/ files. Lesson 4 leaves the test directory entirely — cast is what you reach for when you have a deployed contract, a transaction hash, or calldata you need to decode, and you don't want to write a Solidity script just to look at it. The L1 engineer's debug loop is forge test then cast call, not forge test alone.

Plan

Five categories of invocation:

  1. cast call — read mainnet state from the terminal. We'll query USDC's totalSupply() and balanceOf(address) for a known address.
  2. cast block / cast tx — chain introspection. Look up a recent mainnet block; inspect a specific transaction by hash.
  3. cast calldata / cast decode-calldata — calldata manipulation. Build the bytes for an ERC-20 transfer call; decode bytes back to typed arguments. (Plus how they differ from cast abi-encode / cast abi-decode.)
  4. cast 4byte / cast 4byte-decode — function-selector lookup. Given the first 4 bytes of calldata, find the human-readable function name via the openchain.xyz signature database.
  5. cast send against a local anvil (preview) — state-changing transactions. We won't deploy anything significant; the exercise demonstrates how cast send interacts with a chain (Lesson 5 covers anvil itself in depth).

(Answer: cast call --rpc-url <URL> <contract-address> "<function-signature>" [args...]. The pieces map directly: the --rpc-url flag is the alloy RootProvider's underlying transport URL, the contract address is the to field in the transaction, the function signature is the human-readable ABI shorthand that cast hashes into a 4-byte selector internally (alloy uses the same Function::parse machinery), and any args are positional. The return is raw hex unless you specify return types as "(...returntypes)" after the function signature, in which case cast decodes for you. Same code path, two surfaces — Rust for programs, cast for the shell.)

How cast maps to alloy::Provider

┌─────────────────────────┬────────────────────────────────────────────────┐
│  cast subcommand        │  alloy::Provider method                        │
├─────────────────────────┼────────────────────────────────────────────────┤
│  cast call              │  provider.call(tx)                             │
│  cast send              │  provider.send_transaction(tx)                 │
│  cast block             │  provider.get_block(block_id)                  │
│  cast tx <hash>         │  provider.get_transaction_by_hash(hash)        │
│  cast receipt <hash>    │  provider.get_transaction_receipt(hash)        │
│  cast logs              │  provider.get_logs(filter)                     │
│  cast balance <addr>    │  provider.get_balance(addr)                    │
│  cast nonce <addr>      │  provider.get_transaction_count(addr)          │
│  cast chain-id          │  provider.get_chain_id()                       │
│  cast gas-price         │  provider.get_gas_price()                      │
│  cast block-number      │  provider.get_block_number()                   │
├─────────────────────────┼────────────────────────────────────────────────┤
│  cast abi-encode        │  alloy_dyn_abi::DynSolValue::abi_encode        │
│  cast abi-decode        │  alloy_dyn_abi::DynSolType::abi_decode         │
│  cast 4byte             │  (openchain.xyz signature lookup, not RPC)     │
│  cast keccak <data>     │  alloy_primitives::keccak256(data)             │
└─────────────────────────┴────────────────────────────────────────────────┘

The structural takeaway: cast ≈ alloy::Provider for RPC operations, cast ≈ alloy_dyn_abi for ABI operations. If you've used Provider in the fundamentals course's Provider lesson, you already know what the RPC-side cast subcommands do — you just don't know the argument syntax yet. (alloy_dyn_abi, runtime-typed ABI encoding and decoding, is not covered in the earlier courses; the two ABI rows are here so you know which crate to open.)

Walk-through

Step 1: Orient — cast --version and cast help

cast --version

You should see something like cast Version: 1.7.x matching your forge version (both ship from the same foundry-rs/foundry binary distribution).

cast help

The output is a flat list of subcommands. Three things to notice:

  1. Subcommands are categorized by what they touch. cast call, cast send, cast call --trace interact with chain state. cast abi-*, cast keccak, cast 4byte are local data-manipulation tools (no RPC). cast wallet manages keys. Mentally bucket them: RPC commands need --rpc-url; local commands don't.
  2. Many subcommands have aliases. cast call is also cast c, cast send is also cast s. You don't need to type them out long-form in interactive use. The full names appear in scripts.
  3. cast help <subcommand> gives detailed flags for any subcommand. cast help call shows every flag cast call accepts (block tag, value, gas overrides, etc.). When in doubt, cast help <subcommand> is faster than reading docs.

Step 2: Read mainnet state with cast call

Public RPC endpoint we'll use throughout: https://ethereum.reth.rs/rpc (a public Reth node, listed in the Reth docs — same one used in the fundamentals course's Provider lesson).

cast call --rpc-url https://ethereum.reth.rs/rpc \
  0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  "totalSupply()(uint256)"

That's USDC's contract address on mainnet, calling totalSupply() and asking cast to decode the return as a uint256.

Expected output:

47450119498256141 [4.745e16]

(Read-only call, October 2026; the number changes. cast adds the [4.745e16] scientific-notation hint for large integers.)

Six things to notice:

  1. The function signature is the human-readable Solidity form, not the 4-byte selector. cast parses "totalSupply()(uint256)" internally using the same parser alloy uses, hashes the signature with keccak256, takes the first 4 bytes, and uses that as the function selector in the underlying eth_call. You write Solidity-ergonomic syntax; cast does the encoding.

  2. The (uint256) after the function name is the return-type annotation. Without it, cast prints the raw hex bytes (0x0000...). With it, cast decodes the return as a uint256 and prints the decimal. Multi-return functions follow the same pattern — "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" is the Uniswap V3 pool's slot0 signature, and cast prints each tuple element on its own line.

    [!TIP] Nested return types work inline; separate the decode with $(...), not a pipe The inline annotation handles dynamic arrays and nested tuples. Against a local anvil with a contract whose f() returns (uint256[] xs, (string s, address a) st):

    cast call --rpc-url http://localhost:8545 <addr> "f()(uint256[],(string,address))"
    # [1, 2]
    # ("hi", 0x000000000000000000000000000000000000dEaD)
    

    If you want the raw hex first and the decode as a separate step, pass it as an argument — cast abi-decode does not read stdin, so cast call … | cast abi-decode … fails with error: the following required arguments were not provided: <CALLDATA>:

    cast abi-decode "f()(uint256[],(string,address))" $(cast call --rpc-url http://localhost:8545 <addr> "f()")
    
  3. No private key needed. cast call is read-only; it executes against the node's view of state without broadcasting. This is the workhorse for production debugging — you can simulate any view function against mainnet without spending a wei.

  4. --rpc-url can be replaced by ETH_RPC_URL in your shell env. Set export ETH_RPC_URL=https://ethereum.reth.rs/rpc once and drop the flag from subsequent commands. The Lesson 5 lesson on anvil will show switching ETH_RPC_URL between mainnet and forked anvil.

  5. The output decimal is raw integer, not human-formatted. USDC has 6 decimals, so 47450119498256141 raw means 47,450,119,498.256141 USDC. cast doesn't apply decimal scaling to call results — use cast format-units 47450119498256141 6, which prints 47450119498.256141.

  6. The mainnet block used for cast call is the chain's current head by default. To call against a specific block, add --block <number-or-hash-or-tag>. Useful for replaying past state: --block 12345678 simulates what totalSupply() would have returned at that block.

Try one more — query a specific address's USDC balance:

cast call --rpc-url https://ethereum.reth.rs/rpc \
  0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  "balanceOf(address)(uint256)" \
  0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503  # arbitrary mainnet address

This is the CLI equivalent of provider.call(USDC.balanceOf(addr).await?) in Rust. Same RPC underneath, different keyboard ergonomics.

Step 3: Inspect blocks and transactions

Look at the current block:

cast block latest --rpc-url https://ethereum.reth.rs/rpc

You'll see a YAML-style dump: number, hash, parentHash, timestamp, gasLimit, gasUsed, baseFeePerGas, miner, the full transactions list, withdrawals, etc. Same data structure alloy's Block type contains, formatted for terminal reading.

cast block 19000000 --rpc-url https://ethereum.reth.rs/rpc

Lookup by number — replaying a historical block. Useful when debugging "what state did contract X have at block N."

Inspect a specific transaction:

cast tx 0xa84a9... --rpc-url https://ethereum.reth.rs/rpc  # any real mainnet tx hash

Returns the transaction's from, to, value, input (the calldata), gas, gasPrice, nonce, signature components. The input field is where you'll most often want cast decode-calldata (or cast 4byte-decode) next. For a hash the node doesn't know, cast tx prints Error: tx not found: 0x….

The receipt — what actually happened when the tx mined:

cast receipt 0xa84a9... --rpc-url https://ethereum.reth.rs/rpc

Includes status (1 = success, 0 = reverted), gasUsed, the emitted logs (events), blockNumber, etc. Unlike cast tx, cast receipt on a hash it can't find keeps waiting for it to be mined (add --async to return immediately). When debugging "did my tx succeed," cast receipt is the command to re-check a hash — cast send already prints the receipt when the tx is mined.

Step 4: Manipulate calldata with cast calldata / cast decode-calldata

Build the calldata for an ERC-20 transfer(address,uint256) call:

cast calldata "transfer(address,uint256)" \
  0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503 \
  1000000  # 1 USDC in 6-decimal precision

Output (the actual calldata bytes for the call):

0xa9059cbb00000000000000000000000047ac0fb4f2d84898e4d9e7b4dab3c24507a6d50300000000000000000000000000000000000000000000000000000000000f4240

Three sections to read:

  • 0xa9059cbb — the 4-byte selector for transfer(address,uint256) (keccak256 of the signature, first 4 bytes)
  • 0000...0047ac... — the first argument (address), padded to 32 bytes
  • 0000...0f4240 — the second argument (uint256 1,000,000 = 0xf4240), padded to 32 bytes

This is the exact data field you'd embed in a raw transaction. For multisig proposals, governance calldata, or Solidity scripts that need to construct external calls, this is how you build the bytes.

Compare cast abi-encode with the same arguments — it encodes the arguments only, with no selector:

$ cast abi-encode "transfer(address,uint256)" 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503 1000000
0x00000000000000000000000047ac0fb4f2d84898e4d9e7b4dab3c24507a6d50300000000000000000000000000000000000000000000000000000000000f4240

Reverse the operation — given calldata, recover the typed arguments:

cast decode-calldata "transfer(address,uint256)" \
  0xa9059cbb00000000000000000000000047ac0fb4f2d84898e4d9e7b4dab3c24507a6d50300000000000000000000000000000000000000000000000000000000000f4240

Output:

0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503
1000000 [1e6]

decode-calldata is what you reach for when you have a mystery calldata blob and a function signature. Most production debugging is "I have this calldata from a tx, what does it actually do" — and that's exactly what cast decode-calldata solves. Don't reach for cast abi-decode here: it decodes return data (cast abi-decode "totalSupply()(uint256)" 0x…0f4240 → 1000000 [1e6]), and on calldata it prints Error: no data was decoded.

Step 5: Function-selector lookup with cast 4byte

Sometimes you have calldata but don't know the function signature. The first 4 bytes are the selector; cast queries a public signature database (openchain.xyz, per cast 4byte --help) to recover the human-readable name:

cast 4byte 0xa9059cbb

Output:

transfer(address,uint256)

If multiple candidate signatures hash to the same 4 bytes, cast lists all of them — selector collisions exist (rare for production functions, common for obscure ones). cast 4byte is the first command you run on unknown calldata before reaching for cast decode-calldata.

A full unknown-calldata debug loop:

# Step 5a — given mystery calldata, find the function name:
cast 4byte 0xa9059cbb
# → transfer(address,uint256)

# Step 5b — decode the calldata using the recovered signature:
cast decode-calldata "transfer(address,uint256)" 0xa9059cbb...
# → 0x47ac... 1000000 [1e6]

cast 4byte-decode <calldata> does both steps in one (it also uses the online lookup):

$ cast 4byte-decode 0xa9059cbb00000000000000000000000047ac0fb4f2d84898e4d9e7b4dab3c24507a6d50300000000000000000000000000000000000000000000000000000000000f4240
1) "transfer(address,uint256)"
0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503
1000000 [1e6]

Step 6: Preview cast send against a local anvil

cast send is cast call's state-changing twin. It requires a signer (a private key or one of the wallet options), broadcasts the transaction, waits for it to be mined, and prints the receipt. We won't send anything significant — Lesson 5 covers anvil and the full local-development loop — but it is worth running once against a local anvil:

# Start a local anvil in another terminal (Lesson 5 covers this in depth):
#   anvil
# Anvil prints 10 funded test accounts and their private keys.

# Send 0.01 ETH from anvil's first account:
cast send --rpc-url http://localhost:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503 \
  --value 0.01ether

Output from a freshly started anvil (trimmed; your hash will differ):

blockNumber          1
from                 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
gasUsed              21000
logs                 []
status               1 (success)
transactionHash      0x99a7f92b3dfc6fa750d45a3d18e8746f8c45690e29e6ab31395ff668e5078db7
to                   0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503

Why not a USDC transfer here? A plain anvil starts with empty state, so there is no contract at USDC's address (cast code returns 0x). A transfer(address,uint256) sent there still "succeeds" (status 1, logs []) — it is a call to an empty account and does nothing. Contract calls against mainnet contracts only mean something on anvil --fork-url (Lesson 5).

Three things to notice:

  1. A signer is the only new requirement vs cast call. Everything else is identical. cast signs the transaction with the key, broadcasts via the RPC, and waits for the receipt.
  2. The default anvil private key 0xac0974... comes pre-funded — anvil seeds 10 deterministic accounts on startup. Same private key every time, safe for local development only. Never use anvil's default keys against any real network.
  3. The output is the receipt — status, gas used, emitted logs. That is the alloy pattern provider.send_transaction(...).await?.get_receipt().await? in one command. Add --async to print only the transaction hash and return immediately; then check it later with cast receipt <hash>.

Lesson 5 (next lesson) returns to anvil with mainnet forking, which is where cast send becomes truly useful — you can simulate real mainnet transactions against forked state without spending real ETH.

Common errors

  • Error: error sending request for url (http://localhost:8545/) — the RPC is unreachable. If the URL is localhost:8545 and you didn't mean it, you forgot --rpc-url / ETH_RPC_URL (that's cast's fallback). Otherwise check the URL, your network, or use a different RPC.
  • Error: server returned an error response: error code 3: execution reverted, data: "0x" — among other causes, the function signature you passed doesn't exist on the contract (no matching selector, no fallback). Check the signature against the contract's ABI; cast 4byte on real calldata helps recover it.
  • Error: tx not found: 0x… — you're querying a transaction hash that doesn't exist on the chain you're pointed at (e.g., a mainnet hash against a testnet RPC). Verify the chain. (cast receipt on such a hash doesn't error — it waits.)
  • cast send returns the tx hash but the receipt shows status: 0 — the tx mined but reverted. Use cast call with the same calldata to see the revert reason (cast call simulates without broadcasting and shows revert messages).
  • Error: insufficient funds — your --private-key controls an account with no ETH on the target chain. For local anvil, use one of anvil's seeded accounts; for testnets, request from a faucet.

Design retrospective

Three load-bearing decisions in cast's design:

  1. cast reuses alloy under the hood — no separate JSON-RPC client. Foundry's cast binary links against the same alloy crates Reth uses. Every cast invocation walks the same code path your Rust program would. The implication: if Reth supports a new RPC method (e.g., new tracing endpoints), cast gets it for free once the alloy version bumps. One implementation, two surfaces. The CLI is not maintained separately from the library.

  2. Function signatures are human-readable, not 4-byte selectors. cast could have required you to pass 0xa9059cbb for transfer(address,uint256) — Geth's eth_call does take the raw bytes. cast accepts both, but the human-readable form is the documented default. The discipline: the keyboard ergonomics match the Solidity source you wrote. The thing you type into cast matches the thing you typed into Solidity. No mental translation step.

  3. --rpc-url is per-command, not per-session. You can set ETH_RPC_URL once in your environment (with neither, cast falls back to http://localhost:8545), but every individual cast invocation can override it inline. This is deliberately stateless — there's no "current chain" mode like npm has with npm config set registry. The reason: chain mistakes are catastrophic (sending to mainnet when you meant testnet), and cast's design forces the chain to be visible on every state-changing command. Statelessness is a safety feature, not a usability oversight.

Answer key

After Lesson 4, your shell history should include something like:

# Read mainnet
cast call --rpc-url https://ethereum.reth.rs/rpc \
  0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  "totalSupply()(uint256)"

# Inspect a block
cast block latest --rpc-url https://ethereum.reth.rs/rpc

# Build calldata
cast calldata "transfer(address,uint256)" 0x... 1000000

# Decode mystery calldata
cast 4byte 0xa9059cbb
cast decode-calldata "transfer(address,uint256)" 0xa9059cbb...

# Optional: send against local anvil
cast send --rpc-url http://localhost:8545 \
  --private-key 0xac09... 0x47ac... --value 0.01ether

After Lesson 4 you can:

  • Read any view function on any contract on any chain from your terminal
  • Inspect blocks and transactions without opening a block explorer
  • Build and decode calldata for multisig proposals, governance, or scripts
  • Look up unknown function selectors via cast 4byte (openchain.xyz)
  • Send transactions against local anvil (full anvil treatment in Lesson 5)

Q&A

Q1: Why use cast when Etherscan + a browser do the same thing?

Three reasons:

  1. Composability — cast output pipes into jq, awk, xargs, grep, exactly like any Unix tool. Etherscan output is in a browser.
  2. Reproducibility — a cast command is a shareable bash one-liner; an Etherscan workflow is a series of clicks you can't paste into a runbook.
  3. Speed — cast call against a local Reth node returns in milliseconds; Etherscan loads in seconds with rate limits. For L1 engineers doing dozens of view queries per hour, cast is 10×+ faster than browser-based tools.

Etherscan for one-off exploration; cast for everything else.

Q2: Does cast support every JSON-RPC method or only a subset?

cast exposes over 100 subcommands (109 listed by cast --help in 1.7.1), covering the common RPC methods plus ABI, wallet and conversion utilities. For anything not directly exposed, use cast rpc <method> [params...] which is the raw escape hatch — it sends the method name and parameters as a JSON-RPC request and prints the JSON response. Same pattern as provider.client().request::<...>() in alloy when you want a method without a typed wrapper.

Q3: How does cast handle signed transactions for cast send?

When you pass --private-key, cast constructs the transaction client-side, signs it with the key (using alloy_signer_local), and submits the signed transaction via eth_sendRawTransaction. The private key never leaves your machine. For hardware-wallet workflows, use --ledger or --trezor instead; cast walks the same alloy_signer_* traits. The signing is local; the RPC only sees broadcast bytes.

Q4: When should I write a Rust program with alloy::Provider instead of using cast?

When the workflow is longer than 3 commands or needs branching/loops/error handling beyond bash. Rough rule: ad-hoc queries → cast; repeated workflows or anything that runs in CI → Rust + alloy. For one-time deployments, cast send is fine. For deployment scripts that need to verify, set up roles, transfer ownership, configure parameters — write a Rust binary (or a Foundry script/ file in Solidity). cast scales to a 1-line bash script; alloy scales to a deployment binary.

Q5: Can cast call simulate a transaction with a different msg.sender?

Yes. For cast call, the --from <address> flag sets who the call appears to come from. Useful for testing access-controlled functions — --from <owner-address> lets you simulate what the owner would see. Note: this is a simulated call; nothing is signed. For cast send, --from alone is not enough — without a key cast fails with Error accessing local wallet, and --from X --unlocked on anvil fails with No Signer available until you run cast rpc anvil_impersonateAccount X. In Solidity tests the equivalent is vm.prank (Lesson 5 covers both). cast call --from for simulation; anvil_impersonateAccount + --unlocked to actually send as another address on anvil.

Q6: Does cast work with non-Ethereum EVM chains?

Yes — anything that speaks the standard JSON-RPC interface. Optimism, Arbitrum, Base, Polygon, BNB Chain, your custom L2 — all work identically. Just point --rpc-url at the right endpoint. The exception is chains with non-standard RPC methods (e.g., Tron, NEAR, non-EVM Solana) which obviously don't apply. For any EVM-compatible chain, cast is your CLI; for non-EVM chains, you need the chain's own tooling.

Next lesson (Lesson 5) — anvil + cheatcodes — local development with mainnet state

Lesson 5 wires the last piece: local development against real mainnet state via anvil --fork-url. You'll learn:

  • anvil --fork-url <mainnet-rpc> — spin up a local chain that mirrors mainnet's current state at startup
  • The 10 funded test accounts anvil seeds, and why they're deterministic
  • Anvil-specific RPC methods: anvil_impersonateAccount, anvil_setBalance, anvil_mine, anvil_setStorageAt
  • How Foundry's vm.* cheatcodes (from Lesson 1–Lesson 3 tests) map to anvil's RPC equivalents — same machinery, different surface
  • The full local-dev loop: anvil --fork-url → cast send against forked mainnet → cast call to verify → no real ETH spent

After Lesson 5 you can develop against mainnet state without leaving your laptop. Lesson 5 closes the test-discipline + CLI portion of the course; Lesson 6 is the capstone where you port openhl-liquidation Stage 10b's InsuranceFund to Solidity and prove four conservation invariants for it with invariant testing.

Summary (3 lines)

  • cast = the EVM's curl + jq. Five verbs (call / send / storage / decode-calldata / 4byte) for chain interaction. cast call reads (no gas); cast send writes (gas + nonce + signature) and prints the receipt.
  • cast storage reads slots directly; cast index computes mapping slots; cast calldata / cast decode-calldata build and reverse calldata (abi-encode / abi-decode are the selector-less / return-data siblings); cast 4byte reverses selectors.
  • --rpc-url https://ethereum.reth.rs/rpc is this lesson's example mainnet RPC; without a flag or ETH_RPC_URL, cast uses localhost:8545. Tab completion removes friction. Next lesson: anvil --fork-url + cheatcodes for state-aware testing.