Appearance
L2-2: Moloch Rage Quit(DAO 退出机制)⭐
1. 问题
创建支持 "Rage Quit"(愤怒退出)的 DAO 合约。成员可以通过提案投票管理 DAO;任何成员可以单方面销毁自己的 shares 并按比例提取金库 ETH 退出。核心挑战在于:新成员只能通过提案执行加入(addMember 的 onlySelf 访问控制模式)。
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 合约自己调用 addMember → msg.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 必须满足三个约束:
- 不能被外部随机调用(否则任何人可以随意加入 DAO 稀释份额)
- 必须能被提案执行调用(因为加入新成员需要民主投票)
- 在 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.sender在addMember中 = DAO 合约地址 =address(this)onlySelf通过 ✅
外部直接调用时:
msg.sender= 调用者的 EOA 地址 ≠address(this)onlySelfrevert ❌
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、多代币金库 |