High severity

Reentrancy (external call before state update)

A function makes an external call — sending ETH, or calling into another contract or a token that can run code on transfer — before it finishes updating its own state. The called contract can call back into the same contract while that state is still stale, repeating the action before the first call returns. The classic result is a drained balance: a withdraw function pays out, the receiver re-enters withdraw, and the balance is only zeroed afterwards. The same shape also allows cross-function reentrancy (re-entering a different function that shares the stale state) and read-only reentrancy (a view function returns a mid-update value that another protocol trusts).

Vulnerable pattern

A minimal contract with the bug
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

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

    // The ETH is sent before the balance is zeroed. The receiver's fallback
    // can call withdraw() again while balance[msg.sender] still shows the full
    // amount, draining the contract one re-entrant call at a time.
    function withdraw() external {
        uint256 amount = balance[msg.sender];
        require(amount > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
        balance[msg.sender] = 0;
    }
}

The fix

Follow checks-effects-interactions: update state before the external call, so a re-entrant call sees the new state. Add a nonReentrant guard as defence in depth, and the same guard (or care with shared state) on every function that touches the same balances, so cross-function and read-only reentrancy are covered too.

The same contract, corrected
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract Vault is ReentrancyGuard {
    mapping(address => uint256) public balance;

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

    function withdraw() external nonReentrant {
        uint256 amount = balance[msg.sender];
        require(amount > 0, "nothing to withdraw");
        balance[msg.sender] = 0;                 // effect before interaction
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

Scan your contract for this

pactlint flags reentrancy-eth and reentrancy-no-eth and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base or Polygon with x402, no signup; the first few scans each day are free.

How to scan →

Reentrancy is detected by Slither's stock reentrancy-eth and reentrancy-no-eth detectors. pactlint adds its own triage on top: a finding is downgraded when the function, or every public entry point reaching it, holds a reentrancy lock — including locks Slither misses, like a lock modifier or a manual slot0.unlocked flag — and the benign and event-only variants are dropped as noise, so what is left is the exploitable surface worth checking. Run it in CI with the pactlint GitHub Action. Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.