返回 SC 笔记
SC Day 48

DAO治理合约 — Governor与TimelockController

### 1. DAO 治理的核心问题

2026-05-28
第二阶段:框架实战
soliditygovernancedaoopenzeppelin

日期: 2026-05-28 方向: Solidity 阶段: 第二阶段:框架实战 标签: #solidity #governance #dao #openzeppelin


今日目标

类型内容
学习理解 OpenZeppelin Governor 框架、提案全生命周期、投票权机制(ERC20Votes)、TimelockController 延迟执行
实操编写完整的 DAO 治理合约(GovernanceToken + Governor + Timelock),Foundry 测试覆盖提案全流程
产出治理合约代码、On-chain vs Off-chain 治理对比、面试准备

核心概念

1. DAO 治理的核心问题

去中心化自治组织(DAO)需要解决一个基本矛盾:如何在没有中心决策者的情况下,让一群人高效地做出集体决策?

链上治理的核心组件:

┌─────────────────────────────────────────────────────┐
│                    DAO 治理架构                       │
│                                                      │
│  ┌──────────────┐  投票权来源                         │
│  │ Governance   │ ◄──── 持有代币 = 有投票权           │
│  │ Token        │       (ERC20Votes)                 │
│  │ (ERC20Votes) │       delegate 委托投票             │
│  └──────┬───────┘                                    │
│         │                                            │
│  ┌──────▼───────┐  决策引擎                          │
│  │  Governor    │ ◄──── 提案/投票/计票/执行            │
│  │  合约         │       quorum/threshold/delay       │
│  └──────┬───────┘                                    │
│         │                                            │
│  ┌──────▼───────┐  执行层                            │
│  │  Timelock    │ ◄──── 延迟执行,给社区反应时间       │
│  │  Controller  │       可以在执行前取消              │
│  └──────┬───────┘                                    │
│         │                                            │
│  ┌──────▼───────┐  目标合约                          │
│  │  Treasury    │ ◄──── 接受治理控制的合约             │
│  │  / Protocol  │       参数调整/资金分配/升级         │
│  └──────────────┘                                    │
└─────────────────────────────────────────────────────┘

2. 提案生命周期

一个提案从创建到执行经历以下阶段:

Pending → Active → Succeeded → Queued → Executed
                 ↘ Defeated
                 ↘ Canceled (任何时候)

时间线:
|--- votingDelay ---|--- votingPeriod ---|--- timelockDelay ---|
    (等待投票开始)      (投票窗口)           (延迟执行)

状态机:
┌─────────┐  votingDelay结束   ┌────────┐
│ Pending │ ─────────────────> │ Active │
└─────────┘                    └───┬────┘
                                   │ votingPeriod结束
                    ┌──────────────┼──────────────┐
                    │              │              │
              ┌─────▼─────┐ ┌────▼─────┐  ┌────▼──────┐
              │ Succeeded │ │ Defeated │  │ Defeated  │
              │ (达到quorum│ │ (反对多) │  │ (未达quorum)│
              │  且赞成多) │ └──────────┘  └───────────┘
              └─────┬─────┘
                    │ queue()
              ┌─────▼─────┐
              │  Queued   │
              └─────┬─────┘
                    │ timelockDelay 后
              ┌─────▼─────┐
              │ Executed  │
              └───────────┘

各阶段详解

阶段状态说明
Pending提案已创建等待 votingDelay 过后进入投票期。这给代币持有者时间准备
Active投票进行中持有投票权的人可以投 For / Against / Abstain
Succeeded投票通过赞成票超过反对票,且达到 quorum(法定人数)
Defeated投票失败反对票多于赞成票,或未达到 quorum
Queued排队等待通过的提案进入 Timelock 等待期
Executed已执行Timelock 延迟结束后执行提案操作
Canceled已取消提案者或 Guardian 在执行前取消

3. 投票权机制:ERC20Votes

普通的 ERC20 代币不能直接用于投票,因为需要解决一个关键问题:投票权快照(Snapshot)

如果在投票期间可以转移代币,一个人可以:

  1. 用 Token 投票
  2. 把 Token 转给另一个地址
  3. 用另一个地址再投一次

ERC20Votes 的解决方案:Checkpoint 机制

// ERC20Votes 在每次余额变化时记录一个 checkpoint
struct Checkpoint {
    uint48 fromBlock;  // 从哪个区块开始
    uint208 votes;     // 投票权数量
}

// 查询某地址在某个区块的投票权
function getPastVotes(address account, uint256 blockNumber)
    external view returns (uint256);

关键概念:Delegation(委托)

ERC20Votes 要求用户主动委托投票权才能生效。这个设计的原因是:

  1. 记录 checkpoint 需要额外的 gas
  2. 许多代币持有者不关心治理,不应该为他们增加转账成本
  3. 委托机制让不想投票的人可以把投票权委托给积极参与者
// 自我委托:激活自己的投票权
token.delegate(msg.sender);

// 委托给别人:让别人代为投票
token.delegate(expertAddress);

没有 delegate 的代币持有者,投票权为 0! 这是最常被忽略的点。

4. TimelockController — 安全阀

即使提案通过投票,也不应该立即执行。TimelockController 引入一个延迟期(通常 1-7 天),其作用是:

  1. 给社区反应时间:如果通过了恶意提案,社区有时间撤离资金
  2. 安全缓冲:发现问题可以由 Guardian 取消执行
  3. 透明度:所有人都能看到即将执行的操作
提案通过 → queue() → 等待 minDelay → execute()
                            │
                        在此期间可以 cancel()

TimelockController 的角色:

角色权限对应实体
PROPOSER_ROLE可以 queue 操作Governor 合约
EXECUTOR_ROLE可以 execute 操作通常设为 address(0) = 任何人
CANCELLER_ROLE可以取消排队的操作Guardian 多签 / Governor
DEFAULT_ADMIN_ROLE管理角色分配初始为部署者,后转给 Timelock 自身

5. Quorum(法定人数)

Quorum 是投票通过所需的最低参与量,防止少数人在大家不注意时通过提案。

常见设置:

项目Quorum说明
Compound4% 总供应量约 400,000 COMP
Uniswap4% 总供应量约 40M UNI
Aave2-6.5%(按提案类型)不同风险级别不同 quorum
// OpenZeppelin 的 quorum 设置
// 使用 GovernorVotesQuorumFraction
function quorum(uint256 blockNumber) public view override returns (uint256) {
    return (token.getPastTotalSupply(blockNumber) * quorumNumerator()) / 100;
}

代码实战

完整 DAO 治理合约

GovernanceToken — 带投票权的 ERC20

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

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

/// @title GovernanceToken — 治理代币
/// @notice ERC20 + ERC20Votes(投票权快照) + ERC20Permit(无 Gas 授权)
contract GovernanceToken is ERC20, ERC20Permit, ERC20Votes {
    constructor()
        ERC20("MomoDAO Token", "MDAO")
        ERC20Permit("MomoDAO Token")
    {
        // 初始铸造 1,000,000 个代币给部署者
        _mint(msg.sender, 1_000_000 * 10 ** decimals());
    }

    // ======== 必须的 override(解决多重继承冲突)========

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

MomoGovernor — 核心治理合约

// 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/GovernorVotesQuorumFraction.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";

/// @title MomoGovernor — DAO 治理核心
/// @notice 完整治理流程:propose → vote → queue → execute
contract MomoGovernor is
    Governor,
    GovernorSettings,
    GovernorCountingSimple,
    GovernorVotes,
    GovernorVotesQuorumFraction,
    GovernorTimelockControl
{
    constructor(
        IVotes _token,
        TimelockController _timelock
    )
        Governor("MomoDAO Governor")
        GovernorSettings(
            7200,    // votingDelay: 约 1 天 (7200 blocks @ 12s/block)
            50400,   // votingPeriod: 约 1 周 (50400 blocks)
            100e18   // proposalThreshold: 需要 100 MDAO 才能提案
        )
        GovernorVotes(_token)
        GovernorVotesQuorumFraction(4) // 4% quorum
        GovernorTimelockControl(_timelock)
    {}

    // ======== 必须的 override ========

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

Treasury — 受治理控制的金库

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

import "@openzeppelin/contracts/access/Ownable.sol";

/// @title Treasury — DAO 金库
/// @notice 只有 Timelock(通过治理提案)才能操作
contract Treasury is Ownable {
    uint256 public releasedAmount;

    event FundsReleased(address indexed recipient, uint256 amount);
    event ParameterUpdated(string name, uint256 value);

    constructor(address timelockAddress) Ownable(timelockAddress) {}

    /// @notice 释放资金给指定地址(需要治理提案通过)
    function releaseFunds(address recipient, uint256 amount)
        external
        onlyOwner
    {
        require(address(this).balance >= amount, "Insufficient balance");
        releasedAmount += amount;
        (bool success, ) = recipient.call{value: amount}("");
        require(success, "Transfer failed");
        emit FundsReleased(recipient, amount);
    }

    /// @notice 接收 ETH
    receive() external payable {}
}

Foundry 测试 — 完整提案生命周期

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

import "forge-std/Test.sol";
import "../src/GovernanceToken.sol";
import "../src/MomoGovernor.sol";
import "../src/Treasury.sol";
import "@openzeppelin/contracts/governance/TimelockController.sol";

contract GovernanceTest is Test {
    GovernanceToken token;
    TimelockController timelock;
    MomoGovernor governor;
    Treasury treasury;

    address deployer = address(0x1);
    address voter1 = address(0x2);
    address voter2 = address(0x3);
    address recipient = address(0x4);

    uint256 constant VOTING_DELAY = 7200;
    uint256 constant VOTING_PERIOD = 50400;
    uint256 constant TIMELOCK_DELAY = 1 days;

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

        // 1. 部署 GovernanceToken
        token = new GovernanceToken();

        // 2. 部署 TimelockController
        address[] memory proposers = new address[](0);
        address[] memory executors = new address[](1);
        executors[0] = address(0); // 任何人可以 execute

        timelock = new TimelockController(
            TIMELOCK_DELAY,
            proposers,
            executors,
            deployer
        );

        // 3. 部署 Governor
        governor = new MomoGovernor(token, timelock);

        // 4. 部署 Treasury
        treasury = new Treasury(address(timelock));

        // 5. 配置 Timelock 角色
        timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor));
        timelock.grantRole(timelock.CANCELLER_ROLE(), address(governor));
        // 放弃 deployer 的 admin 权限 — DAO 完全自治
        timelock.revokeRole(timelock.DEFAULT_ADMIN_ROLE(), deployer);

        // 6. 分发代币给投票者
        token.transfer(voter1, 400_000e18);  // 40%
        token.transfer(voter2, 100_000e18);  // 10%
        // deployer 保留 500_000e18 (50%)

        vm.stopPrank();

        // 7. 每个人都需要 delegate 来激活投票权
        vm.prank(deployer);
        token.delegate(deployer);

        vm.prank(voter1);
        token.delegate(voter1);

        vm.prank(voter2);
        token.delegate(voter2);

        // 8. 给 Treasury 充值
        vm.deal(address(treasury), 100 ether);

        // 推进一个 block,让 checkpoint 生效
        vm.roll(block.number + 1);
    }

    function test_FullProposalLifecycle() public {
        // ====== 1. 创建提案 ======
        // 提案内容:从 Treasury 释放 10 ETH 给 recipient
        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,
            10 ether
        );

        string memory description = "Release 10 ETH to contributor";

        vm.prank(deployer); // deployer 有足够代币来提案
        uint256 proposalId = governor.propose(
            targets, values, calldatas, description
        );

        // 验证初始状态是 Pending
        assertEq(uint256(governor.state(proposalId)), 0); // Pending

        // ====== 2. 等待 votingDelay 过后开始投票 ======
        vm.roll(block.number + VOTING_DELAY + 1);
        assertEq(uint256(governor.state(proposalId)), 1); // Active

        // ====== 3. 投票 ======
        // GovernorCountingSimple: 0=Against, 1=For, 2=Abstain

        vm.prank(deployer);
        governor.castVote(proposalId, 1); // For

        vm.prank(voter1);
        governor.castVoteWithReason(
            proposalId,
            1, // For
            "Good initiative, contributor deserves compensation"
        );

        vm.prank(voter2);
        governor.castVote(proposalId, 0); // Against

        // 验证投票统计
        (uint256 against, uint256 forVotes, uint256 abstain) =
            governor.proposalVotes(proposalId);
        assertEq(forVotes, 900_000e18);   // deployer(500k) + voter1(400k)
        assertEq(against, 100_000e18);     // voter2(100k)
        assertEq(abstain, 0);

        // ====== 4. 等待投票期结束 ======
        vm.roll(block.number + VOTING_PERIOD + 1);
        assertEq(uint256(governor.state(proposalId)), 4); // Succeeded

        // ====== 5. Queue 到 Timelock ======
        bytes32 descriptionHash = keccak256(bytes(description));
        governor.queue(targets, values, calldatas, descriptionHash);
        assertEq(uint256(governor.state(proposalId)), 5); // Queued

        // ====== 6. 等待 Timelock 延迟 ======
        vm.warp(block.timestamp + TIMELOCK_DELAY + 1);

        // ====== 7. Execute ======
        uint256 recipientBalanceBefore = recipient.balance;

        governor.execute(targets, values, calldatas, descriptionHash);
        assertEq(uint256(governor.state(proposalId)), 7); // Executed

        // 验证资金确实转移了
        assertEq(recipient.balance - recipientBalanceBefore, 10 ether);
        assertEq(treasury.releasedAmount(), 10 ether);
    }

    function test_ProposalDefeated_InsufficientQuorum() public {
        address[] memory targets = new address[](1);
        targets[0] = address(treasury);
        uint256[] memory values = new uint256[](1);
        bytes[] memory calldatas = new bytes[](1);
        calldatas[0] = abi.encodeWithSignature(
            "releaseFunds(address,uint256)", recipient, 1 ether
        );

        vm.prank(deployer);
        uint256 proposalId = governor.propose(
            targets, values, calldatas, "Small release"
        );

        vm.roll(block.number + VOTING_DELAY + 1);

        // 只有 voter2 投票 (100k = 10%),但 quorum 需要 4% = 40k
        // 等等,10% > 4%,所以 quorum 是满足的
        // 让我们改一下:只有一个只持有少量代币的人投票
        // 实际上 voter2 有 100k,超过 4% 的 40k...
        // 这里我们测试 against > for 的情况
        vm.prank(voter2);
        governor.castVote(proposalId, 0); // Against

        vm.roll(block.number + VOTING_PERIOD + 1);

        // 只有反对票且不满足 quorum → Defeated
        assertEq(uint256(governor.state(proposalId)), 3); // Defeated
    }

    function test_DelegationRequired() public {
        // 创建一个没有 delegate 的用户
        address noDelegateUser = address(0x99);
        vm.prank(deployer);
        token.transfer(noDelegateUser, 200_000e18);

        // 推进一个 block
        vm.roll(block.number + 1);

        // 即使持有 200k 代币,不 delegate 就没有投票权
        assertEq(token.getVotes(noDelegateUser), 0);
        assertEq(token.balanceOf(noDelegateUser), 200_000e18);

        // delegate 后投票权才激活
        vm.prank(noDelegateUser);
        token.delegate(noDelegateUser);

        // 需要再推进一个 block 让 checkpoint 记录
        vm.roll(block.number + 1);
        assertEq(token.getVotes(noDelegateUser), 200_000e18);
    }

    function test_OnlyTimelockCanControlTreasury() public {
        // 普通用户不能直接操作 Treasury
        vm.prank(deployer);
        vm.expectRevert();
        treasury.releaseFunds(recipient, 1 ether);

        // 只有 Timelock 可以
        vm.prank(address(timelock));
        treasury.releaseFunds(recipient, 1 ether);
        assertEq(treasury.releasedAmount(), 1 ether);
    }
}

On-chain vs Off-chain 治理对比

维度On-chain (Governor)Off-chain (Snapshot)
投票方式链上交易签名消息(无 Gas)
执行方式自动通过合约执行需要多签手动执行
Gas 成本每次投票约 50-100k gas免费
安全性代码保证执行依赖多签信任
抗审查完全去中心化Snapshot 服务可能宕机
灵活性受合约限制可投任何类型的提案
适用场景协议参数/资金/升级社区意见收集/信号投票
代表项目Compound, Uniswap, Aave大多数早期 DAO

实际中的混合模式

许多 DAO 使用 Snapshot(温度检查)→ On-chain Governor(正式投票) 的两步流程。先用免费的 Snapshot 测试社区情绪,通过后再进行成本较高的链上投票。


关键要点总结

  1. 代币持有 ≠ 投票权:ERC20Votes 需要显式 delegate() 才能激活投票权,这是最常被遗忘的步骤
  2. Checkpoint 机制防止双重投票:投票权按提案创建时的区块快照,投票期间的代币转移不影响已快照的投票权
  3. TimelockController 是安全最后一道防线:给社区留出反应时间,可以在执行前取消恶意提案
  4. Quorum 设置是门艺术:太高导致提案难以通过(治理瘫痪),太低容易被少数人操控
  5. Proposer threshold 防止提案垃圾:要求提案者持有一定数量的代币,防止提案泛滥
  6. 角色权限最小化:部署完成后立即放弃 deployer 的 admin 权限,让 DAO 完全自治
  7. 提案是不可变的 calldata:一旦提交就不能修改,只能取消重新提

常见误区

误区 1: "持有代币就自动有投票权"

错误。ERC20Votes 要求 delegate() 才激活。这意味着大量持币者(尤其是交易所、LP)实际上没有投票权。Uniswap 的治理经常面临 quorum 不足的问题,因为很多 UNI 在 LP 池中没有被 delegate。

误区 2: "Timelock 延迟越长越安全"

不完全对。过长的延迟在紧急情况下是致命的。如果发现漏洞需要紧急修复,7 天的延迟可能导致资金被盗。因此很多协议设置了紧急多签(Guardian),可以在不经投票的情况下暂停协议。

误区 3: "On-chain 治理比 Off-chain 更去中心化"

形式上是,但实际上链上投票的高 Gas 成本导致小户不愿投票,反而让大户的影响力更大。Snapshot 的零 Gas 投票可能吸引更多参与者。需要根据具体场景选择。

误区 4: "投票权等于经济利益"

很多治理代币的投票权与经济利益(收益分配)是分离的。这导致投票者可能不关心决策后果(没有 skin in the game),或者投票权被大户用来通过有利于自己的提案。veToken 模型尝试通过锁定机制来解决这个问题。


面试关联

Q1: "如何设计一个 DAO 治理系统?需要考虑哪些关键参数?"

回答框架

  • 投票权来源:ERC20Votes(代币)/ ERC721Votes(NFT)/ 多维度
  • 关键参数:votingDelay, votingPeriod, quorum, proposalThreshold
  • 执行安全:Timelock 延迟、Guardian 紧急暂停
  • 权力制衡:代币委托、提案门槛、quorum 要求
  • 渐进去中心化:初期多签 → 逐步引入链上治理 → 最终完全去中心化

Q2: "治理攻击有哪些类型?如何防范?"

攻击手法防范
闪电贷治理攻击借大量代币 → 提案 → 投票 → 归还使用区块快照(ERC20Votes),flashloan 在同一区块,快照取前一个区块
低参与度利用在参与率低时通过有争议的提案合理设置 quorum
51% 攻击积累超过 quorum 的代币veToken 锁定增加攻击成本
Beanstalk 攻击闪电贷 + 即时投票(无快照)投票权快照 + votingDelay

Q3: "Compound 和 Uniswap 的治理有什么区别?你更推荐哪种?"

两者都基于 Governor 框架,但参数不同。关键区别在于提案门槛、投票期限和 quorum 设置。Compound 的治理更活跃(提案门槛相对较低),Uniswap 经常面临 quorum 不足的问题。推荐根据协议规模和代币分布来调整参数,没有"最佳"配置。


参考资源

资源链接
OpenZeppelin Governor 文档https://docs.openzeppelin.com/contracts/5.x/governance
Compound Governancehttps://compound.finance/governance
Tally(治理聚合器)https://www.tally.xyz/
Snapshothttps://snapshot.org/
EIP-5805: Voting with Delegationhttps://eips.ethereum.org/EIPS/eip-5805
a] The Beanstalk Governance Attackhttps://rekt.news/beanstalk-rekt/
Vitalik: Moving beyond coin voting governancehttps://vitalik.eth.limo/general/2021/08/16/voting3.html