Understanding Reentrancy

Reentrancy occurs when an external contract or address calls back into the calling contract before the initial function call completes. This can lead to unexpected state changes, such as multiple withdrawals before balances are updated. A common scenario is a contract calling an external function (e.g., transfer or call) that, in turn, calls back into the contract, potentially triggering the same function again.

Consider the vulnerable example below:

// @version ^0.8.0
pragma solidity ^0.8.0;

contract VulnerableContract {
    mapping(address => uint) public balances;

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

    function withdraw() public {
        uint amount = balances[msg.sender];
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");
        balances[msg.sender] = 0;
    }
}

In the withdraw function, the Ether is sent before the balance is set to zero. An attacker can exploit this by using a malicious contract that re-enters the withdraw function during the call, draining the contract's funds.


Best Practices and Mitigation Strategies

To prevent reentrancy, developers should follow these key strategies:

  1. Checks-Effects-Interactions Pattern: Perform all state updates before making external calls.
  2. Use Reentrancy Guards: Employ the ReentrancyGuard contract from OpenZeppelin.
  3. Avoid call for Simple Transfers: Use transfer or send for known safe operations.

Let’s explore each strategy with code examples.

1. Checks-Effects-Interactions Pattern

This pattern ensures that state is updated before any external interactions occur, preventing malicious callbacks from affecting the state.

// @version ^0.8.0
pragma solidity ^0.8.0;

contract SecureContract {
    mapping(address => uint) public balances;

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

    function withdraw() public {
        uint amount = balances[msg.sender];
        balances[msg.sender] = 0; // Effect
        (bool sent, ) = msg.sender.call{value: amount}(""); // Interaction
        require(sent, "Failed to send Ether");
    }
}

In this version, the balance is reset before the external call. This prevents an attacker from re-entering withdraw to steal additional funds.

2. Reentrancy Guards

OpenZeppelin’s ReentrancyGuard provides a simple and effective way to prevent reentrancy by using a non-reentrant modifier.

// @version ^0.8.0
pragma solidity ^0.8.0;

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

contract ReentrantProtected is ReentrancyGuard {
    mapping(address => uint) public balances;

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

    function withdraw() public nonReentrant {
        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }
}

The nonReentrant modifier ensures that the withdraw function cannot be re-entered during execution.

3. Use transfer or send for Known-Safe Transfers

While call is flexible, it can trigger arbitrary functions in the receiving contract. For simple Ether transfers, use transfer or send which do not allow fallback function execution.

// Using transfer
(bool sent, ) = msg.sender.transfer(amount);
require(sent, "Transfer failed");

These functions are gas-limited and safer for known recipient addresses.


Comparison of Reentrancy Protection Techniques

MethodDescriptionProsCons
Checks-Effects-InteractionsUpdate state before external callsSimple, no dependenciesManual enforcement
ReentrancyGuardOpenZeppelin guard using a mutexRobust, widely adoptedSlight overhead, external dependency
transfer/sendSafe for simple transfersSecure for known addressesLess flexible than call

Real-World Example: Protecting a P2P Lending Contract

Let’s apply these strategies to a simple lending contract.

// @version ^0.8.0
pragma solidity ^0.8.0;

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

contract Lending is ReentrancyGuard {
    mapping(address => uint) public deposits;
    uint public interestRate = 5; // 5%

    function deposit() public payable nonReentrant {
        deposits[msg.sender] += msg.value;
    }

    function withdraw() public nonReentrant {
        uint amount = deposits[msg.sender];
        require(amount > 0, "No deposit to withdraw");

        uint interest = (amount * interestRate) / 100;
        uint total = amount + interest;

        deposits[msg.sender] = 0;
        (bool sent, ) = msg.sender.call{value: total}("");
        require(sent, "Withdrawal failed");
    }
}

This contract uses nonReentrant to prevent recursive calls, applies the Checks-Effects-Interactions pattern, and uses call only after updating the state.


Learn more with useful resources