Appearance
L2-2: Moloch Rage Quit (DAO Exit Mechanism)
1. Problem
Create a DAO contract that supports "Rage Quit." Members can manage the DAO through proposal voting; any member can unilaterally burn their own shares and withdraw ETH from the treasury proportionally. The core challenge: new members can only join through proposal execution (the onlySelf access control pattern for addMember).
2. Why
In a traditional company, a shareholder wanting to exit must find a buyer or go through complex legal procedures. In a DAO, if the majority passes a proposal you disagree with (e.g., investing treasury funds into a project you believe is a scam), what can you do?
The Moloch DAO solution is Rage Quit:
- Any member can unilaterally exit (no approval needed)
- Burn their own shares → withdraw ETH proportionally as
shares / totalShares * treasuryBalance - This is economic "voting with your feet" — protecting the minority from being plundered by the majority
This is the foundational mechanism for "minority protection" in DAO governance and a key concept for understanding DAO economic security.
The onlySelf pattern is the most elegant design in this level: the addMember function is public (because it needs to be called through proposal execution), but it must not be callable directly from outside. The solution is the onlySelf modifier:
solidity
modifier onlySelf() {
require(msg.sender == address(this), "Not proposal execution");
_;
}
When a proposal passes and executes target.call(data) → the DAO contract calls its own addMember → msg.sender == address(this) → passes.
3. Solution
Architecture Design
MolochDAO
├── Membership-based (1 member = 1 vote, based on member count not shares)
├── Shares (obtained by contributing ETH, burned on rageQuit)
│
├── propose(target, data, deadline)
│ └── Proposer auto-casts 1 vote
├── vote(proposalId)
│ └── Each member can only cast 1 vote
├── executeProposal(proposalId)
│ └── votes * 2 > memberCount → target.call(data)
│ └── e.g.: DAO calls its own addMember(newMember, shares)
│
├── addMember(newMember, shares) [onlySelf]
│ └── Can only be called through proposal execution
│
└── rageQuit()
└── (shares * ethBalance) / totalShares → ETH returned
└── Burn shares → remove from memberList
Core Code
Adding members — onlySelf pattern:
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);
}
Propose — Vote — Execute flow:
solidity
function propose(address target, bytes calldata data, uint256 deadline)
external onlyMember returns (uint256)
{
// ...
p.votes = 1; // Proposer auto-casts vote
p.hasVoted[msg.sender] = true; // Prevents voting again in vote()
}
solidity
function executeProposal(uint256 proposalId) external {
// Requires more than half of members voting (strict majority)
if (p.votes * 2 <= memberList.length) revert NotEnoughVotes();
(bool success, ) = p.contractToCall.call(p.data);
}
Rage Quit — minority protection:
solidity
function rageQuit() external onlyMember {
uint256 shares = memberShares[msg.sender];
uint256 returnAmount = (shares * address(this).balance) / totalShares;
// 1. Remove membership
isMember[msg.sender] = false;
totalShares -= shares;
_removeFromMemberList(msg.sender);
// 2. Return ETH
(bool success, ) = msg.sender.call{value: returnAmount}("");
}
4. Pitfalls Encountered
4.1 addMember Access Control — the Elegance of onlySelf
This is the most central design problem in this level. addMember must satisfy three constraints:
- Must not be callable arbitrarily from outside (otherwise anyone could join the DAO and dilute shares at will)
- Must be callable through proposal execution (because adding new members requires democratic voting)
- Solidity has no native modifier for "callable only through proposal execution"
The solution is onlySelf:
solidity
modifier onlySelf() {
require(msg.sender == address(this), "Not proposal execution");
_;
}
When the proposal execution flow executeProposal → target.call(data) calls the DAO's own addMember:
target.call(data)executes within the DAO contract'sexecuteProposalcontextmsg.senderinsideaddMember= DAO contract address =address(this)onlySelfpasses
When called externally:
msg.sender= caller's EOA address !=address(this)onlySelfreverts
4.2 Majority Based on Member Count, Not Shares
solidity
if (p.votes * 2 <= memberList.length) revert NotEnoughVotes();
Pass condition: more than half of members vote in favor (counted by headcount), not by shares. This means:
- 1 member with 1% of shares = 1 vote
- 1 member with 99% of shares = 1 vote
- Prevents whales from unilaterally passing all proposals (1-person-1-vote democracy)
Note: This design also contrasts with token-weighted voting — most DeFi governance uses token weighting (like OZ Governor in other levels), but the Moloch framework chose membership-based governance.
4.3 Proposer Auto-Vote + Anti-Double-Voting
In propose(), the proposer auto-casts 1 vote and hasVoted[proposer] is immediately marked true:
solidity
p.votes = 1;
p.hasVoted[msg.sender] = true;
Without this line, the proposer could vote again in vote() → p.votes += 1 → casting 2 votes as 1 person → violating the 1-member-1-vote design.
4.4 Impact of rageQuit on Proposal Voting
If a member rageQuits after voting:
- Membership is deleted (
isMember = false) - But already-cast votes remain in the proposal's
p.votes - This means a departing member's historical votes are not rolled back
This can have subtle effects — if the departing member cast the decisive vote, and after exit memberCount decreases → the pass condition votes * 2 > memberCount becomes more lenient.
Example: Originally 5 members, 3 votes in favor (just over half: 32=6 > 5). If 1 opponent rageQuits, memberCount becomes 4, but p.votes remains 3 → 32=6 > 4 → the proposal still passes (actually becomes easier to pass).
5. Why the Pitfalls Exist
5.1 onlySelf vs onlyOwner vs onlyMember
| Modifier | Who can call | Use case |
|---|---|---|
onlyOwner | Single admin | Centralized management (upgradeable contracts, emergency pause) |
onlyMember | Any DAO member | Abusable: members could arbitrarily add new members infinitely |
onlySelf | Only the contract itself | Self-call by the contract after democratic vote |
onlySelf is the key to turning an "external action" into a "DAO autonomous action" — adding members must go through the proposal process, but when the proposal executes, the DAO contract "calls itself."
5.2 Membership-based vs Token-weighted Governance
| Dimension | Membership-based (Moloch) | Token-weighted (OZ Governor) |
|---|---|---|
| Voting unit | 1 member = 1 vote | 1 token = 1 weight |
| Participation threshold | Must be voted in | Just hold tokens |
| Whale influence | Limited | Can dominate voting |
| Exit protection | rageQuit (economic exit) | Sell tokens to exit |
5.3 The rageQuit Actuarial Formula
returnAmount = (shares * ethBalance) / totalShares
Implicit assumptions of this formula:
totalSharesincludes the exiting member's shares (computed before subtraction)- This results in the exiting member receiving slightly less ETH than if calculated against the "ex-post totalShares" (because their own shares are included in the denominator first)
Example: totalShares=100, treasury=100 ETH, member holds 10 shares
returnAmount = (10 * 100) / 100 = 10 ETH- After exit: totalShares=90, treasury=90 ETH
- Remaining members: 90 shares → 90 ETH → ratio unchanged
6. How to Solve the Pitfalls
solidity
// Correct: onlySelf ensures adding members requires democratic process
function addMember(address newMember, uint256 shares) external onlySelf {
_addMember(newMember, shares);
}
// Correct: Mark voted at proposal time to prevent double-voting
function propose(...) external onlyMember returns (uint256) {
proposalId = ++proposalCount;
Proposal storage p = proposals[proposalId];
p.votes = 1; // Auto-vote
p.hasVoted[msg.sender] = true; // Anti-double-vote
// ...
}
// Correct: Strict majority — more than half of members
function executeProposal(uint256 proposalId) external {
if (p.votes * 2 <= memberList.length) revert NotEnoughVotes();
// ...
}
// Correct: Swap-and-pop O(1) member removal
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. Technical Takeaways
| Point | Description |
|---|---|
| Rage Quit | (shares * ethBalance) / totalShares — proportional exit |
| onlySelf pattern | msg.sender == address(this) — self-call access control |
| Membership-based voting | 1 person 1 vote, unaffected by share size |
| Proposal execution arbitrary call | target.call(data) → flexible but with security implications |
| Anti-double-voting | hasVoted marked at propose time |
| O(1) deletion | Swap-and-pop array management |
| Moloch framework | Inspiration for this level — core mechanism of MolochDAO v1/v2 |
| Industry comparison | Moloch v2 adds Guild Kick, Loot, multi-token treasury |