Розробка Nouns-style DAO (auction-based)
Nouns DAO запустився в серпні 2021 року і змінив те, як люди думають про NFT та DAO разом. Механіка проста: один Noun (NFT) аукціонується кожні 24 години, всі вирученні кошти йдуть у DAO treasury, кожен Noun = один голос у governance. Нема pre-mine, нема VC allocation, нема whitelist — тільки відкритий аукціон кожен день навічно. На момент написання treasury перевищує 30,000 ETH, і система працює неперервно більше трьох років.
Це не просто механіка, це дизайн-паттерн, який відтворюється: Lil Nouns, Gnars, Purple (Farcaster DAO), Builder DAO (Zora). Розробка Nouns-style DAO — це добре дослідена задача з відкритою reference implementation.
Ключові компоненти системи
Nouns-style DAO складається з чотирьох смарт-контрактів:
- NounsToken (ERC-721 + ERC20Votes) — NFT, кожен є voting unit
- NounsAuctionHouse — механізм щоденного аукціону
- NounsGovernor — governance з fork механізмом (rage quit)
- NounsTreasury (Timelock) — treasury під governance контролем
Додатково: NounsDescriptor — on-chain генерація SVG artwork, NounsSeeder — Chainlink VRF для random seed при генерації.
NounsToken: NFT як voting unit
contract NounsToken is ERC721Checkpointable, INounsToken {
// Адрес для Nounder's reward (кожен 10-й Noun йде засновникам)
address public noundersDAO;
// Тільки AuctionHouse може мінтити
address public minter;
// Seed для генерації artwork
mapping(uint256 => INounsSeeder.Seed) public seeds;
INounsSeeder public seeder;
INounsDescriptor public descriptor;
uint256 private _currentNounId;
function mint() public override onlyMinter returns (uint256) {
// Кожен 10-й Noun — засновникам (nounders reward)
if (_currentNounId <= 1820 && _currentNounId % 10 == 0) {
_mintTo(noundersDAO, _currentNounId++);
}
return _mintTo(minter, _currentNounId++);
}
function _mintTo(address to, uint256 nounId) internal returns (uint256) {
// Отримуємо випадковий seed через Seeder (Chainlink VRF або block hash)
INounsSeeder.Seed memory seed = seeds[nounId] = seeder.generateSeed(nounId, descriptor);
_mint(owner(), to, nounId);
emit NounCreated(nounId, seed);
return nounId;
}
// tokenURI генерується on-chain — ніякого IPFS
function tokenURI(uint256 tokenId) public view override returns (string memory) {
require(_exists(tokenId), "URI query for nonexistent token");
return descriptor.tokenURI(tokenId, seeds[tokenId]);
}
// dataURI — SVG прямо в base64
function dataURI(uint256 tokenId) public view override returns (string memory) {
return descriptor.dataURI(tokenId, seeds[tokenId]);
}
}
ERC-721 з checkpoint voting (ERC721Checkpointable)
Звичайний ERC-721 не має механіки voting power. Nouns розширює його через checkpoint систему — аналог ERC20Votes, але для NFT:
abstract contract ERC721Checkpointable is ERC721Enumerable {
mapping(address => address) private _delegates;
struct Checkpoint {
uint32 fromBlock;
uint96 votes;
}
mapping(address => mapping(uint32 => Checkpoint)) public checkpoints;
mapping(address => uint32) public numCheckpoints;
function votesToDelegate(address delegator) public view returns (uint96) {
return safe96(balanceOf(delegator), "ERC721Checkpointable: votes exceed 96 bits");
}
function delegate(address delegatee) public {
return _delegate(msg.sender, delegatee);
}
function getPriorVotes(address account, uint256 blockNumber) public view returns (uint96) {
require(blockNumber < block.number, "ERC721Checkpointable: not yet determined");
uint32 nCheckpoints = numCheckpoints[account];
if (nCheckpoints == 0) return 0;
// Бінарний пошук через checkpoints
if (checkpoints[account][nCheckpoints - 1].fromBlock <= blockNumber) {
return checkpoints[account][nCheckpoints - 1].votes;
}
// ...реалізація бінарного пошуку
}
}
NounsAuctionHouse: механіка аукціону
Це серце системи. Кожні 24 години — новий аукціон, новий Noun.
contract NounsAuctionHouse is INounsAuctionHouse, PausableUpgradeable, ReentrancyGuardUpgradeable, OwnableUpgradeable {
INounsToken public nouns;
address public weth;
uint256 public timeBuffer; // мінімальне час до кінця аукціону після bid (15 хв)
uint256 public reservePrice; // мінімальна ставка
uint8 public minBidIncrementPercentage; // мінімальний інкремент ставки (%)
uint256 public duration; // тривалість аукціону (24 години)
INounsAuctionHouse.Auction public auction;
struct Auction {
uint256 nounId;
uint256 amount; // поточна ставка
uint256 startTime;
uint256 endTime;
address payable bidder;
bool settled;
}
function createBid(uint256 nounId) external payable override nonReentrant {
INounsAuctionHouse.Auction memory _auction = auction;
require(_auction.nounId == nounId, "Noun not up for auction");
require(block.timestamp < _auction.endTime, "Auction expired");
require(msg.value >= reservePrice, "Must send at least reservePrice");
require(
msg.value >= _auction.amount + ((_auction.amount * minBidIncrementPercentage) / 100),
"Must send more than last bid by minBidIncrementPercentage amount"
);
address payable lastBidder = _auction.bidder;
// Повертаємо попередньому bidder-у
if (lastBidder != address(0)) {
_safeTransferETHWithFallback(lastBidder, _auction.amount);
}
auction.amount = msg.value;
auction.bidder = payable(msg.sender);
// Якщо bid прийшов за timeBuffer до кінця — продовжуємо
bool extended = _auction.endTime - block.timestamp < timeBuffer;
if (extended) {
auction.endTime = block.timestamp + timeBuffer;
}
emit AuctionBid(_auction.nounId, msg.sender, msg.value, extended);
if (extended) {
emit AuctionExtended(_auction.nounId, auction.endTime);
}
}
function settleCurrentAndCreateNewAuction() external override nonReentrant whenNotPaused {
_settleAuction();
_createAuction();
}
function _settleAuction() internal {
INounsAuctionHouse.Auction memory _auction = auction;
require(_auction.startTime != 0, "Auction hasn't begun");
require(!_auction.settled, "Auction has already been settled");
require(block.timestamp >= _auction.endTime, "Auction hasn't completed");
auction.settled = true;
if (_auction.bidder == address(0)) {
// Ніхто не поставив — Noun йде в treasury
nouns.transferFrom(address(this), owner(), _auction.nounId);
} else {
nouns.transferFrom(address(this), _auction.bidder, _auction.nounId);
}
if (_auction.amount > 0) {
// ETH йде в treasury (Timelock)
_safeTransferETHWithFallback(owner(), _auction.amount);
}
emit AuctionSettled(_auction.nounId, _auction.bidder, _auction.amount);
}
// Fallback: якщо ETH transfer не прошов — відправляємо WETH
function _safeTransferETHWithFallback(address to, uint256 amount) internal {
if (!_safeTransferETH(to, amount)) {
IWETH(weth).deposit{ value: amount }();
IERC20(weth).transfer(to, amount);
}
}
}
Anti-sniping: time buffer
timeBuffer — критична захист від last-second sniping. Якщо bid приходить за 15 хвилин до кінця аукціону — кінець сдвигається ще на 15 хвилин. Це ефективно вирішує incentive робити bid в останні секунди — аукціон буде продовжуватися поки є охочі.
On-chain SVG artwork: NounsDescriptor
Одна з найінноваційніших частин Nouns — artwork повністю зберігається on-chain. Нема IPFS, нема centralized сервера. Noun генерується з набору шарів (backgrounds, bodies, accessories, heads, glasses) збережених як RLE-стиснені дані прямо в контракті.
contract NounsDescriptor {
// Стиснені шари artwork в байтах (RLE encoding)
bytes[] public bodies;
bytes[] public accessories;
bytes[] public heads;
bytes[] public glasses;
// Генерація SVG з seed
function generateSVGImage(INounsSeeder.Seed memory seed)
external view returns (string memory svg)
{
ISVGRenderer.SVGParams memory params = ISVGRenderer.SVGParams({
parts: _getPartsForSeed(seed),
background: backgrounds[seed.background]
});
return renderer.generateSVG(params);
}
function tokenURI(uint256 tokenId, INounsSeeder.Seed memory seed)
external view override returns (string memory)
{
string memory name = string(abi.encodePacked('Noun ', tokenId.toString()));
string memory description = string(abi.encodePacked('Noun ', tokenId.toString(), ' is a member of the Nouns DAO'));
return genericDataURI(name, description, seed);
}
function genericDataURI(
string memory name,
string memory description,
INounsSeeder.Seed memory seed
) public view override returns (string memory) {
NFTDescriptor.TokenURIParams memory params = NFTDescriptor.TokenURIParams({
name: name,
description: description,
parts: _getPartsForSeed(seed),
background: backgrounds[seed.background]
});
return NFTDescriptor.constructTokenURI(renderer, params);
}
}
Повністю on-chain NFT — це постійність. Nouns будуть існувати поки існує Ethereum.
NounsGovernor: розширений Governor з fork механізмом
Nouns Governor відрізняється від стандартного OpenZeppelin Governor двома ключовими механіками: objection period та fork.
Objection period
Після закінчення голосування — додатковий період (48 годин), в течение якого тільки голоси Against приймаються. Якщо proposal пройшов голосування, але в останній момент з'явилось багато проти — останні голоси For не помогуть прийняти proposal. Це захист від last-minute vote manipulation.
enum ProposalState {
Pending,
Active,
Canceled,
Defeated,
Succeeded,
Queued,
Expired,
Executed,
Vetoed,
ObjectionPeriod // Новий стан
}
function state(uint256 proposalId) public view override returns (ProposalState) {
// ...стандартна логіка...
// Перевіряємо потрібен ли objection period
if (isForVotesSucceeded && !isObjectionPeriodOver) {
// Якщо в останні X блоки прийшли значні Against голоси
if (proposal.objectionPeriodEndBlock > block.number) {
return ProposalState.ObjectionPeriod;
}
}
}
Fork mechanism (rage quit на рівні proposal)
Найоригінальніша частина Nouns v3: якщо крупний holder не згоден з прийнятим proposal, він може ініціювати fork. DAO розділяється: несогласні забирають пропорціональну долю treasury в новий fork DAO.
function escrowToFork(
uint256[] calldata tokenIds,
uint256[] calldata proposalIds,
string calldata reason
) external onlyNounOwner {
// Токени еско роуються — не можуть бути використані для голосування поки в escrow
for (uint256 i = 0; i < tokenIds.length; i++) {
nouns.safeTransferFrom(msg.sender, address(forkEscrow), tokenIds[i]);
}
emit NounsEscrowed(msg.sender, tokenIds, proposalIds, reason);
// Якщо кількість заещроуених токенів перевищує forkThreshold
// (наприклад 20% від total supply) — fork може бути активірований
if (_isForkThresholdReached()) {
_activateFork();
}
}
function _activateFork() internal {
// Новий fork DAO створюється з тими ж параметрами
// Treasury розділяється пропорционально кількості токенів в escrow
uint256 forkedTreasuryAmount = (address(timelock).balance * escrowedTokens) / totalSupply;
// Деплой нового NounsToken та NounsGovernor для fork DAO
(address forkToken, address forkTreasury) = forkDAODeployer.deployForkDAO(
forkEscrow.numTokensInEscrow(),
forkedTreasuryAmount,
block.timestamp + FORK_PERIOD // період коли інші можуть приєднатися до fork
);
// Transfer ETH в fork treasury
payable(forkTreasury).transfer(forkedTreasuryAmount);
}
Це принципіально інший підхід до governance minority protection порівняно зі стандартним rage quit у Moloch-style DAO.
Кастомізація під конкретний проект
Builder DAO (Zora) створив factory для запуску Nouns-style DAO без кодування: TokenFactory деплоїх кастомні контракти з заданими параметрами. Gnars (skateboarding), Purple (Farcaster), Federation — всі використовують Builder DAO framework.
Параметри кастомізації:
| Параметр | Nouns | Типовий fork | Builder DAO default |
|---|---|---|---|
| Auction duration | 24 години | 12–48 годин | 24 години |
| Reserve price | 1 ETH | 0.01–1 ETH | 0 |
| Founder allocation | 10% (кожен 10-й) | 5–15% | Настраюється |
| Voting delay | 1 день | 1 час – 2 дні | 1 день |
| Voting period | 3 дні | 2–7 днів | 3 дні |
| Quorum | 10% | 5–20% | 10% |
On-chain artwork vs IPFS
Повністю on-chain artwork — дорогий деплой (зберігання байтів в EVM коштує gas). Nouns потратили значні суми на деплой descriptor з artwork. Для більшості форків оптимальний компромісс: artwork на IPFS з on-chain hash, Descriptor генерує metadata динамічно використовуючи IPFS CID.
Економіка: стійкість моделі
Nouns-style модель створює flywheel: аукціон → ETH в treasury → proposals → діяльність DAO → увага → вища ставка на наступний аукціон. Це самофінансуюча система без external fundraising.
Ключовий питання стійкості: наскільки довго participation залишається високою. Nouns вирішує це через постійну on-chain активність (щоденний аукціон — це подія), якість сообщества (кожен Noun дорог, значить власники залучені) та fork механіку (minority protection затримує учасників від "exit by dumping").
Стек для розробки форку
| Компонент | Референс | Альтернатива |
|---|---|---|
| Auction contract | Nouns AuctionHouse | Builder DAO factory |
| NFT + voting | ERC721Checkpointable | ERC721Votes (OZ) |
| Artwork storage | NounsDescriptor (on-chain) | IPFS + descriptor |
| Random seed | Chainlink VRF v2 | Prevrandao (простіше, менш надійно) |
| Governor | NounsGovernor v3 | OZ Governor |
| Frontend | nouns.wtf open source | Builder DAO UI |
Етапи розробки
| Фаза | Вміст | Строк |
|---|---|---|
| Дизайн параметрів | Auction duration, pricing, founder allocation, governance | 1–2 тижні |
| NFT контракт + artwork | ERC-721 + checkpoint voting + descriptor | 3–4 тижні |
| Auction house | Bid механіка, settlement, anti-snipe | 2–3 тижні |
| Governor + Timelock | Governance з кастомними параметрами | 2–3 тижні |
| Artwork preparation | Створення/підготовка шарів, encoding | 2–4 тижні (залежить від арта) |
| Тести | Full coverage, fork simulation, governance attack scenarios | 2–3 тижні |
| Frontend | Auction UI, governance, NFT gallery | 4–6 тижнів |
| Аудит | 3–4 тижні |
Перший запуск аукціону — це публічна подія: на neї потрібно привлечь увагу сообщества до деплоя. Механіка прозора та зрозуміла навіть без технічного бекграунду — це конкурентна перевага над складними DeFi протоколами.







