A Foundry boilerplate for Solidity/DeFi protocols with a full CI pipeline, security toolchain, and local git hooks wired up from day one.
Use this template via the GitHub "Use this template" button — don't clone it directly.
Per-commit / per-PR (fast, blocking):
| Gate | When | What it checks |
|---|---|---|
forge fmt --check |
pre-commit, CI | Style consistency |
forge build |
pre-commit, CI | Compilation; zero warnings and forge-lint findings |
| EIP-170 size limit | CI | No contract exceeds 24,576B |
forge test |
pre-push, CI | Unit + fuzz tests |
forge coverage |
CI | Coverage report (lcov artifact) |
| Slither | CI (separate job) | Static analysis |
| Semgrep | CI (separate job) | Solidity-specific pattern checks (Decurity + custom rules) |
| lintspec | CI | NatSpec completeness |
Nightly (heavy, non-blocking):
| Gate | What it checks |
|---|---|
| Medusa | Property-based fuzzing with persistent corpus |
| Halmos | Symbolic proofs (check_ functions) |
slither-mutate |
Mutation testing (reports survivors) |
Scaffolding:
| Tool | Purpose |
|---|---|
| Recon (Chimera pattern) | test/recon/ — property test structure for Medusa/Echidna/forge |
medusa.json |
Medusa fuzzer config (10 workers, corpus persistence) |
echidna.yaml |
Echidna fuzzer config (same property_ functions, alternative runner) |
scripts/recon.sh |
Regenerates test/recon/ scaffolding when contracts change |
scripts/mutate.sh |
Runs mutation testing locally |
scripts/snapshot.sh |
Updates or checks .gas-snapshot (manual gate before hot-path PRs) |
Slither, Semgrep, and lintspec run as separate jobs — a static-analysis finding has different implications than a test failure, and each tool warrants its own triage workflow.
For greenfield protocols, use the defi-spec-driven Claude Code skill instead of wiring up the template manually. It walks through nine structured phases before a single line of Solidity is written — protocol research, economic invariants, architecture, threat modeling, interface and storage spec, and test design — then bootstraps a repository from this template in phase 8 and implements function-by-function with testing gates through to audit readiness.
If you already have a spec or are adding to an existing codebase, set up the template directly using the steps below.
# 1. Create and clone your repository from the template
gh repo create your-org/your-repo --template melanke/foundry-security-template --private --clone && cd your-repo
# 2. Install git hooks
bash scripts/install-hooks.sh
# 3. Configure environment
cp .env.example .env # fill in RPC URLs and keys as needed
# 4. Install dependencies
forge install
# 5. Run the test suite
forge test
# 6. Check formatting
forge fmt --checkThe CI pipeline pins Foundry to a specific version in every workflow file. Update
it intentionally — Foundry updates can silently shift gas semantics and affect
gas-budget assertions. Search for version: "v1.7.0" across .github/workflows/
to update all jobs at once.
foundry.toml defaults to optimizer_runs = 200 (the Foundry default). Adjust
based on your bottleneck:
1— smallest bytecode; useful for contracts near the EIP-170 24,576B limit200— balanced default10_000+— cheapest repeated calls; for pure math libraries called in loops
Note that optimizer_runs is a per-contract decision. If a specific contract
needs a different setting, consider extracting its hot-path logic into a library
and setting per-profile overrides.
Pinned to 0.8.25 in foundry.toml. Update alongside Foundry intentionally.
Configured in the [fmt] block of foundry.toml. int_types = "long" enforces
uint256 over uint, preventing ABI-level surprises. number_underscore = "thousands"
enforces 10_000 over 10000, eliminating digit-counting errors in constants.
forge install OpenZeppelin/openzeppelin-contracts
forge install transmissions11/solmateDependencies install as git submodules under lib/. Pin them to specific commits
rather than tracking branches — reproducible builds require a fixed dependency tree.
Scripts live in script/. The entry point is run() inside a contract that
extends Script. Deploy with --account (a named keystore) rather than
--private-key to keep secrets out of shell history and environment variables.
Local development — Anvil's default key, no password needed:
cast wallet import ForgeDefault --interactive
# paste: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266Production — import your real deployer key once, use the account name everywhere:
cast wallet import ProductionDeployer --interactive
# paste your private key; set a strong password
# note the address and add it to .env as DEPLOYER_ADDRESS# Local (Anvil must be running: anvil)
forge script script/Counter.s.sol:CounterScript \
--rpc-url $LOCAL_URL \
--account ForgeDefault \
--broadcast
# Testnet / mainnet
forge script script/Counter.s.sol:CounterScript \
--rpc-url $RPC_URL \
--account ProductionDeployer \
--broadcast \
--verify--broadcast sends the transactions. Without it, the script runs as a dry-run.
--verify submits source code to Etherscan after deployment (requires ETHERSCAN_API_KEY in .env).
Broadcast artifacts (transaction hashes, deployed addresses) are saved to
broadcast/ and committed to the repo — they are the authoritative record of
what was deployed where.
slither.config.json filters lib/ so vendored code doesn't trigger false
positives. Detectors to suppress project-wide go in detectors_to_exclude.
For per-occurrence suppression, use inline comments:
// block.timestamp acceptable at ~30min resolution — exact ordering doesn't matter.
// See KNOWN_ISSUES.md §timestamp.
// slither-disable-next-line timestamp
require(block.timestamp >= resolutionTime, "TooEarly");Every suppression — inline or project-level — needs a corresponding entry in
KNOWN_ISSUES.md explaining why it was accepted.
Two rule sources run together on every push and PR:
- Decurity/semgrep-smart-contracts — curated, battle-tested DeFi and Solidity rules cloned at CI time.
.semgrep/— project-specific rules derived from the security patterns inAGENTS.md.
The bundled custom rules catch three recurring DeFi pitfalls:
| Rule | What it catches |
|---|---|
missing-disable-initializers |
UUPS implementation constructor missing _disableInitializers() |
safetransfer-in-loop |
Bare token transfer inside a loop (freeze-on-revert risk) |
revert-in-loop |
revert inside a loop over shared state (bricking risk) |
Triage workflow for every finding:
- Actionable — fix the root cause.
- False positive or accepted risk — suppress inline with a reason comment:
// Snapshot taken after all state is settled; no external call between snapshot and use.
// nosemgrep: exact-balance-check
uint256 snapshot = token.balanceOf(address(this));Unlike Slither suppressions, semgrep suppressions do not require a KNOWN_ISSUES.md entry — the inline comment is sufficient, but the reason must be explicit.
Add project-specific rules to .semgrep/custom-rules.yaml as new patterns emerge.
slither-mutate ships with Slither — no extra installation needed. It runs nightly
rather than on every PR because the tool invokes a full recompile cycle per mutant
(~30-40 seconds each via crytic-compile). A contract with 50 mutants takes ~30 minutes;
running that on every PR would make CI unusable.
Run locally before audits or before merging large changes:
bash scripts/mutate.sh # mutate all src/ contracts
bash scripts/mutate.sh Counter,Vault # mutate specific contractsThis step reports uncaught mutants but does not fail CI (slither-mutate exits 0). Triage each survivor:
- Semantically equivalent (e.g.,
a++vs++awhere the return value is unused): document inKNOWN_ISSUES.md— it cannot be killed by a meaningful test. - Real gap (behavior not asserted by any test): write the missing assertion.
Mutation testing and coverage are complementary: mutation testing finds assertion gaps; coverage tracks the overall floor.
To run on every PR instead of nightly: in mutation.yml, replace the schedule trigger
with pull_request: { branches: [main] } and accept the CI cost.
lintspec enforces @notice, @param, and @return on every public and external
function, event, error, and struct. Adding a new public API without NatSpec will
fail CI.
To also enforce on internal functions, run lintspec init to generate
.lintspec.toml and adjust the visibility rules.
forge coverage --report lcov generates lcov.info, uploaded as a CI artifact.
Pair it with the Coverage Gutters VS Code extension to see untested branches
inline while writing code.
The project convention is 90% minimum before a contract is considered complete for audit. No CI-enforced threshold is wired up out of the box — add one to the workflow once the codebase matures and the coverage floor stabilises.
Coverage distorts gas readings; if you add gas-budget assertions, run them under
FOUNDRY_PROFILE=default and coverage under FOUNDRY_PROFILE=coverage (see
foundry.toml).
The test/recon/ directory uses the Chimera pattern generated by Recon:
test/recon/
Setup.sol — deploys all contracts under test
BeforeAfter.sol — captures state snapshots before/after each call
Properties.sol — your protocol invariants (property_ functions)
TargetFunctions.sol — handler wrappers the fuzzer will call
CryticToFoundry.sol — entry point for Medusa, Echidna, and forge invariant
The same test suite runs with three different runners:
medusa fuzz --config medusa.json --timeout 60 # Medusa
echidna . --contract CryticToFoundry --config echidna.yaml # Echidna
forge test --match-contract CryticToFoundry # Foundry invariant runnerMedusa and Echidna both use the same property_ functions. Choose one as your
primary nightly runner — Medusa is configured in CI. echidna.yaml is provided
for teams that prefer Echidna or want to cross-check results.
When you add contracts, regenerate the scaffolding:
bash scripts/recon.sh # requires: cargo install recon-cliMedusa runs nightly with corpus persistence — each run builds on the last.
The corpus is stored in GitHub Actions cache (not committed to the repo).
corpus/ and corpus-echidna/ are gitignored; CI saves and restores automatically.
test/symbolic/ contains check_ functions — formal proofs, not fuzz tests.
A passing Halmos check means no counterexample exists within the specified bounds.
halmos --match-contract CounterProofTest --loop 4When a check times out (marked unknown):
- Add
vm.assume()to tighten the input space - Increase
--solver-timeout-assertion(costs more CI time) - Split the property into smaller, bounded checks
Halmos runs nightly at 04:00 UTC and on workflow_dispatch.
.gas-snapshot records the gas cost of every test. Use it as a manual gate
before opening PRs that touch hot paths:
bash scripts/snapshot.sh # update after an intentional change
bash scripts/snapshot.sh --check # fail if any test regressedCommit .gas-snapshot to establish the baseline. Not wired into CI because
snapshot values shift with Foundry version upgrades — treating it as a blocking
gate would produce false failures on every toolchain update.
KNOWN_ISSUES.md— single source of truth for every accepted security consideration: tool findings ([Tool]), architectural trade-offs ([DESIGN]), manually identified risks and attack vectors ([RISK]), and spec divergences ([SPEC_DEVIATION]); the first file an auditor readsINVARIANTS.md— protocol invariants mapped toproperty_functions intest/recon/Properties.solSECURITY.md— responsible disclosure policy; update the contact email before deploying.env.example— copy to.env(gitignored) and fill in RPC URLs and keyssrc/— production contracts only; no test helperssrc/interfaces/— oneIContractName.solper production contract; NatSpec, custom errors, events, and function signatures live here; the implementation inherits the interface and uses@inheritdoctest/— organised into subdirectories:test/base/— shared fixtures and base contractstest/unit/— isolated per-function teststest/integration/— multi-contract end-to-end flowstest/fuzz/— Chimera-based property tests (test/recon/)test/mocks/— mock contracts for external dependenciestest/symbolic/— Halmoscheck_proofs
script/— deployment and operational scripts.semgrep/— project-specific Semgrep rules; extend as new patterns emergecorpus/— Medusa corpus (gitignored; persisted via CI cache)corpus-echidna/— Echidna corpus (gitignored; local only).gas-snapshot— committed gas baseline; update intentionally
- External and public function parameters use a trailing underscore (
amount_,recipient_); internal/private parameters do not. This prevents shadowing and distinguishes parameters from state variables at a glance. - All constants use
SCREAMING_SNAKE_CASE. - No magic numbers — every numeric literal must be assigned to a named constant before use. A bare number in arithmetic gives no indication of unit or intent; a named constant makes assumptions explicit and auditable.