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
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
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);
}
}
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) {}
}
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;
}
}
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;
});
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
To test a proposal:
- Delegate votes to an address:
await token.delegate(deployer.address)in Hardhat console - Submit a proposal to send 1 ETH to a test address
- Wait for voting delay, cast a vote, wait for voting period to end
- 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)