Разработка механизма rage quit
Rage quit — это право участника DAO выйти из организации и забрать пропорциональную долю treasury до исполнения proposal, с которым он не согласен. Механика пришла из Moloch DAO (2019) и решает фундаментальную проблему governance: minority protection. Без rage quit миноритарный участник, проигравший голосование, может либо принять решение, либо продать долю на рынке. С rage quit — есть третий вариант: выйти с честной долей активов.
Moloch-style rage quit: классическая реализация
Moloch V2/V3 — референсная реализация. Участники держат shares (не ERC-20, а внутренний учёт) и loot (shares без voting power). Rage quit конвертирует shares/loot в пропорциональную долю treasury tokens.
contract MolochDAO {
struct Member {
address delegateKey;
uint256 shares; // голосующие доли
uint256 loot; // неголосующие доли (для rage quit без потери voting power)
bool exists;
uint256 highestIndexYesVote; // для rage quit lock
uint256 jailed;
}
mapping(address => Member) public members;
uint256 public totalShares;
uint256 public totalLoot;
// Whitelist approved tokens для rage quit
address[] public approvedTokens;
function ragequit(uint256 sharesToBurn, uint256 lootToBurn) public nonReentrant {
require(members[msg.sender].shares >= sharesToBurn, "Insufficient shares");
require(members[msg.sender].loot >= lootToBurn, "Insufficient loot");
// Нельзя rage quit если проголосовал YES за proposal в очереди
// (защита от "vote yes, drain treasury, rage quit" атаки)
require(
canRagequit(members[msg.sender].highestIndexYesVote),
"Cannot ragequit until highest index proposal member voted YES on is processed"
);
_ragequit(msg.sender, sharesToBurn, lootToBurn);
}
function _ragequit(address memberAddress, uint256 sharesToBurn, uint256 lootToBurn) internal {
uint256 initialTotalSharesAndLoot = totalShares + totalLoot;
members[memberAddress].shares -= sharesToBurn;
members[memberAddress].loot -= lootToBurn;
totalShares -= sharesToBurn;
totalLoot -= lootToBurn;
// Пропорциональная выплата по всем approved tokens
for (uint256 i = 0; i < approvedTokens.length; i++) {
address token = approvedTokens[i];
uint256 treasuryBalance = IERC20(token).balanceOf(address(this));
uint256 amountToRagequit = (treasuryBalance * (sharesToBurn + lootToBurn))
/ initialTotalSharesAndLoot;
if (amountToRagequit > 0) {
IERC20(token).safeTransfer(memberAddress, amountToRagequit);
}
}
emit Ragequit(memberAddress, sharesToBurn, lootToBurn);
}
function canRagequit(uint256 highestIndexYesVote) public view returns (bool) {
// Proposal с этим индексом должен быть обработан
require(highestIndexYesVote < proposalQueue.length, "No proposal queue");
return proposals[proposalQueue[highestIndexYesVote]].processed;
}
}
Ключевая защита: highestIndexYesVote lock
Без этой защиты возможна атака: проголосовать YES за proposal, который даёт ETH злоумышленнику → treasury ещё полная → сделать rage quit → злоумышленник получает и долю treasury и выплату по proposal. Lock блокирует rage quit пока proposal ещё не обработан.
Nouns-style fork: rage quit на уровне протокола
Nouns V3 реализует более масштабный rage quit — не выход одного участника, а fork всего DAO. Если достаточный процент токен-холдеров в эскроу (например 20%) — создаётся новый fork DAO с пропорциональной долей treasury.
contract NounsDAOForkEscrow {
INounsToken public nounsToken;
address public dao;
uint256 public forkId;
// tokenId => владелец (для возврата если fork не активирован)
mapping(uint256 => address) private escrowedTokens;
uint256 public numTokensInEscrow;
function escrowToFork(
uint256[] calldata tokenIds,
uint256[] calldata proposalIds,
string calldata reason
) external {
for (uint256 i = 0; i < tokenIds.length; i++) {
require(
nounsToken.ownerOf(tokenIds[i]) == msg.sender,
"Not token owner"
);
escrowedTokens[tokenIds[i]] = msg.sender;
nounsToken.transferFrom(msg.sender, address(this), tokenIds[i]);
}
numTokensInEscrow += tokenIds.length;
emit TokensEscrowed(msg.sender, tokenIds, proposalIds, reason);
}
// Вернуть токены если передумал до активации fork
function returnTokensToOwner(address owner, uint256[] calldata tokenIds) external {
require(msg.sender == dao, "Only DAO");
for (uint256 i = 0; i < tokenIds.length; i++) {
require(escrowedTokens[tokenIds[i]] == owner, "Not escrow owner");
escrowedTokens[tokenIds[i]] = address(0);
nounsToken.transferFrom(address(this), owner, tokenIds[i]);
}
numTokensInEscrow -= tokenIds.length;
}
}
После активации fork — новый NounsToken деплоится с копией supply, treasury делится пропорционально:
function executeFork() external {
// Только DAO может активировать fork
require(msg.sender == address(dao));
uint256 forkSupply = forkEscrow.numTokensInEscrow();
uint256 totalSupply = nounsToken.totalSupply();
// Пропорциональная доля treasury
uint256 ethToFork = (address(timelock).balance * forkSupply) / totalSupply;
// USDC и другие токены тоже делятся
for (uint256 i = 0; i < forkDAOTokens.length; i++) {
uint256 tokenBalance = IERC20(forkDAOTokens[i]).balanceOf(address(timelock));
uint256 tokenAmount = (tokenBalance * forkSupply) / totalSupply;
// transfer в fork treasury
}
// Transfer ETH
payable(forkTreasury).transfer(ethToFork);
emit ForkExecuted(forkId, forkTreasury, ethToFork);
}
ERC-20 DAO с rage quit (Governor + Rage Quit)
Для DAO на ERC-20 токенах rage quit сложнее — токены fungible и freely tradable. Механика должна решать: пользователь должен lock токены для участия в rage quit или может выйти с любым балансом?
Vault-based rage quit
Участники депонируют токены в vault (получают receipt tokens), vault участвует в governance. Rage quit — burn receipt tokens, получить пропорцию vault assets.
contract DAOVaultWithRageQuit {
IERC20 public immutable governanceToken;
// Receipt token — 1:1 с депозитом
IReceiptToken public immutable receiptToken;
// Approved assets в vault (ETH + ERC-20)
address[] public vaultAssets;
// Pending proposals (для rage quit window)
mapping(uint256 => uint256) public proposalRageQuitDeadline;
function deposit(uint256 amount) external {
governanceToken.safeTransferFrom(msg.sender, address(this), amount);
receiptToken.mint(msg.sender, amount);
emit Deposited(msg.sender, amount);
}
function rageQuit(
uint256 receiptAmount,
address[] calldata tokens // какие токены хочет получить
) external {
// Проверяем что нет активных proposals в grace period
// за которые пользователь голосовал
_validateNoActiveLocks(msg.sender);
uint256 totalReceipts = receiptToken.totalSupply();
receiptToken.burn(msg.sender, receiptAmount);
// Пропорциональная выплата
for (uint256 i = 0; i < tokens.length; i++) {
uint256 balance;
if (tokens[i] == address(0)) {
balance = address(this).balance;
} else {
balance = IERC20(tokens[i]).balanceOf(address(this));
}
uint256 payout = (balance * receiptAmount) / totalReceipts;
if (payout > 0) {
if (tokens[i] == address(0)) {
payable(msg.sender).transfer(payout);
} else {
IERC20(tokens[i]).safeTransfer(msg.sender, payout);
}
}
}
emit RageQuit(msg.sender, receiptAmount, tokens);
}
function _validateNoActiveLocks(address user) internal view {
// Проверяем что нет proposals в rage quit window
// за которые голосовал пользователь
uint256[] memory activeProposals = governor.getActiveProposals();
for (uint256 i = 0; i < activeProposals.length; i++) {
uint256 proposalId = activeProposals[i];
if (governor.hasVoted(proposalId, user)) {
uint256 deadline = proposalRageQuitDeadline[proposalId];
require(block.timestamp > deadline, "Active proposal in rage quit window");
}
}
}
}
Rage quit window: критический параметр
Rage quit window — период между принятием proposal и его исполнением, в течение которого несогласные могут выйти. По сути это Timelock с rage quit функциональностью.
| Параметр | Типичное значение | Соображения |
|---|---|---|
| Rage quit window | 3–7 дней | Должен быть достаточен для реакции |
| Voting period | 3–7 дней | Информирует о принятых решениях |
| Execution delay | Rage quit window + buffer | Timelock >= rage quit window |
| Min stake для участия | 0 (любой holder) | Или threshold для предотвращения spam |
Типичная ошибка: Timelock короче rage quit window. Если proposal исполняется через 24 часа, а rage quit window — 3 дня, механика не работает.
Газовая оптимизация
Rage quit с large token list дорог — перебор всех assets в цикле. Оптимизации:
- Claim по одному токену: пользователь указывает конкретные токены для получения, не все
- Merkle distribution: для большого количества assets — snapshot + merkle tree
- Pull payment pattern: средства резервируются, пользователь забирает их отдельными транзакциями
Когда rage quit не подходит
Rage quit эффективен для treasury DAO (держат fungible assets). Для operational DAO (исполняют работу) — сложнее: treasury может состоять из illiquid активов (NFT, vesting токены, LP позиции). Rage quit на illiquid assets требует либо немедленной ликвидации (потери), либо отсроченных выплат.
В таких случаях рассматривают token buyback механизм или exit fee как альтернативу прямому rage quit.







