Перейти к содержанию
Learning Platform
Продвинутый
40 минут
OpenZeppelin Governor Foundry Testing Governance Lifecycle

Требуемые знания:

  • 04-timelock-execution

OpenZeppelin Governor

Зачем это блокчейн-разработчику?

Мы изучили все компоненты: токены с голосованием (ERC20Votes), механизмы голосования (state machine, quorum), timelock (задержки, роли). Теперь собираем всё вместе. В этом уроке — полный код трех контрактов (GovernanceToken, MyGovernor, Treasury), последовательность развертывания, и Foundry тест полного цикла governance: propose -> vote -> queue -> execute.

Модульная архитектура

OpenZeppelin Governor v5: модульная архитектура
Governorpropose() castVote()queue() execute()GovernorCountingSimpleVote counting (For/Against/Abstain)GovernorVotesToken-based voting power (IVotes)GovernorVotesQuorumFractionQuorum as % of total supply (4%)GovernorTimelockControlExecution through TimelockControllerERC20VotesTimelockCtrl6 function overrides needed due to multiple inheritance (Solidity C3 linearization)
GovernorCountingSimple
Vote counting (For/Against/Abstain)
GovernorVotes
Token-based voting power (IVotes)
GovernorVotesQuorumFraction
Quorum as % of total supply (4%)
GovernorTimelockControl
Execution through TimelockController
Optional extensions:
GovernorSettingsGovernorPreventLateQuorumGovernorStorageGovernorProposalGuardian
Modular ArchitectureGovernor base + 4 extensions = полная governance система. GovernorCountingSimple (подсчет), GovernorVotes (токены), GovernorVotesQuorumFraction (кворум), GovernorTimelockControl (исполнение).

OpenZeppelin Governor v5 — модульная система:

ExtensionНазначение
GovernorCountingSimpleПодсчет голосов: For / Against / Abstain
GovernorVotesVoting power из IVotes token (ERC20Votes)
GovernorVotesQuorumFractionQuorum как % от totalSupply (4%)
GovernorTimelockControlИсполнение через TimelockController

Почему модульность? Разные проекты нуждаются в разных стратегиях:

  • Counting: Simple vs Fractional
  • Quorum: Fixed vs Percentage
  • Execution: Direct vs Timelock

Доступные extensions (для справки):

ExtensionОписание
GovernorSettingsConfigurable votingDelay, votingPeriod, proposalThreshold
GovernorPreventLateQuorumПродлевает голосование если quorum достигнут в последний момент
GovernorStorageХранение proposal data on-chain
GovernorProposalGuardianGuardian может отменить вредоносные proposals
GovernorSuperQuorumПовышенный quorum для критических действий
GovernorNoncesKeyedNonce per proposal для ERC-4337 compatibility

Мы используем 4 наиболее распространенных. Остальные — для специализированных случаев.

GovernanceToken.sol

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

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()
        ERC20("Governance Token", "GOV")
        ERC20Permit("Governance Token")
    {
        _mint(msg.sender, 1_000_000 * 1e18);
    }

    function _update(address from, address to, uint256 value)
        internal override(ERC20, ERC20Votes)
    {
        super._update(from, to, value); // C3: ERC20Votes._update -> ERC20._update
    }

    function nonces(address owner)
        public view override(ERC20Permit, Nonces) returns (uint256)
    {
        return super.nonces(owner);
    }

    function clock() public view override returns (uint48) {
        return uint48(block.timestamp);
    }

    function CLOCK_MODE() public pure override returns (string memory) {
        return "mode=timestamp";
    }
}

4 override:

  • _update() — chains ERC20 + ERC20Votes (checkpoint при каждом transfer если delegation активна)
  • nonces() — resolves ERC20Permit vs Nonces conflict
  • clock() — timestamp mode (block.timestamp, не block.number)
  • CLOCK_MODE() — декларация для Governor: delays в секундах

MyGovernor.sol

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

import {Governor} from "@openzeppelin/contracts/governance/Governor.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 {TimelockController} from
    "@openzeppelin/contracts/governance/TimelockController.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";

contract MyGovernor is
    Governor,
    GovernorCountingSimple,
    GovernorVotes,
    GovernorVotesQuorumFraction,
    GovernorTimelockControl
{
    constructor(IVotes _token, TimelockController _timelock)
        Governor("MyGovernor")
        GovernorVotes(_token)
        GovernorVotesQuorumFraction(4) // 4% quorum
        GovernorTimelockControl(_timelock)
    {}

    function votingDelay() public pure override returns (uint256) {
        return 1 days; // Timestamp mode: seconds
    }

    function votingPeriod() public pure override returns (uint256) {
        return 1 weeks;
    }

    function proposalThreshold() public pure override returns (uint256) {
        return 0; // Anyone with votes can propose
    }

    // --- 6 required overrides for multiple inheritance ---

    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 _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();
    }
}

6 override — Solidity требует явного разрешения конфликтов при множественном наследовании (C3 linearization):

  • state() — Governor vs GovernorTimelockControl (timelock adds Queued state)
  • proposalNeedsQueuing() — true when timelock is used
  • _queueOperations() — calls timelock.schedule()
  • _executeOperations() — calls timelock.execute()
  • _cancel() — cancels both in Governor and timelock
  • _executor() — returns timelock address (not Governor)

Treasury.sol

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

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract Treasury is Ownable {
    event Released(address indexed to, uint256 amount);

    constructor() Ownable(msg.sender) {}

    function release(address payable to, uint256 amount) external onlyOwner {
        require(address(this).balance >= amount, "Insufficient balance");
        to.transfer(amount);
        emit Released(to, amount);
    }

    receive() external payable {}
}

Простой контракт: onlyOwner = timelock. Только governance-approved proposals могут вызвать release().

Deployment Sequence

Порядок развертывания критичен:

// 1. Deploy token (1M tokens to deployer)
token = new GovernanceToken();

// 2. Deploy timelock (no proposers yet; anyone can execute)
timelock = new TimelockController(1 days, [], [address(0)], deployer);

// 3. Deploy governor with token and timelock
governor = new MyGovernor(token, timelock);

// 4. Grant PROPOSER_ROLE to governor
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor));

// 5. CRITICAL: Renounce admin role
timelock.renounceRole(timelock.DEFAULT_ADMIN_ROLE(), deployer);

// 6. Deploy treasury, transfer ownership to timelock
treasury = new Treasury();
treasury.transferOwnership(address(timelock));

// 7. Fund timelock with ETH
vm.deal(address(timelock), 10 ether);

// 8. Distribute tokens and delegate
token.transfer(voter1, 400_000 * 1e18);
token.transfer(voter2, 100_000 * 1e18);

// CRITICAL: All voters must delegate!
token.delegate(voter1); // self-delegation for each voter

GovernorLifecycle.t.sol

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

import "forge-std/Test.sol";
import {GovernanceToken} from "../../contracts/governance/GovernanceToken.sol";
import {MyGovernor} from "../../contracts/governance/MyGovernor.sol";
import {Treasury} from "../../contracts/governance/Treasury.sol";
import {TimelockController} from
    "@openzeppelin/contracts/governance/TimelockController.sol";
import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";

contract GovernorLifecycleTest is Test {
    GovernanceToken token;
    TimelockController timelock;
    MyGovernor governor;
    Treasury treasury;

    address deployer = makeAddr("deployer");
    address voter1 = makeAddr("voter1");
    address voter2 = makeAddr("voter2");
    address recipient = makeAddr("recipient");

    uint256 constant TIMELOCK_DELAY = 1 days;

    function setUp() public {
        vm.startPrank(deployer);

        token = new GovernanceToken();

        address[] memory proposers = new address[](0);
        address[] memory executors = new address[](1);
        executors[0] = address(0);
        timelock = new TimelockController(
            TIMELOCK_DELAY, proposers, executors, deployer);

        governor = new MyGovernor(token, timelock);

        timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor));
        timelock.renounceRole(timelock.DEFAULT_ADMIN_ROLE(), deployer);

        treasury = new Treasury();
        treasury.transferOwnership(address(timelock));
        vm.deal(address(timelock), 10 ether);

        token.transfer(voter1, 400_000 * 1e18);
        token.transfer(voter2, 100_000 * 1e18);
        vm.stopPrank();

        // CRITICAL: Delegate to activate voting power
        vm.prank(voter1);
        token.delegate(voter1);
        vm.prank(voter2);
        token.delegate(voter2);
        vm.prank(deployer);
        token.delegate(deployer);
    }

    function test_fullGovernanceLifecycle() public {
        // === PROPOSE ===
        bytes memory transferCall = abi.encodeWithSignature(
            "release(address,uint256)", recipient, 1 ether);
        address[] memory targets = new address[](1);
        targets[0] = address(treasury);
        uint256[] memory values = new uint256[](1);
        bytes[] memory calldatas = new bytes[](1);
        calldatas[0] = transferCall;

        vm.prank(deployer);
        uint256 proposalId = governor.propose(
            targets, values, calldatas, "Send 1 ETH to recipient");
        assertEq(uint256(governor.state(proposalId)),
            uint256(IGovernor.ProposalState.Pending));

        // === VOTING DELAY (1 day) ===
        vm.warp(block.timestamp + governor.votingDelay() + 1);
        assertEq(uint256(governor.state(proposalId)),
            uint256(IGovernor.ProposalState.Active));

        // === VOTE ===
        vm.prank(voter1);
        governor.castVote(proposalId, 1); // For
        vm.prank(voter2);
        governor.castVote(proposalId, 0); // Against

        // === VOTING PERIOD (1 week) ===
        vm.warp(block.timestamp + governor.votingPeriod() + 1);
        assertEq(uint256(governor.state(proposalId)),
            uint256(IGovernor.ProposalState.Succeeded));

        // === QUEUE ===
        bytes32 descriptionHash = keccak256(bytes("Send 1 ETH to recipient"));
        governor.queue(targets, values, calldatas, descriptionHash);
        assertEq(uint256(governor.state(proposalId)),
            uint256(IGovernor.ProposalState.Queued));

        // === TIMELOCK DELAY (1 day) ===
        vm.warp(block.timestamp + TIMELOCK_DELAY + 1);

        // === EXECUTE ===
        governor.execute(targets, values, calldatas, descriptionHash);
        assertEq(uint256(governor.state(proposalId)),
            uint256(IGovernor.ProposalState.Executed));
        assertEq(recipient.balance, 1 ether);
    }

    function test_delegationRequired() public {
        address newVoter = makeAddr("newVoter");
        vm.prank(deployer);
        token.transfer(newVoter, 200_000 * 1e18);

        // Before delegation: tokens but no voting power
        assertEq(token.balanceOf(newVoter), 200_000 * 1e18);
        assertEq(token.getVotes(newVoter), 0); // Zero!

        // After delegation: voting power activated
        vm.prank(newVoter);
        token.delegate(newVoter);
        assertEq(token.getVotes(newVoter), 200_000 * 1e18);
    }
}

Лабораторная работа

Запуск теста:

cd labs/ethereum && forge test --match-path test/governance/GovernorLifecycle.t.sol -vvvv

Флаг -vvvv показывает полные call traces, включая:

  • Governor state transitions (Pending -> Active -> Succeeded -> Queued -> Executed)
  • TimelockController schedule/execute operations
  • Treasury.release() вызов и transfer ETH
  • Все vm.warp timestamp advances

Что проверяет тест:

  1. test_fullGovernanceLifecycle: полный цикл propose -> vote -> queue -> execute

    • voter1 голосует For (400K), voter2 — Against (100K)
    • Quorum met (400K > 40K required)
    • For > Against, proposal Succeeded
    • Queue + timelock delay + execute = recipient получает 1 ETH
  2. test_delegationRequired: демонстрация delegation requirement

    • newVoter получает 200K токенов
    • getVotes(newVoter) = 0 (без delegation!)
    • После delegate(self): getVotes = 200K

Module 7: Итоговая таблица

УрокКлючевая концепцияПрактический навык
GOV-01: Концепции DAODAO = code is the organizationРазличать on-chain vs off-chain governance
GOV-02: Governance TokensERC20Votes: delegation requiredАктивировать voting power через delegate()
GOV-03: Механизмы голосованияProposal state machine + governance attacksАнализировать quorum и защиту от flash loan governance
GOV-04: Timelock и исполнениеTimelockController: security bufferНастраивать роли (proposer, executor, admin renounce)
GOV-05: OpenZeppelin GovernorModular Governor v5 compositionРазвернуть и протестировать полный DAO lifecycle

Что дальше

В следующих уроках — Scalability. Ethereum обрабатывает ~15-30 TPS. Как масштабировать до миллионов пользователей? State channels, Plasma, Optimistic Rollups, ZK Rollups — эволюция решений Layer 2.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс