पाठ 16 / 25

Reentrancy and Checks-Effects-Interactions

Recognise reentrancy and prevent it with CEI and guards.

The classic smart contract bug

Reentrancy happens when a contract makes an external call (for example, sending ether) before updating its own state, and the recipient's code calls back into the original function while the old state is still in place. The 2016 DAO hack drained millions of ether this way: the attacker's receive function called withdraw again and again before the balance was set to zero. The core defence is the checks-effects-interactions (CEI) pattern: first check conditions, then apply effects (update balances and state), and only then perform interactions (external calls and transfers). Add ReentrancyGuard (nonReentrant modifier) on functions that make external calls, especially when several functions share state, because cross-function and read-only reentrancy (a view function returning stale state during a callback, which another protocol trusts) are subtler variants. Token standards with callbacks (ERC-777, ERC-721 safeTransferFrom, ERC-1155) can also trigger reentrancy. Treat every external call as handing control to an attacker.

Checks, effects, interactions

Update state before calling out, so a callback sees the new state.

Three numbered steps in a row: a checklist, a ledger being updated, then an arrow leaving the contract towards an external contract.
Figure 6.1 — The CEI ordering.

A vulnerable vault and the fixed version

The order of state update and external call is the whole difference.

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

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

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

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

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        (bool ok, ) = msg.sender.call{value: amount}("");   // INTERACTION first: attacker re-enters here
        require(ok, "send failed");
        balances[msg.sender] = 0;                            // EFFECT too late
    }
}

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

    error InsufficientBalance(uint256 available, uint256 requested);
    error TransferFailed();

    event Deposited(address indexed account, uint256 amount);
    event Withdrawn(address indexed account, uint256 amount);

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

    function withdraw(uint256 amount) external nonReentrant {
        uint256 bal = balances[msg.sender];                          // CHECKS
        if (amount > bal) revert InsufficientBalance(bal, amount);
        balances[msg.sender] = bal - amount;                         // EFFECTS
        (bool ok, ) = msg.sender.call{value: amount}("");           // INTERACTIONS
        if (!ok) revert TransferFailed();
        emit Withdrawn(msg.sender, amount);
    }
}

CEI first, guard second

nonReentrant is a safety net, not a substitute for correct ordering. Apply CEI everywhere, and add the guard on functions that make external calls or share state with ones that do.

त्वरित जाँच: In the checks-effects-interactions pattern, when should external calls happen?

  • First
  • Before checking inputs
  • After state has been updated
  • In the constructor only
Answer

After state has been updated — Interactions come last so that re-entrant calls see updated state.