Skillbase / spm
Packages

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 chains
207

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 control
374
- 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