DAO治理合约 — Governor与TimelockController
### 1. DAO 治理的核心问题
日期: 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)。
如果在投票期间可以转移代币,一个人可以:
- 用 Token 投票
- 把 Token 转给另一个地址
- 用另一个地址再投一次
ERC20Votes 的解决方案:Checkpoint 机制。
// ERC20Votes 在每次余额变化时记录一个 checkpoint
struct Checkpoint {
uint48 fromBlock; // 从哪个区块开始
uint208 votes; // 投票权数量
}
// 查询某地址在某个区块的投票权
function getPastVotes(address account, uint256 blockNumber)
external view returns (uint256);
关键概念:Delegation(委托)
ERC20Votes 要求用户主动委托投票权才能生效。这个设计的原因是:
- 记录 checkpoint 需要额外的 gas
- 许多代币持有者不关心治理,不应该为他们增加转账成本
- 委托机制让不想投票的人可以把投票权委托给积极参与者
// 自我委托:激活自己的投票权
token.delegate(msg.sender);
// 委托给别人:让别人代为投票
token.delegate(expertAddress);
没有 delegate 的代币持有者,投票权为 0! 这是最常被忽略的点。
4. TimelockController — 安全阀
即使提案通过投票,也不应该立即执行。TimelockController 引入一个延迟期(通常 1-7 天),其作用是:
- 给社区反应时间:如果通过了恶意提案,社区有时间撤离资金
- 安全缓冲:发现问题可以由 Guardian 取消执行
- 透明度:所有人都能看到即将执行的操作
提案通过 → 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 | 说明 |
|---|---|---|
| Compound | 4% 总供应量 | 约 400,000 COMP |
| Uniswap | 4% 总供应量 | 约 40M UNI |
| Aave | 2-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 测试社区情绪,通过后再进行成本较高的链上投票。
关键要点总结
- 代币持有 ≠ 投票权:ERC20Votes 需要显式
delegate()才能激活投票权,这是最常被遗忘的步骤 - Checkpoint 机制防止双重投票:投票权按提案创建时的区块快照,投票期间的代币转移不影响已快照的投票权
- TimelockController 是安全最后一道防线:给社区留出反应时间,可以在执行前取消恶意提案
- Quorum 设置是门艺术:太高导致提案难以通过(治理瘫痪),太低容易被少数人操控
- Proposer threshold 防止提案垃圾:要求提案者持有一定数量的代币,防止提案泛滥
- 角色权限最小化:部署完成后立即放弃 deployer 的 admin 权限,让 DAO 完全自治
- 提案是不可变的 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 Governance | https://compound.finance/governance |
| Tally(治理聚合器) | https://www.tally.xyz/ |
| Snapshot | https://snapshot.org/ |
| EIP-5805: Voting with Delegation | https://eips.ethereum.org/EIPS/eip-5805 |
| a] The Beanstalk Governance Attack | https://rekt.news/beanstalk-rekt/ |
| Vitalik: Moving beyond coin voting governance | https://vitalik.eth.limo/general/2021/08/16/voting3.html |