Best Practices
Guidelines for writing maintainable, secure, and efficient Foundry projects.
Before you ship
- Run
forge testwith-vvvvat least once - Run
forge lintandforge fmt --check - Verify deployments with the exact compiler settings
- Store production keys in encrypted keystores or hardware wallets
- Keep
.envfiles out of version control
Writing contracts
Use named imports
Import only what you need to reduce compilation time and make dependencies explicit:
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";import "@openzeppelin/contracts/token/ERC20/ERC20.sol";Format with forge fmt
Use the built-in formatter for consistent code style:
$ forge fmtCheck formatting without modifying files:
$ forge fmt --checkConfigure formatting in foundry.toml:
[fmt]
line_length = 120
tab_width = 4
bracket_spacing = trueAvoid "Stack too deep" errors
The EVM limits access to the top 16 stack slots. When you hit this error, the fix is usually to restructure your code—group variables into structs, split large functions into smaller helpers, or use block scoping.
See the Stack Too Deep guide for causes, solutions, and when to use via-ir as a last resort.
Organize contract layout
Follow a consistent ordering within contracts:
- Type declarations (structs, enums)
- State variables
- Events
- Errors
- Modifiers
- Constructor
- External functions
- Public functions
- Internal functions
- Private functions
Writing tests
Naming conventions
Use descriptive names that explain behavior:
| Pattern | Usage | Example |
|---|---|---|
test_Description | Standard tests | test_TransferUpdatesBalances |
testFuzz_Description | Fuzz tests | testFuzz_TransferAnyAmount |
test_RevertWhen_Condition | Revert tests | test_RevertWhen_InsufficientBalance |
test_RevertIf_Condition | Alternative revert naming | test_RevertIf_NotOwner |
function test_TransferUpdatesBalances() public {
token.transfer(bob, 100);
assertEq(token.balanceOf(bob), 100);
}
function test_RevertWhen_TransferExceedsBalance() public {
vm.expectRevert(Token.InsufficientBalance.selector);
token.transfer(bob, type(uint256).max);
}Test organization
Structure test files to mirror your source files:
- Token.sol
- Vault.sol
- Governor.sol
- Token.t.sol
- Vault.t.sol
- Governor.t.sol
Group related tests in the same contract:
contract TokenTransferTest is Test {
function test_TransferUpdatesBalances() public { }
function test_TransferEmitsEvent() public { }
function test_RevertWhen_InsufficientBalance() public { }
}
contract TokenApprovalTest is Test {
function test_ApproveUpdatesAllowance() public { }
function test_TransferFromUsesAllowance() public { }
}Use test harnesses
Create harness contracts to expose internal functions for testing:
import {Token} from "../../src/Token.sol";
contract TokenHarness is Token {
function exposed_mint(address to, uint256 amount) external {
_mint(to, amount);
}
function exposed_burn(address from, uint256 amount) external {
_burn(from, amount);
}
}import {TokenHarness} from "./harnesses/TokenHarness.sol";
contract TokenInternalTest is Test {
TokenHarness token;
function setUp() public {
token = new TokenHarness();
}
function test_MintIncreasesSupply() public {
token.exposed_mint(alice, 1000);
assertEq(token.totalSupply(), 1000);
}
}Prefer bound() over assume()
Use bound() to constrain fuzz inputs instead of vm.assume():
function testFuzz_Transfer(uint256 amount) public {
amount = bound(amount, 1, token.balanceOf(alice));
token.transfer(bob, amount);
}function testFuzz_Transfer(uint256 amount) public {
vm.assume(amount > 0 && amount <= token.balanceOf(alice));
token.transfer(bob, amount);
}vm.assume() discards invalid inputs, which can slow down fuzzing. bound() transforms inputs to valid ranges.
Writing scripts
Separate concerns
Split complex deployments into focused scripts:
- DeployToken.s.sol
- DeployVault.s.sol
- ConfigureVault.s.sol
- Deploy.s.solOrchestrates all deployments
import {DeployToken} from "./DeployToken.s.sol";
import {DeployVault} from "./DeployVault.s.sol";
contract DeployScript is Script {
function run() public {
DeployToken tokenDeployer = new DeployToken();
address token = tokenDeployer.run();
DeployVault vaultDeployer = new DeployVault();
vaultDeployer.run(token);
}
}Use environment variables for configuration
Never hardcode addresses or keys in scripts:
function run() public {
address admin = vm.envAddress("ADMIN_ADDRESS");
uint256 initialSupply = vm.envOr("INITIAL_SUPPLY", uint256(1_000_000 ether));
vm.startBroadcast();
new Token(admin, initialSupply);
vm.stopBroadcast();
}Log deployment information
Output addresses and transaction details for verification:
function run() public {
vm.startBroadcast();
Token token = new Token();
console.log("Token deployed at:", address(token));
console.log("Chain ID:", block.chainid);
console.log("Deployer:", msg.sender);
vm.stopBroadcast();
}Security
Run taint analysis
Use forge taint to detect dangerous data flows from untrusted sources:
$ forge taint src/Vault.sol$ forge taintTaint analysis identifies when user-controlled data flows into sensitive operations without validation.
Enable the linter
Catch common issues with forge lint:
$ forge lintConfigure rules in foundry.toml:
[lint]
severity = "warning"
exclude = ["script/**"]Test access control
Verify that protected functions reject unauthorized callers:
function test_RevertWhen_CallerNotOwner() public {
vm.prank(attacker);
vm.expectRevert(Ownable.OwnableUnauthorizedAccount.selector);
vault.withdrawAll();
}Test edge cases
Cover boundary conditions in your tests:
function test_TransferZeroAmount() public {
token.transfer(bob, 0);
assertEq(token.balanceOf(bob), 0);
}
function test_TransferMaxAmount() public {
deal(address(token), alice, type(uint256).max);
vm.prank(alice);
token.transfer(bob, type(uint256).max);
}Key management
Use encrypted keystores
Store keys encrypted, not as plaintext environment variables:
$ cast wallet import deployer --interactive$ forge script script/Deploy.s.sol --account deployer --broadcast --rpc-url https://ethereum.reth.rs/rpcUse hardware wallets for production
For mainnet deployments, use a hardware wallet:
$ forge script script/Deploy.s.sol --ledger --broadcast --rpc-url https://ethereum.reth.rs/rpcSeparate development and production keys
Use different keys for different environments:
| Environment | Key source |
|---|---|
| Local development | Anvil default keys |
| Testnets | Encrypted keystore |
| Mainnet | Hardware wallet |
Anvil provides pre-funded accounts for local testing:
$ anvil$ forge script script/Deploy.s.sol --broadcast --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80Protect environment files
Keep .env files out of version control:
.env
.env.*Use .env.example to document required variables:
$ RPC_URL=
$ ETHERSCAN_API_KEY=
# Use `cast wallet import` instead of PRIVATE_KEYWas this helpful?
