In short

Write down what must always hold, such as “recorded liabilities never exceed the contract’s balance”, and test it after every sequence of calls. Update state before any external call (checks, effects, interactions), add OpenZeppelin’s nonReentrant guard to every function that shares the accounting, and let a failed transfer revert the whole withdrawal. Then run a Foundry invariant test whose handler really reaches the external call, with a malicious receiver among the actors.

  • Reentrancy is SC08:2026 in the OWASP Smart Contract Top 10 (SC05 in 2025): an external call hands control to code that calls back while state is stale.
  • Clear or reduce the caller’s credit before transferring value; a reentrant call then sees nothing left to withdraw.
  • A guard on one function does not protect another function that reads or changes the same balances, or a view function that reads them mid-call.
  • ReentrancyGuardTransient (OpenZeppelin 5.1 and later) keeps its lock in transient storage and needs an EVM with EIP-1153 (Cancun or later).
  • An invariant suite proves little unless it reaches the risky state: count successful withdrawals and fail the run if there were none.

Verify it

  1. Run the invariant suite against the vulnerable contract and confirm it fails; a suite that passes both versions tests nothing.
  2. Check the afterInvariant counter: the run must include successful withdrawals by the malicious receiver.
  3. Confirm every function that changes or reads the shared balances is guarded, not only withdraw.
  4. Make a receiver reject the payment and confirm its credit and the contract balance are unchanged.
  5. Pin the compiler, OpenZeppelin and forge-std versions in the repository.
Applies to
  • Solidity 0.8.24 or later for transient storage (example compiled with 0.8.37)
  • OpenZeppelin Contracts 5.1 or later for ReentrancyGuardTransient (example uses 5.7.0)
  • Foundry Example run with forge 1.8.3 and forge-std 1.16.2

Solidity contracts that hold funds or credits and make external calls. A local invariant suite is bounded evidence, not an audit of a deployed protocol or its economic design.

Part of
Workflow: Test an application
Learning goals and prerequisitesAbout 31 minutes: 7 to read and 24 for the exercise

What you will learn

  • Trace liabilities and funds at every external-call boundary
  • Explain how state ordering and rollback affect a withdrawal
  • Detect an invariant harness that never exercises the risky state

Before you start

Solidity developers and reviewers testing contracts that hold funds

  • Read a short Solidity function and follow a state change, a balance update and an external call.

Refresh first: Preserve business invariants under concurrent requests

Go to the guided practice

A correct transaction can hide an unsafe sequence

A contract’s security depends on state transitions across calls, not just whether each function returns the expected value in isolation. An external call, including a plain transfer of ether to a contract, transfers control to code you do not own, which can call back before the original operation has finished, the on-chain form of the check-then-act race in /domains/appsec/business-logic/transaction-races/. The OWASP Smart Contract Top 10 (2026) lists this as SC08:2026 Reentrancy Attacks (SC05 in the 2025 edition). Ordering, authorization and accounting must remain valid at every externally observable boundary. Cross-function reentrancy is not hypothetical: a bug in the Vyper compiler's named reentrancy locks (CVE-2023-39363, versions 0.2.15, 0.2.16 and 0.3.0) gave each function its own lock regardless of the key, which enabled the July 2023 reentrancy exploits of several Curve stable pools.

Start with invariants stated in domain terms. For a simple custody contract, recorded liabilities should not exceed the funds available to honor them, and one user must not spend another user’s credit. Define assumptions about assets, fees, external donations and administrative actions before turning these statements into tests.

Worked ledger: a callback spends the same credit twice

The model starts with Alice credit 100, Bob credit 200 and contract funds 300. The invariant is total recorded liabilities ≤ available funds, assuming this simple asset has no fees or unusual transfer semantics. A vulnerable withdraw function sends Alice 100 before clearing her credit. At the callback boundary funds have fallen to 200 but recorded liabilities remain 300; the invariant is already false while external code can run.

Alice’s receiver calls withdraw again while her recorded credit still says 100. The nested call pays another 100 and then clears Alice’s credit. When the outer call finishes, Alice has received 200, Bob is still owed 200, and funds are only 100. Checking that Alice’s credit ends at zero misses the loss. Assert funds, each actor’s claim and total payments, not one final field.

The corrected contract below validates Alice’s claim, sets her credit to zero before the external transfer, and reverts if the transfer fails, which restores the credit. At the external-call boundary after a successful 100 payment, funds=200 and liabilities=200. A nested withdrawal is stopped twice over: the nonReentrant guard reverts it on entry, and without the guard it would find zero credit. Whether the receiver propagates that nested failure or catches it changes whether the outer call succeeds; the test must state the receiver behavior.

This is a deliberately small accounting model. Cross-function callbacks may reach other functions sharing state, and token hooks, fees, oracles, upgrades and forced asset movements change the assumptions. Keep the invariant and environmental model explicit and run the executable tests below against the real implementation.

Fixedsrc/Vault.solSolidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

contract Vault is ReentrancyGuardTransient {
    mapping(address => uint256) public credit;

    function deposit() external payable {
        credit[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = credit[msg.sender];
        require(amount > 0, "no credit");
        credit[msg.sender] = 0; // effects before the external call
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed"); // the revert restores the credit
    }
}

See the vulnerable ordering

The vulnerable version differs only in where the credit is cleared. Nothing in it looks wrong to a test that withdraws once from an ordinary account: the bug needs a receiver contract that calls back during the payment.

Vulnerablesrc/VulnerableVault.solSolidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract VulnerableVault {
    mapping(address => uint256) public credit;

    function deposit() external payable {
        credit[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = credit[msg.sender];
        require(amount > 0, "no credit");
        (bool ok,) = msg.sender.call{value: amount}(""); // the receiver runs code here
        require(ok, "transfer failed");
        credit[msg.sender] = 0; // too late: the nested call already saw the old credit
    }
}

Identify where authority or accounting changes

List entry points, privileged roles, upgrade paths, external calls, oracle inputs and state shared between functions. Specify who may pause, withdraw, change configuration or upgrade the contract. An access-control library helps implement these rules, but a mistaken administrator assignment or upgrade authority can still invalidate the intended trust model.

For withdrawals, update state before transferring control and handle transfer failure consistently. Put the reentrancy guard on every function that touches the shared accounting: protecting withdraw alone does not stop a callback from entering transfer or borrow while balances are stale. Read-only reentrancy is the same problem seen from outside: another protocol calls one of your view functions during the callback and prices assets from inconsistent state, which nonReentrantView prevents.

Combine examples, sequences and adversarial receivers

Begin with ordinary deposit and withdrawal tests and unauthorized-operation tests. Add a local receiver contract that attempts a callback during payment and another that rejects payment. Assert final balances, liabilities and events, not only whether a call reverted. A repair that prevents all withdrawals fails the legitimate-use requirement.

Then let Foundry generate call sequences. In the test below a handler bounds the inputs, gives the fuzzer three actors including the malicious receiver, and counts successful withdrawals; invariant_solvent checks the balance against total credit after every call, and afterInvariant fails the run if no withdrawal ever happened. Pointed at the vulnerable contract, the same suite fails within a few calls; against the corrected contract it passes 256 runs of 50 calls each.

Testtest/Vault.invariant.t.solSolidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";

// Receiver R1: withdraws again while it is being paid, and swallows the failure.
contract Reenterer {
    Vault public immutable vault;
    bool private inside;

    constructor(Vault v) {
        vault = v;
    }

    function deposit() external payable {
        vault.deposit{value: msg.value}();
    }

    function withdraw() external {
        vault.withdraw();
    }

    receive() external payable {
        if (inside) return;
        inside = true;
        try vault.withdraw() {} catch {}
        inside = false;
    }
}

contract Handler is Test {
    Vault public immutable vault;
    address[] public actors;
    uint256 public withdrawals; // ghost counter: proves the risky path was reached

    constructor(Vault v) {
        vault = v;
        actors.push(makeAddr("alice"));
        actors.push(makeAddr("bob"));
        actors.push(address(new Reenterer(v)));
    }

    function deposit(uint256 who, uint256 amount) external {
        address actor = actors[who % actors.length];
        amount = bound(amount, 1, 100 ether);
        vm.deal(actor == actors[2] ? address(this) : actor, amount);
        if (actor == actors[2]) {
            Reenterer(payable(actor)).deposit{value: amount}();
        } else {
            vm.prank(actor);
            vault.deposit{value: amount}();
        }
    }

    function withdraw(uint256 who) external {
        address actor = actors[who % actors.length];
        if (vault.credit(actor) == 0) return;
        if (actor == actors[2]) {
            Reenterer(payable(actor)).withdraw();
        } else {
            vm.prank(actor);
            vault.withdraw();
        }
        withdrawals++;
    }

    function liabilities() external view returns (uint256 total) {
        for (uint256 i; i < actors.length; i++) total += vault.credit(actors[i]);
    }
}

contract VaultInvariantTest is Test {
    Vault internal vault;
    Handler internal handler;

    function setUp() public {
        vault = new Vault();
        handler = new Handler(vault);
        targetContract(address(handler));
    }

    // Recorded liabilities never exceed the funds that back them.
    function invariant_solvent() public view {
        assertGe(address(vault).balance, handler.liabilities());
    }

    // A run that never withdraws proves nothing about the external call.
    function afterInvariant() public view {
        assertGt(handler.withdrawals(), 0, "no withdrawal was exercised");
    }
}

Extend the model to real dependencies

Reentrancy is one of ten classes in the OWASP Smart Contract Top 10 (2026); access control, business logic, oracle manipulation, flash-loan attacks, missing input validation, unchecked external calls, arithmetic rounding and precision, integer overflow and proxy upgradeability each need their own invariants and tests. Upgradeable contracts add storage-layout collisions, and signed messages add replay across chains and contracts. This guide covers none of those; the companion guide /domains/appsec/smart-contracts/defi-attack-patterns/ covers oracle manipulation, flash loans, upgradeable-proxy storage collisions and signature replay.

Review source and deployment configuration, pin compiler and dependency versions, and supplement testing with static analysis and independent review. A local invariant suite provides bounded evidence; it does not certify a deployed economic protocol or justify testing against contracts you do not control.

Why revert-only tests and unreached harnesses pass broken code

A test that expects every adversarial receiver transaction to revert can misclassify a safe result. R1 catches the denied nested call, so the outer legitimate withdrawal can succeed once. Assert the economic outcome rather than requiring one universal return status.

Property tests can pass vacuously if the handler selects only actors without credit, never reaches a withdrawal, or discards every reverting sequence. Track reachable deposits, successful withdrawals, callbacks and rejection cases, and keep minimized failing traces. Coverage of meaningful transitions matters more than a large iteration count alone.

Check the result against concrete cases

Each row is a case from the state-table exercise and the state it must end in. The completed attack and both corrected cases are the three Foundry tests in the last exercise step.

Check the result against concrete cases
CaseExpected resultWhat it establishes
Vulnerable first callback boundaryFunds 200, liabilities 300Invariant fails before outer return
Vulnerable completed nested attackAlice paid 200; funds 100; Bob owed 200Final Alice zero does not prove safety
Corrected with R1Alice paid 100; funds 200; Bob owed 200Nested attempt cannot reuse credit
Corrected with R2Original balances/credits restoredFailed transfer does not erase a claim
Harness never depositsPassing invariant gives little relevant evidenceReachability and action coverage matter

Guided practice

Perform a state-table walkthrough for the vulnerable and corrected transitions. No wallet, chain connection or real funds are needed.

Supplied scenario material
Plain text
Start each case from Alice credit=100, Bob credit=200, contract funds=300. No fees, donations or concurrent transactions.
Vulnerable withdraw: read credit; transfer amount (external call); set credit=0.
Corrected withdraw: require credit>0; set credit=0; transfer amount; revert the whole operation if the transfer fails.
Receiver R1: attempts one nested withdrawal, catches its failure, then returns normally.
Receiver R2: rejects the transfer, causing the whole withdrawal to revert.
Case V: vulnerable ordering; Alice’s receiver makes one successful nested call.
Case C: corrected ordering; R1 catches the nested failure.
Case F: corrected ordering; R2 rejects payment; the revert restores the whole operation.
Harness H: runs 100,000 withdraw attempts using Charlie, who never receives credit; the invariant always passes.
  1. 1. Trace V at the callback

    Calculate funds/liabilities after the first payment and after the nested payment completes.

    Case V, in units

    Expected result and reasoning

    After the first payment, funds=200 but liabilities=300 because Alice still has credit 100. After the nested flow completes, Alice credit=0, Bob credit=200, funds=100 and Alice received 200. The remaining liabilities exceed funds by 100.

  2. 2. Trace C with receiver behavior explicit

    Calculate the final state and explain why the outer call need not revert.

    Expected result and reasoning

    Alice credit is cleared before external control. The nested call sees zero and fails; R1 catches that failure and returns normally. The outer payment succeeds once: Alice paid 100, Bob credit=200, funds=200, total liabilities=200.

  3. 3. Trace F under rollback

    State the complete final state after R2 rejects.

    Expected result and reasoning

    The specified whole-operation revert restores Alice credit=100, Bob credit=200 and funds=300. A design that clears credit permanently despite failed payment would violate the intended claim-preservation behavior.

  4. 4. Audit the harness

    Explain why H is weak evidence and name missing sequences.

    Expected result and reasoning

    Charlie never has a withdrawable balance, so H does not reach the external-call state under test. Include deposits by multiple actors, ordinary successful withdrawals, R1 callbacks, R2 rejection and cross-function sequences relevant to the implementation, with balance/liability assertions.

  5. 5. Check the tables in Foundry

    Add these tests next to the contracts on this page and run forge test. Each assertion is one cell of your state tables.

    Solidity
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    import {Test} from "forge-std/Test.sol";
    import {Vault} from "../src/Vault.sol";
    import {VulnerableVault} from "../src/VulnerableVault.sol";
    
    interface IVault {
        function deposit() external payable;
        function withdraw() external;
        function credit(address) external view returns (uint256);
    }
    
    contract R1 {
        IVault public immutable vault;
        bool private inside;
        constructor(IVault v) { vault = v; }
        function deposit() external payable { vault.deposit{value: msg.value}(); }
        function withdraw() external { vault.withdraw(); }
        receive() external payable {
            if (inside) return;
            inside = true;
            try vault.withdraw() {} catch {}
            inside = false;
        }
    }
    
    contract R2 {
        IVault public immutable vault;
        constructor(IVault v) { vault = v; }
        function deposit() external payable { vault.deposit{value: msg.value}(); }
        function withdraw() external { vault.withdraw(); }
        receive() external payable { revert("rejected"); }
    }
    
    contract WorkedExampleTest is Test {
        address bob = makeAddr("bob");
    
        function setUpVault(IVault vault) internal {
            vm.deal(bob, 200);
            vm.prank(bob);
            vault.deposit{value: 200}();
        }
    
        function test_vulnerable_with_R1_pays_alice_twice() public {
            IVault vault = IVault(address(new VulnerableVault()));
            setUpVault(vault);
            R1 alice = new R1(vault);
            alice.deposit{value: 100}();
            alice.withdraw();
            assertEq(address(alice).balance, 200);
            assertEq(address(vault).balance, 100);
            assertEq(vault.credit(bob), 200); // liabilities 200 > funds 100
        }
    
        function test_fixed_with_R1_pays_once() public {
            IVault vault = IVault(address(new Vault()));
            setUpVault(vault);
            R1 alice = new R1(vault);
            alice.deposit{value: 100}();
            alice.withdraw();
            assertEq(address(alice).balance, 100);
            assertEq(address(vault).balance, 200);
            assertEq(vault.credit(address(alice)), 0);
        }
    
        function test_fixed_with_R2_reverts_and_keeps_the_claim() public {
            IVault vault = IVault(address(new Vault()));
            setUpVault(vault);
            R2 alice = new R2(vault);
            alice.deposit{value: 100}();
            vm.expectRevert(bytes("transfer failed"));
            alice.withdraw();
            assertEq(vault.credit(address(alice)), 100);
            assertEq(address(vault).balance, 300);
        }
    }
    Expected result and reasoning

    All three tests pass. Against the vulnerable vault R1 ends with 200 and the vault with 100 while Bob is still owed 200; the corrected vault pays R1 once and keeps 200; R2’s rejected transfer leaves its credit at 100 and the vault at 300.

Your deliverable: A state table for cases V, C and F at each external-call boundary, and a list of the sequences harness H must add before its passing result means anything.

No wallet, chain connection or real funds are needed. The exercise covers one function and one asset; the Foundry tests on this page run the same cases.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. Solidity: Security Considerations
  2. Foundry: Invariant Testing
  3. OpenZeppelin Contracts 5.x: ReentrancyGuard and ReentrancyGuardTransient
  4. OWASP Smart Contract Top 10 (2026)
  5. EIP-1153: Transient storage opcodes. Final (created 15 June 2018).
  6. NVD: CVE-2023-39363, Vyper named reentrancy lock allocation (Curve, July 2023)

Reference information

Download this guide as Markdown