Advanced

Protocol Security & Auditing — Project — Harden a simple vault

Learn how pros audit.

Developer Advanced
7/8 — Protocol Security & Auditing — Project — Harden a simple vault

What to writeImplement a minimal ERC20 vault with caps, pause, CEI, nonReentrant, and invariant-friendly accounting.

  1. State
    Token, owner, cap, pause flag, totalDeposits, and per-user deposits.
  2. Deposit/withdraw
    CEI + nonReentrant; enforce caps; emit events.
  3. Admin
    Pause and update cap; rotate owner.
  4. Invariants/tests
    Add an invariant for asset accounting.

State and constructor with domain checks.

contract SafeVault is ReentrancyGuard {
    IERC20 public immutable token;
    address public owner;
    uint256 public cap;
    bool public paused;
    uint256 public totalDeposits;
    mapping(address => uint256) public deposits;

    event Deposit(address indexed user, uint256 amount, uint256 newBalance);
    event Withdraw(address indexed user, uint256 amount, uint256 newBalance);
    event Paused(bool status);
    event CapUpdated(uint256 cap);
    event OwnerUpdated(address indexed oldOwner, address indexed newOwner);

    modifier onlyOwner() { require(msg.sender == owner, "owner"); _; }
    modifier notPaused() { require(!paused, "paused"); _; }

    constructor(address _token, uint256 _cap) {
        require(_token != address(0), "token");
        token = IERC20(_token);
        owner = msg.sender;
        cap = _cap;
    }

CEI + nonReentrant deposit/withdraw with caps and events.

function deposit(uint256 amount) external notPaused nonReentrant {
    require(amount > 0, "amount");
    require(totalDeposits + amount <= cap, "cap");
    deposits[msg.sender] += amount;
    totalDeposits += amount;
    token.transferFrom(msg.sender, address(this), amount);
    emit Deposit(msg.sender, amount, deposits[msg.sender]);
}

function withdraw(uint256 amount) external notPaused nonReentrant {
    uint256 bal = deposits[msg.sender];
    require(amount <= bal, "exceeds");
    deposits[msg.sender] = bal - amount;
    totalDeposits -= amount;
    token.transfer(msg.sender, amount);
    emit Withdraw(msg.sender, amount, deposits[msg.sender]);
}

Simple admin controls with events.

function setCap(uint256 _cap) external onlyOwner {
    cap = _cap;
    emit CapUpdated(_cap);
}

function setPaused(bool p) external onlyOwner {
    paused = p;
    emit Paused(p);
}

function setOwner(address newOwner) external onlyOwner {
    require(newOwner != address(0), "zero");
    emit OwnerUpdated(owner, newOwner);
    owner = newOwner;
}

Add an invariant test to lock in accounting.

function invariant_assetsMatchDeposits() public {
    assertEq(token.balanceOf(address(vault)), vault.totalDeposits());
}

Full reference vault for copy/paste:

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeVault is ReentrancyGuard {
    IERC20 public immutable token;
    address public owner;
    uint256 public cap;
    bool public paused;
    uint256 public totalDeposits;
    mapping(address => uint256) public deposits;

    event Deposit(address indexed user, uint256 amount, uint256 newBalance);
    event Withdraw(address indexed user, uint256 amount, uint256 newBalance);
    event Paused(bool status);
    event CapUpdated(uint256 cap);
    event OwnerUpdated(address indexed oldOwner, address indexed newOwner);

    modifier onlyOwner() { require(msg.sender == owner, "owner"); _; }
    modifier notPaused() { require(!paused, "paused"); _; }

    constructor(address _token, uint256 _cap) {
        require(_token != address(0), "token");
        token = IERC20(_token);
        owner = msg.sender;
        cap = _cap;
    }

    function deposit(uint256 amount) external notPaused nonReentrant {
        require(amount > 0, "amount");
        require(totalDeposits + amount <= cap, "cap");
        deposits[msg.sender] += amount;
        totalDeposits += amount;
        token.transferFrom(msg.sender, address(this), amount);
        emit Deposit(msg.sender, amount, deposits[msg.sender]);
    }

    function withdraw(uint256 amount) external notPaused nonReentrant {
        uint256 bal = deposits[msg.sender];
        require(amount <= bal, "exceeds");
        deposits[msg.sender] = bal - amount;
        totalDeposits -= amount;
        token.transfer(msg.sender, amount);
        emit Withdraw(msg.sender, amount, deposits[msg.sender]);
    }

    function setCap(uint256 _cap) external onlyOwner {
        cap = _cap;
        emit CapUpdated(_cap);
    }

    function setPaused(bool p) external onlyOwner {
        paused = p;
        emit Paused(p);
    }

    function setOwner(address newOwner) external onlyOwner {
        require(newOwner != address(0), "zero");
        emit OwnerUpdated(owner, newOwner);
        owner = newOwner;
    }
}

Try itTests: reentrancy blocked; cap enforced; paused blocks deposit/withdraw; accounting invariant passes; events emitted on admin changes.

← Previous section
Next section →