Skip to content
On this page

L2-1: DAO Governance(DAO 治理)⭐

1. 问题

创建完整的 DAO 治理合约,实现提案创建、投票、提案队列(1 活跃 + 1 排队)、3 态投票(For/Against/Abstain),并在代币转移时自动移除活跃提案中的投票。这是 Token Voting (L1-1) 的直接升级——从简单投票升级到完整的链上治理系统。

2. 原因

Token Voting (L1-1) 只有最基本的 "投票支持/反对",而真实 DAO 治理需要以下关键功能:

  1. 提案队列管理 — 同一时间只能有一个活跃提案(防止注意力分散和投票疲劳攻击),正在等待的提案自动排队
  2. 惰性状态推进 — 不依赖 keeper bot 或定时器;在用户交互(propose() / vote())时自动检查并推进队列
  3. 3 态投票 — 支持弃权(Abstain),允许成员表达参与但不站队
  4. 投票移除的范围隔离 — 代币转移时只移除活跃提案中的投票(历史提案投票不可篡改)
  5. 简单多数结果判定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 过期(_tryPromoteQueueblock.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 VotingL2-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 执行

Built with AiAda