Smart Contract Auditing and Testing: From Unit Tests to Fuzzing, Invariants, Static Analysis, and Pre-Mainnet Reviews
Smart contract auditing and testing is not one final review before launch. It is a development pipeline that starts from the first contract file and continues through unit tests, integration tests, fork tests, fuzzing, invariant testing, static analysis, coverage checks, storage layout reviews, deployment rehearsals, and independent audit preparation. A serious protocol should not reach mainnet with only happy-path tests. It should prove that critical properties hold under bad inputs, malicious users, oracle failures, upgrade mistakes, edge cases, and real chain conditions.
TL;DR
- Smart contract auditing is a pipeline, not a single event. Testing should begin as soon as development begins.
- Use Foundry for fast Solidity-native tests, fuzzing, invariant testing, gas snapshots, and mainnet fork workflows.
- Use Hardhat when you need TypeScript integration tests, front-end style flows, task automation, and plugin-heavy development.
- Use Slither and other static analysis tools to detect reentrancy patterns, unsafe calls, shadowing, dangerous
tx.origin, missing events, and suspicious code smells. - Use fuzzing to test many randomized inputs and invariant tests to prove core system rules always hold across arbitrary state transitions.
- Fork tests are essential for protocols that depend on real tokens, DEX routers, oracles, bridges, lending markets, and deployed mainnet state.
- Before mainnet, confirm all tests pass, static analysis findings are resolved or documented, storage layout is guarded, privileged roles use multisig and timelock controls, and runbooks are rehearsed.
A formal audit is useful, but it should not be the first time the system is seriously tested. Strong teams write tests while building, fuzz dangerous functions, define invariants, simulate failure paths, run static analysis, review privileged roles, document assumptions, and enter external audits with a clean threat model.
Tooling stack for smart contract testing
A strong smart contract testing stack should cover fast local tests, integration tests, mainnet fork tests, fuzzing, invariants, static analysis, coverage, and deployment safety. No single tool catches everything. The goal is layered defense.
Foundry is one of the most useful tools for Solidity-native testing. It lets developers write tests in Solidity, use cheatcodes, run tests quickly, fork live networks, fuzz inputs, and write invariant tests without leaving the Solidity environment.
Hardhat remains powerful for JavaScript and TypeScript-heavy teams. It is especially useful when contracts need to be tested alongside front-end flows, scripts, deployment tasks, app integrations, and plugin-based workflows.
Slither adds static analysis. It reviews the code without executing it and flags common vulnerability patterns, suspicious code smells, inheritance mistakes, reentrancy risks, shadowing, unchecked logic, missing events, and dangerous patterns.
The Graph, custom indexers, and RPC providers become useful when a protocol needs to validate emitted events, accounting flows, historical data, fork scenarios, and integration behavior against real network conditions.
| Tool | Best use | What it helps catch | Where it fits |
|---|---|---|---|
| Foundry | Solidity-native unit, fuzz, fork, invariant, and gas tests | Logic bugs, edge cases, invariant failures, gas regressions | Core contract development |
| Hardhat | TypeScript tests, fixtures, tasks, deployment scripts, app integration | Integration issues, front-end flows, script errors, deployment mistakes | App and protocol workflows |
| Slither | Static analysis and code smell detection | Reentrancy patterns, shadowing, dangerous calls, missing events | CI, audit preparation, code hygiene |
| Coverage tools | Line and branch coverage measurement | Untested branches and missing negative paths | CI quality gates |
| Fork tests | Testing against real deployed contracts and mainnet state | Oracle issues, router assumptions, token quirks, approval flows | Pre-mainnet validation |
| Invariant tests | Checking properties that must always hold | Accounting breaks, impossible states, sequence bugs | Critical protocol safety |
Test taxonomy: what each test type should prove
Not every test has the same purpose. A strong suite uses different test types for different failure classes. Unit tests prove that specific functions work. Integration tests prove that contracts interact correctly. Fork tests prove that the system behaves against real deployed infrastructure. Fuzz tests explore many inputs. Invariant tests prove that core rules remain true across many sequences.
Unit tests should be fast and focused. They should test one function or one behavior at a time. Integration tests should simulate real flows across multiple contracts. Scenario tests should follow end-to-end user stories such as deposit, borrow, repay, liquidate, claim, pause, upgrade, and withdraw.
Negative tests are just as important as positive tests. If a function should revert when a user has no permission, zero amount, stale oracle, expired deadline, invalid recipient, or insufficient balance, the test suite should explicitly prove it.
A protocol can pass every basic deposit and withdrawal test while still failing under malicious sequencing, stale oracle values, fee-on-transfer tokens, reentrancy, role misuse, upgrade mistakes, or accounting drift. Test the failure paths, not only the intended path.
Foundry example for unit tests and fuzzing
Foundry lets developers write tests directly in Solidity. This is useful because developers can test contract logic using the same language, import helper libraries, and use cheatcodes to control addresses, balances, timestamps, blocks, and external calls.
A simple Foundry test usually lives inside the test/ folder and imports forge-std/Test.sol. The setUp() function prepares the test state before each test.
// SPDX-License-Identifier: MIT
```
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VaultTest is Test {
Vault v;
address alice = address(0xA11CE);
```
function setUp() public {
v = new Vault();
vm.deal(alice, 10 ether);
}
function testDeposit() public {
vm.prank(alice);
v.deposit{value: 1 ether}();
assertEq(v.balanceOf(alice), 1 ether);
assertEq(address(v).balance, 1 ether);
}
function testFuzz_Deposit(uint128 amount) public {
amount = uint128(bound(amount, 1e9, 10 ether));
vm.deal(alice, amount);
vm.prank(alice);
v.deposit{value: amount}();
assertEq(v.balanceOf(alice), amount);
}
```
} The first test checks one specific deposit. The fuzz test checks many possible deposit amounts within a valid range. This is already stronger than a single fixed input because it forces the contract to behave correctly under many different values.
Foundry cheatcodes to know
Cheatcodes are one reason Foundry is useful for security testing. vm.deal sets ETH balances. vm.prank impersonates a caller. vm.warp changes time. vm.roll changes block number. vm.expectRevert verifies failure behavior. vm.mockCall mocks external contract responses.
forge test -vvv ``` forge test --fork-url $RPC forge snapshot forge coverage
A good Foundry suite should combine white-box tests that inspect internal logic through hooks and black-box tests that simulate how real users interact with the system. Critical events, storage changes, access controls, fee behavior, and emergency paths should all be tested.
Hardhat example for integration tests
Hardhat is especially useful when the development environment is TypeScript-heavy. It works well for contract deployment scripts, front-end simulation, fixtures, plugin integrations, coverage reports, and tests that resemble production app flows.
import { expect } from "chai";
```
import { ethers } from "hardhat";
describe("Vault", () => {
it("deposits ETH", async () => {
const [user] = await ethers.getSigners();
```
const Vault = await ethers.getContractFactory("Vault");
const v = await Vault.deploy();
await v.waitForDeployment();
await user.sendTransaction({
to: await v.getAddress(),
value: ethers.parseEther("1")
});
expect(await v.balanceOf(user.address)).to.equal(
ethers.parseEther("1")
);
```
});
it("reverts on withdraw more than balance", async () => {
const [user] = await ethers.getSigners();
```
const Vault = await ethers.getContractFactory("Vault");
const v = await Vault.deploy();
await v.waitForDeployment();
await expect(
v.connect(user).withdraw(ethers.parseEther("1"))
).to.be.reverted;
```
});
}); Hardhat is also useful for network forking. Fork tests let you interact with real deployed contracts in a local environment. This is valuable for protocols that depend on real DEX routers, ERC-20 tokens, Chainlink feeds, lending markets, bridges, and upgradeable contracts.
networks: {
```
hardhat: {
forking: {
url: process.env.RPC_URL
}
}
} A reliable RPC endpoint matters for fork testing because slow or unstable RPC calls can break CI, slow test execution, or produce inconsistent developer workflows. Teams that need managed RPC infrastructure for fork tests and contract simulations can compare providers such as Chainstack, QuickNode, and GetBlock depending on chain support, rate limits, archive access, latency, and budget.
Fuzz testing smart contracts
Unit tests check specific inputs. Fuzz tests throw many randomized inputs at a function to see whether the contract breaks under edge cases. This is useful for amounts, timestamps, ratios, fees, caps, shares, withdrawal values, debt values, oracle prices, and user balances.
A fuzz test should start from a valid state. Random inputs should be constrained with realistic bounds. Without bounds, the fuzzer may waste time testing impossible states instead of dangerous realistic ones.
function testFuzz_WithdrawNeverExceedsBalance(
uint96 depositAmount,
uint96 withdrawAmount
```
) public {
depositAmount = uint96(bound(depositAmount, 1e12, 10 ether));
withdrawAmount = uint96(bound(withdrawAmount, 0, depositAmount));
```
v.deposit{value: depositAmount}();
uint256 balanceBefore = v.balanceOf(address(this));
v.withdraw(withdrawAmount);
assertEq(
v.balanceOf(address(this)),
balanceBefore - withdrawAmount
);
```
} Fuzzing is strong for discovering edge cases that developers did not manually write. It can reveal rounding issues, overflow assumptions, underflow assumptions, boundary mistakes, fee calculation bugs, and accounting drift.
Invariant testing
Invariant testing asks a different question: what must always be true, no matter what sequence of actions happens? Instead of testing one function call, invariant testing explores many calls in many orders and verifies that a core rule never breaks.
For a vault, a basic invariant might be that total user liabilities should equal the ETH held by the contract. For a lending protocol, collateral accounting must remain solvent under supported assumptions. For an AMM, reserves and shares should follow the pool rules. For an upgradeable contract, storage layout must not corrupt balances after an upgrade.
contract Invariants is Test {
Vault v;
address alice = address(0xA1);
address bob = address(0xB2);
function setUp() public {
v = new Vault();
targetContract(address(v));
vm.deal(alice, 100 ether);
vm.deal(bob, 100 ether);
}
function invariant_AccountingConserves() public {
uint256 totalLiabilities =
v.balanceOf(alice) + v.balanceOf(bob);
assertEq(totalLiabilities, address(v).balance);
}
```
} Useful invariants include: total shares never exceed supply rules, no user can withdraw more than deposited, fee recipient share never exceeds configured limits, protocol debt never becomes negative, paused functions remain blocked, and storage layout remains safe after upgrades.
Static analysis and coverage
Static analysis tools review source code without executing the full test suite. They do not prove that a protocol is safe, but they are excellent for catching common mistakes early.
Slither can flag reentrancy patterns, dangerous tx.origin usage, shadowed variables, uninitialized storage pointers, weak access control patterns, unchecked transfer assumptions, missing events, unused return values, and suspicious inheritance structures.
slither . --checklist --exclude-informational ``` forge test --fork-url $RPC --gas-report forge coverage --report lcov # Hardhat alternative npx hardhat coverage
Coverage should be used carefully. High line coverage does not mean a contract is safe. A test suite can touch many lines without checking the right properties. Coverage is useful for finding missing branches, but it must be paired with assertions, negative tests, fuzzing, invariants, and manual review.
Critical paths deserve special attention: access-control modifiers, upgrade hooks, emergency pause logic, fee changes, reward distribution, withdrawal paths, liquidation logic, oracle reads, bridge calls, and accounting updates.
Storage layout and upgrade safety
Upgradeable contracts add a special class of risk. A proxy upgrade can accidentally overwrite old storage slots, corrupt balances, change ownership, break accounting, or bypass initialization rules.
Teams using UUPS, Transparent Proxy, Beacon Proxy, Diamond, or other upgrade patterns should lock storage layout with tests. A new implementation should be tested against old storage assumptions before it is approved for deployment.
Versioned initializers, reserved storage gaps, explicit upgrade tests, storage layout snapshots, and multisig-controlled upgrade paths are important. A clean unit test suite is not enough if the upgrade path can corrupt production state.
Upgrade tests should prove that existing balances, roles, configuration values, paused states, debt records, and user positions survive the upgrade without storage corruption.
Oracles and external calls
Many protocol bugs appear around external dependencies. A contract may work under normal conditions but fail when an oracle is stale, a token charges transfer fees, a router reverts, a bridge is paused, a Chainlink round is incomplete, or an ERC-20 token returns false instead of reverting.
Tests should simulate failure paths. Mock oracle prices that are stale, extreme, zero, delayed, or outside acceptable deviation bounds. Mock external calls that revert, consume unexpected gas, or return malformed values. Test how the system behaves when a sequencer is down or a bridge message is delayed.
For external calls, prefer defensive patterns. Validate return values. Use pull-over-push withdrawals where appropriate. Apply checks-effects-interactions. Add reentrancy guards when needed. Avoid unnecessary external calls during sensitive state transitions.
Smart contract audit checklist
An audit checklist helps teams reach a clean pre-audit state. It forces developers to document assets at risk, attacker models, roles, trust assumptions, upgrade controls, economic limits, oracle assumptions, and emergency procedures.
Pre-audit checklist
- Define assets at risk, including user funds, treasury funds, governance power, rewards, collateral, and protocol-owned liquidity.
- List possible attackers: external users, compromised admins, malicious keepers, oracle manipulators, MEV searchers, bridge attackers, and governance attackers.
- Document trust assumptions for oracles, bridges, multisigs, sequencers, relayers, keepers, front ends, routers, and operators.
- Map all privileged roles: owner, pauser, guardian, upgrader, fee setter, oracle setter, reward distributor, and emergency operator.
- Confirm privileged actions emit events and follow least-privilege access control.
- Place high-impact actions behind multisig and timelock controls where appropriate.
- Write tests for upgrades, initialization, storage layout, and post-upgrade invariants.
- Bound fees, interest rates, reward rates, caps, deadlines, oracle deviation, and maximum slippage.
- Use negative tests for zero addresses, invalid amounts, expired deadlines, missing permissions, stale oracle values, and insufficient balances.
- Prefer pull-based withdrawals and claims instead of pushing funds to arbitrary recipients during complex state changes.
- Define emergency pause behavior with the smallest possible blast radius.
- Run unit, integration, fork, fuzz, and invariant tests in CI before external review.
- Run Slither and document any accepted warnings or waivers.
- Prepare NatSpec comments, README risk disclosures, deployment runbooks, and incident response procedures.
- Get at least one internal reviewer who did not write the original implementation.
Pre-mainnet gate
Before mainnet deployment, the team should pass a strict gate. This is not just about whether tests pass locally. It is about whether the protocol can survive real operating conditions.
| Gate item | What should be true | Why it matters | Failure signal |
|---|---|---|---|
| Tests | Unit, integration, fork, fuzz, and invariants pass in CI | Confirms behavior across normal and adversarial paths | Manual-only testing or local-only tests |
| Static analysis | Slither findings are resolved or documented | Catches common patterns before audit | Warnings ignored without explanation |
| Storage layout | Storage snapshot is taken and guarded | Prevents upgrade corruption | No layout tests for proxies |
| Privileged roles | High-impact roles use multisig and timelock controls | Reduces single-key and rushed-upgrade risk | EOA owns upgrade or treasury functions |
| Runbooks | Pause, upgrade, and incident procedures are rehearsed | Prevents chaos during emergencies | No tested emergency process |
| Deployment security | Deployer wallet, addresses, constructor args, and verification are checked | Prevents launch mistakes | Unknown deployer process or unverified contracts |
For teams deploying valuable contracts, hardware wallets can reduce private key exposure for deployer and admin operations. A hardware wallet such as Ledger can help secure signing workflows, but it does not replace multisig controls, transaction review, timelocks, and operational discipline.
Rehearse a chaos day
A chaos day is a controlled rehearsal where the team simulates failure. This is useful before launch because many incident response plans look good in a document but fail under pressure.
Simulate an oracle outage. Simulate sequencer downtime. Simulate an emergency pause. Simulate a broken reward distributor. Simulate an upgrade rollback. Simulate a compromised guardian. Simulate high gas during a needed transaction. Watch whether dashboards, alerts, runbooks, team communication, and multisig execution actually work.
The goal is not to create drama. The goal is to find weak processes before attackers, markets, or users find them.
How to prepare for an external audit
External auditors work better when the codebase is organized, documented, tested, and threat-modeled. A messy repository wastes review time. A clean repository lets auditors spend more time finding real issues instead of guessing how the system should work.
Before sending code to auditors, freeze the scope. Include architecture diagrams, contract descriptions, privileged role mappings, deployment assumptions, known limitations, invariant list, test instructions, coverage reports, Slither output, and any accepted design risks.
High-TVL protocols should consider multiple independent reviews and a public bug bounty after audit fixes are merged. Audits reduce risk, but they do not eliminate it. Continuous monitoring, restricted permissions, conservative rollout limits, and emergency controls still matter.
Recommended developer workflow
A practical smart contract security workflow should be repeatable. Developers should not wait until the end of the build to think about security. Testing and review should be part of every pull request.
Security-first development workflow
- Write a short threat model before implementing sensitive logic.
- Write unit tests for each function and branch.
- Add negative tests for every expected failure path.
- Add fuzz tests for input-heavy logic such as deposits, withdrawals, swaps, fees, caps, and rewards.
- Add invariants for core accounting, solvency, permissions, supply, and upgrade safety.
- Run fork tests for real tokens, routers, price feeds, lending markets, and external dependencies.
- Run Slither and coverage in CI.
- Require review from someone who did not write the change.
- Document any accepted risk before merging.
- Keep deployment scripts versioned, reviewed, and rehearsed.
Infrastructure for fork tests, simulations, and secure deployment workflows
Smart contract testing often needs reliable RPC endpoints, fork access, archive data, and safe signing practices. Choose infrastructure based on chain support, latency, rate limits, archive requirements, and team workflow.
Quick check
Use these questions to test whether the core concepts are clear.
What is the advantage of Foundry compared with Hardhat?
Foundry is fast and Solidity-native. It is especially strong for unit tests, fuzzing, invariant testing, fork tests, and gas snapshots. Hardhat is stronger for TypeScript workflows, plugin-heavy projects, deployment scripts, and app-level integration tests.
When should you use invariant tests?
Use invariant tests when a property must always hold across many inputs, users, and call sequences. Examples include accounting conservation, solvency, supply caps, withdrawal limits, access control boundaries, and storage layout preservation.
Why pair multisig with a timelock?
A multisig reduces single-key risk. A timelock creates a public review window before high-impact actions such as upgrades, fee changes, treasury movements, and parameter updates become active.
Final recommendation
Smart contract security should be treated as an engineering discipline, not a launch checklist. A formal audit helps, but it cannot replace a disciplined test suite, clear invariants, careful fork tests, static analysis, secure deployment controls, and emergency runbooks.
Foundry and Hardhat solve different parts of the workflow. Foundry gives speed, Solidity-native tests, fuzzing, and invariants. Hardhat gives excellent TypeScript integration and deployment ergonomics. Slither adds early static analysis. Fork tests connect the system to real chain behavior. Invariants protect the assumptions that must never break.
The safest teams test like attackers, document like auditors, deploy like operators, and rehearse like something will eventually fail. That mindset is what separates mainnet-ready systems from contracts that only worked in a demo.
Build safer smart contracts before mainnet
Do not wait for an external audit to discover basic security gaps. Build a testing pipeline with unit tests, fork tests, fuzzing, invariants, static analysis, storage layout checks, and deployment rehearsals before funds are at risk.
FAQs
What is smart contract auditing?
Smart contract auditing is a structured review of contract logic, permissions, economic assumptions, external dependencies, upgrade safety, and failure paths to reduce the risk of exploits or protocol failure.
Is testing the same as auditing?
No. Testing verifies expected behavior and explores failure cases. Auditing reviews the system more broadly, including architecture, assumptions, incentives, permissions, threat models, and implementation details.
Should I use Foundry or Hardhat?
Use Foundry for fast Solidity-native unit tests, fuzzing, invariants, fork tests, and gas snapshots. Use Hardhat for TypeScript integration, plugin workflows, front-end simulation, and deployment scripting. Many teams use both.
What is fuzz testing?
Fuzz testing sends many randomized inputs into a function to discover edge cases that hand-written tests may miss.
What is invariant testing?
Invariant testing checks that core properties always remain true across many random call sequences and state changes.
Why is Slither useful?
Slither detects common Solidity bug patterns and code smells early, including reentrancy patterns, dangerous calls, shadowing, uninitialized storage pointers, and missing events.
Why do fork tests matter?
Fork tests let developers test contracts against realistic deployed state, including real tokens, routers, oracles, lending markets, bridges, and external protocol behavior.
What should be checked before mainnet?
Before mainnet, confirm that tests pass, static analysis is reviewed, storage layout is guarded, privileged roles are secured, deployment scripts are rehearsed, contracts are verified, and emergency runbooks are ready.
Can an audit guarantee safety?
No. An audit reduces risk but cannot guarantee safety. Protocols still need monitoring, conservative permissions, bug bounties, emergency controls, and continuous review.
Why should privileged roles use multisig and timelocks?
Multisigs reduce single-key risk, while timelocks create a public review period before sensitive actions such as upgrades or parameter changes take effect.
References
Official documentation and reputable sources for deeper reading:
- Foundry Book
- Hardhat Docs
- Slither Static Analyzer
- OpenZeppelin Docs
- Solidity Documentation
- Smart Contract Weakness Classification Registry
- Smart Contract Best Practices
- TokenToolHub: Token Safety Checker
- TokenToolHub: Approval Allowance Checker
- TokenToolHub: Advanced Blockchain Guides
This guide is for educational smart contract security research only and is not financial, investment, legal, or professional audit advice. Smart contract testing, static analysis, and external audits reduce risk but do not eliminate it. Always verify current tool documentation, test your own contracts under realistic assumptions, and seek independent professional review before deploying contracts that control user funds.