Web3 Development
Building a DAO Governance System with Solidity and React
TL;DR
DAO governance is more than a voting widget. It is a complete on-chain execution pipeline — proposal creation, voting, timelock delay, and execution — where every step must be tamper-proof and transparent. I have built governance contracts for clients through my services, using OpenZeppelin Governor as the foundation and Foundry for testing. This article walks through the full stack: a Governor contract with configurable voting parameters, a TimelockController for delayed execution, an ERC-20 voting token with delegation, and a React frontend using wagmi and viem for wallet interaction. Every contract is complete and deployable. Every React component handles real edge cases like delegation prompts and proposal state tracking. If you are building a DAO or adding governance to an existing protocol, this is the implementation I reach for every time.
What DAOs Actually Need
Most DAO tutorials show you how to deploy a Governor contract and call it done. That is about 20% of what a functioning DAO needs. I learned this the hard way when a client asked me to "add governance" to their protocol and I realized the on-chain voting was the easy part. The hard part is everything around it.
A production DAO governance system needs these pieces:
- A governance token with delegation. Users must delegate their voting power before it counts. This is not optional — it is how OpenZeppelin Governor works. If your users do not delegate (even to themselves), they have zero voting power regardless of their token balance.
- A Governor contract with sensible defaults. Voting delay, voting period, proposal threshold, and quorum — these are not numbers you can guess. They depend on your token distribution and community activity.
- A TimelockController that sits between the Governor and your protocol. The Governor proposes, but the Timelock executes. This gives your community a window to exit if a malicious proposal passes.
- A frontend that makes all of this usable. On-chain governance with a bad UI is governance nobody participates in.
Here is the architecture I use:
contracts/
governance/
GovernanceToken.sol # ERC20Votes — delegation-enabled token
DAOGovernor.sol # OpenZeppelin Governor — proposals + voting
Timelock.sol # TimelockController — delayed execution
target/
Treasury.sol # Example target contract the DAO controlsThe Governor contract never holds funds or executes actions directly. It creates proposals and, once passed, queues them in the Timelock. The Timelock is the actual owner of your protocol contracts. This separation is critical — it means even if the Governor has a bug, there is a time delay before any damage can happen.
Governor Contract Architecture
OpenZeppelin's Governor is modular. You pick the extensions you need and compose them. After building governance for several projects, here is the combination I always start with:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
import {GovernorSettings} from "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import {GovernorCountingSimple} from "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import {GovernorVotes} from "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import {GovernorVotesQuorumFraction} from "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import {GovernorTimelockControl} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
contract DAOGovernor is
Governor,
GovernorSettings,
GovernorCountingSimple,
GovernorVotes,
GovernorVotesQuorumFraction,
GovernorTimelockControl
{
constructor(
IVotes token,
TimelockController timelock,
uint48 votingDelay_,
uint32 votingPeriod_,
uint256 proposalThreshold_,
uint256 quorumPercentage_
)
Governor("DAOGovernor")
GovernorSettings(votingDelay_, votingPeriod_, proposalThreshold_)
GovernorVotes(token)
GovernorVotesQuorumFraction(quorumPercentage_)
GovernorTimelockControl(timelock)
{}
// --- Required overrides ---
function votingDelay()
public
view
override(Governor, GovernorSettings)
returns (uint256)
{
return super.votingDelay();
}
function votingPeriod()
public
view
override(Governor, GovernorSettings)
returns (uint256)
{
return super.votingPeriod();
}
function quorum(uint256 blockNumber)
public
view
override(Governor, GovernorVotesQuorumFraction)
returns (uint256)
{
return super.quorum(blockNumber);
}
function state(uint256 proposalId)
public
view
override(Governor, GovernorTimelockControl)
returns (ProposalState)
{
return super.state(proposalId);
}
function proposalNeedsQueuing(uint256 proposalId)
public
view
override(Governor, GovernorTimelockControl)
returns (bool)
{
return super.proposalNeedsQueuing(proposalId);
}
function proposalThreshold()
public
view
override(Governor, GovernorSettings)
returns (uint256)
{
return super.proposalThreshold();
}
function _queueOperations(
uint256 proposalId,
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 descriptionHash
)
internal
override(Governor, GovernorTimelockControl)
returns (uint48)
{
return super._queueOperations(
proposalId, targets, values, calldatas, descriptionHash
);
}
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 _cancel(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 descriptionHash
)
internal
override(Governor, GovernorTimelockControl)
returns (uint256)
{
return super._cancel(targets, values, calldatas, descriptionHash);
}
function _executor()
internal
view
override(Governor, GovernorTimelockControl)
returns (address)
{
return super._executor();
}
}A few things worth noting about this implementation. GovernorCountingSimple gives you three-way voting: For, Against, and Abstain. This is what most DAOs need. If you want more exotic voting (quadratic, conviction, ranked choice), you write a custom counting module instead. GovernorVotesQuorumFraction sets quorum as a percentage of total token supply at the time of proposal creation, not a fixed number. This scales automatically as your token distribution changes. GovernorTimelockControl routes all execution through the Timelock, which is the pattern I recommend for every DAO without exception.
The constructor parameters are where governance design meets political science. Here are the values I typically start with for a mid-size DAO:
| Parameter | Value | Reasoning |
|---|---|---|
votingDelay | 7200 (1 day in blocks at 12s) | Gives delegates time to review proposals |
votingPeriod | 50400 (1 week in blocks) | Long enough for global participation |
proposalThreshold | 1000e18 (1000 tokens) | Prevents spam without being exclusionary |
quorumPercentage | 4 (4%) | Realistic for DAOs with < 50% active participation |
Proposal Lifecycle
Every proposal in the Governor follows a strict state machine. Understanding this flow is essential because your frontend needs to handle each state and your tests need to verify transitions.
Pending → Active → Succeeded → Queued → Executed
↘ Defeated
↘ CanceledHere is how a proposal moves through the system:
- Pending. A token holder with enough voting power calls
propose(). The proposal exists but voting has not started. The voting delay period is ticking. - Active. The voting delay passes. Token holders can now cast votes. This is the only window for participation.
- Succeeded / Defeated. The voting period ends. If quorum is met and more votes are For than Against, the proposal succeeds. Otherwise, it is defeated.
- Queued. Someone calls
queue()on the succeeded proposal. This schedules it in the Timelock with the configured delay. - Executed. After the timelock delay, someone calls
execute(). The Timelock runs the proposal's transactions against the target contracts.
The proposal itself is just a set of transactions encoded as arrays:
// Example: Propose releasing 50,000 tokens from the treasury
address[] memory targets = new address[](1);
targets[0] = address(treasury);
uint256[] memory values = new uint256[](1);
values[0] = 0;
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSignature(
"releaseFunds(address,uint256)",
recipientAddress,
50_000e18
);
string memory description = "Proposal #12: Fund Q2 development grant";
governor.propose(targets, values, calldatas, description);A single proposal can contain multiple transactions. This is how DAOs execute complex operations atomically — for example, updating a protocol parameter, transferring funds, and granting a role, all in one proposal.
Voting Mechanisms
The governance token is what makes voting possible. It needs to implement ERC20Votes, which adds checkpointing and delegation. Without checkpointing, a user could buy tokens, vote, then sell — double-counting their influence. Checkpointing snapshots voting power at the block a proposal was created.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
contract GovernanceToken is ERC20, ERC20Permit, ERC20Votes {
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
)
ERC20(name, symbol)
ERC20Permit(name)
{
_mint(msg.sender, initialSupply);
}
// --- Required overrides ---
function _update(
address from,
address to,
uint256 value
)
internal
override(ERC20, ERC20Votes)
{
super._update(from, to, value);
}
function nonces(address owner)
public
view
override(ERC20Permit, Nonces)
returns (uint256)
{
return super.nonces(owner);
}
}The critical thing most tutorials skip: delegation is mandatory. When a user receives governance tokens, their voting power is zero until they call delegate(address). They can delegate to themselves or to another address. This is not a bug — it is by design. Checkpointing every transfer would be too gas-expensive, so OpenZeppelin only tracks voting power for addresses that have explicitly opted in through delegation.
This has a massive UX implication. Your frontend must prompt users to delegate as soon as they receive tokens. I will cover this in the frontend section.
Voting itself is straightforward. During the active period, a token holder calls castVote() with their choice:
// 0 = Against, 1 = For, 2 = Abstain
governor.castVote(proposalId, 1); // Vote For
// With a reason (stored in events, useful for transparency)
governor.castVoteWithReason(proposalId, 1, "This aligns with our Q2 roadmap");Timelock Controller
The TimelockController is the security backbone of your DAO. It sits between the Governor (which decides) and the target contracts (which execute). The delay period gives the community a safety window — if a malicious proposal somehow passes, token holders can see the queued transaction and exit the protocol before it executes.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {TimelockController} from "@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)
{}
}The deployment order matters and gets people every time. Here is the correct sequence:
1. Deploy GovernanceToken
2. Deploy Timelock (minDelay = 3600 for 1 hour, or 86400 for 1 day)
- proposers: [] (empty — we assign the Governor after deploy)
- executors: [address(0)] (anyone can execute after delay)
- admin: deployer (temporary — revoke after setup)
3. Deploy DAOGovernor (token = GovernanceToken, timelock = Timelock)
4. Grant PROPOSER_ROLE on Timelock to DAOGovernor
5. Grant CANCELLER_ROLE on Timelock to DAOGovernor
6. Revoke TIMELOCK_ADMIN_ROLE from deployer
7. Transfer ownership of target contracts to TimelockStep 6 is non-negotiable for a real DAO. If the deployer retains admin rights on the Timelock, the DAO is not actually decentralized — someone can bypass governance entirely. Here is the setup script I use in Foundry:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Script} from "forge-std/Script.sol";
import {GovernanceToken} from "../src/GovernanceToken.sol";
import {Timelock} from "../src/Timelock.sol";
import {DAOGovernor} from "../src/DAOGovernor.sol";
import {Treasury} from "../src/Treasury.sol";
contract DeployDAO is Script {
function run() external {
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerKey);
vm.startBroadcast(deployerKey);
// 1. Token
GovernanceToken token = new GovernanceToken(
"Governance Token", "GOV", 1_000_000e18
);
// 2. Timelock (1-day delay)
address[] memory proposers = new address[](0);
address[] memory executors = new address[](1);
executors[0] = address(0); // Anyone can execute
Timelock timelock = new Timelock(
86400, proposers, executors, deployer
);
// 3. Governor
DAOGovernor governor = new DAOGovernor(
token,
timelock,
7200, // 1 day voting delay
50400, // 1 week voting period
1000e18, // 1000 token proposal threshold
4 // 4% quorum
);
// 4-5. Grant roles to Governor
timelock.grantRole(
timelock.PROPOSER_ROLE(), address(governor)
);
timelock.grantRole(
timelock.CANCELLER_ROLE(), address(governor)
);
// 6. Revoke admin from deployer
timelock.revokeRole(
timelock.DEFAULT_ADMIN_ROLE(), deployer
);
// 7. Deploy treasury owned by Timelock
Treasury treasury = new Treasury(address(timelock));
vm.stopBroadcast();
}
}The Timelock delay is a governance design decision. Too short (under an hour) and it provides no real safety window. Too long (over a week) and your DAO cannot respond to emergencies. I default to 1 day for most projects and add an emergency multisig with a shorter delay for critical security situations.
Frontend Integration with wagmi
The frontend is where most DAO projects fall apart. I have seen governance contracts with zero proposals because the UI made it too hard to participate. Here is the React setup I use with wagmi v2 and viem.
First, the contract configuration:
// lib/contracts.ts
export const GOVERNOR_ADDRESS = "0x..." as const;
export const TOKEN_ADDRESS = "0x..." as const;
export const governorAbi = [
// propose
{
name: "propose",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "targets", type: "address[]" },
{ name: "values", type: "uint256[]" },
{ name: "calldatas", type: "bytes[]" },
{ name: "description", type: "string" },
],
outputs: [{ name: "proposalId", type: "uint256" }],
},
// castVoteWithReason
{
name: "castVoteWithReason",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "proposalId", type: "uint256" },
{ name: "support", type: "uint8" },
{ name: "reason", type: "string" },
],
outputs: [{ name: "balance", type: "uint256" }],
},
// state
{
name: "state",
type: "function",
stateMutability: "view",
inputs: [{ name: "proposalId", type: "uint256" }],
outputs: [{ name: "", type: "uint8" }],
},
// proposalVotes
{
name: "proposalVotes",
type: "function",
stateMutability: "view",
inputs: [{ name: "proposalId", type: "uint256" }],
outputs: [
{ name: "againstVotes", type: "uint256" },
{ name: "forVotes", type: "uint256" },
{ name: "abstainVotes", type: "uint256" },
],
},
] as const;
export const tokenAbi = [
{
name: "delegate",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "delegatee", type: "address" }],
outputs: [],
},
{
name: "delegates",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "address" }],
},
{
name: "getVotes",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
] as const;Now the delegation component. This is the first thing a user should see if they have not delegated:
// components/DelegateVotingPower.tsx
"use client";
import { useAccount, useReadContract, useWriteContract } from "wagmi";
import { TOKEN_ADDRESS, tokenAbi } from "@/lib/contracts";
import { zeroAddress } from "viem";
const PROPOSAL_STATES = [
"Pending",
"Active",
"Canceled",
"Defeated",
"Succeeded",
"Queued",
"Expired",
"Executed",
] as const;
export function DelegateVotingPower() {
const { address } = useAccount();
const { writeContract, isPending } = useWriteContract();
const { data: currentDelegate } = useReadContract({
address: TOKEN_ADDRESS,
abi: tokenAbi,
functionName: "delegates",
args: address ? [address] : undefined,
});
const { data: votingPower } = useReadContract({
address: TOKEN_ADDRESS,
abi: tokenAbi,
functionName: "getVotes",
args: address ? [address] : undefined,
});
const needsDelegation =
!currentDelegate || currentDelegate === zeroAddress;
if (!needsDelegation) {
return (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-4">
<p className="text-sm text-emerald-400">
Voting power active: {formatVotes(votingPower)} votes
</p>
</div>
);
}
return (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
<p className="mb-3 text-sm text-amber-200">
You hold governance tokens but have not delegated your voting
power. Delegate to yourself to start voting on proposals.
</p>
<button
onClick={() =>
writeContract({
address: TOKEN_ADDRESS,
abi: tokenAbi,
functionName: "delegate",
args: [address!],
})
}
disabled={isPending}
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-black transition-colors hover:bg-amber-400 disabled:opacity-50"
>
{isPending ? "Delegating..." : "Activate Voting Power"}
</button>
</div>
);
}
function formatVotes(votes: bigint | undefined): string {
if (!votes) return "0";
return (Number(votes) / 1e18).toLocaleString();
}The voting component handles the three-way vote and tracks proposal state:
// components/ProposalCard.tsx
"use client";
import { useReadContract, useWriteContract } from "wagmi";
import { GOVERNOR_ADDRESS, governorAbi } from "@/lib/contracts";
import { formatEther } from "viem";
interface ProposalCardProps {
proposalId: bigint;
title: string;
description: string;
}
export function ProposalCard({
proposalId,
title,
description,
}: ProposalCardProps) {
const { writeContract, isPending } = useWriteContract();
const { data: stateIndex } = useReadContract({
address: GOVERNOR_ADDRESS,
abi: governorAbi,
functionName: "state",
args: [proposalId],
});
const { data: votes } = useReadContract({
address: GOVERNOR_ADDRESS,
abi: governorAbi,
functionName: "proposalVotes",
args: [proposalId],
});
const proposalState =
stateIndex !== undefined ? PROPOSAL_STATES[stateIndex] : "Loading";
const isActive = stateIndex === 1;
function castVote(support: number) {
writeContract({
address: GOVERNOR_ADDRESS,
abi: governorAbi,
functionName: "castVoteWithReason",
args: [proposalId, support, ""],
});
}
return (
<div className="rounded-xl border border-white/10 bg-surface p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
getStateStyles(proposalState)
}`}
>
{proposalState}
</span>
</div>
<p className="mb-6 text-sm text-secondary">{description}</p>
{votes && (
<div className="mb-6 space-y-2">
<VoteBar
label="For"
votes={votes[1]}
total={votes[0] + votes[1] + votes[2]}
color="bg-emerald-500"
/>
<VoteBar
label="Against"
votes={votes[0]}
total={votes[0] + votes[1] + votes[2]}
color="bg-red-500"
/>
<VoteBar
label="Abstain"
votes={votes[2]}
total={votes[0] + votes[1] + votes[2]}
color="bg-gray-500"
/>
</div>
)}
{isActive && (
<div className="flex gap-3">
<button
onClick={() => castVote(1)}
disabled={isPending}
className="flex-1 rounded-lg bg-emerald-600 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
Vote For
</button>
<button
onClick={() => castVote(0)}
disabled={isPending}
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white transition-colors hover:bg-red-500 disabled:opacity-50"
>
Vote Against
</button>
<button
onClick={() => castVote(2)}
disabled={isPending}
className="flex-1 rounded-lg bg-gray-600 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-500 disabled:opacity-50"
>
Abstain
</button>
</div>
)}
</div>
);
}
function VoteBar({
label,
votes,
total,
color,
}: {
label: string;
votes: bigint;
total: bigint;
color: string;
}) {
const percentage =
total > 0n ? Number((votes * 10000n) / total) / 100 : 0;
return (
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-secondary">{label}</span>
<span className="text-white">
{Number(formatEther(votes)).toLocaleString()} ({percentage.toFixed(1)}%)
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/5">
<div
className={`h-full rounded-full ${color}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}
function getStateStyles(state: string): string {
switch (state) {
case "Active":
return "bg-blue-500/20 text-blue-400";
case "Succeeded":
case "Executed":
return "bg-emerald-500/20 text-emerald-400";
case "Defeated":
case "Expired":
case "Canceled":
return "bg-red-500/20 text-red-400";
case "Queued":
return "bg-amber-500/20 text-amber-400";
default:
return "bg-gray-500/20 text-gray-400";
}
}For proposal creation, I build a form that encodes the calldata client-side using viem's encodeFunctionData:
// components/CreateProposal.tsx
"use client";
import { useState } from "react";
import { useWriteContract } from "wagmi";
import { encodeFunctionData } from "viem";
import { GOVERNOR_ADDRESS, governorAbi } from "@/lib/contracts";
const TREASURY_ADDRESS = "0x..." as const;
const treasuryAbi = [
{
name: "releaseFunds",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [],
},
] as const;
export function CreateProposal() {
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [description, setDescription] = useState("");
const { writeContract, isPending } = useWriteContract();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const calldata = encodeFunctionData({
abi: treasuryAbi,
functionName: "releaseFunds",
args: [recipient as `0x${string}`, BigInt(amount) * 10n ** 18n],
});
writeContract({
address: GOVERNOR_ADDRESS,
abi: governorAbi,
functionName: "propose",
args: [
[TREASURY_ADDRESS],
[0n],
[calldata],
description,
],
});
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm text-secondary">
Recipient Address
</label>
<input
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="0x..."
className="w-full rounded-lg border border-white/10 bg-elevated px-4 py-2 text-white placeholder-muted"
/>
</div>
<div>
<label className="mb-1 block text-sm text-secondary">
Amount (tokens)
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="10000"
className="w-full rounded-lg border border-white/10 bg-elevated px-4 py-2 text-white placeholder-muted"
/>
</div>
<div>
<label className="mb-1 block text-sm text-secondary">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
placeholder="Proposal #X: Describe what this proposal does and why..."
className="w-full rounded-lg border border-white/10 bg-elevated px-4 py-2 text-white placeholder-muted"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-lg bg-primary py-3 font-medium text-black transition-colors hover:bg-accent disabled:opacity-50"
>
{isPending ? "Submitting..." : "Create Proposal"}
</button>
</form>
);
}Testing Governance
Governance testing is uniquely challenging because proposals span multiple blocks. You cannot just call functions sequentially — you need to advance the blockchain through voting delays, voting periods, and timelock delays. Foundry makes this manageable with vm.roll() and vm.warp().
Here is the test structure I use:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Test} from "forge-std/Test.sol";
import {GovernanceToken} from "../src/GovernanceToken.sol";
import {Timelock} from "../src/Timelock.sol";
import {DAOGovernor} from "../src/DAOGovernor.sol";
import {Treasury} from "../src/Treasury.sol";
import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
contract GovernanceTest is Test {
GovernanceToken token;
Timelock timelock;
DAOGovernor governor;
Treasury treasury;
address deployer = makeAddr("deployer");
address voter1 = makeAddr("voter1");
address voter2 = makeAddr("voter2");
address recipient = makeAddr("recipient");
uint48 constant VOTING_DELAY = 7200;
uint32 constant VOTING_PERIOD = 50400;
uint256 constant PROPOSAL_THRESHOLD = 1000e18;
uint256 constant QUORUM_PERCENTAGE = 4;
uint256 constant TIMELOCK_DELAY = 86400;
function setUp() public {
vm.startPrank(deployer);
// Deploy token and distribute
token = new GovernanceToken("GOV", "GOV", 1_000_000e18);
token.transfer(voter1, 400_000e18);
token.transfer(voter2, 100_000e18);
// Deploy timelock
address[] memory proposers = new address[](0);
address[] memory executors = new address[](1);
executors[0] = address(0);
timelock = new Timelock(
TIMELOCK_DELAY, proposers, executors, deployer
);
// Deploy governor
governor = new DAOGovernor(
token,
timelock,
VOTING_DELAY,
VOTING_PERIOD,
PROPOSAL_THRESHOLD,
QUORUM_PERCENTAGE
);
// Configure roles
timelock.grantRole(
timelock.PROPOSER_ROLE(), address(governor)
);
timelock.grantRole(
timelock.CANCELLER_ROLE(), address(governor)
);
timelock.revokeRole(
timelock.DEFAULT_ADMIN_ROLE(), deployer
);
// Deploy treasury owned by timelock
treasury = new Treasury(address(timelock));
vm.stopPrank();
// Voters must delegate to themselves
vm.prank(voter1);
token.delegate(voter1);
vm.prank(voter2);
token.delegate(voter2);
// Deployer delegates too
vm.prank(deployer);
token.delegate(deployer);
}
function testFullProposalLifecycle() public {
// Fund the treasury
vm.prank(deployer);
token.transfer(address(treasury), 50_000e18);
// Create proposal
address[] memory targets = new address[](1);
targets[0] = address(treasury);
uint256[] memory values = new uint256[](1);
values[0] = 0;
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSignature(
"releaseFunds(address,uint256)",
recipient,
50_000e18
);
string memory description = "Release dev funds";
vm.prank(voter1);
uint256 proposalId = governor.propose(
targets, values, calldatas, description
);
// Verify pending state
assertEq(
uint256(governor.state(proposalId)),
uint256(IGovernor.ProposalState.Pending)
);
// Advance past voting delay
vm.roll(block.number + VOTING_DELAY + 1);
// Verify active state
assertEq(
uint256(governor.state(proposalId)),
uint256(IGovernor.ProposalState.Active)
);
// Cast votes
vm.prank(voter1);
governor.castVote(proposalId, 1); // For
vm.prank(voter2);
governor.castVote(proposalId, 1); // For
// Advance past voting period
vm.roll(block.number + VOTING_PERIOD + 1);
// Verify succeeded
assertEq(
uint256(governor.state(proposalId)),
uint256(IGovernor.ProposalState.Succeeded)
);
// Queue in timelock
bytes32 descriptionHash = keccak256(bytes(description));
governor.queue(
targets, values, calldatas, descriptionHash
);
assertEq(
uint256(governor.state(proposalId)),
uint256(IGovernor.ProposalState.Queued)
);
// Advance past timelock delay
vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
// Execute
governor.execute(
targets, values, calldatas, descriptionHash
);
assertEq(
uint256(governor.state(proposalId)),
uint256(IGovernor.ProposalState.Executed)
);
// Verify funds were released
assertEq(token.balanceOf(recipient), 50_000e18);
}
function testCannotVoteBeforeDelay() public {
(uint256 proposalId,,,) = _createProposal();
// Try to vote during pending period
vm.prank(voter1);
vm.expectRevert();
governor.castVote(proposalId, 1);
}
function testDefeatedWhenQuorumNotMet() public {
(
uint256 proposalId,
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas
) = _createProposal();
vm.roll(block.number + VOTING_DELAY + 1);
// Only voter2 votes (100k of 1M = 10%, but need quorum)
// Actually 100k is enough for 4% quorum, so we skip voting entirely
// to test defeated state
vm.roll(block.number + VOTING_PERIOD + 1);
assertEq(
uint256(governor.state(proposalId)),
uint256(IGovernor.ProposalState.Defeated)
);
}
function _createProposal()
internal
returns (
uint256 proposalId,
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas
)
{
targets = new address[](1);
targets[0] = address(treasury);
values = new uint256[](1);
values[0] = 0;
calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSignature(
"releaseFunds(address,uint256)",
recipient,
10_000e18
);
vm.prank(voter1);
proposalId = governor.propose(
targets, values, calldatas, "Test proposal"
);
}
}The key testing insight: always test the negative paths. Can someone vote before the delay? Can a defeated proposal be queued? Can someone execute before the timelock expires? These are the edge cases that get exploited in production. I run fuzz tests on the voting parameters too — randomizing vote counts and checking that quorum calculations hold.
Security Considerations
Governance attacks are among the most devastating in DeFi because they can drain entire treasuries in a single transaction. Here are the specific threats I design against and the mitigations I implement for every DAO contract.
Flash loan governance attacks. An attacker borrows a massive amount of governance tokens via a flash loan, delegates, creates a proposal, and votes — all in one transaction. The defense is the voting delay plus checkpointing. Because ERC20Votes snapshots voting power at the proposal creation block, flash-loaned tokens acquired after that block have zero voting power. The voting delay ensures there is always a gap between proposal creation and voting.
Low-quorum exploitation. If quorum is set too low and voter participation drops, an attacker can pass proposals with a small token position. I never set quorum below 4% and monitor participation rates. If participation consistently drops below 10%, it is time to revisit the governance structure — maybe a multisig with veto power, or optimistic governance where proposals pass unless vetoed.
Proposal stuffing. An attacker creates dozens of proposals to overwhelm voters and sneak a malicious one through. The proposal threshold mitigates this — requiring a significant token stake to propose. For higher-value DAOs, I add a proposal cap per address per epoch.
Timelock bypass. If the Timelock admin role is not revoked from the deployer, the entire governance system is theater. I always verify this in my deployment tests:
function testDeployerHasNoTimelockAdmin() public {
assertFalse(
timelock.hasRole(timelock.DEFAULT_ADMIN_ROLE(), deployer)
);
}Proposal description hash collision. Two proposals with identical targets, values, calldatas, and description hash cannot coexist. An attacker could front-run a legitimate proposal with identical parameters to block it. The mitigation is including a unique identifier (like a proposal number or timestamp) in every description string.
Token concentration. If a single entity holds > 50% of delegated voting power, governance is a rubber stamp. I track delegation distribution off-chain and surface it in the frontend. Transparency is the defense here — the community needs to see when voting power is concentrated.
Additional hardening I apply to every production deployment:
- Emergency multisig. A Gnosis Safe with a shorter timelock delay (1 hour vs 1 day) that can pause the protocol if a malicious proposal is queued. This multisig should not be able to execute arbitrary transactions — only trigger the pause function.
- Proposal validation. Custom logic in the Governor to reject proposals targeting sensitive functions (like upgrading the Governor itself) unless they meet a higher quorum.
- Vote delegation tracking. Emit events when large delegations happen. Front-run detection at the protocol level.
Key Takeaways
- Delegation is the first UX problem. If your users do not delegate, they cannot vote. Prompt them immediately. Self-delegation should be one click.
- The Timelock is your safety net. Every DAO execution must go through a timelock. Revoke admin access from the deployer. No exceptions.
- Test the full lifecycle. A governance test that does not advance through voting delay, voting period, and timelock delay is not testing governance. Use
vm.roll()andvm.warp()in Foundry. - Quorum and threshold are political decisions. There is no universally correct number. Start conservative (higher quorum, higher threshold) and adjust based on actual participation data.
- Flash loan attacks are solved by design. ERC20Votes checkpointing plus voting delay eliminates flash loan governance attacks. Do not skip either.
- Monitor after deployment. Governance does not end at the smart contract. Track participation rates, delegation distribution, and proposal patterns. Low participation is a governance crisis waiting to happen.
If you are building a DAO or adding governance to an existing protocol, I offer smart contract development and auditing services that cover the full lifecycle — from architecture design through mainnet deployment and monitoring setup.
*Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK. He builds production smart contracts, DeFi protocols, and full-stack dApps through iamuvin.com↗. Follow his work at @IAMUVIN↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.