back

Rick ·

Forge, Cast, Anvil, and Chisel: what does each Foundry tool do?

Dev tooling

foundryup puts four binaries on your PATH, and the official one-liners are accurate but interchangeable-sounding: Forge is "Foundry's build, test, and deploy tool", Cast is "Foundry's command-line tool for interacting with Ethereum", Anvil is "Foundry's local Ethereum node", Chisel is "Foundry's interactive Solidity REPL".

What that leaves out is when to reach for which. The four tools are not four ways to do the same thing. Each of them owns a different question, and once you see the split, the choice stops being a preference.

So I took one small job — split an ERC-20 payment three ways — and carried it from a half-formed idea to a transfer of real USDC on a fork of mainnet, using each tool exactly where it fits. Every command and every output below came out of one script that runs the whole sequence end to end against a fork pinned at block 25,900,000. No transaction was sent to mainnet and no funds moved: the only mainnet traffic is the reads Anvil makes to copy state, and the fork evaporates when the process exits.

The four questions

Tool The question it owns Where it runs Median time on my machine
chisel What does this expression do? An EVM in its own process 88 ms for chisel eval '1 + 1'
forge Is the contract correct? An EVM in its own process 142 ms for 5 tests and 2,048 fuzz cases
anvil What if the chain were mine to change? A node, listening on a port 427 ms from launch to first answer
cast What is actually true on that chain? A client, over JSON-RPC 30 ms against the local fork

Diagram of the four Foundry tools. Chisel answers what does this expression do, with chisel eval and the fork command. Forge answers whether the contract is correct, with forge build and forge test. Cast answers what is true on that chain, with cast call, cast send, cast run and cast storage. Anvil answers what if the chain were mine to change, with anvil fork-url. Chisel and Forge run an EVM inside their own process, where cheatcodes and fuzzing live and nothing survives the command unless you broadcast it. Cast and Anvil work over JSON-RPC: anvil is the node, cast is the client, chisel fork and forge create also dial it, and the same commands point at mainnet. All four share one project: foundry.toml holds the solc version, optimizer, evm_version and the rpc_endpoints aliases every tool accepts as --rpc-url, and out/ holds the ABIs, bytecode and storage layouts that let cast run print Splitter::split and cast storage print slot names. Foundry 1.8.1 also ships a fifth binary, solar, plus an embedded language server behind forge lsp.

The versions matter for the outputs below: Foundry 1.8.1 (released 28 August 2026), solc 0.8.36, on an Apple M3 Pro. I also ran the whole sequence on Foundry 1.5.1, which is what a machine that has not run foundryup since last winter still has, and the differences are worth a section of their own.

Chisel: before there is a contract

The job is to split a payment between three payees at 3,333, 3,333 and 3,334 basis points. Before writing anything, I want to know what integer division does to that split, and where the multiply overflows.

You can answer this with a contract and a test. It is faster to ask directly:

➜ uint256 amount = 1000;
➜ uint256 a = amount * 3333 / 10000;
➜ uint256 b = amount * 3333 / 10000;
➜ uint256 c = amount * 3334 / 10000;
➜ a + b + c
Type: uint256
├ Hex: 0x3e7
├ Hex (full word): 0x00000000000000000000000000000000000000000000000000000000000003e7
└ Decimal: 999
➜ amount - (a + b + c)
Type: uint256
├ Hex: 0x1
├ Hex (full word): 0x0000000000000000000000000000000000000000000000000000000000000001
└ Decimal: 1

One unit of dust on a payment of 1,000. That is the whole design problem in two lines: the shares do not divide the amount, so the contract has to decide where the remainder goes.

The second question was where the arithmetic breaks. My instinct said the multiply overflows above type(uint256).max / 10000, because 10,000 is the denominator. Chisel disagreed:

➜ uint256 guess = type(uint256).max / 10000;
➜ (guess + 1) * 3334 > 0
Type: bool
└ Value: true
➜ uint256 ceiling = type(uint256).max / 3334;
➜ (ceiling + 1) * 3334
Traces:
  [2259] 0xBd770416a3345F91E4B34576cb804a576fa48EB1::run()
    └─ ← [Revert] panic: arithmetic underflow or overflow (0x11)

The binding constraint is the largest share, not the denominator, so the real ceiling is three times higher than my guess. That is a small thing to be wrong about, and two lines in a REPL is a cheap way to stop being wrong about it.

It helps to know what Chisel actually is. !source prints the contract it has been building up:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0;

import {Vm} from "node_modules/forge-std/src/Vm.sol";

contract REPL {
    Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));

    /// @notice REPL contract entry point
    function run() public {
        uint256 amount = 1000;
        uint256 cut = amount * 3334 / 10000;
    }
}

Every line you type is appended to the body of run(), and the whole thing is recompiled and re-executed from scratch. That explains both the good and the annoying parts. The good part is that vm is already in scope, so cheatcodes work: vm.warp(1767225600) followed by block.timestamp prints 1767225600. The annoying part is that side effects re-run on every line, and an expression that the REPL cannot type is simply not printed.

Chisel also forks. !fork mainnet resolves the alias from foundry.toml, and !fork http://127.0.0.1:8546 points it at a local Anvil, which is how you get a pinned block:

➜ IERC20 usdc = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
➜ usdc.decimals()
Type: uint8
├ Hex: 0x6
├ Hex (full word): 0x0000000000000000000000000000000000000000000000000000000000000006
└ Decimal: 6
➜ usdc.balanceOf(0x28C6c06298d514Db089934071355E5743bf21d60)
Type: uint256
├ Hex: 0xbd59d5400
├ Hex (full word): 0x0000000000000000000000000000000000000000000000000000000bd59d5400
└ Decimal: 50828497920

Forge: once there is a contract

The contract that came out of that is small. The interesting part is the loop and where the remainder lands:

    function split(address token, uint256 amount) external returns (uint256 dust) {
        uint256 n = payees.length;
        uint256 distributed;
        for (uint256 i; i < n; ++i) {
            uint256 cut = (amount * shares[i]) / BPS_DENOMINATOR;
            distributed += cut;
            if (cut != 0) _pull(token, payees[i], cut);
        }
        dust = amount - distributed;
        if (dust != 0) _pull(token, payees[0], dust);
        emit Split(token, msg.sender, amount, dust);
    }

forge build compiles it and lints it — the documentation is explicit that "by default, forge build runs the linter". Both versions do, but 1.8 expanded the rule set enough to change the experience: 32 diagnostics on this project, where 1.5.1 produced none. Most are notes about repeated literals. Two are worth reading:

warning[calls-loop]: external call inside a loop
   ╭▸ contracts/Splitter.sol:57:14
   │
57 │         if (!IERC20(token).transferFrom(msg.sender, to, amount)) revert TransferFailed(token, to, amount);
   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   │
   ╰ help: https://getfoundry.sh/forge/linting/calls-loop

warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
   ╭▸ contracts/Splitter.sol:53:9
   │
53 │         emit Split(token, msg.sender, amount, dust);
   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   │
   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events

Both are real for a contract that calls an arbitrary token address in a loop. Four other warnings flag for (uint256 i; ...) as "read before being initialized", which is idiomatic Solidity and reads as a false alarm; --no-lint or lint_on_build = false turns the pass off if the noise wins.

Then the tests. Two of them are the properties I actually care about, expressed as fuzz tests rather than examples:

    function testFuzz_DustIsBelowThePayeeCount(uint96 amount) public {
        token.mint(PAYER, amount);

        vm.prank(PAYER);
        uint256 dust = splitter.split(address(token), amount);

        assertLt(dust, splitter.payeeCount(), "dust smaller than payee count");
    }
Ran 5 tests for test/Splitter.t.sol:SplitterTest
[PASS] testFuzz_ConservesTheWholeAmount(uint96) (runs: 1024, μ: 218629, ~: 222894)
[PASS] testFuzz_DustIsBelowThePayeeCount(uint96) (runs: 1024, μ: 195514, ~: 199779)
[PASS] test_RevertWhen_AmountOverflowsTheBpsMultiply() (gas: 270432)
[PASS] test_RevertWhen_SharesDoNotSumToDenominator() (gas: 5323)
[PASS] test_SplitsByShares() (gas: 222724)
Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 19.45ms (38.61ms CPU time)

Five tests and 2,048 fuzz cases in 19.5 ms of execution, 142 ms for the whole command. This is the part of Foundry that changed how Solidity gets tested, and it is why I argued in Foundry vs Hardhat: why Foundry won that the insight mattered more than the ecosystem — though Hardhat 3 has since closed most of that gap.

The other thing Forge does is disagree with you. I kept a deliberately wrong assumption in the repository — that the shares divide the amount exactly — and let the fuzzer at it:

[FAIL: the shares divide the amount exactly: 2 != 0; counterexample: calldata=0xfd66c5020000000000000000000000000000000000000000000000000000000022190e0d args=[572067341 [5.72e8]]] testFuzz_SplitLeavesNoDust(uint96) (runs: 0, μ: 0, ~: 0)

Three details in that line are worth knowing. runs: 0 means the very first generated input failed. The counterexample is the input as generated rather than a minimized one — I pointed a separate probe at a predicate that fails for everything above 1e18, and both versions reported a 23- to 26-digit input instead of 1000000000000000001; neither has a shrink setting in forge config. And the failing input is written to cache/fuzz and replayed first on the next run, which is helpful while you fix the bug and confusing when you change the seed and nothing changes.

That last point has a sharper version. foundry.toml here pins seed = "0x5350". On 1.5.1 that produced the identical counterexample on four consecutive runs. On 1.8.1, with the same pinned seed and the persisted failure cleared each time, it produced four different ones: 572,067,341, then 1,390,319,151,561,515, then 53,509,424,469,580, then 21. Fuzzing was parallelized across worker threads in 1.7.0, and a pinned seed no longer pins the result.

Anvil: when you need a chain you are allowed to lie to

Unit tests with a mock token prove the arithmetic. They do not prove the contract survives contact with a real ERC-20, and USDC is not a plain ERC-20: it is a proxy, it has 6 decimals, and it answers paused() and isBlacklisted(address).

Anvil answers that by copying mainnet:

anvil --fork-url https://eth.drpc.org --fork-block-number 25900000 --port 8546
Fork
==================
Endpoint:       https://eth.drpc.org/
Block number:   25900000
Block hash:     0x2f9b90496c4accc7b18b0b162237478e0b0640b429ade1b5024635976b9f1fc9
Chain ID:       1

Two practical notes. Pinning the block is what makes the run reproducible: five full runs, on both toolchain versions, produced the same deployed address, the same transaction hashes and the same gas. And pinning a block means asking for archive state: the endpoint I first tried keeps roughly 128 blocks and answered Archive requests require a personal token, so pick an endpoint that serves history before blaming Anvil.

The dev accounts also behave differently on a fork than on a fresh chain. 0xf39F…2266 is a real mainnet address with a real history, so on this fork it starts at nonce 7,711 — while Anvil still credits it with 10,000 ETH locally. The first contract it deploys therefore does not land on the familiar 0x5FbDB2315678afecb367f032d93F642f64180aa3.

The capability that makes a fork genuinely useful is that you can become someone else. The payer here is an address holding 50,828.497920 USDC, and I have no key for it:

cast rpc anvil_impersonateAccount 0x28C6c06298d514Db089934071355E5743bf21d60 --rpc-url local

That is the documented flow — impersonate, then send with --from $ADDRESS --unlocked — and it is the entire reason to fork instead of mocking. The matching safety net is evm_snapshot, which returns an id you hand back to evm_revert. Here is what the harness recorded after doing exactly that — the RPC result, then the balances re-read from the chain:

revert returned: true
alice  0
payer  50828497920
block  25900001

After the revert, the payees are back to zero, the payer has its whole balance back, and the chain is back at the block before the split. You can run the same destructive experiment as many times as you like.

Cast: the one you will type most often

Cast is a client. Everything below is a JSON-RPC call to the node named by --rpc-url local, which is an alias defined under [rpc_endpoints] in foundry.toml. Forge takes the same aliases on --rpc-url, and Chisel resolves them in !fork.

Reading first:

$ cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "symbol()(string)" --rpc-url local
"USDC"
$ cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "decimals()(uint8)" --rpc-url local
6
$ cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "balanceOf(address)(uint256)" 0x28C6c06298d514Db089934071355E5743bf21d60 --rpc-url local
50828497920 [5.082e10]
$ cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "paused()(bool)" --rpc-url local
false
$ cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "isBlacklisted(address)(bool)" 0x28C6c06298d514Db089934071355E5743bf21d60 --rpc-url local
false

Those last two calls are the reason to test against the real token rather than a mock: paused() and isBlacklisted(address) answer, so both switches exist and both would stop this contract dead.

Then writing. The split is sent from the impersonated account after an approve; here is its receipt, trimmed to the fields worth reading:

blockNumber          25900003
cumulativeGasUsed    150143
from                 0x28C6c06298d514Db089934071355E5743bf21d60
gasUsed              150143
status               1 (success)
transactionHash      0xece2fe548c06b6dd39c4138ad66be0b6c90df4a2f0802e4339bc757a02e516f6
to                   0xc1C78ed758474757090b876d08Af6d1286F7facb

150,143 gas to move real USDC to three addresses. And the money arrives where the arithmetic said it would:

alice  16941138358
bob    16941138356
carol  16946221206
payer  0
sum    50828497920
before 50828497920

Alice is two units ahead of Bob because she is the first payee and collects the dust. The sum matches the payer's original balance exactly.

When something does not match, cast run re-executes the transaction locally and prints the whole call tree:

  [138135] 0xc1C78ed758474757090b876d08Af6d1286F7facb::split(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, 50828497920 [5.082e10])
    ├─ [48549] 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48::transferFrom(0x28C6c06298d514Db089934071355E5743bf21d60, 0x00000000000000000000000000000000000A11cE, 16941138356 [1.694e10])
    │   ├─ [41254] 0x43506849D7C04F9138D1A2050bbF3A0c054402dd::transferFrom(0x28C6c06298d514Db089934071355E5743bf21d60, 0x00000000000000000000000000000000000A11cE, 16941138356 [1.694e10]) [delegatecall]
    │   │   ├─ emit Transfer(from: 0x28C6c06298d514Db089934071355E5743bf21d60, to: 0x00000000000000000000000000000000000A11cE, amount: 16941138356 [1.694e10])

Three things fall out of that trace. USDC is a proxy delegating to an implementation. The first transferFrom costs 48,549 gas and the next one 28,449, because the second pays warm storage prices. And Splitter::split is decoded by name, because the artifacts are sitting in out/. cast storage reads the same artifacts and prints the deployed contract's slots with their variable names.

A lot of Cast never touches a chain at all:

$ cast sig "split(address,uint256)"
0x5d1e2d1b
$ cast 4byte 0x5d1e2d1b
split(address,uint256)
$ cast decode-calldata "split(address,uint256)" $(cast calldata "split(address,uint256)" 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 50828497920)
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
50828497920 [5.082e10]
$ cast format-units 50828497920 6
50828.497920

What each question costs

Horizontal bar chart of median wall-clock time for one command per Foundry tool, nine runs each, Foundry 1.8.1 on an Apple M3 Pro. cast call against the local fork 30 milliseconds. chisel eval '1 + 1' 88 milliseconds. forge build with no change 129 milliseconds. forge test with 5 tests and 2,048 fuzz cases 142 milliseconds. cast call against a public endpoint 333 milliseconds. anvil fork start to first answer 427 milliseconds. forge build cold 671 milliseconds. The public endpoint bar is dark because it is a read from a public mainnet endpoint at block 25,900,000; the anvil row also contacts that endpoint while starting, and every other row is entirely local.

Every local command lands under a second, and the whole test suite costs less than the cold build. The one number that stands out is the read against a public endpoint: 333 ms, eleven times the same read against the local fork, with the slowest of nine samples at 1.1 seconds. If a script feels slow, count its remote calls before optimizing anything else.

What changed while you were not running foundryup

I ran everything twice, once on 1.5.1 from last December and once on 1.8.1 from two weeks ago. The measured results were identical — same addresses, same transaction hashes, same gas — but the tools were not.

forge test on the same workload went from a 226 ms median to 142 ms. forge build gained the lint pass, from zero diagnostics to 32. And Chisel, which 1.8.0 migrated to the Solar parser, stopped tripping over two things that made it feel brittle:

Input 1.5.1 1.8.1
uint256 t; then t += 5; then t Error (7576): Undeclared identifier. 5
I(0xA0b8…eB48).decimals() on a pinned fork nothing printed 6
1 + 1 then $_ * 21 second line ignored 42

That last row is a new feature: $_ holds the previous result. If you tried Chisel a year ago and concluded the REPL was too fragile to bother with, that judgment has expired.

The same release ships a fifth binary. foundry_v1.8.1_darwin_arm64.tar.gz contains forge, cast, anvil, chisel and solar — Paradigm's Rust Solidity compiler, which Foundry now bundles and also exposes as an embedded language server through forge lsp.

Which of the four you will actually use

Forge and Cast are the two you will type every day. Where the project spends its effort is a different ranking: in the twelve months to 8 September 2026, crates/forge took 771 commits, crates/anvil 600, crates/cast 426 and crates/chisel 94. Anvil is high on that list because it is a whole node, not because you interact with it more.

Anvil does stop being an occasional tool once you have used a pinned fork, though. Mocking an ERC-20 tells you your arithmetic is consistent; forking tells you your contract works against the token that actually exists, with its proxy, its decimals, its pause switch and its gas. The moment a project touches a deployed contract it did not write, the fork stops being a nicety.

Chisel is the one people forget, and 94 commits a year suggests it is also the one the maintainers think about least. It is still the fastest path from "does Solidity round this the way I think" to an answer, in a language where guessing wrong about rounding is expensive — 88 milliseconds, and no project required.

The larger point is the one the diagram makes. Two of these tools run an EVM inside their own process, where nothing is real and everything is cheap. Two of them speak to a node, where things are real — and where the only difference between the fork you are experimenting on and mainnet is the URL after --rpc-url.