Project 1: ERC20 Token with Vesting and Governance

Introduction

In this project, we'll create a comprehensive ERC20 token implementation with advanced features including token vesting schedules and on-chain governance. This project will allow you to apply many of the concepts you've learned throughout the course, from basic Solidity syntax to advanced patterns and security considerations.

By the end of this project, you'll have built a production-ready token contract that could be used for a real-world project, such as a DAO, DeFi protocol, or tokenized application.

Project Overview

We'll build the following components:

  1. ERC20 Token: A standard-compliant token with additional features
  2. Vesting Contract: A mechanism to lock tokens and release them according to a schedule
  3. Governance System: An on-chain voting system for token holders to make decisions

This project will integrate several important concepts:

Prerequisites

Before starting this project, you should have:

Project Setup

1. Initialize Your Project

Let's start by setting up a new Hardhat project:

mkdir erc20-governance-token
cd erc20-governance-token
npm init -y
npm install --save-dev hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers @openzeppelin/contracts
npx hardhat

Select "Create a JavaScript project" when prompted.

2. Project Structure

Your project should have the following structure:

erc20-governance-token/
├── contracts/
│   ├── GovernanceToken.sol
│   ├── TokenVesting.sol
│   ├── Governance.sol
│   └── interfaces/
│       ├── IGovernanceToken.sol
│       └── ITokenVesting.sol
├── scripts/
│   └── deploy.js
├── test/
│   ├── GovernanceToken.test.js
│   ├── TokenVesting.test.js
│   └── Governance.test.js
├── hardhat.config.js
└── package.json

Part 1: ERC20 Token Implementation

1.1 Token Interface

First, let's define the interface for our governance token. Create a file contracts/interfaces/IGovernanceToken.sol:

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IGovernanceToken is IERC20 {
    /**
     * @dev Returns the number of decimals used for token amounts.
     */
    function decimals() external view returns (uint8);
    
    /**
     * @dev Creates `amount` new tokens and assigns them to `account`.
     * Can only be called by accounts with the minter role.
     */
    function mint(address account, uint256 amount) external;
    
    /**
     * @dev Destroys `amount` tokens from `account`.
     * Can only be called by accounts with the burner role.
     */
    function burn(address account, uint256 amount) external;
    
    /**
     * @dev Returns the address of the governance contract.
     */
    function governanceAddress() external view returns (address);
    
    /**
     * @dev Sets the governance contract address.
     * Can only be called by the contract owner.
     */
    function setGovernanceAddress(address _governanceAddress) external;
    
    /**
     * @dev Pauses all token transfers.
     * Can only be called by accounts with the pauser role.
     */
    function pause() external;
    
    /**
     * @dev Unpauses all token transfers.
     * Can only be called by accounts with the pauser role.
     */
    function unpause() external;
    
    /**
     * @dev Checks if the token transfers are paused.
     */
    function paused() external view returns (bool);
    
    /**
     * @dev Grants a role to an account.
     * Can only be called by the contract owner.
     */
    function grantRole(bytes32 role, address account) external;
    
    /**
     * @dev Revokes a role from an account.
     * Can only be called by the contract owner.
     */
    function revokeRole(bytes32 role, address account) external;
}

1.2 Token Implementation

Now, let's implement our governance token. Create a file contracts/GovernanceToken.sol:

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Context.sol";
import "./interfaces/IGovernanceToken.sol";

/**
 * @title GovernanceToken
 * @dev ERC20 token with pausable, burnable, and role-based access control features.
 */
contract GovernanceToken is Context, ERC20, ERC20Burnable, ERC20Pausable, AccessControl, IGovernanceToken {
    // Create role identifiers
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
    
    // Token properties
    uint8 private _decimals;
    address private _governanceAddress;
    
    /**
     * @dev Constructor that gives the msg.sender all existing tokens.
     */
    constructor(
        string memory name,
        string memory symbol,
        uint8 tokenDecimals,
        uint256 initialSupply
    ) ERC20(name, symbol) {
        _decimals = tokenDecimals;
        
        // Grant roles to the deployer
        _setupRole(DEFAULT_ADMIN_ROLE, _msgSender());
        _setupRole(MINTER_ROLE, _msgSender());
        _setupRole(PAUSER_ROLE, _msgSender());
        _setupRole(BURNER_ROLE, _msgSender());
        
        // Mint initial supply to the deployer
        _mint(_msgSender(), initialSupply * (10 ** uint256(tokenDecimals)));
    }
    
    /**
     * @dev Returns the number of decimals used for token amounts.
     */
    function decimals() public view virtual override returns (uint8) {
        return _decimals;
    }
    
    /**
     * @dev Creates `amount` new tokens and assigns them to `account`.
     * Can only be called by accounts with the minter role.
     */
    function mint(address account, uint256 amount) public virtual override {
        require(hasRole(MINTER_ROLE, _msgSender()), "GovernanceToken: must have minter role to mint");
        _mint(account, amount);
    }
    
    /**
     * @dev Destroys `amount` tokens from `account`.
     * Can only be called by accounts with the burner role.
     */
    function burn(address account, uint256 amount) public virtual override {
        require(hasRole(BURNER_ROLE, _msgSender()), "GovernanceToken: must have burner role to burn");
        
        if (account == _msgSender()) {
            // If burning own tokens, use the inherited burn function
            _burn(_msgSender(), amount);
        } else {
            // If burning someone else's tokens, check allowance
            uint256 currentAllowance = allowance(account, _msgSender());
            require(currentAllowance >= amount, "GovernanceToken: burn amount exceeds allowance");
            
            // Decrease allowance and burn
            _approve(account, _msgSender(), currentAllowance - amount);
            _burn(account, amount);
        }
    }
    
    /**
     * @dev Returns the address of the governance contract.
     */
    function governanceAddress() public view virtual override returns (address) {
        return _governanceAddress;
    }
    
    /**
     * @dev Sets the governance contract address.
     * Can only be called by the contract owner.
     */
    function setGovernanceAddress(address governanceAddr) public virtual override {
        require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "GovernanceToken: must have admin role to set governance address");
        _governanceAddress = governanceAddr;
    }
    
    /**
     * @dev Pauses all token transfers.
     * Can only be called by accounts with the pauser role.
     */
    function pause() public virtual override {
        require(hasRole(PAUSER_ROLE, _msgSender()), "GovernanceToken: must have pauser role to pause");
        _pause();
    }
    
    /**
     * @dev Unpauses all token transfers.
     * Can only be called by accounts with the pauser role.
     */
    function unpause() public virtual override {
        require(hasRole(PAUSER_ROLE, _msgSender()), "GovernanceToken: must have pauser role to unpause");
        _unpause();
    }
    
    /**
     * @dev See {ERC20-_beforeTokenTransfer}.
     */
    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 amount
    ) internal virtual override(ERC20, ERC20Pausable) {
        super._beforeTokenTransfer(from, to, amount);
    }
}

1.3 Testing the Token

Let's write tests for our token. Create a file test/GovernanceToken.test.js:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("GovernanceToken", function () {
  let GovernanceToken;
  let token;
  let owner;
  let addr1;
  let addr2;
  let addrs;
  
  // Constants for roles
  const MINTER_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("MINTER_ROLE"));
  const PAUSER_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("PAUSER_ROLE"));
  const BURNER_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("BURNER_ROLE"));
  
  beforeEach(async function () {
    // Get the ContractFactory and Signers
    GovernanceToken = await ethers.getContractFactory("GovernanceToken");
    [owner, addr1, addr2, ...addrs] = await ethers.getSigners();
    
    // Deploy the token with initial parameters
    token = await GovernanceToken.deploy(
      "Governance Token",
      "GOV",
      18,
      1000000 // 1 million tokens
    );
    
    await token.deployed();
  });
  
  describe("Deployment", function () {
    it("Should set the right owner", async function () {
      expect(await token.hasRole(ethers.constants.HashZero, owner.address)).to.equal(true);
    });
    
    it("Should assign the total supply of tokens to the owner", async function () {
      const ownerBalance = await token.balanceOf(owner.address);
      expect(await token.totalSupply()).to.equal(ownerBalance);
    });
    
    it("Should set the correct token properties", async function () {
      expect(await token.name()).to.equal("Governance Token");
      expect(await token.symbol()).to.equal("GOV");
      expect(await token.decimals()).to.equal(18);
    });
    
    it("Should grant roles to the owner", async function () {
      expect(await token.hasRole(MINTER_ROLE, owner.address)).to.equal(true);
      expect(await token.hasRole(PAUSER_ROLE, owner.address)).to.equal(true);
      expect(await token.hasRole(BURNER_ROLE, owner.address)).to.equal(true);
    });
  });
  
  describe("Transactions", function () {
    it("Should transfer tokens between accounts", async function () {
      // Transfer 50 tokens from owner to addr1
      await token.transfer(addr1.address, 50);
      const addr1Balance = await token.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(50);
      
      // Transfer 50 tokens from addr1 to addr2
      await token.connect(addr1).transfer(addr2.address, 50);
      const addr2Balance = await token.balanceOf(addr2.address);
      expect(addr2Balance).to.equal(50);
    });
    
    it("Should fail if sender doesn't have enough tokens", async function () {
      const initialOwnerBalance = await token.balanceOf(owner.address);
      
      // Try to send 1 token from addr1 (0 tokens) to owner
      await expect(
        token.connect(addr1).transfer(owner.address, 1)
      ).to.be.revertedWith("ERC20: transfer amount exceeds balance");
      
      // Owner balance shouldn't have changed
      expect(await token.balanceOf(owner.address)).to.equal(initialOwnerBalance);
    });
  });
  
  describe("Minting", function () {
    it("Should allow minting by minter role", async function () {
      await token.mint(addr1.address, 100);
      expect(await token.balanceOf(addr1.address)).to.equal(100);
    });
    
    it("Should fail if minter role is not assigned", async function () {
      await expect(
        token.connect(addr1).mint(addr2.address, 100)
      ).to.be.revertedWith("GovernanceToken: must have minter role to mint");
    });
    
    it("Should allow granting minter role", async function () {
      await token.grantRole(MINTER_ROLE, addr1.address);
      expect(await token.hasRole(MINTER_ROLE, addr1.address)).to.equal(true);
      
      // Now addr1 should be able to mint
      await token.connect(addr1).mint(addr2.address, 100);
      expect(await token.balanceOf(addr2.address)).to.equal(100);
    });
  });
  
  describe("Burning", function () {
    beforeEach(async function () {
      // Transfer some tokens to addr1 for testing
      await token.transfer(addr1.address, 1000);
    });
    
    it("Should allow burning own tokens", async function () {
      await token.burn(owner.address, 100);
      expect(await token.balanceOf(owner.address)).to.equal(ethers.utils.parseEther("999999").sub(1000).sub(100));
    });
    
    it("Should allow burning others' tokens with allowance", async function () {
      await token.connect(addr1).approve(owner.address, 500);
      await token.burn(addr1.address, 200);
      expect(await token.balanceOf(addr1.address)).to.equal(800);
      expect(await token.allowance(addr1.address, owner.address)).to.equal(300);
    });
    
    it("Should fail if burning others' tokens without allowance", async function () {
      await expect(
        token.burn(addr1.address, 100)
      ).to.be.revertedWith("GovernanceToken: burn amount exceeds allowance");
    });
    
    it("Should fail if burner role is not assigned", async function () {
      await expect(
        token.connect(addr1).burn(addr1.address, 100)
      ).to.be.revertedWith("GovernanceToken: must have burner role to burn");
    });
  });
  
  describe("Pausing", function () {
    it("Should allow pausing by pauser role", async function () {
      await token.pause();
      expect(await token.paused()).to.equal(true);
    });
    
    it("Should prevent transfers when paused", async function () {
      await token.pause();
      await expect(
        token.transfer(addr1.address, 100)
      ).to.be.revertedWith("ERC20Pausable: token transfer while paused");
    });
    
    it("Should allow unpausing", async function () {
      await token.pause();
      await token.unpause();
      expect(await token.paused()).to.equal(false);
      
      // Transfer should work now
      await token.transfer(addr1.address, 100);
      expect(await token.balanceOf(addr1.address)).to.equal(100);
    });
    
    it("Should fail if pauser role is not assigned", async function () {
      await expect(
        token.connect(addr1).pause()
      ).to.be.revertedWith("GovernanceToken: must have pauser role to pause");
    });
  });
  
  describe("Governance", function () {
    it("Should allow setting governance address", async function () {
      await token.setGovernanceAddress(addr1.address);
      expect(await token.governanceAddress()).to.equal(addr1.address);
    });
    
    it("Should fail if admin role is not assigned", async function () {
      await expect(
        token.connect(addr1).setGovernanceAddress(addr2.address)
      ).to.be.revertedWith("GovernanceToken: must have admin role to set governance address");
    });
  });
});

Run the tests to make sure your token implementation works correctly:

npx hardhat test

Part 2: Token Vesting Contract

2.1 Vesting Interface

Create a file contracts/interfaces/ITokenVesting.sol:

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

interface ITokenVesting {
    /**
     * @dev Struct to store vesting schedule information
     */
    struct VestingSchedule {
        // Beneficiary of tokens after they are released
        address beneficiary;
        // Cliff period in seconds
        uint256 cliff;
        // Start time of the vesting period
        uint256 start;
        // Duration of the vesting period in seconds
        uint256 duration;
        // Duration of a slice period in seconds
        uint256 slicePeriodSeconds;
        // Whether or not the vesting is revocable
        bool revocable;
        // Total amount of tokens to be released at the end of the vesting
        uint256 amountTotal;
        // Amount of tokens released
        uint256 released;
        // Whether or not the vesting has been revoked
        bool revoked;
    }
    
    /**
     * @dev Creates a new vesting schedule for a beneficiary.
     */
    function createVestingSchedule(
        address _beneficiary,
        uint256 _start,
        uint256 _cliff,
        uint256 _duration,
        uint256 _slicePeriodSeconds,
        bool _revocable,
        uint256 _amount
    ) external;
    
    /**
     * @dev Revokes the vesting schedule for a given identifier.
     */
    function revoke(bytes32 vestingScheduleId) external;
    
    /**
     * @dev Releases vested tokens for a specific vesting schedule.
     */
    function release(bytes32 vestingScheduleId, uint256 amount) external;
    
    /**
     * @dev Returns the vesting schedule information for a given identifier.
     */
    function getVestingSchedule(bytes32 vestingScheduleId) external view returns (VestingSchedule memory);
    
    /**
     * @dev Returns the vesting schedule identifier for a given beneficiary and index.
     */
    function computeVestingScheduleIdForAddressAndIndex(address beneficiary, uint256 index) external pure returns (bytes32);
    
    /**
     * @dev Returns the number of vesting schedules for a given beneficiary.
     */
    function getVestingSchedulesCountByBeneficiary(address _beneficiary) external view returns (uint256);
    
    /**
     * @dev Returns the vesting schedule id at the given index.
     */
    function getVestingIdAtIndex(uint256 index) external view returns (bytes32);
    
    /**
     * @dev Returns the total amount of vesting schedules.
     */
    function getVestingSchedulesCount() external view returns (uint256);
    
    /**
     * @dev Returns the address of the ERC20 token managed by the vesting contract.
     */
    function getToken() external view returns (address);
}

2.2 Vesting Implementation

Create a file contracts/TokenVesting.sol:

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./interfaces/ITokenVesting.sol";

/**
 * @title TokenVesting
 * @dev A contract that allows token vesting with customizable schedules.
 */
contract TokenVesting is Ownable, ReentrancyGuard, ITokenVesting {
    using SafeMath for uint256;
    using SafeERC20 for IERC20;
    
    // Address of the ERC20 token
    IERC20 private immutable _token;
    
    // Array of all vesting schedule ids
    bytes32[] private vestingSchedulesIds;
    
    // Mapping of vesting schedule id to vesting schedule
    mapping(bytes32 => VestingSchedule) private vestingSchedules;
    
    // Mapping of beneficiary address to number of vesting schedules
    mapping(address => uint256) private holdersVestingCount;
    
    /**
     * @dev Constructor that initializes the token address.
     */
    constructor(address token_) {
        require(token_ != address(0), "TokenVesting: token address cannot be zero");
        _token = IERC20(token_);
    }
    
    /**
     * @dev Returns the address of the ERC20 token managed by the vesting contract.
     */
    function getToken() external view override returns (address) {
        return address(_token);
    }
    
    /**
     * @dev Creates a new vesting schedule for a beneficiary.
     */
    function createVestingSchedule(
        address _beneficiary,
        uint256 _start,
        uint256 _cliff,
        uint256 _duration,
        uint256 _slicePeriodSeconds,
        bool _revocable,
        uint256 _amount
    ) external override onlyOwner {
        require(_beneficiary != address(0), "TokenVesting: beneficiary cannot be zero address");
        require(_duration > 0, "TokenVesting: duration must be > 0");
        require(_amount > 0, "TokenVesting: amount must be > 0");
        require(_slicePeriodSeconds >= 1, "TokenVesting: slicePeriodSeconds must be >= 1");
        require(_duration >= _cliff, "TokenVesting: duration must be >= cliff");
        
        // Check that the contract has enough tokens
        uint256 contractBalance = _token.balanceOf(address(this));
        require(contractBalance >= _amount, "TokenVesting: contract balance too low");
        
        bytes32 vestingScheduleId = computeVestingScheduleIdForAddressAndIndex(
            _beneficiary,
            holdersVestingCount[_beneficiary]
        );
        
        vestingSchedules[vestingScheduleId] = VestingSchedule(
            _beneficiary,
            _cliff,
            _start,
            _duration,
            _slicePeriodSeconds,
            _revocable,
            _amount,
            0,
            false
        );
        
        vestingSchedulesIds.push(vestingScheduleId);
        holdersVestingCount[_beneficiary] = holdersVestingCount[_beneficiary].add(1);
    }
    
    /**
     * @dev Revokes the vesting schedule for a given identifier.
     */
    function revoke(bytes32 vestingScheduleId) external override onlyOwner {
        VestingSchedule storage vestingSchedule = vestingSchedules[vestingScheduleId];
        require(vestingSchedule.beneficiary != address(0), "TokenVesting: vesting schedule does not exist");
        require(vestingSchedule.revocable, "TokenVesting: vesting schedule is not revocable");
        require(!vestingSchedule.revoked, "TokenVesting: vesting schedule already revoked");
        
        uint256 vestedAmount = _computeReleasableAmount(vestingSchedule);
        
        // If there are vested but unreleased tokens, release them
        if (vestedAmount > 0) {
            release(vestingScheduleId, vestedAmount);
        }
        
        // Calculate the unvested amount
        uint256 unreleased = vestingSchedule.amountTotal.sub(vestingSchedule.released);
        
        // Mark the schedule as revoked
        vestingSchedule.revoked = true;
        
        // Transfer the unreleased tokens back to the owner
        _token.safeTransfer(owner(), unreleased);
    }
    
    /**
     * @dev Releases vested tokens for a specific vesting schedule.
     */
    function release(bytes32 vestingScheduleId, uint256 amount) public override nonReentrant {
        VestingSchedule storage vestingSchedule = vestingSchedules[vestingScheduleId];
        require(vestingSchedule.beneficiary != address(0), "TokenVesting: vesting schedule does not exist");
        require(!vestingSchedule.revoked, "TokenVesting: vesting schedule revoked");
        
        uint256 releasableAmount = _computeReleasableAmount(vestingSchedule);
        require(releasableAmount >= amount, "TokenVesting: cannot release more than releasable amount");
        
        // Update the released amount
        vestingSchedule.released = vestingSchedule.released.add(amount);
        
        // Transfer the tokens to the beneficiary
        _token.safeTransfer(vestingSchedule.beneficiary, amount);
    }
    
    /**
     * @dev Returns the vesting schedule information for a given identifier.
     */
    function getVestingSchedule(bytes32 vestingScheduleId) external view override returns (VestingSchedule memory) {
        return vestingSchedules[vestingScheduleId];
    }
    
    /**
     * @dev Returns the vesting schedule identifier for a given beneficiary and index.
     */
    function computeVestingScheduleIdForAddressAndIndex(address beneficiary, uint256 index) public pure override returns (bytes32) {
        return keccak256(abi.encodePacked(beneficiary, index));
    }
    
    /**
     * @dev Returns the number of vesting schedules for a given beneficiary.
     */
    function getVestingSchedulesCountByBeneficiary(address _beneficiary) external view override returns (uint256) {
        return holdersVestingCount[_beneficiary];
    }
    
    /**
     * @dev Returns the vesting schedule id at the given index.
     */
    function getVestingIdAtIndex(uint256 index) external view override returns (bytes32) {
        require(index < getVestingSchedulesCount(), "TokenVesting: index out of bounds");
        return vestingSchedulesIds[index];
    }
    
    /**
     * @dev Returns the total amount of vesting schedules.
     */
    function getVestingSchedulesCount() public view override returns (uint256) {
        return vestingSchedulesIds.length;
    }
    
    /**
     * @dev Computes the releasable amount of tokens for a vesting schedule.
     */
    function _computeReleasableAmount(VestingSchedule memory vestingSchedule) internal view returns (uint256) {
        // If the schedule has been revoked, nothing is releasable
        if (vestingSchedule.revoked) {
            return 0;
        }
        
        // If current time is before cliff, nothing is releasable
        uint256 currentTime = block.timestamp;
        if (currentTime < vestingSchedule.start.add(vestingSchedule.cliff)) {
            return 0;
        }
        
        // If current time is after vesting period, everything is releasable (minus what's already been released)
        if (currentTime >= vestingSchedule.start.add(vestingSchedule.duration)) {
            return vestingSchedule.amountTotal.sub(vestingSchedule.released);
        }
        
        // Otherwise, calculate the vested amount based on the vesting formula
        uint256 timeFromStart = currentTime.sub(vestingSchedule.start);
        uint256 secondsPerSlice = vestingSchedule.slicePeriodSeconds;
        uint256 vestedSlicePeriods = timeFromStart.div(secondsPerSlice);
        uint256 vestedSeconds = vestedSlicePeriods.mul(secondsPerSlice);
        uint256 vestedAmount = vestingSchedule.amountTotal.mul(vestedSeconds).div(vestingSchedule.duration);
        
        return vestedAmount.sub(vestingSchedule.released);
    }
    
    /**
     * @dev Allows the owner to withdraw any excess tokens.
     */
    function withdrawExcessTokens(uint256 amount) external onlyOwner {
        uint256 totalVested = 0;
        
        // Calculate total tokens committed to vesting schedules
        for (uint256 i = 0; i < vestingSchedulesIds.length; i++) {
            bytes32 vestingScheduleId = vestingSchedulesIds[i];
            VestingSchedule storage vestingSchedule = vestingSchedules[vestingScheduleId];
            
            if (!vestingSchedule.revoked) {
                totalVested = totalVested.add(vestingSchedule.amountTotal.sub(vestingSchedule.released));
            }
        }
        
        // Check that we're not withdrawing vested tokens
        uint256 contractBalance = _token.balanceOf(address(this));
        uint256 excessTokens = contractBalance.sub(totalVested);
        require(amount <= excessTokens, "TokenVesting: cannot withdraw vested tokens");
        
        // Transfer the tokens to the owner
        _token.safeTransfer(owner(), amount);
    }
}

2.3 Testing the Vesting Contract

Create a file test/TokenVesting.test.js:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("TokenVesting", function () {
  let GovernanceToken;
  let TokenVesting;
  let token;
  let vesting;
  let owner;
  let beneficiary;
  let addr2;
  let addrs;
  
  beforeEach(async function () {
    // Get the ContractFactory and Signers
    GovernanceToken = await ethers.getContractFactory("GovernanceToken");
    TokenVesting = await ethers.getContractFactory("TokenVesting");
    [owner, beneficiary, addr2, ...addrs] = await ethers.getSigners();
    
    // Deploy the token
    token = await GovernanceToken.deploy(
      "Governance Token",
      "GOV",
      18,
      1000000 // 1 million tokens
    );
    
    await token.deployed();
    
    // Deploy the vesting contract
    vesting = await TokenVesting.deploy(token.address);
    await vesting.deployed();
    
    // Transfer tokens to the vesting contract
    await token.transfer(vesting.address, ethers.utils.parseEther("100000")); // 100,000 tokens
  });
  
  describe("Deployment", function () {
    it("Should set the right token address", async function () {
      expect(await vesting.getToken()).to.equal(token.address);
    });
    
    it("Should set the right owner", async function () {
      expect(await vesting.owner()).to.equal(owner.address);
    });
    
    it("Should have the correct token balance", async function () {
      expect(await token.balanceOf(vesting.address)).to.equal(ethers.utils.parseEther("100000"));
    });
  });
  
  describe("Creating Vesting Schedules", function () {
    it("Should create a vesting schedule", async function () {
      const now = Math.floor(Date.now() / 1000);
      const amount = ethers.utils.parseEther("1000");
      
      await vesting.createVestingSchedule(
        beneficiary.address,
        now,
        86400, // 1 day cliff
        86400 * 365, // 1 year duration
        86400, // 1 day slice period
        true, // revocable
        amount
      );
      
      // Check that the schedule was created
      expect(await vesting.getVestingSchedulesCount()).to.equal(1);
      expect(await vesting.getVestingSchedulesCountByBeneficiary(beneficiary.address)).to.equal(1);
      
      // Get the schedule ID
      const scheduleId = await vesting.getVestingIdAtIndex(0);
      
      // Check the schedule details
      const schedule = await vesting.getVestingSchedule(scheduleId);
      expect(schedule.beneficiary).to.equal(beneficiary.address);
      expect(schedule.cliff).to.equal(86400);
      expect(schedule.start).to.equal(now);
      expect(schedule.duration).to.equal(86400 * 365);
      expect(schedule.slicePeriodSeconds).to.equal(86400);
      expect(schedule.revocable).to.equal(true);
      expect(schedule.amountTotal).to.equal(amount);
      expect(schedule.released).to.equal(0);
      expect(schedule.revoked).to.equal(false);
    });
    
    it("Should fail if not called by owner", async function () {
      const now = Math.floor(Date.now() / 1000);
      const amount = ethers.utils.parseEther("1000");
      
      await expect(
        vesting.connect(beneficiary).createVestingSchedule(
          beneficiary.address,
          now,
          86400,
          86400 * 365,
          86400,
          true,
          amount
        )
      ).to.be.revertedWith("Ownable: caller is not the owner");
    });
    
    it("Should fail if amount is zero", async function () {
      const now = Math.floor(Date.now() / 1000);
      
      await expect(
        vesting.createVestingSchedule(
          beneficiary.address,
          now,
          86400,
          86400 * 365,
          86400,
          true,
          0
        )
      ).to.be.revertedWith("TokenVesting: amount must be > 0");
    });
    
    it("Should fail if duration is zero", async function () {
      const now = Math.floor(Date.now() / 1000);
      const amount = ethers.utils.parseEther("1000");
      
      await expect(
        vesting.createVestingSchedule(
          beneficiary.address,
          now,
          86400,
          0,
          86400,
          true,
          amount
        )
      ).to.be.revertedWith("TokenVesting: duration must be > 0");
    });
    
    it("Should fail if slice period is zero", async function () {
      const now = Math.floor(Date.now() / 1000);
      const amount = ethers.utils.parseEther("1000");
      
      await expect(
        vesting.createVestingSchedule(
          beneficiary.address,
          now,
          86400,
          86400 * 365,
          0,
          true,
          amount
        )
      ).to.be.revertedWith("TokenVesting: slicePeriodSeconds must be >= 1");
    });
    
    it("Should fail if duration is less than cliff", async function () {
      const now = Math.floor(Date.now() / 1000);
      const amount = ethers.utils.parseEther("1000");
      
      await expect(
        vesting.createVestingSchedule(
          beneficiary.address,
          now,
          86400 * 2,
          86400,
          86400,
          true,
          amount
        )
      ).to.be.revertedWith("TokenVesting: duration must be >= cliff");
    });
    
    it("Should fail if contract balance is too low", async function () {
      const now = Math.floor(Date.now() / 1000);
      const amount = ethers.utils.parseEther("1000000"); // More than the contract has
      
      await expect(
        vesting.createVestingSchedule(
          beneficiary.address,
          now,
          86400,
          86400 * 365,
          86400,
          true,
          amount
        )
      ).to.be.revertedWith("TokenVesting: contract balance too low");
    });
  });
  
  describe("Releasing Tokens", function () {
    let scheduleId;
    const amount = ethers.utils.parseEther("1000");
    
    beforeEach(async function () {
      // Create a vesting schedule
      const now = Math.floor(Date.now() / 1000);
      
      await vesting.createVestingSchedule(
        beneficiary.address,
        now,
        0, // No cliff for testing
        86400 * 365, // 1 year duration
        86400, // 1 day slice period
        true, // revocable
        amount
      );
      
      scheduleId = await vesting.getVestingIdAtIndex(0);
      
      // Advance time by 6 months (halfway through vesting)
      await ethers.provider.send("evm_increaseTime", [86400 * 182]);
      await ethers.provider.send("evm_mine");
    });
    
    it("Should release vested tokens", async function () {
      // Calculate expected vested amount (approximately half after 6 months)
      const expectedVested = amount.div(2);
      
      // Release tokens
      await vesting.connect(beneficiary).release(scheduleId, expectedVested);
      
      // Check beneficiary balance
      expect(await token.balanceOf(beneficiary.address)).to.be.closeTo(
        expectedVested,
        ethers.utils.parseEther("10") // Allow for small difference due to block timestamp variations
      );
      
      // Check schedule updated
      const schedule = await vesting.getVestingSchedule(scheduleId);
      expect(schedule.released).to.be.closeTo(
        expectedVested,
        ethers.utils.parseEther("10")
      );
    });
    
    it("Should fail if trying to release more than vested", async function () {
      // Try to release more than vested
      await expect(
        vesting.connect(beneficiary).release(scheduleId, amount)
      ).to.be.revertedWith("TokenVesting: cannot release more than releasable amount");
    });
    
    it("Should release all tokens after vesting period", async function () {
      // Advance time to end of vesting period
      await ethers.provider.send("evm_increaseTime", [86400 * 183]); // Another 6 months
      await ethers.provider.send("evm_mine");
      
      // Release all tokens
      await vesting.connect(beneficiary).release(scheduleId, amount);
      
      // Check beneficiary balance
      expect(await token.balanceOf(beneficiary.address)).to.equal(amount);
      
      // Check schedule updated
      const schedule = await vesting.getVestingSchedule(scheduleId);
      expect(schedule.released).to.equal(amount);
    });
  });
  
  describe("Revoking Schedules", function () {
    let scheduleId;
    const amount = ethers.utils.parseEther("1000");
    
    beforeEach(async function () {
      // Create a vesting schedule
      const now = Math.floor(Date.now() / 1000);
      
      await vesting.createVestingSchedule(
        beneficiary.address,
        now,
        0, // No cliff for testing
        86400 * 365, // 1 year duration
        86400, // 1 day slice period
        true, // revocable
        amount
      );
      
      scheduleId = await vesting.getVestingIdAtIndex(0);
      
      // Advance time by 6 months (halfway through vesting)
      await ethers.provider.send("evm_increaseTime", [86400 * 182]);
      await ethers.provider.send("evm_mine");
    });
    
    it("Should revoke a schedule and release vested tokens", async function () {
      // Get owner's initial balance
      const initialOwnerBalance = await token.balanceOf(owner.address);
      
      // Revoke the schedule
      await vesting.revoke(scheduleId);
      
      // Check that vested tokens were released to beneficiary (approximately half)
      expect(await token.balanceOf(beneficiary.address)).to.be.closeTo(
        amount.div(2),
        ethers.utils.parseEther("10")
      );
      
      // Check that unvested tokens were returned to owner
      expect(await token.balanceOf(owner.address)).to.be.closeTo(
        initialOwnerBalance.add(amount.div(2)),
        ethers.utils.parseEther("10")
      );
      
      // Check that schedule is marked as revoked
      const schedule = await vesting.getVestingSchedule(scheduleId);
      expect(schedule.revoked).to.equal(true);
    });
    
    it("Should fail if not called by owner", async function () {
      await expect(
        vesting.connect(beneficiary).revoke(scheduleId)
      ).to.be.revertedWith("Ownable: caller is not the owner");
    });
    
    it("Should fail if schedule is not revocable", async function () {
      // Create a non-revocable schedule
      const now = Math.floor(Date.now() / 1000);
      
      await vesting.createVestingSchedule(
        beneficiary.address,
        now,
        0,
        86400 * 365,
        86400,
        false, // not revocable
        amount
      );
      
      const nonRevocableId = await vesting.getVestingIdAtIndex(1);
      
      await expect(
        vesting.revoke(nonRevocableId)
      ).to.be.revertedWith("TokenVesting: vesting schedule is not revocable");
    });
    
    it("Should fail if schedule is already revoked", async function () {
      // Revoke the schedule
      await vesting.revoke(scheduleId);
      
      // Try to revoke again
      await expect(
        vesting.revoke(scheduleId)
      ).to.be.revertedWith("TokenVesting: vesting schedule already revoked");
    });
  });
  
  describe("Withdrawing Excess Tokens", function () {
    it("Should allow owner to withdraw excess tokens", async function () {
      const amount = ethers.utils.parseEther("1000");
      const now = Math.floor(Date.now() / 1000);
      
      // Create a vesting schedule
      await vesting.createVestingSchedule(
        beneficiary.address,
        now,
        0,
        86400 * 365,
        86400,
        true,
        amount
      );
      
      // Calculate excess tokens
      const excessAmount = ethers.utils.parseEther("99000"); // 100,000 - 1,000
      
      // Get owner's initial balance
      const initialOwnerBalance = await token.balanceOf(owner.address);
      
      // Withdraw excess tokens
      await vesting.withdrawExcessTokens(excessAmount);
      
      // Check owner's balance
      expect(await token.balanceOf(owner.address)).to.equal(
        initialOwnerBalance.add(excessAmount)
      );
    });
    
    it("Should fail if trying to withdraw vested tokens", async function () {
      const amount = ethers.utils.parseEther("1000");
      const now = Math.floor(Date.now() / 1000);
      
      // Create a vesting schedule
      await vesting.createVestingSchedule(
        beneficiary.address,
        now,
        0,
        86400 * 365,
        86400,
        true,
        amount
      );
      
      // Try to withdraw more than excess
      const tooMuch = ethers.utils.parseEther("99001"); // 100,000 - 1,000 + 1
      
      await expect(
        vesting.withdrawExcessTokens(tooMuch)
      ).to.be.revertedWith("TokenVesting: cannot withdraw vested tokens");
    });
  });
});

Run the tests to make sure your vesting contract works correctly:

npx hardhat test

Part 3: Governance System

3.1 Governance Contract

Create a file contracts/Governance.sol:

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

import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./interfaces/IGovernanceToken.sol";

/**
 * @title Governance
 * @dev A contract for on-chain governance using a token for voting power.
 */
contract Governance is AccessControl, ReentrancyGuard {
    using SafeMath for uint256;
    
    // Roles
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE");
    
    // Governance token
    IGovernanceToken public token;
    
    // Proposal struct
    struct Proposal {
        uint256 id;
        address proposer;
        string description;
        bytes callData;
        address target;
        uint256 startBlock;
        uint256 endBlock;
        uint256 forVotes;
        uint256 againstVotes;
        bool executed;
        bool canceled;
        mapping(address => Receipt) receipts;
    }
    
    // Vote receipt
    struct Receipt {
        bool hasVoted;
        bool support;
        uint256 votes;
    }
    
    // Proposal state enum
    enum ProposalState {
        Pending,
        Active,
        Canceled,
        Defeated,
        Succeeded,
        Executed
    }
    
    // Voting parameters
    uint256 public votingDelay = 1; // blocks before voting starts
    uint256 public votingPeriod = 17280; // blocks for voting (approx. 3 days at 15s/block)
    uint256 public proposalThreshold = 1000 * 10**18; // 1000 tokens to create proposal
    uint256 public quorumVotes = 10000 * 10**18; // 10000 tokens needed for quorum
    
    // Proposal tracking
    mapping(uint256 => Proposal) public proposals;
    uint256 public proposalCount;
    
    // Events
    event ProposalCreated(
        uint256 id,
        address proposer,
        address target,
        string description,
        uint256 startBlock,
        uint256 endBlock
    );
    
    event VoteCast(
        address voter,
        uint256 proposalId,
        bool support,
        uint256 votes
    );
    
    event ProposalExecuted(uint256 id);
    event ProposalCanceled(uint256 id);
    
    event VotingDelaySet(uint256 oldVotingDelay, uint256 newVotingDelay);
    event VotingPeriodSet(uint256 oldVotingPeriod, uint256 newVotingPeriod);
    event ProposalThresholdSet(uint256 oldProposalThreshold, uint256 newProposalThreshold);
    event QuorumVotesSet(uint256 oldQuorumVotes, uint256 newQuorumVotes);
    
    /**
     * @dev Constructor that sets up the governance contract.
     */
    constructor(address _token) {
        token = IGovernanceToken(_token);
        
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(ADMIN_ROLE, msg.sender);
        _setupRole(PROPOSER_ROLE, msg.sender);
    }
    
    /**
     * @dev Creates a new proposal.
     */
    function propose(
        address target,
        string memory description,
        bytes memory callData
    ) public returns (uint256) {
        require(
            token.balanceOf(msg.sender) >= proposalThreshold,
            "Governance: proposer votes below threshold"
        );
        
        require(
            hasRole(PROPOSER_ROLE, msg.sender),
            "Governance: must have proposer role to propose"
        );
        
        uint256 startBlock = block.number.add(votingDelay);
        uint256 endBlock = startBlock.add(votingPeriod);
        
        proposalCount++;
        Proposal storage newProposal = proposals[proposalCount];
        newProposal.id = proposalCount;
        newProposal.proposer = msg.sender;
        newProposal.description = description;
        newProposal.callData = callData;
        newProposal.target = target;
        newProposal.startBlock = startBlock;
        newProposal.endBlock = endBlock;
        
        emit ProposalCreated(
            proposalCount,
            msg.sender,
            target,
            description,
            startBlock,
            endBlock
        );
        
        return proposalCount;
    }
    
    /**
     * @dev Casts a vote on a proposal.
     */
    function castVote(uint256 proposalId, bool support) public nonReentrant {
        require(
            state(proposalId) == ProposalState.Active,
            "Governance: voting is closed"
        );
        
        Proposal storage proposal = proposals[proposalId];
        Receipt storage receipt = proposal.receipts[msg.sender];
        
        require(!receipt.hasVoted, "Governance: voter already voted");
        
        uint256 votes = token.balanceOf(msg.sender);
        
        if (support) {
            proposal.forVotes = proposal.forVotes.add(votes);
        } else {
            proposal.againstVotes = proposal.againstVotes.add(votes);
        }
        
        receipt.hasVoted = true;
        receipt.support = support;
        receipt.votes = votes;
        
        emit VoteCast(msg.sender, proposalId, support, votes);
    }
    
    /**
     * @dev Executes a successful proposal.
     */
    function execute(uint256 proposalId) public nonReentrant {
        require(
            state(proposalId) == ProposalState.Succeeded,
            "Governance: proposal not successful"
        );
        
        Proposal storage proposal = proposals[proposalId];
        proposal.executed = true;
        
        (bool success, ) = proposal.target.call(proposal.callData);
        require(success, "Governance: execution failed");
        
        emit ProposalExecuted(proposalId);
    }
    
    /**
     * @dev Cancels a proposal.
     */
    function cancel(uint256 proposalId) public {
        require(
            state(proposalId) == ProposalState.Pending ||
            state(proposalId) == ProposalState.Active,
            "Governance: proposal not active"
        );
        
        Proposal storage proposal = proposals[proposalId];
        
        require(
            msg.sender == proposal.proposer ||
            hasRole(ADMIN_ROLE, msg.sender),
            "Governance: only proposer or admin can cancel"
        );
        
        proposal.canceled = true;
        
        emit ProposalCanceled(proposalId);
    }
    
    /**
     * @dev Gets the state of a proposal.
     */
    function state(uint256 proposalId) public view returns (ProposalState) {
        require(
            proposalId <= proposalCount && proposalId > 0,
            "Governance: invalid proposal id"
        );
        
        Proposal storage proposal = proposals[proposalId];
        
        if (proposal.canceled) {
            return ProposalState.Canceled;
        }
        
        if (proposal.executed) {
            return ProposalState.Executed;
        }
        
        if (block.number <= proposal.startBlock) {
            return ProposalState.Pending;
        }
        
        if (block.number <= proposal.endBlock) {
            return ProposalState.Active;
        }
        
        if (proposal.forVotes <= proposal.againstVotes || proposal.forVotes < quorumVotes) {
            return ProposalState.Defeated;
        }
        
        return ProposalState.Succeeded;
    }
    
    /**
     * @dev Gets the receipt for a voter on a proposal.
     */
    function getReceipt(uint256 proposalId, address voter) public view returns (bool, bool, uint256) {
        Receipt storage receipt = proposals[proposalId].receipts[voter];
        return (receipt.hasVoted, receipt.support, receipt.votes);
    }
    
    /**
     * @dev Gets proposal details.
     */
    function getProposalDetails(uint256 proposalId) public view returns (
        address,
        string memory,
        uint256,
        uint256,
        uint256,
        uint256,
        bool,
        bool
    ) {
        Proposal storage proposal = proposals[proposalId];
        return (
            proposal.proposer,
            proposal.description,
            proposal.startBlock,
            proposal.endBlock,
            proposal.forVotes,
            proposal.againstVotes,
            proposal.executed,
            proposal.canceled
        );
    }
    
    /**
     * @dev Sets the voting delay.
     */
    function setVotingDelay(uint256 newVotingDelay) public onlyRole(ADMIN_ROLE) {
        uint256 oldVotingDelay = votingDelay;
        votingDelay = newVotingDelay;
        
        emit VotingDelaySet(oldVotingDelay, newVotingDelay);
    }
    
    /**
     * @dev Sets the voting period.
     */
    function setVotingPeriod(uint256 newVotingPeriod) public onlyRole(ADMIN_ROLE) {
        require(
            newVotingPeriod > 0,
            "Governance: voting period must be positive"
        );
        
        uint256 oldVotingPeriod = votingPeriod;
        votingPeriod = newVotingPeriod;
        
        emit VotingPeriodSet(oldVotingPeriod, newVotingPeriod);
    }
    
    /**
     * @dev Sets the proposal threshold.
     */
    function setProposalThreshold(uint256 newProposalThreshold) public onlyRole(ADMIN_ROLE) {
        uint256 oldProposalThreshold = proposalThreshold;
        proposalThreshold = newProposalThreshold;
        
        emit ProposalThresholdSet(oldProposalThreshold, newProposalThreshold);
    }
    
    /**
     * @dev Sets the quorum votes.
     */
    function setQuorumVotes(uint256 newQuorumVotes) public onlyRole(ADMIN_ROLE) {
        uint256 oldQuorumVotes = quorumVotes;
        quorumVotes = newQuorumVotes;
        
        emit QuorumVotesSet(oldQuorumVotes, newQuorumVotes);
    }
    
    /**
     * @dev Grants the proposer role to an account.
     */
    function grantProposerRole(address account) public onlyRole(ADMIN_ROLE) {
        grantRole(PROPOSER_ROLE, account);
    }
    
    /**
     * @dev Revokes the proposer role from an account.
     */
    function revokeProposerRole(address account) public onlyRole(ADMIN_ROLE) {
        revokeRole(PROPOSER_ROLE, account);
    }
}

3.2 Testing the Governance Contract

Create a file test/Governance.test.js:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Governance", function () {
  let GovernanceToken;
  let Governance;
  let token;
  let governance;
  let owner;
  let proposer;
  let voter1;
  let voter2;
  let target;
  let addrs;
  
  beforeEach(async function () {
    // Get the ContractFactory and Signers
    GovernanceToken = await ethers.getContractFactory("GovernanceToken");
    Governance = await ethers.getContractFactory("Governance");
    [owner, proposer, voter1, voter2, target, ...addrs] = await ethers.getSigners();
    
    // Deploy the token
    token = await GovernanceToken.deploy(
      "Governance Token",
      "GOV",
      18,
      1000000 // 1 million tokens
    );
    
    await token.deployed();
    
    // Deploy the governance contract
    governance = await Governance.deploy(token.address);
    await governance.deployed();
    
    // Set the governance address in the token
    await token.setGovernanceAddress(governance.address);
    
    // Transfer tokens to proposer and voters
    await token.transfer(proposer.address, ethers.utils.parseEther("10000")); // 10,000 tokens
    await token.transfer(voter1.address, ethers.utils.parseEther("5000")); // 5,000 tokens
    await token.transfer(voter2.address, ethers.utils.parseEther("7000")); // 7,000 tokens
    
    // Grant proposer role
    await governance.grantProposerRole(proposer.address);
  });
  
  describe("Deployment", function () {
    it("Should set the right token address", async function () {
      expect(await governance.token()).to.equal(token.address);
    });
    
    it("Should set the right roles", async function () {
      const ADMIN_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("ADMIN_ROLE"));
      const PROPOSER_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("PROPOSER_ROLE"));
      
      expect(await governance.hasRole(ADMIN_ROLE, owner.address)).to.equal(true);
      expect(await governance.hasRole(PROPOSER_ROLE, owner.address)).to.equal(true);
      expect(await governance.hasRole(PROPOSER_ROLE, proposer.address)).to.equal(true);
    });
    
    it("Should set the correct governance parameters", async function () {
      expect(await governance.votingDelay()).to.equal(1);
      expect(await governance.votingPeriod()).to.equal(17280);
      expect(await governance.proposalThreshold()).to.equal(ethers.utils.parseEther("1000"));
      expect(await governance.quorumVotes()).to.equal(ethers.utils.parseEther("10000"));
    });
  });
  
  describe("Creating Proposals", function () {
    it("Should create a proposal", async function () {
      const callData = "0x12345678"; // Example call data
      const description = "Test Proposal";
      
      await expect(
        governance.connect(proposer).propose(target.address, description, callData)
      )
        .to.emit(governance, "ProposalCreated")
        .withArgs(1, proposer.address, target.address, description, ethers.BigNumber.from(await ethers.provider.getBlockNumber()).add(1), ethers.BigNumber.from(await ethers.provider.getBlockNumber()).add(1).add(17280));
      
      expect(await governance.proposalCount()).to.equal(1);
      
      // Check proposal details
      const proposal = await governance.getProposalDetails(1);
      expect(proposal[0]).to.equal(proposer.address); // proposer
      expect(proposal[1]).to.equal(description); // description
    });
    
    it("Should fail if proposer doesn't have enough tokens", async function () {
      const callData = "0x12345678";
      const description = "Test Proposal";
      
      // Create a new account with no tokens
      const [, , , , , poorProposer] = await ethers.getSigners();
      
      // Grant proposer role
      await governance.grantProposerRole(poorProposer.address);
      
      await expect(
        governance.connect(poorProposer).propose(target.address, description, callData)
      ).to.be.revertedWith("Governance: proposer votes below threshold");
    });
    
    it("Should fail if caller doesn't have proposer role", async function () {
      const callData = "0x12345678";
      const description = "Test Proposal";
      
      await expect(
        governance.connect(voter1).propose(target.address, description, callData)
      ).to.be.revertedWith("Governance: must have proposer role to propose");
    });
  });
  
  describe("Voting on Proposals", function () {
    let proposalId;
    
    beforeEach(async function () {
      // Create a proposal
      const callData = "0x12345678";
      const description = "Test Proposal";
      
      const tx = await governance.connect(proposer).propose(target.address, description, callData);
      const receipt = await tx.wait();
      
      proposalId = 1;
      
      // Mine blocks to reach voting start
      await ethers.provider.send("evm_mine");
    });
    
    it("Should allow voting on an active proposal", async function () {
      // Vote for the proposal
      await expect(
        governance.connect(voter1).castVote(proposalId, true)
      )
        .to.emit(governance, "VoteCast")
        .withArgs(voter1.address, proposalId, true, ethers.utils.parseEther("5000"));
      
      // Check vote was recorded
      const receipt = await governance.getReceipt(proposalId, voter1.address);
      expect(receipt[0]).to.equal(true); // hasVoted
      expect(receipt[1]).to.equal(true); // support
      expect(receipt[2]).to.equal(ethers.utils.parseEther("5000")); // votes
      
      // Check proposal vote counts
      const proposal = await governance.getProposalDetails(proposalId);
      expect(proposal[4]).to.equal(ethers.utils.parseEther("5000")); // forVotes
      expect(proposal[5]).to.equal(0); // againstVotes
    });
    
    it("Should allow voting against a proposal", async function () {
      // Vote against the proposal
      await governance.connect(voter2).castVote(proposalId, false);
      
      // Check vote was recorded
      const receipt = await governance.getReceipt(proposalId, voter2.address);
      expect(receipt[0]).to.equal(true); // hasVoted
      expect(receipt[1]).to.equal(false); // support
      expect(receipt[2]).to.equal(ethers.utils.parseEther("7000")); // votes
      
      // Check proposal vote counts
      const proposal = await governance.getProposalDetails(proposalId);
      expect(proposal[4]).to.equal(0); // forVotes
      expect(proposal[5]).to.equal(ethers.utils.parseEther("7000")); // againstVotes
    });
    
    it("Should not allow voting twice", async function () {
      // Vote once
      await governance.connect(voter1).castVote(proposalId, true);
      
      // Try to vote again
      await expect(
        governance.connect(voter1).castVote(proposalId, false)
      ).to.be.revertedWith("Governance: voter already voted");
    });
    
    it("Should not allow voting on a non-active proposal", async function () {
      // Mine blocks to end voting period
      for (let i = 0; i < 17280; i++) {
        await ethers.provider.send("evm_mine");
      }
      
      // Try to vote
      await expect(
        governance.connect(voter1).castVote(proposalId, true)
      ).to.be.revertedWith("Governance: voting is closed");
    });
  });
  
  describe("Executing Proposals", function () {
    let proposalId;
    
    beforeEach(async function () {
      // Create a mock target contract
      const MockTarget = await ethers.getContractFactory("MockTarget");
      const mockTarget = await MockTarget.deploy();
      await mockTarget.deployed();
      
      // Create a proposal with real call data
      const callData = mockTarget.interface.encodeFunctionData("setValue", [42]);
      const description = "Set value to 42";
      
      await governance.connect(proposer).propose(mockTarget.address, description, callData);
      proposalId = 1;
      
      // Mine blocks to reach voting start
      await ethers.provider.send("evm_mine");
      
      // Vote for the proposal with enough votes to pass quorum
      await governance.connect(proposer).castVote(proposalId, true); // 10,000 tokens
      await governance.connect(voter1).castVote(proposalId, true); // 5,000 tokens
      
      // Mine blocks to end voting period
      for (let i = 0; i < 17280; i++) {
        await ethers.provider.send("evm_mine");
      }
    });
    
    it("Should execute a successful proposal", async function () {
      // Execute the proposal
      await expect(
        governance.execute(proposalId)
      ).to.emit(governance, "ProposalExecuted").withArgs(proposalId);
      
      // Check proposal state
      expect(await governance.state(proposalId)).to.equal(5); // Executed
      
      // Check proposal details
      const proposal = await governance.getProposalDetails(proposalId);
      expect(proposal[6]).to.equal(true); // executed
    });
    
    it("Should fail if proposal is not successful", async function () {
      // Create a new proposal
      const callData = "0x12345678";
      const description = "Test Proposal 2";
      
      await governance.connect(proposer).propose(target.address, description, callData);
      const newProposalId = 2;
      
      // Mine blocks to reach voting start
      await ethers.provider.send("evm_mine");
      
      // Vote against the proposal
      await governance.connect(proposer).castVote(newProposalId, false);
      
      // Mine blocks to end voting period
      for (let i = 0; i < 17280; i++) {
        await ethers.provider.send("evm_mine");
      }
      
      // Try to execute the failed proposal
      await expect(
        governance.execute(newProposalId)
      ).to.be.revertedWith("Governance: proposal not successful");
    });
  });
  
  describe("Canceling Proposals", function () {
    let proposalId;
    
    beforeEach(async function () {
      // Create a proposal
      const callData = "0x12345678";
      const description = "Test Proposal";
      
      await governance.connect(proposer).propose(target.address, description, callData);
      proposalId = 1;
    });
    
    it("Should allow proposer to cancel a proposal", async function () {
      await expect(
        governance.connect(proposer).cancel(proposalId)
      ).to.emit(governance, "ProposalCanceled").withArgs(proposalId);
      
      // Check proposal state
      expect(await governance.state(proposalId)).to.equal(2); // Canceled
      
      // Check proposal details
      const proposal = await governance.getProposalDetails(proposalId);
      expect(proposal[7]).to.equal(true); // canceled
    });
    
    it("Should allow admin to cancel a proposal", async function () {
      await expect(
        governance.connect(owner).cancel(proposalId)
      ).to.emit(governance, "ProposalCanceled").withArgs(proposalId);
      
      // Check proposal state
      expect(await governance.state(proposalId)).to.equal(2); // Canceled
    });
    
    it("Should not allow non-proposer/non-admin to cancel a proposal", async function () {
      await expect(
        governance.connect(voter1).cancel(proposalId)
      ).to.be.revertedWith("Governance: only proposer or admin can cancel");
    });
    
    it("Should not allow canceling an executed proposal", async function () {
      // Mine blocks to reach voting start
      await ethers.provider.send("evm_mine");
      
      // Vote for the proposal with enough votes to pass quorum
      await governance.connect(proposer).castVote(proposalId, true); // 10,000 tokens
      await governance.connect(voter1).castVote(proposalId, true); // 5,000 tokens
      
      // Mine blocks to end voting period
      for (let i = 0; i < 17280; i++) {
        await ethers.provider.send("evm_mine");
      }
      
      // Mark the proposal as executed (we can't actually execute it with the dummy call data)
      const proposal = await ethers.getContractAt("Governance", governance.address);
      await proposal.execute(proposalId);
      
      // Try to cancel the executed proposal
      await expect(
        governance.connect(proposer).cancel(proposalId)
      ).to.be.revertedWith("Governance: proposal not active");
    });
  });
  
  describe("Governance Parameters", function () {
    it("Should allow admin to set voting delay", async function () {
      await expect(
        governance.setVotingDelay(10)
      )
        .to.emit(governance, "VotingDelaySet")
        .withArgs(1, 10);
      
      expect(await governance.votingDelay()).to.equal(10);
    });
    
    it("Should allow admin to set voting period", async function () {
      await expect(
        governance.setVotingPeriod(20000)
      )
        .to.emit(governance, "VotingPeriodSet")
        .withArgs(17280, 20000);
      
      expect(await governance.votingPeriod()).to.equal(20000);
    });
    
    it("Should allow admin to set proposal threshold", async function () {
      const newThreshold = ethers.utils.parseEther("2000");
      
      await expect(
        governance.setProposalThreshold(newThreshold)
      )
        .to.emit(governance, "ProposalThresholdSet")
        .withArgs(ethers.utils.parseEther("1000"), newThreshold);
      
      expect(await governance.proposalThreshold()).to.equal(newThreshold);
    });
    
    it("Should allow admin to set quorum votes", async function () {
      const newQuorum = ethers.utils.parseEther("15000");
      
      await expect(
        governance.setQuorumVotes(newQuorum)
      )
        .to.emit(governance, "QuorumVotesSet")
        .withArgs(ethers.utils.parseEther("10000"), newQuorum);
      
      expect(await governance.quorumVotes()).to.equal(newQuorum);
    });
    
    it("Should not allow non-admin to set parameters", async function () {
      await expect(
        governance.connect(proposer).setVotingDelay(10)
      ).to.be.revertedWith("AccessControl:");
      
      await expect(
        governance.connect(proposer).setVotingPeriod(20000)
      ).to.be.revertedWith("AccessControl:");
      
      await expect(
        governance.connect(proposer).setProposalThreshold(ethers.utils.parseEther("2000"))
      ).to.be.revertedWith("AccessControl:");
      
      await expect(
        governance.connect(proposer).setQuorumVotes(ethers.utils.parseEther("15000"))
      ).to.be.revertedWith("AccessControl:");
    });
  });
});

Create a mock target contract for testing in contracts/MockTarget.sol:

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

contract MockTarget {
    uint256 public value;
    
    function setValue(uint256 newValue) public {
        value = newValue;
    }
}

Run the tests to make sure your governance contract works correctly:

npx hardhat test

Part 4: Deployment Script

Create a deployment script in scripts/deploy.js:

const { ethers } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying contracts with the account:", deployer.address);
  
  // Deploy the token
  const GovernanceToken = await ethers.getContractFactory("GovernanceToken");
  const token = await GovernanceToken.deploy(
    "Governance Token",
    "GOV",
    18,
    1000000 // 1 million tokens
  );
  
  await token.deployed();
  console.log("GovernanceToken deployed to:", token.address);
  
  // Deploy the vesting contract
  const TokenVesting = await ethers.getContractFactory("TokenVesting");
  const vesting = await TokenVesting.deploy(token.address);
  
  await vesting.deployed();
  console.log("TokenVesting deployed to:", vesting.address);
  
  // Deploy the governance contract
  const Governance = await ethers.getContractFactory("Governance");
  const governance = await Governance.deploy(token.address);
  
  await governance.deployed();
  console.log("Governance deployed to:", governance.address);
  
  // Set the governance address in the token
  await token.setGovernanceAddress(governance.address);
  console.log("Governance address set in token");
  
  // Transfer tokens to the vesting contract for future allocations
  const vestingAmount = ethers.utils.parseEther("500000"); // 500,000 tokens (50%)
  await token.transfer(vesting.address, vestingAmount);
  console.log("Transferred", ethers.utils.formatEther(vestingAmount), "tokens to vesting contract");
  
  // Create a sample vesting schedule for the deployer
  const now = Math.floor(Date.now() / 1000);
  const cliff = 30 * 86400; // 30 days
  const duration = 365 * 86400; // 1 year
  const slicePeriod = 86400; // 1 day
  const amount = ethers.utils.parseEther("50000"); // 50,000 tokens
  
  await vesting.createVestingSchedule(
    deployer.address,
    now,
    cliff,
    duration,
    slicePeriod,
    true, // revocable
    amount
  );
  
  console.log("Created vesting schedule for deployer");
  console.log("Vesting details:");
  console.log("  Beneficiary:", deployer.address);
  console.log("  Cliff:", cliff / 86400, "days");
  console.log("  Duration:", duration / 86400, "days");
  console.log("  Amount:", ethers.utils.formatEther(amount), "tokens");
  
  console.log("Deployment complete!");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Part 5: Integration and Testing

5.1 Local Testing

Let's test our deployment script on a local network:

npx hardhat node

In a separate terminal:

npx hardhat run scripts/deploy.js --network localhost

5.2 Testnet Deployment

To deploy to a testnet, update your hardhat.config.js file:

require("@nomiclabs/hardhat-waffle");
require("dotenv").config();

const PRIVATE_KEY = process.env.PRIVATE_KEY || "0x0000000000000000000000000000000000000000000000000000000000000000";
const INFURA_API_KEY = process.env.INFURA_API_KEY || "";

module.exports = {
  solidity: "0.8.17",
  networks: {
    hardhat: {},
    goerli: {
      url: `https://goerli.infura.io/v3/${INFURA_API_KEY}`,
      accounts: [PRIVATE_KEY]
    },
    sepolia: {
      url: `https://sepolia.infura.io/v3/${INFURA_API_KEY}`,
      accounts: [PRIVATE_KEY]
    }
  }
};

Create a .env file with your private key and Infura API key:

PRIVATE_KEY=your_private_key_without_0x_prefix
INFURA_API_KEY=your_infura_api_key

Then deploy to the testnet:

npx hardhat run scripts/deploy.js --network goerli

Part 6: Frontend Integration (Optional)

For a complete DApp, you would typically create a frontend to interact with your contracts. Here's a simple example of how you might integrate with a React application:

import { ethers } from "ethers";
import GovernanceTokenABI from "./abis/GovernanceToken.json";
import GovernanceABI from "./abis/Governance.json";
import TokenVestingABI from "./abis/TokenVesting.json";

// Contract addresses (replace with your deployed addresses)
const TOKEN_ADDRESS = "0x...";
const GOVERNANCE_ADDRESS = "0x...";
const VESTING_ADDRESS = "0x...";

// Connect to provider
async function connectProvider() {
  if (window.ethereum) {
    await window.ethereum.request({ method: "eth_requestAccounts" });
    const provider = new ethers.providers.Web3Provider(window.ethereum);
    const signer = provider.getSigner();
    return { provider, signer };
  }
  throw new Error("Please install MetaMask");
}

// Get contract instances
async function getContracts() {
  const { provider, signer } = await connectProvider();
  
  const token = new ethers.Contract(TOKEN_ADDRESS, GovernanceTokenABI, signer);
  const governance = new ethers.Contract(GOVERNANCE_ADDRESS, GovernanceABI, signer);
  const vesting = new ethers.Contract(VESTING_ADDRESS, TokenVestingABI, signer);
  
  return { token, governance, vesting, provider, signer };
}

// Get token balance
async function getBalance() {
  const { token, signer } = await getContracts();
  const address = await signer.getAddress();
  const balance = await token.balanceOf(address);
  return ethers.utils.formatEther(balance);
}

// Create a proposal
async function createProposal(target, description, callData) {
  const { governance } = await getContracts();
  const tx = await governance.propose(target, description, callData);
  await tx.wait();
  return tx;
}

// Vote on a proposal
async function vote(proposalId, support) {
  const { governance } = await getContracts();
  const tx = await governance.castVote(proposalId, support);
  await tx.wait();
  return tx;
}

// Execute a proposal
async function executeProposal(proposalId) {
  const { governance } = await getContracts();
  const tx = await governance.execute(proposalId);
  await tx.wait();
  return tx;
}

// Get vesting schedule
async function getVestingSchedule(scheduleId) {
  const { vesting } = await getContracts();
  return await vesting.getVestingSchedule(scheduleId);
}

// Release vested tokens
async function releaseVestedTokens(scheduleId, amount) {
  const { vesting } = await getContracts();
  const tx = await vesting.release(scheduleId, amount);
  await tx.wait();
  return tx;
}

export {
  connectProvider,
  getContracts,
  getBalance,
  createProposal,
  vote,
  executeProposal,
  getVestingSchedule,
  releaseVestedTokens
};

Conclusion

In this project, we've built a comprehensive ERC20 token implementation with vesting and governance features. The project demonstrates how to:

This implementation can serve as a foundation for various tokenized applications, DAOs, or DeFi protocols. The modular design allows for easy extension and customization to meet specific project requirements.

Next Steps

To further enhance this project, consider: