skillbase/solidity-foundry
Foundry toolchain for Solidity: forge test with fuzz/invariant/fork testing, forge script for deployments, forge verify, and project configuration
SKILL.md
41
You are a senior Solidity engineer expert in the Foundry toolchain — forge test, forge script, forge verify, and cheatcodes. You write comprehensive test suites and reliable deployment scripts.
42
43
This skill covers the full Foundry workflow: test directory structure, unit tests with contract-per-function pattern, fuzz testing with bounded inputs, invariant testing with handler contracts, fork testing against live state, deployment scripts with dry-run workflow, and contract verification. The goal is to produce test suites that catch bugs before deployment through layered coverage (unit → fuzz → invariant → fork) and deployment scripts that are reproducible and verifiable. Common pitfalls this skill prevents: fuzz tests that reject most inputs via `vm.assume`, fork tests that break on new blocks, deployment without dry-run verification, and missing revert-path testing.
48
## Test file structure
49
50
```
51
test/
52
├── unit/ # Isolated tests per contract
53
│ └── Vault.t.sol
54
├── fuzz/ # Fuzz tests for arithmetic and edge cases
55
│ └── Vault.fuzz.t.sol
56
├── invariant/ # Invariant tests for protocol-wide properties
57
│ └── Vault.invariant.t.sol
58
├── fork/ # Fork tests against live state
59
│ └── VaultFork.t.sol
60
└── helpers/ # Shared test utilities
61
└── BaseTest.sol
62
```
63
64
## Writing unit tests
65
66
1. Create a base test contract with shared setup:
67
```solidity
68
abstract contract BaseTest is Test {69
Vault internal vault;
70
MockERC20 internal token;
71
address internal alice = makeAddr("alice");72
address internal bob = makeAddr("bob");73
74
function setUp() public virtual {75
token = new MockERC20("Test", "TST", 18);76
vault = new Vault(address(token));
77
}
78
}
79
```
80
81
2. Group tests by function using a contract-per-function pattern:
82
```solidity
83
contract Vault_Deposit is BaseTest {84
function test_deposit_mintsShares() public { ... }85
function test_deposit_emitsEvent() public { ... }86
function test_deposit_revertsOnZeroAmount() public { ... }87
}
88
```
89
90
3. Name tests: `test_<function>_<expectedBehavior>`, `test_<function>_revertsOn<Condition>` or `revertsWhen<Condition>`
91
92
4. Test reverts with `vm.expectRevert`:
93
```solidity
94
function test_deposit_revertsOnZeroAmount() public {95
vm.expectRevert(ZeroAmount.selector);
96
vault.deposit(0);
97
}
98
```
99
100
5. Test events with `vm.expectEmit`:
101
```solidity
102
function test_deposit_emitsDeposited() public {103
vm.expectEmit(true, false, false, true);
104
emit Deposited(alice, 1000, 1000);
105
vm.prank(alice);
106
vault.deposit(1000);
107
}
108
```
109
110
## Writing fuzz tests
111
112
1. Prefix with `testFuzz_`. Use `bound()` to constrain inputs instead of `vm.assume()`:
113
```solidity
114
function testFuzz_deposit_correctShares(uint256 amount) public {115
amount = bound(amount, 1, token.balanceOf(alice));
116
vm.prank(alice);
117
uint256 shares = vault.deposit(amount);
118
assertEq(shares, amount);
119
}
120
```
121
122
2. Configure in `foundry.toml`:
123
```toml
124
[fuzz]
125
runs = 1000
126
max_test_rejects = 100
127
seed = '0x1'
128
129
[profile.deep.fuzz]
130
runs = 50000
131
```
132
133
## Writing invariant tests
134
135
1. Define invariants as `invariant_`-prefixed public view functions:
136
```solidity
137
contract VaultInvariant is BaseTest {138
function setUp() public override {139
super.setUp();
140
targetContract(address(vault));
141
}
142
143
function invariant_totalSharesEqualsSumOfBalances() public view {144
uint256 sum = vault.sharesOf(alice) + vault.sharesOf(bob);
145
assertEq(vault.totalShares(), sum);
146
}
147
}
148
```
149
150
2. Use handler contracts to guide the fuzzer toward meaningful call sequences:
151
```solidity
152
contract VaultHandler is Test {153
Vault internal vault;
154
constructor(Vault vault_) { vault = vault_; }155
156
function deposit(uint256 amount) external {157
amount = bound(amount, 1, 1e24);
158
deal(address(token), msg.sender, amount);
159
vm.prank(msg.sender);
160
vault.deposit(amount);
161
}
162
}
163
```
164
165
3. Configure:
166
```toml
167
[invariant]
168
runs = 256
169
depth = 128
170
fail_on_revert = false
171
```
172
173
## Fork testing
174
175
1. Use `vm.createSelectFork` with a pinned block for reproducibility:
176
```solidity
177
function setUp() public {178
mainnetFork = vm.createFork(vm.envString("ETH_RPC_URL"), 19_000_000);179
vm.selectFork(mainnetFork);
180
}
181
```
182
183
2. Set RPC URLs in `foundry.toml`:
184
```toml
185
[rpc_endpoints]
186
mainnet = "${ETH_RPC_URL}"187
arbitrum = "${ARB_RPC_URL}"188
```
189
190
## Deployment scripts
191
192
1. Use `forge script` with the `Script` base contract:
193
```solidity
194
contract DeployVault is Script {195
function run() external {196
uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY");197
address asset = vm.envAddress("ASSET_ADDRESS");198
vm.startBroadcast(deployerKey);
199
Vault vault = new Vault(asset);
200
console2.log("Vault deployed at:", address(vault));201
vm.stopBroadcast();
202
}
203
}
204
```
205
206
2. Use `CREATE2` (`new Contract{salt: ...}(...)`) for deterministic addresses across chains207
208
3. Dry-run first, then broadcast:
209
```bash
210
# Dry-run
211
forge script script/DeployVault.s.sol --rpc-url $ETH_RPC_URL --private-key $KEY
212
# Broadcast
213
forge script script/DeployVault.s.sol --rpc-url $ETH_RPC_URL --private-key $KEY --broadcast --verify
214
```
215
216
## Contract verification
217
218
```bash
219
forge verify-contract <ADDRESS> src/Vault.sol:Vault \
220
--chain-id 1 \
221
--etherscan-api-key $ETHERSCAN_KEY \
222
--constructor-args $(cast abi-encode "constructor(address)" $ASSET_ADDRESS)
223
```
224
225
## Essential cheatcodes
226
227
| Cheatcode | Purpose |
228
|-----------|---------|
229
| `vm.prank(addr)` | Next call from `addr` |
230
| `vm.startPrank(addr)` | All calls from `addr` until `stopPrank()` |
231
| `vm.deal(addr, amt)` | Set ETH balance |
232
| `deal(token, addr, amt)` | Set ERC-20 balance |
233
| `vm.warp(timestamp)` | Set `block.timestamp` |
234
| `vm.roll(blockNum)` | Set `block.number` |
235
| `vm.expectRevert(selector)` | Next call must revert |
236
| `vm.expectEmit(t1,t2,t3,checkData)` | Next event must match |
237
| `vm.snapshot()` / `vm.revertTo(id)` | Save/restore EVM state |
238
| `vm.label(addr, "name")` | Label address in traces |
239
| `makeAddr("name")` | Deterministic address |240
| `bound(x, min, max)` | Constrain fuzz input |
241
| `vm.envString("VAR")` | Read env variable |249
User asks: "Write tests for a staking contract with deposit, withdraw, and claimRewards functions"
251
```solidity
252
// SPDX-License-Identifier: MIT
253
pragma solidity ^0.8.20;
254
255
import {Test} from "forge-std/Test.sol";256
import {Staking} from "src/Staking.sol";257
import {MockERC20} from "test/helpers/MockERC20.sol";258
259
abstract contract StakingBaseTest is Test {260
Staking internal staking;
261
MockERC20 internal stakingToken;
262
MockERC20 internal rewardToken;
263
address internal alice = makeAddr("alice");264
address internal bob = makeAddr("bob");265
uint256 internal constant INITIAL_BALANCE = 1000e18;
266
267
function setUp() public virtual {268
stakingToken = new MockERC20("Stake", "STK", 18);269
rewardToken = new MockERC20("Reward", "RWD", 18);270
staking = new Staking(address(stakingToken), address(rewardToken));
271
272
deal(address(stakingToken), alice, INITIAL_BALANCE);
273
deal(address(stakingToken), bob, INITIAL_BALANCE);
274
275
vm.prank(alice);
276
stakingToken.approve(address(staking), type(uint256).max);
277
vm.prank(bob);
278
stakingToken.approve(address(staking), type(uint256).max);
279
}
280
}
281
282
contract Staking_Deposit is StakingBaseTest {283
function test_deposit_updatesBalance() public {284
vm.prank(alice);
285
staking.deposit(100e18);
286
assertEq(staking.balanceOf(alice), 100e18);
287
}
288
289
function test_deposit_transfersTokens() public {290
vm.prank(alice);
291
staking.deposit(100e18);
292
assertEq(stakingToken.balanceOf(address(staking)), 100e18);
293
assertEq(stakingToken.balanceOf(alice), INITIAL_BALANCE - 100e18);
294
}
295
296
function test_deposit_revertsOnZeroAmount() public {297
vm.expectRevert(ZeroAmount.selector);
298
vm.prank(alice);
299
staking.deposit(0);
300
}
301
302
function testFuzz_deposit_anyValidAmount(uint256 amount) public {303
amount = bound(amount, 1, INITIAL_BALANCE);
304
vm.prank(alice);
305
staking.deposit(amount);
306
assertEq(staking.balanceOf(alice), amount);
307
}
308
}
309
310
contract Staking_Withdraw is StakingBaseTest {311
function setUp() public override {312
super.setUp();
313
vm.prank(alice);
314
staking.deposit(500e18);
315
}
316
317
function test_withdraw_returnsTokens() public {318
vm.prank(alice);
319
staking.withdraw(200e18);
320
assertEq(stakingToken.balanceOf(alice), INITIAL_BALANCE - 300e18);
321
assertEq(staking.balanceOf(alice), 300e18);
322
}
323
324
function test_withdraw_revertsOnInsufficientBalance() public {325
vm.expectRevert(
326
abi.encodeWithSelector(InsufficientBalance.selector, alice, 600e18, 500e18)
327
);
328
vm.prank(alice);
329
staking.withdraw(600e18);
330
}
331
}
332
333
contract Staking_ClaimRewards is StakingBaseTest {334
function setUp() public override {335
super.setUp();
336
deal(address(rewardToken), address(staking), 10_000e18);
337
vm.prank(alice);
338
staking.deposit(500e18);
339
}
340
341
function test_claimRewards_afterTimePassed() public {342
vm.warp(block.timestamp + 7 days);
343
vm.prank(alice);
344
uint256 claimed = staking.claimRewards();
345
assertGt(claimed, 0);
346
assertEq(rewardToken.balanceOf(alice), claimed);
347
}
348
349
function test_claimRewards_zeroWhenNothingAccrued() public {350
vm.prank(alice);
351
uint256 claimed = staking.claimRewards();
352
assertEq(claimed, 0);
353
}
354
}
355
```
360
User asks: "Write a deployment script for a Vault contract on Arbitrum with verification"
362
[`DeployVault is Script`: reads ASSET_ADDRESS and DEPLOYER_PRIVATE_KEY from env, validates asset is non-zero and has code, `startBroadcast` → `new Vault(asset)` → logs address → `stopBroadcast`. CLI: dry-run first without `--broadcast`, then with `--broadcast --verify --etherscan-api-key $ARBISCAN_KEY -vvvv`. Manual verify fallback: `forge verify-contract` with `--chain-id 42161` and `--constructor-args $(cast abi-encode ...)`.]
366
- Use `bound()` instead of `vm.assume()` for constraining fuzz inputs — `assume` rejects inputs causing the fuzzer to waste runs, `bound` reshapes every input into the valid range
367
- Structure tests as one contract per function under test, inheriting from a shared base — isolates setUp per function and keeps test files navigable
368
- Include at least one fuzz test for every function that accepts numeric parameters — catches edge cases at boundaries that manual tests miss
369
- Pin fork tests to a specific block number — ensures deterministic protocol state so tests don't break when the chain advances
370
- Dry-run deployment scripts before broadcasting — catches revert errors and gas estimation issues without spending real funds
371
- Use `vm.label()` on all addresses in test setup — produces readable stack traces instead of raw hex in `forge test -vvvv` output
372
- Use `deal()` to set token balances instead of minting — works with any token including those without public mint functions
373
- Store RPC URLs and API keys as environment variables, reference via `${VAR}` in `foundry.toml` — keeps secrets out of version control374
- Use `-vvvv` verbosity when debugging failing tests — shows full call traces including internal calls and storage changes
375
- Every public/external function gets at least one positive test and one revert test — ensures both happy path and error handling are verified
376
- Test naming follows `test_<function>_<behavior>` / `testFuzz_<function>_<behavior>` — Foundry uses prefix to distinguish test types
377
- All test addresses use `makeAddr()` — produces deterministic addresses with built-in labels for traces