DEFI RISK AND SMART CONTRACT SECURITY

Building Safe Smart Contracts Avoiding Reentrancy Traps

8 min read
#Ethereum #Smart Contracts #Security Audits #security #Reentrancy
Building Safe Smart Contracts Avoiding Reentrancy Traps

Building Safe Smart Contracts: Avoiding Reentrancy Traps

Reentrancy is one of the most notorious pitfalls in Ethereum smart contract development. For a deeper dive into how these attacks unfold, see Reentrancy Attacks Unveiled Secure Smart Contract Design in DeFi. It allows an external call to a contract to invoke back into the calling contract before the first call has finished, potentially corrupting state or draining funds. In this guide we dissect the mechanics of reentrancy, illustrate classic attack vectors, and present a comprehensive set of defensive patterns. The goal is to equip developers with the knowledge and tools needed to design contracts that are robust against this class of vulnerabilities.


The Anatomy of a Reentrancy Attack

A reentrancy attack occurs when a contract calls an external address (usually another contract) that, during its execution, makes a recursive call back to the original contract. If the original contract updates its state after the external call, the attacker can exploit the window of unupdated state to execute logic multiple times.

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

    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send");
        balances[msg.sender] -= amount; // state update after external call
    }
}

In this example, the attacker deploys a contract that, when receiving Ether, immediately calls withdraw on Victim. Because the balance is only subtracted after the external call, the attacker can repeatedly trigger withdraw, draining the victim's funds.


Why Reentrancy Matters in DeFi

DeFi protocols are high-value targets. Even small design oversights can lead to multi-million‑dollar losses. Reentrancy has been responsible for several high-profile incidents: From Vulnerability to Resilience Mastering Reentrancy Defense in Smart Contracts.

Protocol Loss Root Cause
DAO $150M Recursive calls via a multi-signature wallet
Parity Multisig $170M Shared library that could be replaced
Aave 0.4M Misordered state updates in a flash loan contract
Compound 0.6M Reentrancy during collateral withdrawal

These cases underscore the need for disciplined coding practices and rigorous security reviews.


Common Patterns That Enable Reentrancy

  1. External Calls Before State Changes – a pattern that can be mitigated by following the guidelines in How to Stop Reentrancy Loops Before They Strike.
  2. Library Functions with delegatecall
  3. Transfer of Ether via call or transfer
  4. Unprotected Callback Functions
  5. Nested Contract Interaction

Defensive Programming Patterns

1. Checks-Effects-Interactions

This classic pattern requires that checks on input and conditions are performed first, then effects on the contract’s own state, and only finally interactions with external contracts. For more detailed guidance, refer to Strengthening DeFi Contracts with Reentrancy Safeguards.

function safeWithdraw(uint256 amount) public {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;              // Effect
    (bool sent, ) = msg.sender.call{value: amount}(""); // Interaction
    require(sent, "Transfer failed");
}

By moving the state update before the external call, the contract’s invariant holds during the external execution.

2. Pull over Push

Instead of sending funds directly in a transaction, the contract records an owed amount and allows users to pull funds themselves. See How to Stop Reentrancy Loops Before They Strike for best‑practice examples.

function requestWithdrawal(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;
    withdrawals[msg.sender] += amount;
}

function withdraw() external {
    uint256 amount = withdrawals[msg.sender];
    withdrawals[msg.sender] = 0;
    (bool sent, ) = msg.sender.call{value: amount}("");
    require(sent, "Transfer failed");
}

Because the external call occurs in a separate transaction, there is no opportunity for the contract to reenter during the withdrawal.

3. Reentrancy Guard

A simple mutex pattern prevents reentrancy by ensuring a function cannot be entered while it is already running. The following modifier is a common implementation; for a deeper dive, see Defensive Programming in DeFi Guarding Against Reentrancy.

bool private locked;

modifier noReentrancy() {
    require(!locked, "Reentrancy detected");
    locked = true;
    _;
    locked = false;
}

Apply this modifier to functions that perform external calls or handle critical state changes. Note that using require(!locked) and setting locked back to false after the function body ensures that reentrancy is blocked even if an exception occurs.

4. Using SafeMath / SafeERC20

Arithmetic overflows can compound reentrancy problems. Libraries like OpenZeppelin’s SafeMath and SafeERC20 add overflow checks and standard ERC20 safety patterns. Though arithmetic overflow is not the same as reentrancy, it reduces overall attack surface.

5. Library Isolation

When using delegatecall to external libraries, deploy the library separately and verify its code immutably. Prefer static libraries (compiled contracts) over upgradeable ones unless you have a robust upgrade mechanism with access control.

6. Limit Gas Forwarding

When calling external contracts, avoid forwarding all available gas. Use call{gas: gasLimit, value: amount}(""). This limits the attacker’s ability to perform heavy reentrancy logic.


Storage Layout and Ordering

Reentrancy can be mitigated by carefully ordering state variables to reduce the chance of accidental overwrites during a callback. Group frequently modified variables together and place less critical ones farther apart. Use storage and memory distinctions to prevent accidental external writes from affecting internal state.

struct UserInfo {
    uint256 balance;
    uint256 stake;
    uint256 rewardDebt;
}
mapping(address => UserInfo) public users;

Because each user has its own struct, a callback cannot modify another user’s data unless it knows the storage slot.


Upgradability and Reentrancy

Upgradeable contracts (e.g., using a proxy pattern) introduce new risks. When the logic contract changes, the storage layout must remain unchanged; otherwise, reentrancy could exploit gaps or overlapping slots. Always audit new logic contracts against the storage layout and run compatibility tests.


Testing Reentrancy Scenarios

Unit Tests

  • Mock Attack Contract: Write a contract that calls a vulnerable function and immediately reenters. Assert that the state remains consistent.
  • Gas Limits: Test that reentrancy guards block reentrancy even when gas limits are high.
const Attacker = await ethers.getContractFactory("Attacker");
const vulnerable = await ethers.getContractFactory("Victim");
const attacker = await Attacker.deploy(vulnerable.address);
await vulnerable.connect(attacker).withdraw(ethers.utils.parseEther("1"));

Integration Tests

  • Deploy the full system (e.g., a lending pool with a flash loan feature) and attempt a reentrancy attack on the borrow/repay cycle.
  • Use testnets with higher gas limits to simulate worst‑case scenarios.

Formal Verification

Tools like Solidity Formal, K Framework, and MythX can prove that certain patterns are safe. For a practical guide, see Reentrancy Attack Prevention Practical Techniques for Smart Contract Security. Formal verification is not a silver bullet but provides a higher assurance level for critical components.


Audit Checklist for Reentrancy

Item Check
External Calls Are all external calls after state changes?
Library Calls Is delegatecall used? If so, is the library immutable?
Reentrancy Guards Are critical functions protected by a mutex?
Pull/Push Does the contract allow users to withdraw via a pull pattern?
Gas Forwarding Is gas forwarding limited?
Storage Layout Are variables correctly ordered and non‑overlapping?
Upgrade Path Does storage remain compatible after upgrades?
Unit Tests Do tests cover reentrancy with a malicious contract?
Formal Analysis Has the contract been formally verified?

An auditor should walk through each of these points, marking compliance or flagging issues.


Real-World Case Studies

The DAO Hack

  • What happened? A malicious user used a multi‑signature wallet to submit a proposal that recursively withdrew funds before the wallet updated its balances.
  • Lesson: Avoid external calls before updating state; check that all privileged functions cannot be reentered.

Parity Multisig Failure

  • What happened? A library was replaced with an empty contract, allowing attackers to gain ownership of multisig wallets.
  • Lesson: Delegatecall to external libraries is dangerous; lock upgrade paths or use immutable libraries.

Aave Flash Loan Reentrancy

  • What happened? A borrower's contract performed reentrant calls during a flash loan, manipulating the lending pool’s state.
  • Lesson: Even short‑lived operations (flash loans) must guard against reentrancy; ensure that pool state changes precede external interactions.

Putting It All Together: A Secure Withdrawal Function

Below is a complete example that integrates the discussed patterns:

pragma solidity ^0.8.17;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SecureBank {
    using SafeERC20 for IERC20;

    struct Account {
        uint256 balance;
        uint256 stake;
    }

    mapping(address => Account) public accounts;
    mapping(address => uint256) public pendingWithdrawals;

    bool private locked;

    modifier noReentrancy() {
        require(!locked, "Reentrancy detected");
        locked = true;
        _;
        locked = false;
    }

    function deposit(IERC20 token, uint256 amount) external {
        require(amount > 0, "Zero deposit");
        token.safeTransferFrom(msg.sender, address(this), amount);
        accounts[msg.sender].balance += amount;
    }

    function requestWithdrawal(IERC20 token, uint256 amount) external {
        Account storage user = accounts[msg.sender];
        require(user.balance >= amount, "Insufficient balance");
        user.balance -= amount;
        pendingWithdrawals[msg.sender] += amount;
    }

    function withdraw(IERC20 token) external noReentrancy {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "No pending withdrawal");
        pendingWithdrawals[msg.sender] = 0;
        token.safeTransfer(msg.sender, amount);
    }
}

Key points:

  • noReentrancy protects the withdraw function.
  • The requestWithdrawal function splits the logic into two transactions, preventing a single reentrancy window.
  • State updates occur before any external calls.

Conclusion

Reentrancy remains a central concern in smart contract security. By embracing proven patterns—checks-effects-interactions, pull over push, reentrancy guards, careful storage layout, and rigorous testing—you can dramatically reduce the attack surface of your DeFi protocols. Combine these techniques with formal verification and thorough audits to build contracts that stand up to scrutiny and protect the funds of millions of users.

The ecosystem is evolving rapidly, and new tools and best practices emerge continuously. Stay informed, review your code frequently, and always assume that the next vulnerability could arise from an unexpected interaction. With diligence and disciplined engineering, you can build smart contracts that are not only functional but resilient against reentrancy and other sophisticated attack vectors.

JoshCryptoNomad
Written by

JoshCryptoNomad

CryptoNomad is a pseudonymous researcher traveling across blockchains and protocols. He uncovers the stories behind DeFi innovation, exploring cross-chain ecosystems, emerging DAOs, and the philosophical side of decentralized finance.

Contents