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);
}
}
Then in your test:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy Protection", function () {
let victim, attacker, owner;
beforeEach(async function () {
[owner] = await ethers.getSigners();
// Deploy vulnerable contract
const Vulnerable = await ethers.getContractFactory("Vulnerable");
victim = await Vulnerable.deploy();
await victim.deployed();
// Fund it
await owner.sendTransaction({
to: victim.address,
value: ethers.utils.parseEther("10")
});
});
it("should prevent reentrancy", async function () {
const attacker = await (await ethers.getContractFactory("Attacker")).deploy(victim.address);
await attacker.deployed();
const initialBalance = await ethers.provider.getBalance(victim.address);
await attacker.attack({ value: ethers.utils.parseEther("1") });
const finalBalance = await ethers.provider.getBalance(victim.address);
// Victim should not be drained due to reentrancy protection
expect(finalBalance).to.be.gt(0);
});
});
For fuzzing, tools like Echidna can generate sequences of calls to try to reenter.
2. Access Control
Access control ensures that only authorized users can execute sensitive functions, such as minting tokens, pausing the contract, or upgrading logic. Improper access control can lead to unauthorized minting, fund drainage, or contract destruction.
Simple Ownership (Ownable)
OpenZeppelin's Ownable contract provides a basic access control mechanism with a single owner.
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyContract is Ownable {
function mint(address to, uint256 amount) public onlyOwner {
// only owner can mint
}
}
Limitations:
Only one owner (or one account).
If the owner's private key is compromised, the contract is compromised.
Cannot grant granular permissions (e.g., some users can mint, others can pause).
Role-Based Access Control (AccessControl)
OpenZeppelin's AccessControl provides a flexible, multi-role system based on the standard from Ethereum (EIP-5982). You define roles as bytes32 constants and grant them to addresses.
import "@openzeppelin/contracts/access/AccessControl.sol";
contract MyProtocol is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender); // admin can grant/revoke roles
_grantRole(MINTER_ROLE, msg.sender);
_grantRole(PAUSER_ROLE, msg.sender);
}
function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
// mint logic
}
function pause() public onlyRole(PAUSER_ROLE) {
// pause logic
}
}
Key features:
-
Role hierarchy: The DEFAULT_ADMIN_ROLE can grant and revoke any role, including itself.
-
Granular permissions: Assign different roles to different addresses.
-
Renouncing roles: Use renounceRole to remove a role from yourself.
-
Inspect roles: hasRole, getRoleAdmin, grantRole, revokeRole.
Best Practices
Principle of Least Privilege
Only give the minimum necessary permissions to each address. For example, a bot that only mints tokens should not have the DEFAULT_ADMIN_ROLE.
Use a Multi-Sig for Admin Roles
For production, the DEFAULT_ADMIN_ROLE should be held by a multi-signature wallet (e.g., Gnosis Safe) or a DAO to prevent a single point of failure.
constructor(address multiSig) {
_grantRole(DEFAULT_ADMIN_ROLE, multiSig);
}
Timelocks for Sensitive Operations
Combine access control with a timelock (e.g., OpenZeppelin TimelockController) so that role changes or upgrades have a delay, giving users time to react.
Emergency Pause
Consider having a dedicated EMERGENCY_PAUSE_ROLE that can pause the contract without delay, but only a few trusted parties hold it.