DeFi protocols handle valuable assets. A single vulnerability can lead to catastrophic losses. Always follow these patterns and get professional audits.
Security Patterns for DeFi dApps
1. Reentrancy Protection
Reentrancy is one of the most infamous vulnerabilities in smart contract development. It occurs when an external call is made to an untrusted contract before the calling contract has updated its own state, allowing the called contract to recursively call back into the original function and manipulate the contract’s state in unexpected ways.
The Classic Reentrancy Attack (The DAO Hack)
Consider a simple withdrawal function:
// VULNERABLE
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
If msg.sender is a malicious contract, its receive() function can call withdraw() again before balances[msg.sender] is updated. This leads to draining the contract.
Attack sequence:
Attacker calls withdraw(1 ether).
Contract sends 1 ether to attacker, triggering attacker's receive().
Attacker's receive() calls withdraw(1 ether) again.
Contract still has the attacker's old balance (not yet subtracted), so it sends another 1 ether.
This repeats until the contract is empty.
The Solution: Checks-Effects-Interactions Pattern
Always follow this order:
Checks: Validate conditions (require statements).
Effects: Update the contract's state (e.g., subtract balance).
Interactions: Make external calls.
Fixed version:
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // EFFECTS first
(bool success, ) = msg.sender.call{value: amount}(""); // INTERACTION last
require(success, "Transfer failed");
}
Now, if the attacker tries to re-enter, their balance is already reduced, so the second withdraw will fail the require.
Using OpenZeppelin's ReentrancyGuard
For extra safety, especially when you have multiple functions that could be re-entered, use ReentrancyGuard. It provides a nonReentrant modifier that prevents a function from being called while it is already executing.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureContract is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
The modifier uses a uint256 status variable (0 = unlocked, 1 = locked) and reverts if locked. It is gas-efficient and prevents reentrancy across all nonReentrant functions.
Advanced Considerations
Cross-Function Reentrancy
Reentrancy can also happen when two different functions share state and one calls the other. ReentrancyGuard prevents any nonReentrant function from being called while another is executing, covering cross-function cases.
Read-Only Reentrancy
Even view functions can be dangerous if they rely on transient state that changes during reentrancy. For example, a contract that calculates a user's balance based on some dynamic factor might return inconsistent values during a reentrant call. Always ensure that your state is consistent before external calls.
Dangers of transfer and send
Ethereum's transfer and send forward only 2300 gas, which is often enough to prevent reentrancy because the attacker's contract cannot perform complex logic. However, this is not a reliable security measure because:
Gas costs may change (e.g., with hard forks).
Some contracts (e.g., multi-sig) may require more gas.
It gives a false sense of security.
Best practice: Use call with reentrancy guards and proper effects-first ordering.
Reentrancy via Token Hooks (ERC-777)
ERC-777 tokens have tokensReceived hooks that are called when tokens are sent. If you integrate such tokens, an attacker can re-enter your contract during the token transfer. Always use nonReentrant on functions that handle external tokens with hooks, and be aware that even safeTransfer from OpenZeppelin can trigger hooks.
Testing for Reentrancy
Test Setup
First, ensure you have the necessary testing dependencies:
npm install --save-dev hardhat @nomiclabs/hardhat-ethers ethers chai
Write tests that simulate reentrant attacks. For example, using a malicious contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Vulnerable {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// VULNERABLE
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
}
contract Attacker {
Vulnerable public victim;
uint256 public attackCount;
constructor(address _victim) {
victim = Vulnerable(_victim);
}
receive() external payable {
if (address(victim).balance >= 1 ether && attackCount < 5) {
attackCount++;
victim.withdraw(1 ether);
}
}
function attack() external payable {
require(msg.value >= 1 ether);
victim.deposit{value: 1 ether}(); // deposit first
victim.withdraw(1 ether);
}
}