Overview
Foundry is a Rust toolkit for Ethereum development made of four binaries: forge (build, test, fuzz, script, verify), cast (RPC calls, transactions, ABI utilities, keystores), anvil (local node, mainnet forking) and chisel (Solidity REPL). Tests and deployment scripts are written in Solidity, dependencies are git submodules under lib/, and configuration lives in foundry.toml.
Instructions
Installation
# macOS / Linux with Homebrew
brew install foundry
# Or a release archive, verified against the published SHA-256 before extracting
FOUNDRY_VERSION=v1.8.3
ARCHIVE="foundry_${FOUNDRY_VERSION}_linux_amd64.tar.gz" # also linux_arm64, darwin_arm64, darwin_amd64, alpine_amd64
curl -fsSLO "https://github.com/foundry-rs/foundry/releases/download/${FOUNDRY_VERSION}/${ARCHIVE}"
curl -fsSLO "https://github.com/foundry-rs/foundry/releases/download/${FOUNDRY_VERSION}/${ARCHIVE%.tar.gz}.sha256"
sha256sum -c "${ARCHIVE%.tar.gz}.sha256" \
&& mkdir -p "$HOME/.local/bin" \
&& tar -xzf "$ARCHIVE" -C "$HOME/.local/bin" # forge, cast, anvil, chisel, solar
export PATH="$HOME/.local/bin:$PATH" # if it is not on PATH already
forge --version # forge Version: 1.8.3
# Or Docker / a source build
docker pull ghcr.io/foundry-rs/foundry:v1.8.3
cargo install --git https://github.com/foundry-rs/foundry --profile release --locked forge cast chisel anvil solar
On macOS use shasum -a 256 -c in place of sha256sum -c. The project's own installer and version manager is foundryup (see https://getfoundry.sh/introduction/installation); it compares each binary's hash with the release attestation. Foundry no longer publishes npm packages. In GitHub Actions use foundry-rs/foundry-toolchain@v1.
Project setup
forge init vault-demo && cd vault-demo
# src/ contracts · test/ *.t.sol · script/ *.s.sol · lib/ dependencies · foundry.toml
forge install OpenZeppelin/openzeppelin-contracts@v5.7.0 # pinned tag, recorded in foundry.lock
forge remappings # includes @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ and forge-std/=lib/forge-std/src/
forge install adds a git submodule and does not commit unless --commit is passed. Commit foundry.lock. forge update moves only branch-based dependencies; a tag is changed explicitly, e.g. forge update openzeppelin/openzeppelin-contracts@tag=v5.7.0.
# foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"
[profile.ci] # FOUNDRY_PROFILE=ci forge test
fuzz = { runs = 10000 }
Smart contract
// src/Vault.sol — ERC-4626 vault with an owner-controlled rate
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract Vault is ERC4626, Ownable {
error RateTooHigh(uint256 rate);
uint256 public totalDeposited;
uint256 public yieldRate; // basis points per year, 500 = 5%
constructor(IERC20 asset_, uint256 yieldRate_, address owner_)
ERC4626(asset_)
ERC20("Vault Share", "vSHARE")
Ownable(owner_)
{
yieldRate = yieldRate_;
}
function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
shares = super.deposit(assets, receiver);
totalDeposited += assets;
}
function setYieldRate(uint256 newRate) external onlyOwner {
if (newRate > 2000) revert RateTooHigh(newRate); // max 20%
yieldRate = newRate;
}
}
Testing in Solidity
Test contracts inherit Test from forge-std; setUp() runs before every test. A test function with parameters is a fuzz test (256 runs by default).
// test/Vault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Vault} from "../src/Vault.sol";
contract MockUSDC is ERC20("USD Coin", "USDC") {
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract VaultTest is Test {
Vault vault;
MockUSDC token;
address alice = makeAddr("alice");
function setUp() public {
token = new MockUSDC();
vault = new Vault(token, 500, address(this));
token.mint(alice, 10_000e6);
}
function test_Deposit() public {
vm.startPrank(alice); // every call until stopPrank comes from alice
token.approve(address(vault), 1_000e6);
uint256 shares = vault.deposit(1_000e6, alice);
vm.stopPrank();
assertEq(vault.balanceOf(alice), shares);
assertEq(vault.totalDeposited(), 1_000e6);
}
function test_RevertWhen_CallerIsNotOwner() public {
vm.prank(alice); // next call only
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice));
vault.setYieldRate(1000);
}
function testFuzz_Deposit(uint256 amount) public {
amount = bound(amount, 1, 10_000e6); // prefer bound() over vm.assume()
vm.startPrank(alice);
token.approve(address(vault), amount);
vault.deposit(amount, alice);
vm.stopPrank();
assertEq(vault.totalDeposited(), amount);
}
// Fork test: "mainnet" is the alias from [rpc_endpoints]
function test_ForkMainnetUsdc() public {
vm.createSelectFork("mainnet");
IERC20 usdc = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
assertGt(usdc.totalSupply(), 0);
}
}
forge test # all tests
forge test --match-test testFuzz_ -vvv # filter by name; -vvv prints traces for failures
forge test --no-match-test Fork # skip tests that need an RPC endpoint
forge test --gas-report # per-function gas table
forge snapshot && forge snapshot --check # write .gas-snapshot, then fail on regressions
forge fmt --check && forge lint # formatter and built-in linter
Deployment script
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Script, console} from "forge-std/Script.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Vault} from "../src/Vault.sol";
contract DeployScript is Script {
function run() public {
address asset = vm.envAddress("ASSET_ADDRESS");
address owner = vm.envAddress("OWNER_ADDRESS");
vm.startBroadcast(); // signer comes from --account, --ledger or --private-key
Vault vault = new Vault(IERC20(asset), 500, owner);
vm.stopBroadcast();
console.log("Vault deployed at:", address(vault));
}
}
cast wallet import deployer --interactive # encrypted keystore in ~/.foundry/keystores
forge script script/Deploy.s.sol --rpc-url sepolia # simulate only
forge script script/Deploy.s.sol --rpc-url sepolia --account deployer \
--broadcast --verify --etherscan-api-key "$ETHERSCAN_API_KEY"
# A broadcast interrupted halfway continues with the same command plus --resume
Without --broadcast nothing is sent. Receipts are written to broadcast/Deploy.s.sol/11155111/run-latest.json (the directory is the chain id).
Examples
Example 1: Run the test suite and read the result
User request: "Run the vault tests, including the mainnet fork test, and show me the result."
export MAINNET_RPC_URL="https://ethereum.reth.rs/rpc"
forge test --match-contract VaultTest # plain `forge test` also runs the Counter suite that `forge init` generates
Ran 4 tests for test/Vault.t.sol:VaultTest
[PASS] testFuzz_Deposit(uint256) (runs: 256, μ: 200059, ~: 199478)
[PASS] test_Deposit() (gas: 207745)
[PASS] test_ForkMainnetUsdc() (gas: 16165)
[PASS] test_RevertWhen_CallerIsNotOwner() (gas: 36107)
Suite result: ok. 4 passed; 0 failed; 0 skipped
With MAINNET_RPC_URL unset the fork test fails with "vm.createSelectFork: environment variable MAINNET_RPC_URL not found"; forge test --rerun then retries only the failed tests.
Example 2: Deploy to a local Anvil node and interact with cast
User request: "Deploy the vault locally and change the yield rate to 8%."
anvil # terminal 1: chain id 31337 on http://127.0.0.1:8545, 10 funded accounts
# terminal 2 — account (0) printed by anvil; this key is public, never fund it on a real network
export RPC_URL=http://127.0.0.1:8545
export ANVIL_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export OWNER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
forge create test/Vault.t.sol:MockUSDC --rpc-url $RPC_URL --private-key $ANVIL_KEY --broadcast
# Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
export ASSET_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
forge script script/Deploy.s.sol --rpc-url $RPC_URL --private-key $ANVIL_KEY --broadcast
# Vault deployed at: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
# ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
export VAULT=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
cast call $VAULT "yieldRate()(uint256)" --rpc-url $RPC_URL # 500
cast send $VAULT "setYieldRate(uint256)" 800 --rpc-url $RPC_URL --private-key $ANVIL_KEY
# status 1 (success)
cast call $VAULT "yieldRate()(uint256)" --rpc-url $RPC_URL # 800
cast send $VAULT "setYieldRate(uint256)" 2500 --rpc-url $RPC_URL --private-key $ANVIL_KEY
# Error: Failed to estimate gas: ... execution reverted ... RateTooHigh(2500)
cast balance $OWNER_ADDRESS --ether --rpc-url $RPC_URL # 9999.99...
anvil --fork-url "$MAINNET_RPC_URL" --fork-block-number 21000000 starts the same node on top of mainnet state at a fixed block.
Guidelines
- Keys stay out of the shell and the repo — on real networks sign with
--account(keystore fromcast wallet import) or--ledger.--private-keyandvm.envUint("PRIVATE_KEY")are for Anvil's public test keys only; keep.envout of version control. - Simulate before broadcasting — run
forge scriptwithout--broadcastfirst.--chainonly setsblock.chainid; the network is chosen by--rpc-url(a URL or an[rpc_endpoints]alias). testFail*is gone — such tests now fail with "testFail*has been removed". Name themtest_RevertWhen_...and usevm.expectRevert(), matching custom errors by selector.- Fuzzing — constrain inputs with
bound();vm.assume()discards inputs and slows the run. Raisefuzz.runsin a CI profile (FOUNDRY_PROFILE=ci). Stateful properties go ininvariant_*functions. - Fork tests need an RPC endpoint — pin a block (
vm.createSelectFork("mainnet", 21_000_000)) so results are reproducible and cached, and keep them out of the default run with--no-match-testwhen no endpoint is configured. - Gas tracking — commit
.gas-snapshotand runforge snapshot --checkin CI;forge snapshot --diffshows the change per test. - Dependencies — pin tags with
forge install owner/repo@tag, commitfoundry.lockand.gitmodules, and clone with submodules (actions/checkoutwithsubmodules: recursive). Libraries from npm work withlibs = ["lib", "node_modules"], but submodules are the supported path. - Verify what you install — check release archives against the
.sha256asset before extracting; for signed build provenance the docs usegh attestation verifywith--repo foundry-rs/foundry. - Debugging —
cast decode-calldata,cast decode-error,cast 4byte,cast txandforge test -vvvv(full traces) explain most failures. Foundry targets the EVM and Solidity/Vyper; it is not a frontend or indexing framework.