Skip to content
On this page

L2-2: Moloch Rage Quit(DAO 退出机制)⭐

1. 问题

创建支持 "Rage Quit"(愤怒退出)的 DAO 合约。成员可以通过提案投票管理 DAO;任何成员可以单方面销毁自己的 shares 并按比例提取金库 ETH 退出。核心挑战在于:新成员只能通过提案执行加入addMemberonlySelf 访问控制模式)。

2. 原因

传统公司股东要退出需要找到买家或经历复杂的法律程序。在 DAO 中,如果多数成员通过了一个你不认同的提案(例如:把金库资金投给一个你认为是骗局的项目),你能做什么?

Moloch DAO 的解决方案是 Rage Quit

  • 任何成员可以单方面退出(不需要任何人批准)
  • 销毁自己的 shares → 按 shares / totalShares * treasuryBalance 提取 ETH
  • 这是经济上的"用脚投票"——保护少数派不被多数派掠夺

这是 DAO 治理中"少数人保护"的基础机制,也是理解 DAO 经济安全的关键概念。

onlySelf 模式是本关最精妙的设计:addMember 函数是 public 的(因为需要通过提案执行调用),但不能被外部直接调用。解决方案是用 onlySelf modifier:

solidity
modifier onlySelf() {
    require(msg.sender == address(this), "Not proposal execution");
    _;
}

当提案通过后执行 target.call(data) → DAO 合约自己调用 addMembermsg.sender == address(this) → 通过。

3. 方案

架构设计

MolochDAO
  ├── 成员制 (1 成员 1 票,基于成员数不基于 shares)
  ├── Shares (贡献 ETH 换取,rageQuit 时销毁)

  ├── propose(target, data, deadline)
  │     └── 提案者自动投 1 票
  ├── vote(proposalId)
  │     └── 每个成员只能投 1 票
  ├── executeProposal(proposalId)
  │     └── votes * 2 > memberCount → target.call(data)
  │         └── 例如:DAO 调用自身的 addMember(newMember, shares)

  ├── addMember(newMember, shares)  [onlySelf]
  │     └── 只能被提案执行调用

  └── rageQuit()
        └── (shares * ethBalance) / totalShares → ETH 返还
        └── 销毁 shares → 从 memberList 移除

核心代码

成员加入 — onlySelf 模式:

solidity
function addMember(address newMember, uint256 shares) external onlySelf {
    _addMember(newMember, shares);
}

function _addMember(address newMember, uint256 shares) internal {
    require(!isMember[newMember], "Already member");
    isMember[newMember] = true;
    memberShares[newMember] = shares;
    totalShares += shares;
    memberList.push(newMember);
}

提案 — 投票 — 执行流程:

solidity
function propose(address target, bytes calldata data, uint256 deadline)
    external onlyMember returns (uint256)
{
    // ...
    p.votes = 1;                    // 提案者自动投票
    p.hasVoted[msg.sender] = true;  // 防止在 vote() 中再次投票
}

function executeProposal(uint256 proposalId) external {
    // 需超过半数成员投票 (严格多数)
    if (p.votes * 2 <= memberList.length) revert NotEnoughVotes();
    (bool success, ) = p.contractToCall.call(p.data);
}

Rage Quit — 少数派保护:

solidity
function rageQuit() external onlyMember {
    uint256 shares = memberShares[msg.sender];
    uint256 returnAmount = (shares * address(this).balance) / totalShares;

    // 1. 移除成员资格
    isMember[msg.sender] = false;
    totalShares -= shares;
    _removeFromMemberList(msg.sender);

    // 2. 返还 ETH
    (bool success, ) = msg.sender.call{value: returnAmount}("");
}

4. 遭遇的陷阱

4.1 addMember 的访问控制 — onlySelf 的精妙

这是本关最核心的设计问题。addMember 必须满足三个约束:

  1. 不能被外部随机调用(否则任何人可以随意加入 DAO 稀释份额)
  2. 必须能被提案执行调用(因为加入新成员需要民主投票)
  3. 在 Solidity 中没有"只能被 proposal execution 调用"的原生 modifier

解决方案是 onlySelf:

solidity
modifier onlySelf() {
    require(msg.sender == address(this), "Not proposal execution");
    _;
}

当提案执行流程 executeProposal → target.call(data) 调用 DAO 自身的 addMember 时:

  • target.call(data) 是在 DAO 合约的 executeProposal 上下文中执行的
  • msg.senderaddMember 中 = DAO 合约地址 = address(this)
  • onlySelf 通过 ✅

外部直接调用时:

  • msg.sender = 调用者的 EOA 地址 ≠ address(this)
  • onlySelf revert ❌

4.2 多数基于成员数而非 shares

solidity
if (p.votes * 2 <= memberList.length) revert NotEnoughVotes();

通过条件:超过一半的成员投票赞成(按成员人头计算),而非按 shares 计算。这意味着:

  • 1 个有 1% shares 的成员 = 1 票
  • 1 个有 99% shares 的成员 = 1 票
  • 防止大户单方面通过所有提案(1 人 1 票的民主制)

注意: 这个设计也与代币权重投票形成对比——大多数 DeFi 治理使用代币权重(如同关卡的 OZ Governor),但 Moloch 框架选择了成员制。

4.3 提案者自动投票 + 防重复

propose() 中提案者自动投 1 票,hasVoted[proposer] 立即标记为 true:

solidity
p.votes = 1;
p.hasVoted[msg.sender] = true;

如果没有这行,提案者可以在 vote() 中再次投票 → p.votes += 1 → 一人投 2 票 → 不符合 1 成员 1 票的设计。

4.4 rageQuit 对提案投票的影响

如果成员在投票后 rageQuit:

  • 成员资格被删除(isMember = false
  • 但已投的票仍在提案的 p.votes
  • 这意味着退出成员的历史投票不会回滚

这可能产生微妙的影响——如果恰好退出的成员是决定性的那票,而退出后 memberCount 减少 → 通过条件 votes * 2 > memberCount 变得更宽松。

示例: 原有 5 个成员,3 票赞成(刚好超过半数:32=6 > 5)。如果 1 个反对者 rageQuit,memberCount 变为 4,但 p.votes 仍为 3 → 32=6 > 4 → 提案仍可通过(实际上更容易通过了)。

5. 陷阱的原因

5.1 onlySelf vs onlyOwner vs onlyMember 的对比

Modifier允许谁调用适用场景
onlyOwner单一管理员中心化管理(可升级合约、紧急暂停)
onlyMember任何 DAO 成员可被滥用:成员可随意添加新成员无限复制
onlySelf仅合约自身通过民主投票后由合约自我调用

onlySelf 是将"外部动作"变为"DAO 自主动作"的关键——添加成员必须通过提案流程,但提案执行时是 DAO 合约"自己调用自己"。

5.2 成员制 vs 代币权重制

维度成员制 (Moloch)代币权重制 (OZ Governor)
投票单位1 成员 = 1 票1 代币 = 1 权重
参与门槛需要投票加入持有代币即可
大户影响力被限制可主导投票
退出保护rageQuit(经济退出)卖币退出

5.3 rageQuit 的精算公式

returnAmount = (shares * ethBalance) / totalShares

这个公式的隐含假设:

  • totalShares 包含即将退出的成员的 shares(在减法之前计算)
  • 这导致退出成员获得的总 ETH 略少于按「事后 totalShares」计算的结果(因为自己的 shares 先计入分母)

示例: 总 shares=100, 金库=100 ETH, 成员持有 10 shares

  • returnAmount = (10 * 100) / 100 = 10 ETH
  • 退出后: totalShares=90, 金库=90 ETH
  • 剩余成员: 90 shares → 90 ETH → 比例不变 ✅

6. 如何解决陷阱

solidity
// ✅ onlySelf 确保加入成员必须经过民主流程
function addMember(address newMember, uint256 shares) external onlySelf {
    _addMember(newMember, shares);
}

// ✅ 提案时标记投票,防止重复
function propose(...) external onlyMember returns (uint256) {
    proposalId = ++proposalCount;
    Proposal storage p = proposals[proposalId];
    p.votes = 1;                    // 自动投票
    p.hasVoted[msg.sender] = true;  // 防重复
    // ...
}

// ✅ 严格多数:超过半数成员
function executeProposal(uint256 proposalId) external {
    if (p.votes * 2 <= memberList.length) revert NotEnoughVotes();
    // ...
}

// ✅ Swap-and-pop O(1) 成员删除
function _removeFromMemberList(address member) internal {
    for (uint256 i; i < list.length; ++i) {
        if (list[i] == member) {
            list[i] = list[list.length - 1];
            list.pop();
            break;
        }
    }
}

7. 技术要点

要点说明
Rage Quit(shares * ethBalance) / totalShares — 按比例退出
onlySelf 模式msg.sender == address(this) — 自我调用的访问控制
成员制投票1 人 1 票,不受 shares 大小影响
提案执行任意调用target.call(data) → 灵活但有安全隐患
防重复投票hasVoted 在 propose 时就标记
O(1) 删除swap-and-pop 数组管理
Moloch 框架本关灵感来源 — MolochDAO v1/v2 的核心机制
工业级对比Moloch v2 增加了 Guild Kick、Loot、多代币金库

Built with AiAda