A DeFi protocol loses money to a reentrancy bug almost every month in 2026, and the pattern barely changes. GMX V1 lost $42 million in July 2025 to a reentrancy flaw in its order-execution logic. Solv Protocol lost $2.7 million in March 2026 the same way. FutureSwap, Ekubo, and a handful of smaller protocols followed with losses ranging from $74,000 to $1.4 million, all tracing back to external calls made before internal state got updated. According to OWASP’s 2026 Smart Contract Top 10, reentrancy remains one of the most common root causes of exploited code, right alongside access-control mistakes and logic errors.
None of this is exotic. Reentrancy has had a name and a fix since the DAO hack in 2016. What’s changed is the tooling: static analyzers like Slither now catch most reentrancy patterns before deployment, and test frameworks like Foundry make it fast to write an actual exploit against your own contract before someone else does. This tutorial walks through setting up that workflow end to end — from a vulnerable contract, to a static-analysis pass, to a working proof-of-concept exploit test, to the fix, to a CI pipeline that keeps the bug from coming back.
You’ll build a small vault contract with an intentional reentrancy bug, break it with a real attacker contract inside a Foundry test, patch it three different ways, and wire Slither into GitHub Actions so the next contributor can’t reintroduce the same mistake. By the end you’ll have a repository you can reuse as a template for every contract you ship after this one.
Why reentrancy still drains protocols in 2026
Reentrancy happens when a contract calls out to an external address (another contract, or an EOA via a fallback function) before it finishes updating its own state. If that external call hands control back to the attacker, the attacker’s code can call back into the original function while the first call is still mid-execution — withdrawing funds, minting tokens, or opening positions multiple times against a balance that hasn’t been decremented yet.
The 2025-2026 incident list shows the pattern hasn’t gone away just because it’s well documented. GMX V1’s $42 million loss came from executeDecreaseOrder accepting an attacker-controlled address as a refund recipient, then transferring control to it mid-transaction, per Halborn’s post-mortem and OWASP’s own case study. Yearn’s yETH pool lost roughly $9 million on November 30, 2025 after an attacker minted 235 septillion yETH from just 16 wei of input, a related class of accounting bug documented by Check Point Research. Smaller, less headline-grabbing hits kept landing through the first half of 2026: FutureSwap lost $74,000 on January 14 in a straightforward reentrancy attack on Arbitrum, and an unverified Ethereum mainnet contract lost about $11,000 on March 23 to the same bug class, according to BlockSec’s weekly incident roundup.
What ties these together isn’t a lack of awareness — every Solidity course covers the checks-effects-interactions pattern — it’s that manual review misses the interaction under time pressure, and small or mid-size teams often ship without running a static analyzer or writing an actual exploit test. This tutorial fixes both gaps.
Reentrancy isn’t even the single biggest category of DeFi losses right now. A smart contract audit landscape report covering 2026 attributes $953.2 million in losses to access-control vulnerabilities, versus $63.8 million to logic errors and $35.7 million to reentrancy specifically, per Blockeden’s analysis. That ranking matters for how you should prioritize your own review time: reentrancy testing catches a well-understood, well-tooled bug class, but it’s one item on a longer checklist that also needs to cover who’s allowed to call privileged functions and whether your math handles edge-case inputs correctly. The table below lines up a sample of 2025-2026 incidents so you can see how the loss sizes and root causes compare.
| Protocol | Date | Amount lost | Root cause |
|---|---|---|---|
| GMX V1 | July 2025 | $42 million | Reentrancy in executeDecreaseOrder |
| Yearn Finance (yETH pool) | Nov 30, 2025 | ~$9 million | Virtual balance accounting flaw (16 wei mint exploit) |
| Balancer V2 | Nov 2025 | ~$128 million | Rounding/precision logic flaw |
| Truebit | Jan 8, 2026 | $26.2 million | Integer overflow in a bonding curve |
| Aperture Finance | Jan 25, 2026 | $3.2 million | Input validation bypass via transferFrom |
| Solv Protocol | March 2026 | ~$2.7 million | Reentrancy in a bonding-curve redemption function |
| Trusted Volumes | May 7, 2026 | $5.9 million | Access-control flaw in an RFQ swap proxy |
| Ekubo | May 5, 2026 | $1.4 million | Callback failed to verify payer identity |
Every one of these was, in hindsight, a known bug class with a known fix. None of them needed a novel attack technique. That’s the argument for building the workflow in this tutorial into your normal development loop rather than treating it as a pre-launch checkbox.
Prerequisites and versions
You don’t need prior smart contract security experience, but you should be comfortable writing basic Solidity and running commands in a terminal. Install the following before you start:
| Tool | Version used in this tutorial | Install command |
|---|---|---|
| Foundry (forge, anvil, cast) | v1.3.2 or later | curl -L https://foundry.paradigm.xyz | bash && foundryup |
| Slither | 0.11.5 or later | pip3 install slither-analyzer |
| Solidity compiler | 0.8.26 | installed automatically by Foundry via forge build |
| OpenZeppelin Contracts | v5.4.0 | forge install OpenZeppelin/[email protected] |
| Node.js (for Hardhat comparison section) | 20.x LTS | nvm install 20 |
| Python | 3.10+ | required by Slither |
| Git | any recent version | – |
Foundry is the primary tool here because its Solidity-native tests make writing an actual attacker contract fast — no separate JavaScript test harness needed. We’ll also touch on Hardhat 3.14.0 near the end for teams already standardized on it.
Step 1: Scaffold the project with Foundry
Start with a clean Foundry project. Run forge init to generate the standard layout with src/, test/, and lib/ directories.
mkdir reentrancy-lab && cd reentrancy-lab
forge init --no-git
forge install OpenZeppelin/[email protected]
forge --version
Confirm the compiler version pin in foundry.toml so builds are reproducible across machines:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.26"
optimizer = true
optimizer_runs = 200
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/"
]
Delete the default src/Counter.sol and its test file — you won’t need the boilerplate example for this exercise.
Step 2: Write a deliberately vulnerable vault contract
Create src/VulnerableVault.sol. This is a simple ETH deposit/withdraw vault with the classic bug: it sends ETH to the caller before it zeroes out their balance.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// VULNERABLE: external call happens before state update
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
balances[msg.sender] = 0; // too late — attacker already re-entered
}
function vaultBalance() external view returns (uint256) {
return address(this).balance;
}
}
The bug is on the line ordering: call fires before balances[msg.sender] = 0 runs. If the recipient is a contract with a receive() function, that function executes during the call, and it can call withdraw() again while the original balance is still non-zero.
Step 3: Run Slither to catch it statically
Before writing a single test, run Slither against the contract. Static analysis catches this class of bug in seconds, without needing to simulate an attack.
slither src/VulnerableVault.sol --solc-remaps @openzeppelin/=lib/openzeppelin-contracts/
Expected output (trimmed):
VulnerableVault.withdraw() (src/VulnerableVault.sol#10-17) sends eth to arbitrary user
Dangerous calls:
- (success,None) = msg.sender.call{value: amount}() (src/VulnerableVault.sol#13)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#reentrancy-vulnerabilities
INFO:Detectors:
Reentrancy in VulnerableVault.withdraw() (src/VulnerableVault.sol#10-17):
External calls:
- (success,None) = msg.sender.call{value: amount}() (src/VulnerableVault.sol#13)
State variables written after the call(s):
- balances[msg.sender] = 0 (src/VulnerableVault.sol#16)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#reentrancy-vulnerabilities-1
. analyzed (2 contracts with 100 detectors), 2 result(s) found
Slither 0.11.5 flags exactly the line that matters: a state variable written after an external call. This is the same class of finding Slither’s reentrancy-eth and reentrancy-no-eth detectors would raise on GMX V1’s code, per OWASP’s writeup of that incident. Getting a clean Slither run doesn’t guarantee a contract is safe, but a flagged reentrancy warning on a fund-moving function should never ship without review.
Step 4: Write an attacker contract to prove the bug is exploitable
Static analysis tells you where to look. A proof-of-concept exploit tells you whether it’s actually exploitable, and how bad the damage is. Create src/Attacker.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {VulnerableVault} from "./VulnerableVault.sol";
contract Attacker {
VulnerableVault public immutable target;
uint256 public constant DRAIN_ROUNDS = 5;
uint256 private roundsLeft;
constructor(address _target) {
target = VulnerableVault(_target);
}
function attack() external payable {
require(msg.value > 0, "need seed ETH");
roundsLeft = DRAIN_ROUNDS;
target.deposit{value: msg.value}();
target.withdraw();
}
receive() external payable {
if (roundsLeft > 0 && address(target).balance >= msg.value) {
roundsLeft -= 1;
target.withdraw();
}
}
}
The attacker deposits a small amount, calls withdraw(), and its receive() fires mid-transfer — re-entering withdraw() up to five more times before the first call ever gets to zero out the balance.
Step 5: Write the Foundry exploit test
Now prove it in a test, using Foundry’s cheatcodes to fund accounts and assert on balances. Create test/Reentrancy.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {VulnerableVault} from "../src/VulnerableVault.sol";
import {Attacker} from "../src/Attacker.sol";
contract ReentrancyTest is Test {
VulnerableVault vault;
Attacker attacker;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
vault = new VulnerableVault();
attacker = new Attacker(address(vault));
vm.deal(alice, 5 ether);
vm.deal(bob, 5 ether);
vm.prank(alice);
vault.deposit{value: 5 ether}();
vm.prank(bob);
vault.deposit{value: 5 ether}();
}
function test_ReentrancyDrainsVault() public {
uint256 vaultBefore = vault.vaultBalance();
assertEq(vaultBefore, 10 ether);
vm.deal(address(this), 1 ether);
attacker.attack{value: 1 ether}();
uint256 vaultAfter = vault.vaultBalance();
assertLt(vaultAfter, vaultBefore, "vault should be drained beyond attacker's own deposit");
assertEq(vaultAfter, 0, "attacker drained the entire vault");
}
}
Run it with verbose tracing so you can see the reentrant calls unfold:
forge test --match-test test_ReentrancyDrainsVault -vvvv
Expected output (trimmed):
[PASS] test_ReentrancyDrainsVault() (gas: 187452)
Traces:
[187452] ReentrancyTest::test_ReentrancyDrainsVault()
├─ [0] VulnerableVault::vaultBalance() [staticcall]
│ └─ ← [Return] 10000000000000000000 [1e19]
├─ [130221] Attacker::attack{value: 1000000000000000000}()
│ ├─ [22456] VulnerableVault::deposit{value: 1000000000000000000}()
│ ├─ [98123] VulnerableVault::withdraw()
│ │ ├─ [3541] Attacker::receive{value: 1000000000000000000}()
│ │ │ └─ [21044] VulnerableVault::withdraw()
│ │ │ ├─ [3200] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ └─ ... (repeats until vault is empty)
└─ [0] VulnerableVault::vaultBalance() [staticcall]
└─ ← [Return] 0
Suite result: ok. 1 passed; 0 failed; 0 skipped
The trace shows the attacker’s receive() calling back into withdraw() before the first call ever reaches the line that zeroes the balance. Both Alice’s and Bob’s deposits get drained even though the attacker only put in 1 ETH.
Step 6: Fix it with the checks-effects-interactions pattern
The cheapest fix costs nothing in gas or dependencies: reorder the function so state updates happen before the external call. Create src/SafeVaultCEI.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
contract SafeVaultCEI {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
balances[msg.sender] = 0; // effect happens first now
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
}
Now the attacker’s receive() still fires, but when it calls withdraw() again, balances[msg.sender] is already zero, so the require(amount > 0) check reverts the reentrant call.
Step 7: Add a defense-in-depth ReentrancyGuard
Checks-effects-interactions is the fix, but a modifier-based guard is cheap insurance against a future contributor reordering the function by accident. OpenZeppelin’s ReentrancyGuard uses a transient-storage-backed lock as of Contracts v5.4.0.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVaultGuarded is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
}
Belt-and-suspenders here matters because real incidents keep showing up in code that had the checks-effects-interactions pattern in most functions but missed it in one. Use both: order state updates correctly, and add nonReentrant on every function that moves value.
Step 8: Re-run the exploit test against both fixes
Update the test file to target both patched contracts and confirm the attack now fails.
function test_CEIVaultResistsReentrancy() public {
SafeVaultCEI safeVault = new SafeVaultCEI();
Attacker ceiAttacker = new Attacker(address(safeVault));
vm.deal(address(this), 1 ether);
vm.expectRevert();
ceiAttacker.attack{value: 1 ether}();
}
function test_GuardedVaultResistsReentrancy() public {
SafeVaultGuarded guardedVault = new SafeVaultGuarded();
Attacker guardedAttacker = new Attacker(address(guardedVault));
vm.deal(address(this), 1 ether);
vm.expectRevert();
guardedAttacker.attack{value: 1 ether}();
}
Run the full suite and confirm both the original exploit passes (proving the bug exists) and the patched versions revert on the reentrant call:
forge test -vv
[PASS] test_ReentrancyDrainsVault() (gas: 187452)
[PASS] test_CEIVaultResistsReentrancy() (gas: 42981)
[PASS] test_GuardedVaultResistsReentrancy() (gas: 51230)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 4.12ms
Step 9: Add fuzz and invariant tests
A single exploit test proves one attack path. Foundry’s fuzzer and invariant testing catch paths you didn’t think to write by hand. Add a fuzz test that randomizes deposit amounts:
function testFuzz_VaultNeverLosesMoreThanDeposited(uint96 depositAmount) public {
vm.assume(depositAmount > 0.01 ether && depositAmount < 100 ether);
SafeVaultCEI safeVault = new SafeVaultCEI();
vm.deal(alice, depositAmount);
vm.prank(alice);
safeVault.deposit{value: depositAmount}();
Attacker ceiAttacker = new Attacker(address(safeVault));
vm.deal(address(this), 1 ether);
vm.expectRevert();
ceiAttacker.attack{value: 1 ether}();
assertEq(address(safeVault).balance, depositAmount);
}
For invariant testing, define a handler contract that randomly deposits, withdraws, and attacks across many pseudo-random call sequences, then assert the invariant that address(vault).balance >= sum of all balances never breaks across thousands of runs. Foundry's default invariant run count in foundry.toml is controlled by [invariant] runs = 256 and depth = 15 — raise both for contracts handling real funds.
Step 10: Automate Slither and forge test in CI
A local scan is only as good as the discipline to run it every time. Wire both tools into GitHub Actions so every pull request gets scanned automatically. Create .github/workflows/security.yml:
name: contract-security
on: [pull_request]
jobs:
slither:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Install Slither
run: pip3 install slither-analyzer
- name: Run forge tests
run: forge test -vv
- name: Run Slither
run: slither . --fail-high --fail-medium
The --fail-high and --fail-medium flags make the CI job exit non-zero when Slither finds high- or medium-severity issues, blocking the merge until someone reviews the finding. Low- and informational-severity findings still print in the job log but won't block the merge, which keeps the gate useful without turning every style nit into a blocked pull request. Adjust the thresholds as your team's tolerance changes — a protocol handling real user funds should eventually run with --fail-medium on by default, while an early prototype might start with only --fail-high to avoid noise while the codebase is still shifting.
Add a second job to the same workflow file that runs on a schedule rather than per-PR, since a full Mythril pass or an extended fuzz run (forge test --fuzz-runs 50000) is often too slow to run on every commit but is worth doing nightly against the main branch.
Step 11: Check for read-only reentrancy too
The vault example above covers classic reentrancy, where the attacker drains funds directly. A subtler variant, read-only reentrancy, doesn't touch the vulnerable contract's own funds at all — it exploits a view function that returns a stale, mid-transaction state to a second, integrated protocol (like a price oracle reading a pool's reserves mid-swap). A DEV Community security writeup flagged read-only reentrancy as a contributing factor in several of the roughly $86 million in DeFi losses tracked across January 2026 alone.
To test for it, write a Foundry test where a second "consumer" contract reads a price or exchange-rate view function from inside the attacker's receive() callback, and assert that the value returned matches the post-settlement state, not a mid-transaction snapshot. Slither's reentrancy-no-eth detector flags some of these cases, but not all — manual test coverage on any function another contract might call as an oracle is still necessary.
Step 12: Run Mythril as a second analyzer to cross-check
No single static analyzer catches everything. Slither is fast and has a low false-positive rate on reentrancy specifically, but pairing it with a symbolic-execution tool like Mythril adds coverage for bugs that need path exploration rather than pattern matching (integer issues, unchecked external call return values, complex access-control logic).
pip3 install mythril
myth analyze src/VulnerableVault.sol --solc-json mythril-config.json
Mythril runs slower than Slither because it explores execution paths symbolically rather than just parsing the AST, so most teams run Slither on every commit and Mythril on a scheduled nightly job or before a release, not on every push.
Beyond reentrancy: other bug classes the same workflow catches
The Slither and Foundry setup you just built isn't a single-purpose reentrancy scanner. Once it's wired into your repository, the same commands surface several other vulnerability classes that show up just as often in the 2025-2026 incident data, sometimes for bigger dollar amounts than reentrancy itself.
Access control gaps were the single largest loss category in the Blockeden report cited above, at $953.2 million. Slither's arbitrary-send-erc20 and unprotected-upgrade detectors flag functions that move funds or change implementation addresses without an onlyOwner-style check. Write a Foundry test that calls every state-changing function from a random, non-privileged address with vm.prank(makeAddr("attacker")) and assert it reverts — this single pattern would have caught the KiloEx exploit, where an unprotected MinimalForwarder contract let an attacker drain roughly $7 million across three chains in April 2025.
Integer and rounding errors cost Truebit $26.2 million in January 2026 through an overflow in a bonding curve, and contributed to Balancer V2's $128 million loss the previous November. Solidity 0.8.x reverts on overflow by default, but bonding-curve and AMM math often uses unchecked blocks or fixed-point libraries where the checked-arithmetic safety net doesn't apply. Foundry's fuzzer is the right tool here: run forge test --fuzz-runs 10000 against any function doing multiplication before division, and watch for reverts or unexpected zero results at extreme input values.
Unverified or unaudited third-party calls are a growing target specifically because attackers know smaller teams skip Slither's more expensive checks on contracts they call but don't own. Chainalysis flagged this pattern directly, noting that attackers increasingly target unverified contracts precisely because their bytecode hasn't been through public review. Add slither . --exclude-dependencies to focus scans on your own code, but don't skip a manual review of every external contract address your protocol calls before mainnet deployment.
Foundry vs Hardhat for security testing
Both frameworks can run the exact test pattern above. The difference is language and speed of the reentrancy-simulation loop.
| Feature | Foundry v1.3.2 | Hardhat 3.14.0 |
|---|---|---|
| Test language | Solidity | TypeScript/JavaScript (Solidity tests supported in Hardhat 3) |
| Attacker contract in same test file | Native, no compile step outside the test run | Requires separate contract deploy + ethers/viem calls |
| Fuzzing | Built in (forge test --fuzz-runs) | Requires plugin or external fuzzer |
| Invariant testing | Built in | Not native; requires custom scripting |
| Trace output for reentrant calls | -vvvv call trace out of the box | Requires hardhat-tracer plugin |
| Best fit | Solidity-first teams, security-focused workflows | Teams already standardized on TypeScript tooling and multichain deployment scripts |
If your team already has a Hardhat 3 deployment pipeline, you don't need to migrate everything to Foundry just for security testing — Hardhat 3 added first-class Solidity test support specifically to close this gap, per Nomic Foundation's own release notes. But writing the attacker contract and its callback logic is still faster in a Solidity-native test, which is why most audit firms use Foundry for exploit proof-of-concepts regardless of which framework the production repo uses.
Common pitfalls
- Trusting a clean Slither run as proof of safety. Slither catches known patterns. A logic bug that doesn't match any detector signature (like the Balancer V2 rounding-precision issue that cost roughly $128 million in November 2025) sails through untouched.
- Fixing the ordering but forgetting the guard, or the reverse. Checks-effects-interactions and
nonReentrantsolve overlapping but not identical problems. Use both on any function that sends value. - Only testing the happy path in the exploit test. Test the attacker draining more than they deposited, draining exactly their deposit, and draining nothing — all three should behave correctly on the patched contract.
- Ignoring cross-function reentrancy. A guard on
withdraw()doesn't protect you if the attacker re-enters a different function, liketransfer(), that shares the same unprotected state. - Skipping read-only reentrancy checks on view functions used by other protocols. If your contract exposes a price or balance view that another protocol reads, treat it as an attack surface even though it doesn't move funds directly.
- Running Slither once and never again. New detectors ship regularly (0.11.5 added a new
reentrancy-balancedetector per the January 2026 release notes) — a contract that passed a scan six months ago hasn't been checked against the newest detectors. - Pinning an old OpenZeppelin version out of inertia. Import
ReentrancyGuardfrom a current release; older versions used a storage slot instead of transient storage, costing more gas per call. - Not testing against a forked mainnet before deploying. Run
forge test --fork-url $MAINNET_RPC_URLagainst your actual dependencies (oracles, routers) before shipping, since testnet mocks can hide integration-specific reentrancy paths.
Troubleshooting
- Slither fails with "Source file requires different compiler version." Run
solc-select install 0.8.26 && solc-select use 0.8.26before rerunning Slither, and confirmsolc_versioninfoundry.tomlmatches. - Slither can't find OpenZeppelin imports. Pass the same remapping you use in Foundry:
slither . --solc-remaps @openzeppelin/=lib/openzeppelin-contracts/. forge testhangs on invariant tests. Lowerrunsanddepthin the[invariant]section offoundry.tomlwhile debugging, then raise them again before your final pre-deploy run.- Attacker contract's
receive()never fires. Confirm the target contract sends value withcall{value: amount}("")rather thantransfer()orsend()— both of the latter cap forwarded gas at 2,300, which usually isn't enough to execute a nontrivial fallback. - Test passes locally but the CI job fails on Slither. Check that
submodules: recursiveis set in the checkout step; missing OpenZeppelin submodules cause Slither to misreport unrelated import errors as vulnerabilities. --fail-highblocks every PR, including false positives. Use aslither.config.jsonwithfilter_pathsand a triage step to mark reviewed false positives so they don't reappear on every run.- Gas cost jumps after adding
nonReentrant. That's expected; the guard's storage write costs a few thousand gas depending on whether it's the first use of transient storage in the transaction. This is a deliberate tradeoff for the safety margin. - Fuzz test reports a false failure on an edge value like zero. Add explicit
vm.assume()bounds excluding zero and near-type(uint256).maxvalues unless those are genuinely valid inputs for your contract. - Mythril analysis times out on a large contract. Scope the analysis to a single function with
--transaction-count 2or increase the timeout with-t 300; symbolic execution scales poorly with contract size.
Advanced tips
Once the basic workflow is running, a few upgrades pay off on any contract that will hold real value.
First, add a differential test that runs the same exploit scenario against both the vulnerable and patched versions in a single test function, asserting the vulnerable one loses funds and the patched one doesn't. This documents the fix's effectiveness directly in the test suite, which is useful evidence during an external audit.
Second, use Foundry's forge coverage to check that your reentrancy tests actually exercise the vulnerable line, not just the surrounding function. A test suite with 90% line coverage can still miss the one branch where the bug lives.
forge coverage --report lcov
genhtml lcov.info -o coverage-report
open coverage-report/index.html
Third, if the contract will hold significant value, budget for a professional audit on top of this workflow, not instead of it. Firms like Trail of Bits and OpenZeppelin publish their own reentrancy checklists (Trail of Bits' own analysis of the Balancer V2 incident is a good example); running Slither and Foundry tests before an audit engagement means the auditors spend their time on the logic bugs a tool can't catch, instead of re-discovering issues automated tooling would have found for free.
Fourth, consider the Ethereum Foundation's bug bounty structure as a backstop, not a substitute. The Foundation raised its maximum bug bounty to $1,000,000 for critical protocol-level vulnerabilities, per Cryptorank's reporting on the program — a strong incentive that exists precisely because pre-launch testing, however thorough, doesn't catch every path.
Fifth, keep a written record of every finding you triage and dismiss, whether from Slither, Mythril, or a human reviewer. When Slither flags something as a false positive in your slither.config.json, note why in a comment next to the entry. Six months later, when a new contributor reopens the same question, that one-line note saves a re-investigation and keeps institutional knowledge from walking out the door with whoever made the original call.
Monitoring for reentrancy attempts after deployment
Testing before deployment reduces risk, but it doesn't eliminate the value of watching your contract in production. Several of the 2026 incidents in the table above involved attackers probing a contract for days or weeks before finding a working exploit path, which means monitoring can sometimes catch an attack in progress before it drains the full balance.
Set up a simple alert using Foundry's cast tool to watch for unusually deep call stacks or repeated calls to the same function within a single transaction. A basic version uses an event emitted on every withdrawal, then a script that flags any block where the same address triggers the event more than twice in one transaction hash:
cast logs --from-block latest \
--address 0xYourVaultAddress \
"Withdrawal(address,uint256)" \
--rpc-url $MAINNET_RPC_URL
For production systems, most teams graduate from a homemade cast script to a dedicated monitoring service (Forta, OpenZeppelin Defender, or an in-house indexer) that can page an on-call engineer and, in some setups, automatically pause the contract through a circuit-breaker function. If you add a pausable pattern, test the pause function itself for reentrancy and access-control issues with the exact same workflow covered above — a broken emergency stop is its own category of incident.
Complete working project structure
By the end of this tutorial your repository should look like this:
reentrancy-lab/
├── foundry.toml
├── .github/
│ └── workflows/
│ └── security.yml
├── lib/
│ ├── forge-std/
│ └── openzeppelin-contracts/
├── src/
│ ├── VulnerableVault.sol
│ ├── SafeVaultCEI.sol
│ ├── SafeVaultGuarded.sol
│ └── Attacker.sol
└── test/
└── Reentrancy.t.sol
Run forge test -vv && slither . --fail-high --fail-medium one more time before committing, and you have a repeatable template: drop in a new contract, write the attacker, prove the exploit, patch, and re-prove the fix, every time.
Frequently asked questions
Is Slither enough on its own to catch reentrancy?
No. Slither is very good at catching the classic pattern of an external call followed by a state write, but it can miss logic-level reentrancy that spans multiple functions or contracts. Pair it with an actual exploit test in Foundry, and consider a second analyzer like Mythril for symbolic-execution coverage.
Does the checks-effects-interactions pattern alone fully prevent reentrancy?
It prevents single-function reentrancy, where the attacker re-enters the same function. It does not automatically prevent cross-function reentrancy, where shared state is manipulated through a different function during the callback. Combine it with a nonReentrant guard for defense in depth.
What's the gas cost difference between checks-effects-interactions and a ReentrancyGuard?
Checks-effects-interactions costs nothing extra; it's just reordering existing statements. OpenZeppelin's transient-storage-backed ReentrancyGuard in Contracts v5.4.0 adds a modest gas overhead per protected call for the lock write and check, which is worth it on any function moving user funds.
Should I use Foundry or Hardhat for security testing?
Foundry's Solidity-native tests and built-in fuzzing make writing exploit proof-of-concepts faster, which is why most audit firms default to it. Hardhat 3.14.0 added first-class Solidity test support, so teams already standardized on Hardhat's TypeScript deployment tooling don't need to migrate their whole stack just to run these tests.
How often should I re-run Slither on a deployed contract?
Run it on every pull request via CI, and again whenever Slither ships a new detector release, since new detectors can flag patterns in code that passed a scan months earlier. Slither 0.11.5, released in January 2026, added a new reentrancy-balance detector as one example.
What is read-only reentrancy and do I need to test for it separately?
Read-only reentrancy exploits a view function that returns stale mid-transaction state to an external protocol reading it as a price or exchange-rate oracle. It doesn't drain the vulnerable contract's own funds, so standard reentrancy tests can miss it. Write a separate test where a second contract reads your view function from inside a reentrant callback and assert the value is correct.
Can a professional audit replace this testing workflow?
No, they're complementary. Running Slither and writing exploit tests before an audit means auditors spend their limited engagement time on subtler logic bugs instead of re-finding issues automated tooling already caught for free, which typically improves the value of the audit rather than replacing the need for one.
Does OpenZeppelin's ReentrancyGuard protect against reentrancy across different contracts I deploy?
No. Each contract needs its own guard instance and its own nonReentrant modifiers on value-moving functions. A guard on Contract A doesn't protect Contract B, even if they share state through an external call.
Do I need both Slither and Foundry, or is one enough?
Use both; they catch different things. Slither reads your code without executing it and flags known dangerous patterns in seconds, which makes it cheap to run on every commit. Foundry tests actually execute an attack against a running contract state, proving whether a flagged pattern is truly exploitable and confirming your fix works. A clean Slither report with no exploit tests is a false sense of security; a passing exploit test suite that never ran a static analyzer risks missing bug classes you didn't think to write a test for.
Related coverage
- Smart Contract Audit: 12 Steps, 90 Min [2026]
- DeFi Exploits Hit Q2 Record: 99 Hacks, $746M Lost [2026]
- Term Finance Loses $8.5M as August DeFi Hacks Hit 17 [2026]
- MEV Bot Protection Setup: 4 Chains, 12 Steps [2026]
- npm Supply Chain Attacks: 1.2M Malicious Packages [2026]
- More cryptocurrency security coverage




