Skip to content
On this page

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 addMembermsg.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:

  1. Must not be callable arbitrarily from outside (otherwise anyone could join the DAO and dilute shares at will)
  2. Must be callable through proposal execution (because adding new members requires democratic voting)
  3. 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's executeProposal context
  • msg.sender inside addMember = DAO contract address = address(this)
  • onlySelf passes

When called externally:

  • msg.sender = caller's EOA address != address(this)
  • onlySelf reverts

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

ModifierWho can callUse case
onlyOwnerSingle adminCentralized management (upgradeable contracts, emergency pause)
onlyMemberAny DAO memberAbusable: members could arbitrarily add new members infinitely
onlySelfOnly the contract itselfSelf-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

DimensionMembership-based (Moloch)Token-weighted (OZ Governor)
Voting unit1 member = 1 vote1 token = 1 weight
Participation thresholdMust be voted inJust hold tokens
Whale influenceLimitedCan dominate voting
Exit protectionrageQuit (economic exit)Sell tokens to exit

5.3 The rageQuit Actuarial Formula

returnAmount = (shares * ethBalance) / totalShares

Implicit assumptions of this formula:

  • totalShares includes 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

PointDescription
Rage Quit(shares * ethBalance) / totalShares — proportional exit
onlySelf patternmsg.sender == address(this) — self-call access control
Membership-based voting1 person 1 vote, unaffected by share size
Proposal execution arbitrary calltarget.call(data) → flexible but with security implications
Anti-double-votinghasVoted marked at propose time
O(1) deletionSwap-and-pop array management
Moloch frameworkInspiration for this level — core mechanism of MolochDAO v1/v2
Industry comparisonMoloch v2 adds Guild Kick, Loot, multi-token treasury

Built with AiAda