Skip to content
Logo

Best Practices

Guidelines for writing maintainable, secure, and efficient Foundry projects.

Before you ship

  • Run forge test with -vvvv at least once
  • Run forge lint and forge fmt --check
  • Verify deployments with the exact compiler settings
  • Store production keys in encrypted keystores or hardware wallets
  • Keep .env files 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";

Format with forge fmt

Use the built-in formatter for consistent code style:

$ forge fmt

Check formatting without modifying files:

$ forge fmt --check

Configure formatting in foundry.toml:

foundry.toml
[fmt]
line_length = 120
tab_width = 4
bracket_spacing = true

Avoid "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:

  1. Type declarations (structs, enums)
  2. State variables
  3. Events
  4. Errors
  5. Modifiers
  6. Constructor
  7. External functions
  8. Public functions
  9. Internal functions
  10. Private functions

Writing tests

Naming conventions

Use descriptive names that explain behavior:

PatternUsageExample
test_DescriptionStandard teststest_TransferUpdatesBalances
testFuzz_DescriptionFuzz teststestFuzz_TransferAnyAmount
test_RevertWhen_ConditionRevert teststest_RevertWhen_InsufficientBalance
test_RevertIf_ConditionAlternative revert namingtest_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:

test/Token.t.sol
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);
    }
}

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);
}

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
script/Deploy.s.sol
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:

Good
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:

Check a specific contract
$ forge taint src/Vault.sol
Check all contracts
$ forge taint

Taint analysis identifies when user-controlled data flows into sensitive operations without validation.

Enable the linter

Catch common issues with forge lint:

$ forge lint

Configure rules in foundry.toml:

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:

Create a keystore
$ cast wallet import deployer --interactive
Use the keystore
$ forge script script/Deploy.s.sol --account deployer --broadcast --rpc-url https://ethereum.reth.rs/rpc

Use 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/rpc

Separate development and production keys

Use different keys for different environments:

EnvironmentKey source
Local developmentAnvil default keys
TestnetsEncrypted keystore
MainnetHardware wallet

Anvil provides pre-funded accounts for local testing:

Start Anvil
$ anvil
Use Anvil's first account
$ forge script script/Deploy.s.sol --broadcast --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Protect environment files

Keep .env files out of version control:

.env
.env.*

Use .env.example to document required variables:

.env.example
$ RPC_URL=
$ ETHERSCAN_API_KEY=
# Use `cast wallet import` instead of PRIVATE_KEY