Solidity 治理合约详解:DAO 如何在链上协调

理解提案生命周期、法定人数、时间锁,以及 Governor + Timelock + ERC20Votes 架构

6 分钟阅读
Solidity 治理合约详解:DAO 如何在链上协调

Solidity 治理合约详解:DAO 如何在链上协调

目录


一、治理合约的本质

治理合约的行为很像多签钱包,只不过投票权按投票者的代币余额加权。

一个提案本质就是一笔以太坊交易——包含目标地址和 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 治理由三个合约协作:

  1. ERC20Votes 代币(上一篇):记录每个地址的历史投票权;
  2. Governor 合约:管理提案生命周期——propose、castVote、queue、execute,定义投票延迟、投票期、法定人数;
  3. 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 事件——一个治理决定如何变成一笔真实的链上交易。

八、安全漏洞

攻击案例原理
闪电贷攻击BeanStalkemergencyCommit 允许用闪电贷获得的投票权绕过时间锁,一笔交易通过恶意提案掏空协议
低价攻击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 推进时间)

  1. test_FullLifecycle:propose 修改 feeRate → vm.roll 过 votingDelay → castVote(赞成) → vm.roll 过 votingPeriod → queue → vm.warp 过 timelock delay → execute → 断言 treasury.feeRate 被改;
  2. test_ProposalDefeated_NoQuorum:投票太少未达法定人数,state 变 Defeated,execute revert;
  3. test_ProposalDefeated_MoreAgainst:反对 > 赞成,被否决;
  4. test_CannotExecuteBeforeTimelock:queue 后立即 execute 应 revert(时间锁未到);
  5. test_VotePowerFromSnapshot:投票开始后买入代币不增加该提案的投票权(getPastVotes 取快照);
  6. test_OnlyTimelockCanSetFee:直接调 treasury.setFeeRate 应 revert(owner 是 Timelock);
  7. test_ProposalIdDeterministic:相同内容的提案算出相同 proposalId。

Sepolia 部署与验证步骤

  1. 部署 GovToken(mint + 各地址 delegate 给自己)、MyTimelock、MyGovernor、TreasuryParams(owner = Timelock);
  2. 给 Timelock 授予对 Treasury 的控制(Treasury.owner = Timelock),并把 Governor 设为 Timelock 的 proposer;
  3. 在 Etherscan/Tally 上 propose 一个 setFeeRate(42) 提案;
  4. delegate 后投票,等过投票期 queue,等过时间锁 execute;
  5. treasury.feeRate 确认变成 42——你的 DAO 真的改了链上状态。

进阶挑战(可选)

  • 接入 Tally.xyz 前端可视化你在 Sepolia 上的 DAO 和提案;
  • 复现一次”闪电贷攻击为何无效”:写测试证明投票期内临时获得的代币因 getPastVotes 快照而不计票;
  • 写注释回答:为什么被治理合约的 owner 要设成 Timelock 而不是 Governor?如果设成 Governor 会少了什么保护?

💬 评论