Solidity 治理合约详解:DAO 如何在链上协调
目录
- 一、治理合约的本质
- 二、核心术语
- 三、提案生命周期(状态机)
- 四、Governor + Timelock + ERC20Votes 架构
- 五、关键函数
- 六、Governor Bravo 的改进
- 七、真实案例:Uniswap 提案 9
- 八、安全漏洞
- 九、新兴方案与开放问题
- 十、总结
- 十一、动手练习项目:迷你 DAO MiniGovernor
一、治理合约的本质
治理合约的行为很像多签钱包,只不过投票权按投票者的代币余额加权。
一个提案本质就是一笔以太坊交易——包含目标地址和 calldata——社区投票通过后才执行。换句话说,DAO = “由代币加权投票决定要不要执行某笔交易”的多签。
二、核心术语
| 术语 | 含义 |
|---|---|
| 提案(Proposal) | 需社区投票批准的以太坊交易。为防垃圾提案,通常要求提案者持有最低比例代币 |
| 投票(Vote) | 加权交易,投票权 = 某快照区块时的代币持有量 |
| 法定人数(Quorum) | 提案有效所需的最低投票比例阈值。防止极少数人就能通过提案,也防止僵局 |
| 投票期(Voting Period) | 从提案创建开始的倒计时。期内没达到法定人数则提案失败 |
| 排队与执行(Queued & Execution) | 通过后进入排队状态,等待安全延迟 |
| 时间锁(Timelock) | 批准到执行之间的强制延迟,让反对者有时间在不利变更生效前撤出资产 |
三、提案生命周期(状态机)
Pending(待定)→ Active(投票中)→ Defeated(被否决)/ Canceled(取消)
↓
Succeeded(通过)→ Queued(排队)→ Executed(执行)/ Expired(过期)
- Pending:提案创建后、投票开始前的延迟期(voting delay);
- Active:投票期内,可以 castVote;
- Succeeded:达到法定人数且赞成 > 反对;
- Queued:进入 Timelock 等待期;
- Executed:时间锁过后执行那笔交易;
- Defeated/Expired/Canceled:各种失败终态。
Compound 和 Uniswap 的实现状态转移略有不同,但都是这套模式。
四、Governor + Timelock + ERC20Votes 架构
现代 OpenZeppelin 治理由三个合约协作:
- ERC20Votes 代币(上一篇):记录每个地址的历史投票权;
- Governor 合约:管理提案生命周期——propose、castVote、queue、execute,定义投票延迟、投票期、法定人数;
- TimelockController:真正持有权力(资金、被治理合约的所有权)的合约。Governor 投票通过后,把待执行交易交给 Timelock 排队,延迟后由 Timelock 执行。
关键设计:被治理的协议(金库、参数合约)的 owner 应是 Timelock,而不是 Governor 本身。这样所有变更都强制经过时间锁延迟,给用户逃生窗口。Governor 是 Timelock 的 proposer,Timelock 才是最终执行者。
五、关键函数
// 1. 提案:目标、金额、calldata、描述
function propose(address[] targets, uint256[] values, bytes[] calldatas, string description)
returns (uint256 proposalId);
// 2. 投票:0=反对 1=赞成 2=弃权
function castVote(uint256 proposalId, uint8 support) returns (uint256 weight);
function castVoteWithReason(uint256 proposalId, uint8 support, string reason);
// 3. 排队(进 Timelock)
function queue(address[] targets, uint256[] values, bytes[] calldatas, bytes32 descriptionHash);
// 4. 执行
function execute(address[] targets, uint256[] values, bytes[] calldatas, bytes32 descriptionHash);
proposalId 的计算:keccak256(abi.encode(targets, values, calldatas, descriptionHash))——同样的提案内容产生同样的 ID,所以无需额外存储,execute 时凭内容重算 ID 即可定位提案。描述只传哈希也是这个原因。
计票:投票权取自 getPastVotes(voter, proposalSnapshot)——快照取在投票开始的那个时间点,防止投票期内买币加票。
六、Governor Bravo 的改进
Compound 2021 年的升级引入:
- 显式的弃权(abstain)选项(在赞成/反对之外);
- 可升级代理模式;
- 给投票附加理由字符串(castVoteWithReason)。
七、真实案例:Uniswap 提案 9
Uniswap 提案 9 演示了”为流动性池新增 1 个基点的费率档位”。在 Tally.xyz 上可看到链上执行的可视化,最终交易在 Uniswap 工厂合约触发了 FeeAmountEnabled 事件——一个治理决定如何变成一笔真实的链上交易。
八、安全漏洞
| 攻击 | 案例 | 原理 |
|---|---|---|
| 闪电贷攻击 | BeanStalk | emergencyCommit 允许用闪电贷获得的投票权绕过时间锁,一笔交易通过恶意提案掏空协议 |
| 低价攻击 | True Seigniorage Dollar | 攻击者低价买入治理代币达成 51% 攻击,对”TVL 远高于代币市值”的协议尤其有效 |
| 社会/政治攻击 | — | 协调拉票操纵投票结果,类似民主制度的脆弱性 |
防御要点:投票权必须取历史快照(防闪电贷),关键操作必须经时间锁(给逃生窗口),法定人数和提案门槛要合理设置。
九、新兴方案与开放问题
- Vitalik 提出二次方投票(按持币量的平方根加权)让小持有者更有话语权,但地址拆分攻击(把币分散到多个地址绕过平方根惩罚)仍未解决;
- 文章强调:安全公平地进行治理仍是开放研究问题,没有定论的标准实现。
工具:OpenZeppelin Governor Wizard、Tally Developer Portal、Alchemy DAO 文档。
十、总结
- 治理合约 = 代币加权的多签,提案就是待投票的交易;
- 生命周期:Pending → Active → Succeeded → Queued → Executed(及各失败态);
- 架构 = ERC20Votes(记票权)+ Governor(管流程)+ Timelock(持权力、强制延迟);
- proposalId 由提案内容哈希得出,计票用历史快照防闪电贷;
- 主要攻击:闪电贷、低价 51%、社会操纵——靠快照 + 时间锁 + 合理参数防御。
十一、动手练习项目:迷你 DAO MiniGovernor
项目目标
用 OpenZeppelin Governor + TimelockController + ERC20Votes 搭一个完整 DAO,跑通”提案 → 投票 → 排队 → 执行”全流程,让 DAO 通过投票修改一个被治理金库的参数。部署到 Sepolia。
合约要求
1. GovToken.sol:ERC20 + ERC20Permit + ERC20Votes(复用上一篇成果),mint 给几个测试地址。
2. TreasuryParams.sol(被治理的目标合约)
uint256 public feeRate;、address public owner;setFeeRate(uint256) external onlyOwner;- owner 将设为 Timelock 地址(关键!)。
3. 用 OpenZeppelin 标准合约组装:
MyTimelock is TimelockController:构造传 minDelay、proposers(Governor)、executors(可设 address(0) 表示任意人可执行);MyGovernor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, GovernorVotesQuorumFraction, GovernorTimelockControl:- votingDelay 设 1 个区块、votingPeriod 设较短便于测试(如 50 区块)、proposalThreshold、quorum 设 4%;
- 实现所有必须 override 的函数(state、propose、_execute、_cancel、proposalNeedsQueuing 等)。
测试要求(Foundry,重点 vm.roll/vm.warp 推进时间)
test_FullLifecycle:propose 修改 feeRate → vm.roll 过 votingDelay → castVote(赞成) → vm.roll 过 votingPeriod → queue → vm.warp 过 timelock delay → execute → 断言treasury.feeRate被改;test_ProposalDefeated_NoQuorum:投票太少未达法定人数,state 变 Defeated,execute revert;test_ProposalDefeated_MoreAgainst:反对 > 赞成,被否决;test_CannotExecuteBeforeTimelock:queue 后立即 execute 应 revert(时间锁未到);test_VotePowerFromSnapshot:投票开始后买入代币不增加该提案的投票权(getPastVotes 取快照);test_OnlyTimelockCanSetFee:直接调treasury.setFeeRate应 revert(owner 是 Timelock);test_ProposalIdDeterministic:相同内容的提案算出相同 proposalId。
Sepolia 部署与验证步骤
- 部署 GovToken(mint + 各地址 delegate 给自己)、MyTimelock、MyGovernor、TreasuryParams(owner = Timelock);
- 给 Timelock 授予对 Treasury 的控制(Treasury.owner = Timelock),并把 Governor 设为 Timelock 的 proposer;
- 在 Etherscan/Tally 上 propose 一个
setFeeRate(42)提案; - delegate 后投票,等过投票期 queue,等过时间锁 execute;
- 读
treasury.feeRate确认变成 42——你的 DAO 真的改了链上状态。
进阶挑战(可选)
- 接入 Tally.xyz 前端可视化你在 Sepolia 上的 DAO 和提案;
- 复现一次”闪电贷攻击为何无效”:写测试证明投票期内临时获得的代币因 getPastVotes 快照而不计票;
- 写注释回答:为什么被治理合约的 owner 要设成 Timelock 而不是 Governor?如果设成 Governor 会少了什么保护?