DEV Community

ANKUSH CHOUDHARY JOHAL
ANKUSH CHOUDHARY JOHAL

Posted on • Originally published at johal.in

Step-by-Step Guide to Building DAOs with OpenZeppelin and Solidity 0.8.20

Step-by-Step Guide to Building DAOs with OpenZeppelin and Solidity 0.8.20

What is a DAO?

A Decentralized Autonomous Organization (DAO) is a blockchain-native organization governed by smart contracts and token-holder voting, with no central leadership. DAOs enable transparent, trustless decision-making for communities, treasuries, and protocols.

This guide uses Solidity 0.8.20 (with built-in overflow checks and improved safety) and OpenZeppelin’s audited contract libraries to build a production-ready DAO with minimal custom code.

Prerequisites

  • Node.js v18+ installed
  • MetaMask wallet configured for local testing
  • Basic Solidity and JavaScript knowledge
  • Hardhat development environment (we’ll set this up below)

Step 1: Initialize the Development Environment

First, create a new project directory and initialize Hardhat:

mkdir dao-example && cd dao-example
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
Enter fullscreen mode Exit fullscreen mode

Select "Create a JavaScript project" when prompted, and agree to install dependencies. Next, install OpenZeppelin’s contracts library:

npm install @openzeppelin/contracts@4.9.3
Enter fullscreen mode Exit fullscreen mode

Note: OpenZeppelin 4.9.3 is fully compatible with Solidity 0.8.20.

Step 2: Define the Governance Token

DAOs require a governance token to weight votes. We’ll use OpenZeppelin’s ERC20Votes contract, which extends ERC20 with vote delegation and checkpointing (required for Governor compatibility).

Create a new file contracts/GovernanceToken.sol:

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

import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";

contract GovernanceToken is ERC20Votes {
    uint256 public s_maxSupply = 1000000 * 10**18;

    constructor() ERC20("GovernanceToken", "GT") ERC20Permit("GovernanceToken") {}

    function mint(address to, uint256 amount) public {
        require(totalSupply() + amount <= s_maxSupply, "Max supply exceeded");
        _mint(to, amount);
    }

    // Override _afterTokenTransfer to satisfy ERC20Votes requirements
    function _afterTokenTransfer(
        address from,
        address to,
        uint256 amount
    ) internal override(ERC20Votes) {
        super._afterTokenTransfer(from, to, amount);
    }

    function _mint(address to, uint256 amount) internal override(ERC20Votes) {
        super._mint(to, amount);
    }

    function _burn(address from, uint256 amount) internal override(ERC20Votes) {
        super._burn(from, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

The ERC20Votes contract includes ERC20Permit for gasless approvals, and automatically tracks voting power checkpoints.

Step 3: Configure the Timelock Controller

A TimelockController delays execution of approved proposals to give users time to exit if they disagree with a decision. OpenZeppelin’s TimelockController is audited and customizable.

Create contracts/Timelock.sol:

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

import "@openzeppelin/contracts/governance/TimelockController.sol";

contract Timelock is TimelockController {
    constructor(
        uint256 minDelay,
        address[] memory proposers,
        address[] memory executors,
        address admin
    ) TimelockController(minDelay, proposers, executors, admin) {}
}
Enter fullscreen mode Exit fullscreen mode

Parameters:

  • minDelay: Minimum time between proposal success and execution (e.g., 1 day = 86400 seconds)
  • proposers: Addresses allowed to submit proposals (we’ll set this to the Governor contract later)
  • executors: Addresses allowed to execute proposals (usually any address, set to empty array for public execution)
  • admin: Address with admin rights to modify timelock settings (set to address(0) to revoke after setup)

Step 4: Deploy the Governor Contract

OpenZeppelin’s Governor contract handles proposal creation, voting, and execution. We’ll use Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, and GovernorTimelockControl to add all required functionality.

Create contracts/Governor.sol:

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

import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";

contract DAOGovernor is
    Governor,
    GovernorSettings,
    GovernorCountingSimple,
    GovernorVotes,
    GovernorTimelockControl
{
    constructor(
        IVotes _token,
        TimelockController _timelock,
        uint48 _votingDelay,
        uint32 _votingPeriod,
        uint256 _proposalThreshold
    )
        Governor("DAOGovernor")
        GovernorSettings(_votingDelay, _votingPeriod, _proposalThreshold)
        GovernorVotes(_token)
        GovernorTimelockControl(_timelock)
    {}

    // Override required functions to resolve multiple inheritance
    function votingDelay() public view override(IGovernor, GovernorSettings) returns (uint48) {
        return super.votingDelay();
    }

    function votingPeriod() public view override(IGovernor, GovernorSettings) returns (uint32) {
        return super.votingPeriod();
    }

    function proposalThreshold() public view override(IGovernor, GovernorSettings) returns (uint256) {
        return super.proposalThreshold();
    }

    function _executor() internal view override(GovernorTimelockControl) returns (address) {
        return super._executor();
    }

    function _cancel(
        address caller,
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        bytes32 descriptionHash
    ) internal override(Governor, GovernorTimelockControl) returns (uint256) {
        return super._cancel(caller, targets, values, calldatas, descriptionHash);
    }

    function getVotes(
        address account,
        uint256 timepoint
    ) public view override(IGovernor, GovernorVotes) returns (uint256) {
        return super.getVotes(account, timepoint);
    }

    function state(
        uint256 proposalId
    ) public view override(Governor, GovernorTimelockControl) returns (ProposalState) {
        return super.state(proposalId);
    }

    function propose(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        string memory description
    ) public override(Governor, IGovernor) returns (uint256) {
        return super.propose(targets, values, calldatas, description);
    }

    function _executeOperations(
        uint256 proposalId,
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        bytes32 descriptionHash
    ) internal override(Governor, GovernorTimelockControl) {
        super._executeOperations(proposalId, targets, values, calldatas, descriptionHash);
    }

    function _propose(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        string memory description,
        address proposer
    ) internal override(Governor, GovernorTimelockControl) returns (uint256) {
        return super._propose(targets, values, calldatas, description, proposer);
    }

    function quorum(uint256 timepoint) public view override(IGovernor, GovernorVotes) returns (uint256) {
        // 4% quorum of total supply
        return (token().totalSupply() * 4) / 100;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key configuration parameters:

  • votingDelay: Blocks to wait between proposal creation and start of voting (e.g., 1 block = ~12 seconds on Ethereum)
  • votingPeriod: Blocks voting remains open (e.g., 45818 blocks = ~7 days on Ethereum)
  • proposalThreshold: Minimum votes required to submit a proposal (e.g., 1% of total supply)
  • quorum: Minimum votes required for a proposal to pass (set to 4% of total supply in the example)

Step 5: Write Deployment Scripts

Create a deployment script scripts/deploy.js to deploy all contracts in the correct order:

const hre = require("hardhat");

async function main() {
  const [deployer] = await hre.ethers.getSigners();
  console.log("Deploying with account:", deployer.address);

  // 1. Deploy Governance Token
  const Token = await hre.ethers.getContractFactory("GovernanceToken");
  const token = await Token.deploy();
  await token.waitForDeployment();
  console.log("GovernanceToken deployed to:", await token.getAddress());

  // 2. Deploy Timelock (1 day min delay, no proposers/executors yet, admin = deployer)
  const minDelay = 86400; // 1 day in seconds
  const proposers = [];
  const executors = [];
  const admin = deployer.address;
  const Timelock = await hre.ethers.getContractFactory("Timelock");
  const timelock = await Timelock.deploy(minDelay, proposers, executors, admin);
  await timelock.waitForDeployment();
  console.log("Timelock deployed to:", await timelock.getAddress());

  // 3. Deploy Governor (voting delay: 1 block, voting period: 45818 blocks (~7 days), proposal threshold: 1% of supply)
  const votingDelay = 1;
  const votingPeriod = 45818;
  const proposalThreshold = hre.ethers.parseEther("10000"); // 1% of 1M supply
  const Governor = await hre.ethers.getContractFactory("DAOGovernor");
  const governor = await Governor.deploy(
    await token.getAddress(),
    await timelock.getAddress(),
    votingDelay,
    votingPeriod,
    proposalThreshold
  );
  await governor.waitForDeployment();
  console.log("DAOGovernor deployed to:", await governor.getAddress());

  // 4. Configure Timelock: grant proposer role to Governor, revoke admin role
  const proposerRole = await timelock.PROPOSER_ROLE();
  const executorRole = await timelock.EXECUTOR_ROLE();
  const adminRole = await timelock.TIMELOCK_ADMIN_ROLE();

  await timelock.grantRole(proposerRole, await governor.getAddress());
  await timelock.grantRole(executorRole, hre.ethers.ZeroAddress); // Anyone can execute
  await timelock.revokeRole(adminRole, deployer.address); // Revoke deployer admin access
  console.log("Timelock configured successfully");
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Step 6: Test the DAO

Compile contracts and run a local test:

npx hardhat compile
npx hardhat node
npx hardhat run scripts/deploy.js --network localhost
Enter fullscreen mode Exit fullscreen mode

To test a proposal:

  1. Delegate votes to an address: await token.delegate(deployer.address) in Hardhat console
  2. Submit a proposal to send 1 ETH to a test address
  3. Wait for voting delay, cast a vote, wait for voting period to end
  4. Execute the proposal after the timelock delay

Security Best Practices

  • Always use audited OpenZeppelin contracts for core functionality
  • Set reasonable voting periods and quorums to prevent governance attacks
  • Revoke timelock admin roles after initial setup to ensure decentralization
  • Test all proposal types on a testnet before mainnet deployment
  • Use multisig wallets for initial token distribution and critical operations

Conclusion

Using OpenZeppelin and Solidity 0.8.20, you can deploy a secure, production-ready DAO in minutes with minimal custom code. Extend this base with custom proposal types, treasury management, or NFT-based voting as needed for your use case.

Top comments (0)