Розробка смарт-контрактів DAO
DAO — це не просто контракт з голосуванням. Це набір смарт-контрактів, який реалізує життєвий цикл управління: створення пропозиції → голосування → timelock → виконання. Додайте управління казною, механіку делегування, розрахунки кворуму та логіку оновлення — ви отримаєте систему, яка при неправильному дизайні стає або занадто централізованою (все вирішує мультисиг), або неспроможною (кворум ніколи не досягається).
Стандарти та фреймворки
Перед тим, як будувати що-небудь з нуля, зрозумійте, що вже існує.
OpenZeppelin Governor — де-факто стандарт для EVM DAO. Модульна архітектура: Governor (базовий контракт) розширений через мікси GovernorSettings, GovernorCountingSimple, GovernorVotes, GovernorVotesQuorumFraction, GovernorTimelockControl. Використовується Compound, Uniswap, Gitcoin, ENS DAO.
Compound Governor Bravo — старіший стандарт, від якого розвинувся OpenZeppelin Governor. Все ще використовується у форках Compound. Менш гнучкий, але перевірений в бою.
Aragon — високорівневий фреймворк з архітектурою плагінів. Корисна, коли вам потрібна користувацька логіка управління через плагіни без модифікації основних контрактів.
Zodiac (Gnosis) — набір паттернів для розширення Gnosis Safe через модулі. Дозволяє перетворити мультисиг на DAO з on-chain голосуванням.
Архітектура OpenZeppelin Governor
Мінімальна збірка
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
contract MyDAO is
Governor,
GovernorSettings,
GovernorCountingSimple,
GovernorVotes,
GovernorVotesQuorumFraction,
GovernorTimelockControl
{
constructor(
IVotes _token,
TimelockController _timelock
)
Governor("MyDAO")
GovernorSettings(
1 days, // voting delay: як довго чекати після пропозиції перед початком голосування
1 weeks, // voting period: тривалість голосування
100_000e18 // proposal threshold: мінімум токенів для створення пропозиції
)
GovernorVotes(_token)
GovernorVotesQuorumFraction(4) // 4% від загального пропозиції для кворуму
GovernorTimelockControl(_timelock)
{}
// Обов'язкові перевизначення для вирішення конфліктів мікси
function votingDelay() public view override(Governor, GovernorSettings)
returns (uint256) { return super.votingDelay(); }
function votingPeriod() public view override(Governor, GovernorSettings)
returns (uint256) { return super.votingPeriod(); }
function quorum(uint256 blockNumber)
public view override(Governor, GovernorVotesQuorumFraction)
returns (uint256) { return super.quorum(blockNumber); }
function state(uint256 proposalId)
public view override(Governor, GovernorTimelockControl)
returns (ProposalState) { return super.state(proposalId); }
function _execute(uint256 proposalId, address[] memory targets, uint256[] memory values,
bytes[] memory calldatas, bytes32 descriptionHash)
internal override(Governor, GovernorTimelockControl) {
super._execute(proposalId, targets, values, calldatas, descriptionHash);
}
function _cancel(address[] memory targets, uint256[] memory values,
bytes[] memory calldatas, bytes32 descriptionHash)
internal override(Governor, GovernorTimelockControl) returns (uint256) {
return super._cancel(targets, values, calldatas, descriptionHash);
}
function _executor() internal view override(Governor, GovernorTimelockControl)
returns (address) { return super._executor(); }
function supportsInterface(bytes4 interfaceId)
public view override(Governor, GovernorTimelockControl) returns (bool) {
return super.supportsInterface(interfaceId);
}
}
TimelockController — критичний компонент
Timelock — це затримка між моментом, коли пропозиція пройде голосування, і моментом, коли вона може бути виконана. Це дає спільноті час реагувати, якщо пропозиція виявиться шкідливою.
// Деплой TimelockController
TimelockController timelock = new TimelockController(
2 days, // minDelay: мінімальна затримка
proposers, // хто може ставити в чергу (зазвичай Governor)
executors, // хто може виконувати (address(0) = будь-хто)
admin // admin (зазвичай address(0) після налаштування)
);
// Після деплою — Governor має бути PROPOSER та CANCELLER
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor));
timelock.grantRole(timelock.CANCELLER_ROLE(), address(governor));
// EXECUTOR_ROLE — address(0) означає, що будь-хто може виконати пройдену пропозицію
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(0));
// Відкликати права admin від deployer!
timelock.revokeRole(timelock.TIMELOCK_ADMIN_ROLE(), deployer);
Останній крок критичний: якщо deployer зберігає права admin Timelock, вони можуть обійти управління. Більшість експлуатацій управління побудовані саме на залишених правах admin.
ERC-20 Votes: токен управління
Токен голосування повинен реалізувати інтерфейс IVotes. OpenZeppelin ERC20Votes зберігає історію чекпоїнтів балансів для голосування на основі снапшоту.
contract GovernanceToken is ERC20, ERC20Permit, ERC20Votes {
constructor(address initialHolder)
ERC20("MyDAO Token", "MDT")
ERC20Permit("MyDAO Token")
{
_mint(initialHolder, 10_000_000e18);
}
// ERC20Votes вимагає явного делегування
// Нові одержувачі токенів повинні викликати delegate(self) для активації голосувальної сили
function _afterTokenTransfer(address from, address to, uint256 amount)
internal override(ERC20, ERC20Votes) {
super._afterTokenTransfer(from, to, amount);
}
function _mint(address to, uint256 amount)
internal override(ERC20, ERC20Votes) {
super._mint(to, amount);
}
function _burn(address account, uint256 amount)
internal override(ERC20, ERC20Votes) {
super._burn(account, amount);
}
}
Важливий нюанс делегування: в ERC20Votes токени не мають голосувальної сили, доки власник не викликає delegate(address). Зазвичай delegate(msg.sender) — самоделегування. Це не очевидно для нових користувачів і вимагає явного введення. Багато DAO вирішують це через автоматичне самоделегування при першому передачі.
Делегування для пасивних емітентів
Проблема участі: більшість емітентів пасивні. Делеговане голосування дозволяє їм передати голосувальну силу спеціалізованим учасникам (делегатам) без передачі токенів.
// Користувач делегує свою голосувальну силу іншій адресі
governanceToken.delegate(trustedDelegate);
// Тепер trustedDelegate голосує з вагою всіх делегаторів
// Токени залишаються у оригінального власника
// Відкликати делегування — повернути собі
governanceToken.delegate(msg.sender);
Compound встановив фреймворк для публічного делегування: делегати публікують свої позиції, аргументують рішення, учасники вибирають делегатів за вирівнюванням. ENS DAO, Gitcoin, Uniswap активно використовують екосистеми делегатів.
Життєвий цикл пропозиції
Очікування → Активно → (Поражена | Успішна) → В черзі → Виконана
↘ Скасована
↗ Закінчилась (не виконана в межах MAX_TIMELOCK)
Створення пропозиції
// Пропозиція = набір транзакцій для виконання, якщо пройшла
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
// Приклад: змінити параметр протоколу
targets[0] = address(protocol);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature(
"setFeeRate(uint256)",
500 // нова ставка комісії: 5%
);
uint256 proposalId = governor.propose(
targets,
values,
calldatas,
"# Пропозиція: оновити ставку комісії\n\nПропозиція змінити ставку комісії з 3% на 5%..."
);
Опис зберігається off-chain (зазвичай IPFS); on-chain зберігає тільки keccak256(description) через descriptionHash.
Голосування з обгрунтуванням
// Три варіанти: 0 = Проти, 1 = За, 2 = Утримаюсь
governor.castVote(proposalId, 1); // за
// З обгрунтуванням (зберігається в подіях)
governor.castVoteWithReason(proposalId, 1, "Це збільшення комісії необхідне для стійкості протоколу");
// Через мета-транзакцію (безгазове голосування через підпис EIP-712)
governor.castVoteBySig(proposalId, 1, v, r, s);
Безгазове голосування через castVoteBySig критично для участі — якщо користувачі повинні платити gas за голосування, участь різко падає. Relayer покриває газ; користувач підписує повідомлення EIP-712.
Управління казною
Казна DAO — це кошти, які керує контракт Timelock (і через нього, Governor). Ніхто не може витратити казну без пройденої пропозиції.
Мультисиг як додатковий захисний шар
Для надзвичайних ситуацій (критична уразливість без часу на цикл управління), найкраща практика — окремий 5/9 мультисиг Гранди з правами екстреного паузу.
contract DAOTreasury {
address public immutable governor; // тільки Governor може витратити
address public immutable guardian; // Guardian може паузувати при загрозі
bool public paused;
modifier onlyGovernance() {
require(msg.sender == governor, "Only governance");
_;
}
modifier whenNotPaused() {
require(!paused, "Treasury paused");
_;
}
// Виділення грантів, фінансування робіт, інвестиції
function transfer(address token, address recipient, uint256 amount)
external onlyGovernance whenNotPaused
{
IERC20(token).safeTransfer(recipient, amount);
emit Transfer(token, recipient, amount);
}
// Guardian може тільки паузувати, не витратити
function pause() external {
require(msg.sender == guardian, "Only guardian");
paused = true;
}
// Unpause — тільки через управління
function unpause() external onlyGovernance {
paused = false;
}
}
Користувацькі механізми голосування
Квадратичне голосування
Квадратичне голосування зменшує вплив китів: голосувальна сила = sqrt(баланс токенів). Кит з 1M токенів отримує голосувальну силу 1000, не 1M.
function _getVotes(
address account,
uint256 blockNumber,
bytes memory /*params*/
) internal view virtual override returns (uint256) {
uint256 balance = token.getPastVotes(account, blockNumber);
// Квадратний корінь через метод Вавилона
return _sqrt(balance);
}
function _sqrt(uint256 x) internal pure returns (uint256 y) {
if (x == 0) return 0;
uint256 z = (x + 1) / 2;
y = x;
while (z < y) {
y = z;
z = (x / z + z) / 2;
}
}
Проблема квадратичного голосування: атаки Sybil. Одна адреса з 1M токенів проти 1000 адрес по 1000 токенів кожна — другий випадок отримує загальну голосувальну силу 1000 * sqrt(1000) ≈ 31623 проти sqrt(1M) = 1000. Це робить дроблення прибутковим. Квадратичне голосування працює тільки в парі зі Sybil-стійкістю (Proof of Humanity, Worldcoin).
Conviction Voting
Conviction voting (використовується Gardens/1Hive) накопичує голосувальну силу з часом: чим довше ви утримуєте голос за пропозицію, тим більший вага він отримує. Видалення голосу обнуляє conviction. Хорошо для постійного фінансування (витрати казни без окремих пропозицій для кожної малої виплати).
struct ProposalConviction {
uint256 stakedTokens;
uint256 lastConviction; // значення conviction при останньому оновленні
uint256 lastTimestamp;
}
// conviction = stakedTokens * (1 - alpha^timePassed) / (1 - alpha)
// alpha = коефіцієнт розпаду (наприклад 0.9 за день)
function calculateConviction(
ProposalConviction storage p,
uint256 currentTime
) internal view returns (uint256) {
uint256 timePassed = currentTime - p.lastTimestamp;
// Спрощена цілочисельна версія з коефіцієнтом розпаду
uint256 decayFactor = DECAY_PRECISION - (DECAY_RATE * timePassed);
return (p.lastConviction * decayFactor / DECAY_PRECISION) + p.stakedTokens;
}
Механізм оновлення Governor
Контракти Governor повинні бути розгорнені за UUPS проксі — це дозволяє оновлювати логіку через пропозиції управління.
contract UpgradeableGovernor is Governor, UUPSUpgradeable {
function _authorizeUpgrade(address newImplementation)
internal override onlyGovernance {}
// onlyGovernance модифікатор — тільки через пройдену пропозицію
modifier onlyGovernance() {
require(msg.sender == address(this), "Only governance can upgrade");
_;
}
}
Оновлення через самовиклик: пропозиція викликає upgradeTo(newImplementation) на самому Governor. Це означає, що оновлення завершило повний цикл управління включаючи затримку Timelock.
Часті помилки при розробці
Атаки flash loan на управління. Зловмисник позичає flash loan → отримує величезну голосувальну силу → створює й одразу ж проходить пропозицію → повертає кредит. Захист: voting delay (затримка між пропозицією й початком голосування) плюс голосування на основі снапшоту (голоси враховуються в момент снапшоту, не поточні баланси).
Пастка низького кворуму. 4% кворум здається розумним, але якщо великий емітент делегує — реально активних голосів може не вистачити. Калібруйте кворум проти реальної статистики явки виборців.
Спам пропозицій. Без proposalThreshold будь-хто може створювати пропозиції. З низьким порогом — занадто багато пропозицій. Встановіть поріг достатньо високо, щоб зробити спам дорогим, але не настільки високо, щоб заблокувати реальних учасників.
Короткий timelock. 24-годинний Timelock — занадто мало для DeFi протоколу. Стандарт: мінімум 48–72 години для production. Для основних оновлень — 7 днів.
Параметри за замовчуванням для різних типів DAO
| Параметр | Мала Community DAO | DeFi Protocol DAO | Treasury DAO |
|---|---|---|---|
| Voting Delay | 1 день | 2 дні | 1 день |
| Voting Period | 5 днів | 7 днів | 7 днів |
| Timelock | 24 години | 48–72 години | 48 годин |
| Quorum | 10% | 4% | 5% |
| Proposal Threshold | 1% загального | 0.25% | 0.5% |
Процес розробки
| Фаза | Зміст | Графік |
|---|---|---|
| Дизайн механіки | Виберіть модель голосування, параметри, політику казни | 1–2 тижні |
| Токен управління | ERC20Votes + механіка розповсюджування | 2–3 тижні |
| Governor + Timelock | Збірка з модулів OZ + налаштування | 2–3 тижні |
| Контракт казни | Управління активами, екстремальна пауза | 1–2 тижні |
| Тестування | Тести на форках, симуляція голосування, сценарії атак | 2–3 тижні |
| Frontend | UI пропозиції, інтерфейс голосування, директорія делегатів | 3–5 тижнів |
| Аудит | 3–4 тижні |
Смарт-контракти управління — одна з небагатьох областей, де аудит двома командами виправданий. Уразливість у Governor може дозволити зловмиснику осушити всю казну однією пропозицією.







