Skip to content
On this page

L2-20: OZ Governor DAO (OpenZeppelin Governor Standard Governance)

1. Problem

Build a production-grade DAO governance system using OpenZeppelin's Governor standard contracts. Core modules: GovernorCountingSimple (For/Against/Abstain three-state voting), GovernorVotes (token voting weight via ERC20Votes), GovernorTimelockControl (delayed execution after proposal passes), GovernorSettings (configurable voting parameters).

This is the standardized implementation of the Compound Governor Bravo pattern, used by hundreds of DAOs.

2. Why

Implementing DAO governance contracts from scratch is error-prone -- vote counting, quorum calculation, and timelock security checks are all potential vulnerability points. The OpenZeppelin Governor framework provides modular, audited governance components:

  • CountingSimple: verified For/Against/Abstain counting logic
  • Votes integration: seamless integration with ERC20Votes (voting tokens with historical snapshots) via the IVotes interface
  • TimelockControl: enforces a mandatory delay (typically 2 days) after proposal approval, giving users time to exit
  • Configurable Settings: votingDelay, votingPeriod, and proposalThreshold are all adjustable

This challenge teaches not "how to write a governance contract from scratch" but "how to correctly assemble and use proven governance modules" -- a core skill of professional Solidity development.

3. Solution

Architecture Design

GovernanceToken (ERC20 + ERC20Permit + ERC20Votes)

    │  provides tokens + voting weight

OZGovernorDAO (Governor + CountingSimple + Votes + Timelock + Settings)

    │  manages

TimelockController (delayed executor)

    │  executes

Target Contract

Governance Token

solidity
contract GovernanceToken is ERC20, ERC20Permit, ERC20Votes {
    constructor(string memory name_, string memory symbol_,
                address initialHolder, uint256 initialSupply)
        ERC20(name_, symbol_)
        ERC20Permit(name_)
    {
        _mint(initialHolder, initialSupply);
    }

    // Must override: Solidity requires explicit _update resolution in multiple inheritance
    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);
    }
}

Governor Contract

solidity
contract OZGovernorDAO is
    Governor,
    GovernorCountingSimple,   // For/Against/Abstain counting
    GovernorVotes,             // ERC20Votes integration
    GovernorTimelockControl,   // Timelock delayed execution
    GovernorSettings           // Configurable parameters
{
    uint256 public constant QUORUM_BPS = 400; // 4% quorum

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

    function quorum(uint256 timepoint) public view override returns (uint256) {
        uint256 totalSupply = token().getPastTotalSupply(timepoint);
        return (totalSupply * QUORUM_BPS) / 10_000;
    }

    // Explicit overrides required for diamond inheritance
    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 proposalThreshold() public view override(Governor, GovernorSettings)
        returns (uint256) { return super.proposalThreshold(); }

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

Proposal Lifecycle

1. propose(targets, values, calldatas, description)
   → state = Pending (during votingDelay)
2. [after votingDelay]
   → state = Active (during votingPeriod, voting begins)
3. castVote(proposalId, support)  // 0=Against, 1=For, 2=Abstain
4. [votingPeriod ends, forVotes > againstVotes, quorum met]
   → state = Succeeded
5. queue(targets, values, calldatas, descriptionHash)
   → enters Timelock queue (during minDelay)
6. [after minDelay]
   → execute(targets, values, calldatas, descriptionHash)

Deployment Order

  1. Deploy GovernanceToken("GovToken", "GOV", deployer, 1000000e18)
  2. Deploy TimelockController(minDelay=2days, proposers=[], executors=[], admin=address(0))
  3. Deploy OZGovernorDAO(token, timelock, votingDelay=1day, votingPeriod=1week, proposalThreshold=1000e18)
  4. Grant the Governor contract proposer + executor roles in the Timelock
  5. Renounce the Timelock admin role (renounceRole)

4. Pitfalls Encountered

4.1 Diamond Inheritance Function Override Conflicts

OZGovernorDAO inherits from 5 contracts, and votingDelay(), votingPeriod(), and proposalThreshold() are defined in both Governor and GovernorSettings. Solidity requires explicitly specifying which parent contract's implementation to use. Failing to write override(Governor, GovernorSettings) causes a compilation error.

4.2 ERC20Votes Snapshot Mechanism

getPastTotalSupply(timepoint) and getPastVotes(account, timepoint) use "checkpoints" to record historical balances. This requires _update to write a checkpoint on every token transfer. Forgetting to override _update in GovernanceToken (calling ERC20Votes._update) will cause voting weight queries to fail.

4.3 Timelock Role Configuration

TimelockController has three roles: PROPOSER_ROLE (can submit proposals to the Timelock queue), EXECUTOR_ROLE (can execute operations in the queue), CANCELLER_ROLE (can cancel queued operations). These roles must be granted to the Governor contract -- otherwise proposals cannot be executed after passing.

4.4 Timelock minDelay Setting

minDelay too short (e.g., 1 hour) → users have no time to exit before a malicious proposal executes. Too long (e.g., 30 days) → governance is extremely inefficient. The standard value is 2 days (172800 seconds).

5. Why the Pitfalls Happen

5.1

Solidity's multiple inheritance uses C3 linearization to resolve the diamond problem. When multiple parent contracts define the same function, override(A, B) tells the compiler you are aware of the conflict and choose to call super.func() (whose resolution follows the C3 order).

5.2

ERC20Votes tracks historical voting weight through event sourcing. Each token transfer emits a DelegateVotesChanged event and writes a "checkpoint" for both sender and receiver. getPastVotes performs a binary search over the checkpoint array.

6. How to Resolve the Pitfalls

  • Carefully follow OpenZeppelin's Governor documentation, especially the explicit overrides required for diamond inheritance
  • Test the full proposal lifecycle: propose → vote → queue → execute
  • Correctly implement the _update override in GovernanceToken (call ERC20Votes._update)
  • Verify Timelock role configuration after deployment:
    solidity
    timelock.grantRole(PROPOSER_ROLE, address(governor));
    timelock.grantRole(EXECUTOR_ROLE, address(governor));
    timelock.grantRole(CANCELLER_ROLE, address(governor));
    timelock.renounceRole(TIMELOCK_ADMIN_ROLE, address(deployer));
    

7. Technical Highlights

Key PointDescription
Governor modulesCountingSimple + Votes + Timelock + Settings
ERC20VotesGovernance token with historical snapshots (IVotes interface)
3-state votingAgainst(0) / For(1) / Abstain(2)
Quorum calculation4% based on getPastTotalSupply(timepoint)
Timelock delayWait for minDelay after passing before execution
Diamond inheritanceoverride(Governor, GovernorSettings) explicit specification
Deployment orderToken → Timelock → Governor → grant roles

Built with AiAda