Why reentrancy tests need a dedicated harness

A normal unit test often calls a function once and checks the final state. That is not enough for reentrancy. The bug only appears when control flow leaves your contract and returns before the original function finishes.

A good reentrancy test should answer these questions:

  • Can an external callback re-enter the same function?
  • Can it enter a different function that shares the same state?
  • Does the contract preserve balances, ownership, or accounting after the attack?
  • Does the defense fail closed, or does it partially update state before reverting?

A malicious harness is useful because it can:

  • receive ETH or tokens and immediately call back into the target contract
  • control the timing of the re-entry
  • record whether the second call succeeded
  • assert that the target contract’s state remains consistent

The attack surface you should test

Reentrancy is not limited to call{value: ...}. Any external interaction can be a callback point:

  • ETH transfers using call
  • ERC777 token hooks
  • ERC1155 receiver callbacks
  • safeTransferFrom on NFT contracts
  • external oracle or adapter calls
  • low-level calls to untrusted contracts

The most common vulnerable pattern is:

  1. contract reads user balance
  2. contract sends ETH or calls external code
  3. contract updates balance afterward

If the external code re-enters before step 3, the same balance may be spent twice.


A minimal vulnerable example

The following contract is intentionally unsafe. It sends ETH before reducing the user’s balance.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    mapping(address => uint256) public balances;

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

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");

        // Vulnerable: external call before state update
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        balances[msg.sender] -= amount;
    }
}

A test that only checks one withdrawal would miss the bug. A reentrancy harness can exploit the gap between the transfer and the balance update.


Building a malicious test harness

The harness is a small contract that deposits into the target, then re-enters during the ETH receive callback.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IVault {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
    function balances(address user) external view returns (uint256);
}

contract ReentrancyAttacker {
    IVault public immutable vault;
    uint256 public reentryCount;
    uint256 public maxReentries;
    uint256 public attackAmount;

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

    function attack(uint256 _maxReentries) external payable {
        maxReentries = _maxReentries;
        attackAmount = msg.value;

        vault.deposit{value: msg.value}();
        vault.withdraw(msg.value);
    }

    receive() external payable {
        if (reentryCount < maxReentries) {
            reentryCount++;
            vault.withdraw(attackAmount);
        }
    }
}

How it works

  • attack() deposits ETH into the vault and starts the first withdrawal.
  • The vault sends ETH back to the attacker.
  • The attacker’s receive() function fires.
  • The attacker re-enters withdraw() before the original call finishes.

This pattern is simple, but it is enough to validate whether your defense actually blocks recursive entry.


What to assert in the test

A reentrancy test should not only check that the attack reverts. Sometimes the correct defense is to allow the first call but prevent the second. Other times the contract should revert the entire transaction.

Useful assertions include:

  • the attacker cannot withdraw more than their deposit
  • the vault’s ETH balance remains correct
  • the attacker’s recorded balance is zero or reduced as expected
  • reentry counter stays at zero when a guard is active
  • no partial state changes survive a revert

If your contract emits events, you can also assert that only one withdrawal event is emitted. But the core of the test should be state integrity, not event count.


Testing the vulnerable contract

The exact test framework is less important than the structure. The key is to deploy the vault, fund the attacker, and inspect balances after the attack attempt.

A typical test flow is:

  1. deploy the vault
  2. deploy the attacker with the vault address
  3. send ETH to the attacker via attack()
  4. verify whether the attack drained more than expected
  5. compare final balances against the initial accounting

If the vault is vulnerable, the attacker may receive multiple withdrawals from a single deposit. If the vault is safe, the second call should fail or the entire transaction should revert.


Defending with a reentrancy guard

The most common mitigation is a mutex-style guard. OpenZeppelin’s ReentrancyGuard is widely used, but the important part is understanding what it protects and what it does not.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

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

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient balance");

        balances[msg.sender] -= amount;

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

Why this version is safer

  • the balance is reduced before the external call
  • nonReentrant blocks recursive entry into the same function
  • if the external call fails, the whole transaction reverts and state is restored

This is the preferred pattern for value transfers in most contracts.


Cross-function reentrancy: the bug many tests miss

A contract can be safe against direct re-entry into one function but still vulnerable if another function shares the same state. For example:

  • withdraw() is guarded
  • claimReward() is not guarded
  • both functions read and write the same balance or reward pool

An attacker may re-enter through a different path and exploit inconsistent intermediate state.

Example scenario

Suppose withdraw() sends ETH and then updates a reward accumulator. During the callback, the attacker calls claimReward(), which reads the old accumulator value. The contract is still vulnerable even though withdraw() itself has a guard.

Test strategy

Design a harness that can call multiple public functions from receive() and try alternate entry points. Your test should verify:

  • shared state is updated atomically
  • all externally callable state-mutating functions are covered
  • the guard is applied consistently where needed

Comparing common defense patterns

Defense patternStrengthsWeaknessesBest use case
checks-effects-interactionsSimple, cheap, easy to auditEasy to implement incorrectlyBalance withdrawals, claims, refunds
ReentrancyGuardStrong protection against recursive entryDoes not fix bad accounting logicPublic functions with external calls
Pull paymentsAvoids direct external transfer in critical pathRequires users to claim funds laterEscrow, payouts, refunds
Internal accounting onlyMinimizes external interactionMore complex architectureHigh-value protocols, aggregators

A strong test suite should verify the chosen pattern, not just the symptom.


Practical best practices for attack harnesses

Keep the harness deterministic

Avoid randomness in the attacker contract. You want the same sequence of calls every time so failures are reproducible.

Parameterize the number of re-entries

A single re-entry may not expose all bugs. Test with:

  • zero re-entries, to confirm normal behavior
  • one re-entry, to catch the classic case
  • multiple re-entries, to stress recursive paths

Test both revert and non-revert outcomes

Some defenses intentionally revert the entire transaction. Others stop the second call but allow the first to succeed. Your assertions should match the intended design.

Use realistic callback surfaces

If your contract interacts with tokens or receiver interfaces, test the actual callback mechanism rather than a simplified ETH-only attack. For example, ERC777 and ERC1155 hooks can trigger reentrancy in production even when ETH transfers are absent.

Verify post-attack invariants

After the test, check:

  • contract ETH balance
  • attacker ETH balance
  • internal accounting mappings
  • total supply or pool totals
  • any pending claim state

A reentrancy test is only complete when the final state is mathematically consistent.


A compact checklist for reentrancy test coverage

  • Test every external call site
  • Test every public function that mutates shared state
  • Test direct and cross-function reentrancy
  • Test with both guarded and unguarded versions of the contract
  • Assert final balances, not just success or failure
  • Confirm that failed attacks do not leave partial state behind

Common mistakes when writing these tests

Assuming transfer() is always safe

Historically, transfer() limited gas and reduced callback risk, but relying on gas stipends is not a robust security strategy. Modern Solidity code should not treat transfer() as a reentrancy defense.

Guarding only one function

If multiple functions touch the same accounting, they may all need protection or a redesigned flow.

Forgetting token callbacks

A contract that never sends ETH can still be reentered through token standards with receiver hooks.

Testing only the happy path

A defense that works under normal conditions may still fail when the external call reverts, consumes gas, or triggers nested calls.


When to prefer a pull-based design

If your contract pays users, a pull-based architecture is often easier to secure and test than push-based transfers.

Instead of sending funds immediately:

  1. record the amount owed
  2. let the user withdraw later
  3. update state before any external transfer

This reduces the number of dangerous external calls in critical paths and makes reentrancy tests simpler. Your harness then focuses on the withdrawal function alone.


Conclusion

Reentrancy testing is most effective when you model the attacker explicitly. A dedicated harness gives you precise control over callback timing, re-entry depth, and alternate entry points. Combined with strong assertions on balances and shared state, it can expose vulnerabilities that ordinary unit tests will miss.

The best defense is still architectural: update state before external calls, minimize callback surfaces, and use guards where appropriate. But even a well-designed contract should be tested with a malicious harness to prove that the defense holds under real attack conditions.

Learn more with useful resources