Smart contract testing frameworks are development tools that let you simulate, deploy, and attack your own contracts before anyone else does — and choosing the wrong one, or using the right one wrong, is how you end up on Rekt News. The Nomad Bridge hack in August 2022 wiped out $190M in hours because a single initialization function left a critical root value set to zero, allowing anyone to spoof messages. Tests existed. They just didn't cover the right attack surface. That's not a testing framework problem — it's a testing strategy problem. But your framework determines what's even possible to test.
So let's actually answer this. Hardhat vs Foundry — not in theory, not on vibes, but for the specific job of finding vulnerabilities before your contract goes live.
What Is Hardhat and What Is Foundry?
Hardhat is a JavaScript/TypeScript-based Ethereum development environment. You write your tests in JS using Ethers.js or Waffle, you get a local node, task runners, plugins, and a relatively gentle learning curve. It's been the industry default since around 2020 and the ecosystem around it is enormous.
Foundry is a Rust-based toolkit from Paradigm. You write tests in Solidity itself — same language as the contracts — and it comes with Forge (the testing engine), Cast (CLI for chain interactions), and Anvil (local node). It's faster than Hardhat by a significant margin and ships with fuzz testing built in, not bolted on.
Both can catch bugs. But they catch different bugs with different effort levels. That matters enormously if you're security-focused.
Why Does Your Testing Framework Choice Affect Security Outcomes?
Because the kind of tests your framework makes easy is the kind of tests developers actually write. If fuzz testing requires three plugins and a YAML config, most devs skip it. If it's one line of code, they use it on everything.
According to Chainalysis's 2023 Crypto Crime Report, smart contract exploits accounted for the majority of the $3.8B stolen from crypto protocols that year. A meaningful chunk of those would have been caught by property-based or fuzz testing — tests that check whether a contract behaves correctly across thousands of randomized inputs, not just the happy path your dev wrote at 11pm.
That's where Foundry has a structural advantage. Fuzzing is native. It's not a thought experiment.
What Does the Vulnerable Pattern Actually Look Like?
Here's a simplified version of the access control mistake that kills projects. Not hypothetical — this class of bug took down contracts worth hundreds of millions across 2021-2023.
// VULNERABLE — Solidity 0.8.20
// Missing access control on initialize()
// Attacker can call this after deployment and take ownership
contract VaultV1 {
address public owner;
bool public initialized;
mapping(address => uint256) public balances;
// Anyone can call this. No onlyOwner. No initializer guard.
function initialize(address _owner) external {
owner = _owner;
initialized = true;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Not owner");
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
receive() external payable {
balances[msg.sender] += msg.value;
}
}
The attack is trivial. Deploy the contract. Call initialize(attackerAddress) before the legitimate team does. Now you're the owner. Call withdraw. Done. The Nomad hack wasn't this exact pattern but the failure mode is identical: a value that should be set and protected was left open to manipulation.
Here's the fixed version using OpenZeppelin 5.x patterns:
// FIXED — Solidity 0.8.24, OpenZeppelin 5.x
// Uses OZ Initializable to enforce single-call initialization
// Uses Ownable2Step for safer ownership transfer
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
contract VaultV2 is Initializable, Ownable2StepUpgradeable {
mapping(address => uint256) public balances;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
// initializer modifier from OZ Initializable — can only be called once
function initialize(address _owner) external initializer {
__Ownable2Step_init();
_transferOwnership(_owner);
}
function withdraw(uint256 amount) external onlyOwner {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
receive() external payable {
balances[msg.sender] += msg.value;
}
}
The initializer modifier ensures this runs exactly once. _disableInitializers() in the constructor blocks anyone from calling it on the implementation contract directly. Ownable2StepUpgradeable requires the new owner to accept the transfer — no fat-finger ownership gifts to the zero address.
How Would Hardhat and Foundry Test This Differently?
In Hardhat, you'd write something like this in TypeScript:
// Hardhat test — TypeScript, Ethers.js
it("should prevent double initialization", async function () {
const [owner, attacker] = await ethers.getSigners();
const Vault = await ethers.getContractFactory("VaultV2");
const vault = await Vault.deploy();
await vault.initialize(owner.address);
// Should revert on second call
await expect(
vault.connect(attacker).initialize(attacker.address)
).to.be.revertedWith("Initializable: contract is already initialized");
});
Clean. Readable. Works. But you wrote this test because you knew to write it. The framework didn't help you find the question — you had to bring the question.
In Foundry:
// Foundry test — pure Solidity
// forge test --match-test testFuzzInitialize -v
contract VaultTest is Test {
VaultV2 vault;
address owner = address(0x1);
function setUp() public {
vault = new VaultV2();
vault.initialize(owner);
}
// Fuzz: try 256 random addresses as attackers
function testFuzzInitialize(address attacker) public {
vm.assume(attacker != address(0));
vm.prank(attacker);
vm.expectRevert();
vault.initialize(attacker);
}
// Invariant: owner should never change after init
function invariant_ownerNeverChanges() public {
assertEq(vault.owner(), owner);
}
}
Same concept — but the fuzz test runs across 256 random addresses by default (configurable to thousands). The invariant test runs after every function call sequence Foundry generates automatically. You don't have to enumerate the attackers. Foundry finds the edge cases you didn't think to write.
That's the real difference. Hardhat tests what you imagine. Foundry's fuzzer tests what you didn't imagine.
How I'd Catch This Before It Ships — Actual Audit Process
When I look at a contract for the first time, access control is my first stop. Not reentrancy — everyone's looking for reentrancy. According to data from Immunefi's 2023 bug bounty report, access control vulnerabilities accounted for nearly 50% of all critical findings. Reentrancy gets the press. Access control gets the money.
Here's the specific sequence I run:
- Grep for every
externalandpublicfunction. List them. Ask: who can call this, and what does it change? - Find every state-changing function that lacks a role modifier —
onlyOwner,onlyRole, or a custom equivalent. - Check initialization patterns. If the contract is upgradeable, does the implementation disable initializers in its constructor? If not, stop everything.
- In Foundry: write an invariant test for every ownership and role assumption. Run
forge test --fuzz-runs 10000. If something breaks, it'll break here. - In Hardhat: use hardhat-deploy's fixture system to simulate fresh deployments and replay attack sequences across multiple blocks.
Static tools like Slither will catch some of this — Slither's uninitialized-local and missing-zero-check detectors fire on obvious cases. But Slither doesn't understand your protocol's economic logic. It doesn't know that a function being callable by anyone is fine in one context and catastrophic in another. That's where human reasoning and AI-assisted analysis fill the gap.
Which Framework Actually Wins on Security?
Foundry wins for security-focused testing. Not because Hardhat is bad — Hardhat's plugin ecosystem, readable TypeScript tests, and Hardhat Network's console.log debugging make it excellent for integration testing and frontend-connected workflows. But for the specific job of finding vulnerabilities:
- Foundry's native fuzzing means you're running property-based tests with near-zero setup cost
- Invariant testing lets you define what should never be true and let the framework try to break it — that's exactly how attackers think
- Forge runs faster (Rust vs Node), so you can crank fuzz runs up to 100,000 without waiting an hour
- Writing tests in Solidity means you're thinking in the same execution model as the contract — no context switch, no abstraction mismatch
The counterintuitive take: the best security setup isn't either/or. Use Foundry for invariant and fuzz tests. Use Hardhat for integration tests that simulate real user flows with your frontend. Run both in CI. The teams that skip one or the other are leaving coverage gaps.
What Should You Do Right Now?
If you're a developer about to ship:
- Install Foundry (
curl -L https://foundry.paradigm.xyz | bash) and runforge test --fuzz-runs 10000against your existing test suite — just adding the fuzz-runs flag on existing fuzz tests costs you nothing and finds more edge cases. - Write at least one invariant test per critical state variable — ownership, token supply, collateral ratios. Prefix the function with
invariant_and Foundry handles the rest. - Run Slither (
slither .) and filter for HIGH severity before every PR merge — non-negotiable. - For every
externalfunction in your contract, write out in plain English who can call it. If you can't answer immediately, the access control is probably wrong.
If you're an investor or DeFi user about to ape into a new protocol: the presence of a test suite doesn't mean the contract is safe. It means someone wrote tests. Those are different things. Before you put real money in, run the contract address through an analysis tool that checks the actual bytecode and logic — not just whether a GitHub repo exists.
Paste your contract into SmartContractAuditor.ai before you deploy. It takes 30 seconds and flags exactly the kind of access control and initialization issues covered here — and explains why each finding is dangerous in plain English, not just a Slither detector code. Run it before you ship. Not after you're on Rekt News.
Frequently Asked Questions
What is the difference between Hardhat and Foundry for smart contract testing?
Hardhat is a JavaScript/TypeScript-based development environment where tests are written using Ethers.js — it excels at integration testing and has a massive plugin ecosystem. Foundry is a Rust-based toolkit where tests are written in Solidity itself, and it includes native fuzz and invariant testing that runs significantly faster. For security-focused vulnerability hunting, Foundry's built-in fuzzing gives it a structural advantage over Hardhat.
How does fuzz testing in Foundry help catch smart contract vulnerabilities?
Foundry's fuzzer automatically generates hundreds or thousands of randomized inputs for your test functions, checking whether your contract breaks any defined properties across unexpected edge cases — not just the inputs you thought to test. This is how you catch integer overflow on weird token amounts, access control bypasses with unusual address patterns, or state corruption from call sequences you didn't anticipate. Invariant tests go further, letting Foundry generate entire sequences of function calls to try to violate a property like "the total supply never exceeds X."
Has an access control vulnerability ever been exploited in real DeFi? Give an example.
Yes — the Nomad Bridge hack in August 2022 lost $190M due to an initialization flaw that let attackers spoof arbitrary cross-chain messages. The root cause was a trusted root value set to zero, meaning any message could be "proven" valid. This is a class of access control and initialization vulnerability that proper invariant testing would surface. According to Immunefi's 2023 report, access control issues account for close to 50% of all critical bug bounty findings.
Can Slither replace Foundry or Hardhat for security testing?
No — Slither is a static analysis tool that scans source code for known vulnerability patterns, but it doesn't execute your contract or understand economic logic. It can't tell whether an open external function is intentionally permissionless or accidentally unprotected in your specific context. Slither should be part of your security stack alongside a testing framework, not instead of one. The strongest setups run Slither in CI, write invariant and fuzz tests in Foundry, and do integration testing in Hardhat.
Is smart contract testing covered in a professional audit?
Professional audits assess your test coverage and may run their own tests, but they don't replace your testing — they extend it. Auditors typically write proof-of-concept exploit scripts to verify findings, which is effectively targeted fuzz testing. A contract with strong Foundry invariant tests going into an audit will have fewer obvious findings, meaning auditors spend more time on complex logic bugs. Arriving at an audit with zero tests is how you waste your audit budget on issues you should have caught yourself.