Appearance
L2-1: DAO Governance(DAO 治理)⭐
1. 问题
创建完整的 DAO 治理合约,实现提案创建、投票、提案队列(1 活跃 + 1 排队)、3 态投票(For/Against/Abstain),并在代币转移时自动移除活跃提案中的投票。这是 Token Voting (L1-1) 的直接升级——从简单投票升级到完整的链上治理系统。
2. 原因
Token Voting (L1-1) 只有最基本的 "投票支持/反对",而真实 DAO 治理需要以下关键功能:
- 提案队列管理 — 同一时间只能有一个活跃提案(防止注意力分散和投票疲劳攻击),正在等待的提案自动排队
- 惰性状态推进 — 不依赖 keeper bot 或定时器;在用户交互(
propose()/vote())时自动检查并推进队列 - 3 态投票 — 支持弃权(Abstain),允许成员表达参与但不站队
- 投票移除的范围隔离 — 代币转移时只移除活跃提案中的投票(历史提案投票不可篡改)
- 简单多数结果判定 —
votesFor > votesAgainst即通过(弃权不影响结果,仅用于法定人数统计)
这是 Compound Governor Alpha 简化的前置概念——理解提案队列机制是后续 OZ Governor 学习的基础。
3. 方案
架构设计
提案队列:
activeProposalId → 当前可投票的提案 (0 = 无活跃提案)
queuedProposalId → 等待中的提案 (0 = 无排队提案)
状态推进:
_tryPromoteQueue() 在 propose() 和 vote() 入口自动调用
├── activeProposalId == 0? → 检查队列 → 提升
└── 活跃提案已过期? → 提升队列提案 → 重置队列
合约结构
solidity
contract Governance {
IERC20 public immutable token;
uint256 public immutable votingPeriod;
struct Proposal {
string title;
uint256 deadline;
address creator;
uint256 votesFor;
uint256 votesAgainst;
uint256 votesAbstain;
}
uint256 public proposalCount;
mapping(uint256 => Proposal) public proposals;
uint256 public activeProposalId;
uint256 public queuedProposalId;
// 投票追踪
mapping(uint256 => mapping(address => bool)) public hasVoted;
mapping(uint256 => mapping(address => uint8)) public voteChoice;
mapping(uint256 => mapping(address => uint256)) public voteWeight;
}
提案生命周期
propose(title) → Proposal(deadline=now+votingPeriod)
├── 无活跃提案 → activeProposalId = proposalId
└── 有活跃提案 → queuedProposalId = proposalId
vote(voteType) → 仅在 activeProposalId 上投票
└── 投票权重 = token.balanceOf(voter)
_tryPromoteQueue() → 在每次 propose/vote 时调用
├── 活跃过期 → activeId = queuedId → queued = 0
└── 活跃未过期 → 无操作
getResult(proposalId) → 仅限已过期的提案
└── votesFor > votesAgainst → true
核心代码
惰性队列推进:
solidity
function _tryPromoteQueue() internal {
if (activeProposalId != 0) {
Proposal storage active = proposals[activeProposalId];
if (block.timestamp > active.deadline) {
activeProposalId = queuedProposalId;
queuedProposalId = 0;
}
}
}
仅移除活跃提案投票:
solidity
function removeVotes(address from) external onlyTokenContract {
uint256 proposalId = activeProposalId; // ← 仅活跃提案
// 从 votesFor/votesAgainst/votesAbstain 中扣除
hasVoted[proposalId][from] = false;
delete voteChoice[proposalId][from];
delete voteWeight[proposalId][from];
}
3 态投票:
solidity
function vote(uint8 voteType) external {
_tryPromoteQueue(); // 入口处惰性推进
// ...
if (voteType == uint8(VoteType.For)) {
proposal.votesFor += balance;
} else if (voteType == uint8(VoteType.Against)) {
proposal.votesAgainst += balance;
} else {
proposal.votesAbstain += balance; // 弃权:参与但不影响结果
}
}
4. 遭遇的陷阱
4.1 提案队列死锁
当 P1(活跃)和 P2(排队)的 deadline 很接近时,P1 过期后 P2 被提升,但 P2 也立即过期——投票窗口为 0。
具体场景:
- P1 在 t=0 创建,deadline = t+7d
- P2 在 t=1 创建(排队),deadline = t+1+7d
- P1 只在 t+7d+1 过期(
_tryPromoteQueue在block.timestamp > deadline时触发) - P2 被提升时:
block.timestamp = t+7d+1 > P2.deadline = t+8d→ 未过期 ✅
但如果 P2 在 t=6d+23h 创建(P1 deadline 前 1h):
- P1 deadline = t+7d
- P2 deadline = t+6d+23h+7d = t+13d+23h
- P2 提升后有足够的投票时间 ✅
真正的死锁场景: 如果 P1 创建后 P2 立刻创建且 deadline 非常接近 P1 → P2 提升后可能也快到期。解决方案是确保 P2 的创建时间至少比 P1 晚投票周期的一半。
4.2 _tryPromoteQueue 的调用时机
_tryPromoteQueue 只在 propose() 和 vote() 入口调用。如果无人投票且无人提案,过期提案永远不会被推进——队列永远卡住。
这是有意为之(惰性评估模式):不需要 keeper bot 或外部 cron,等待下一次用户交互。
4.3 removeVotes 的提案范围
removeVotes(from) 只操作 activeProposalId:
- ✅ 历史提案投票不受影响(不可篡改)
- ✅ 代币转移后,发送方在活跃提案中的投票被移除
- ❌ 但如果用户持有代币、投票、然后卖币(同一地址),新持有者不能再次投票(因为
hasVoted[activeId][buyer]仍是 false)
正确理解: removeVotes 是为 DecentralizedResistanceToken._update() 设计的——当 from 地址转移代币时,代币合约调用 governance.removeVotes(from) 移除 from 在活跃提案中的投票。新持有者不会自动获得投票权(代币余额已变)。
4.4 队列容量限制
maxQueuedProposals 错误在 activeProposalId != 0 && queuedProposalId != 0 时抛出——即同时只能有 2 个未处理提案(1 活跃 + 1 排队)。这是防止提案洪泛攻击的精简设计。
5. 陷阱的原因
5.1 惰性推进的时间漂移
惰性推进不依赖精确的 block 边界。_tryPromoteQueue 在用户下次交互时才触发,这意味着:
- 如果 DRT 暂时不活跃(无人提案/投票),过期提案会一直占用
activeProposalId - 前排提案的"实际推进时间" = 下一次 propose/vote 的时间
- 这不影响结果正确性(投票已结束,deadline 已过期)
5.2 与 Token Voting (L1-1) 的关键差异
| 行为 | L1-1: Token Voting | L2-1: DAO Governance |
|---|---|---|
| 提案数量 | 1 个 | 最多 2 个(1 活跃 + 1 排队) |
| removeVotes 范围 | 唯一提案 | 仅活跃提案 |
| 投票选项 | For/Against (bool) | For/Against/Abstain (uint8) |
| 提案结构 | 隐式(直接存在合约中) | 显式(Proposal 结构体 + proposalCount) |
5.3 弃权票的设计理由
弃权票计入 votesAbstain 但不影响 getResult()(只看 votesFor > votesAgainst)。弃权票的意义:
- 表达"我在关注但无立场"(参与度信号)
- 在更复杂的治理中可用于法定人数计算
- 防止"沉默"被误解为"同意"
6. 如何解决陷阱
solidity
// ✅ 惰性推进的正确位置:每个入口都调用
function propose(string calldata title) external returns (uint256) {
_tryPromoteQueue(); // ← 关键
// ... 创建提案
}
function vote(uint8 voteType) external {
_tryPromoteQueue(); // ← 关键
// ... 投票
}
// ✅ removeVotes 只操作活跃提案
function removeVotes(address from) external onlyTokenContract {
uint256 proposalId = activeProposalId; // ← 始终操作 activeProposalId
// ... 移除投票
}
// ✅ 结果判定仅依赖简单多数
function getResult(uint256 proposalId) external view returns (bool) {
require(block.timestamp > proposal.deadline, "Voting not ended");
return proposals[proposalId].votesFor > proposals[proposalId].votesAgainst;
}
7. 技术要点
| 要点 | 说明 |
|---|---|
| 提案队列 | 最多 1 活跃 + 1 排队,防洪泛攻击 |
| 惰性状态转换 | _tryPromoteQueue 在用户交互时触发 |
| 3 态投票 | For(1) / Against(0) / Abstain(2) |
| removeVotes 隔离 | 仅影响 activeProposalId,历史不可篡改 |
| 简单多数 | votesFor > votesAgainst — 弃权不影响结果 |
| DRT 集成 | 代币 _update 时调用 governance.removeVotes(from) |
| 与 OZ Governor 区别 | 无 timelock、无 quorum、无 calldata 执行 |