Разработка агрегатора голосований нескольких DAO
Крупный держатель токенов, участвующий в нескольких DAO одновременно, сталкивается с операционным хаосом: Uniswap Governor, Compound Governor, Aave AIP, ENS Governor — у каждого свой интерфейс, своя логика пропозалов, свои сроки. Пропустить deadline голосования — обычное дело. Агрегатор governance решает эту проблему: единый дашборд, единая точка мониторинга, возможность делегировать управление правилам.
Это не просто read-only дашборд (таким является Tally). Агрегатор с on-chain компонентом позволяет одной транзакцией голосовать в нескольких DAO, управлять делегированием в одном месте, и задавать автоматические правила голосования.
Архитектура системы
Уровни агрегации
Агрегатор состоит из трёх слоёв:
Indexing layer — индексирует события из всех отслеживаемых Governor-контрактов. Каждый протокол имеет свою специфику событий (Governor Bravo vs OZ Governor различаются), поэтому нужны адаптеры.
State layer — единая модель данных для пропозалов из разных источников. Нормализация: несмотря на разные контракты, все пропозалы имеют общую структуру {id, protocol, status, deadline, description, calldata}.
Action layer — on-chain или off-chain механизм исполнения голосований. Это самый сложный компонент.
Адаптеры протоколов
Каждый Governor-совместимый контракт имеет немного разный ABI. OZ Governor v4/v5 отличается от Compound Governor Bravo, который отличается от Compound Governor Alpha.
interface GovernorAdapter {
getProposals(fromBlock: number): Promise<Proposal[]>;
getProposalState(proposalId: bigint): Promise<ProposalState>;
castVote(proposalId: bigint, support: number): Promise<TransactionRequest>;
getVotingPower(voter: string, blockNumber: number): Promise<bigint>;
}
class OZGovernorAdapter implements GovernorAdapter {
constructor(private contract: Contract) {}
async getProposals(fromBlock: number) {
const filter = this.contract.filters.ProposalCreated();
const events = await this.contract.queryFilter(filter, fromBlock);
return events.map(e => this.normalizeProposal(e));
}
async castVote(proposalId: bigint, support: number) {
return {
to: this.contract.target,
data: this.contract.interface.encodeFunctionData('castVote', [proposalId, support])
};
}
}
class CompoundBravoAdapter implements GovernorAdapter {
// Compound Bravo использует uint вместо enum для support
// и имеет другую структуру ProposalCreated события
async castVote(proposalId: bigint, support: number) {
return {
to: this.contract.target,
data: this.contract.interface.encodeFunctionData('castVote', [proposalId, support])
};
}
}
На момент разработки стоит проверить наличие готовых адаптеров в библиотеках типа wagmi/viem action libraries или The Graph subgraphs от каждого протокола.
On-chain компонент: мультивызов голосований
Контракт-агрегатор позволяет голосовать в нескольких DAO одной транзакцией. Основан на паттерне Multicall.
contract GovernanceAggregator {
struct VoteInstruction {
address governor; // адрес Governor контракта
uint256 proposalId;
uint8 support; // 0=Against, 1=For, 2=Abstain
bytes reason; // опциональное обоснование
}
mapping(address => mapping(address => bool)) public authorizedDelegates;
modifier onlyAuthorized(address voter) {
require(
msg.sender == voter || authorizedDelegates[voter][msg.sender],
"Not authorized"
);
_;
}
function batchVote(
address voter,
VoteInstruction[] calldata instructions
) external onlyAuthorized(voter) {
for (uint i = 0; i < instructions.length; i++) {
VoteInstruction calldata inst = instructions[i];
// Используем try/catch: один failed vote не блокирует остальные
try IGovernor(inst.governor).castVoteWithReason(
inst.proposalId,
inst.support,
string(inst.reason)
) {
emit VoteCast(voter, inst.governor, inst.proposalId, inst.support);
} catch Error(string memory reason) {
emit VoteFailed(voter, inst.governor, inst.proposalId, reason);
}
}
}
}
Важный нюанс: каждый Governor контракт проверяет msg.sender как voter. Агрегатор голосует от своего имени — это работает только если voting power делегирована на адрес агрегатора. Альтернативный подход — агрегатор как smart wallet, который вызывает Governor через DELEGATECALL — но это значительно усложняет безопасность.
Делегирование на агрегатор
Для ERC-20Votes совместимых токенов пользователь делегирует voting power на адрес агрегатора:
// Пользователь один раз делегирует каждый токен
await uniToken.delegate(aggregatorAddress);
await compToken.delegate(aggregatorAddress);
// Агрегатор теперь может голосовать voting power пользователя
// НО: агрегатор голосует за всех делегировавших вместе
// Нужна логика разделения voice если мнения расходятся
Это фундаментальная проблема: агрегатор, получив делегирование от 1000 пользователей, должен голосовать единым голосом. Если 60% хотят For, а 40% — Against — как голосовать?
Решение — proportional voting: агрегатор голосует своим весом пропорционально предпочтениям пользователей. Compound Governor Bravo поддерживает castVoteWithWeightBySig для этого. OZ Governor v5 добавил fractionalVoting через GovernorCountingFractional модуль.
Правила автоматического голосования
Ключевая фича продвинутого агрегатора — автоматическое исполнение голосований по заданным правилам.
interface VotingRule {
protocol: string; // "uniswap" | "compound" | "*"
proposalType: string; // "parameter-change" | "treasury" | "*"
conditions: Condition[]; // логические условия
defaultVote: 0 | 1 | 2; // Against/For/Abstain если условия не сработали
requireConfirmation: boolean; // запросить подтверждение у voter
}
// Пример правила: всегда голосовать Against treasury proposals > $1M
const rule: VotingRule = {
protocol: "*",
proposalType: "treasury",
conditions: [{
field: "requestedAmount",
operator: ">",
value: 1_000_000_000000 // $1M в USDC (6 decimals)
}],
defaultVote: 0, // Against
requireConfirmation: false
};
Применение правил — off-chain процесс. Сервис анализирует новые пропозалы, запускает proposal parser (извлекает тип и параметры из calldata), применяет правила пользователя и генерирует batch vote инструкцию.
Parsing calldata пропозалов
Из calldata пропозала нужно извлечь смысл. Например, пропозал Compound на изменение collateral factor:
const KNOWN_SIGNATURES = {
'0x3c3e4f7a': { name: 'setCollateralFactor', protocol: 'compound' },
'0x5b85a600': { name: '_setReserveFactor', protocol: 'compound' },
// ... другие известные селекторы
};
function parseProposalCalldata(calldata: string): ProposalAction {
const selector = calldata.slice(0, 10);
const known = KNOWN_SIGNATURES[selector];
if (!known) return { type: 'unknown', selector };
const iface = new ethers.Interface([`function ${known.name}(...)`]);
const decoded = iface.decodeFunctionData(known.name, calldata);
return { type: known.name, protocol: known.protocol, params: decoded };
}
Это трудоёмкая но необходимая работа для каждого поддерживаемого протокола.
Уведомления и дедлайны
Агрегатор без уведомлений о дедлайнах теряет половину ценности. Система уведомлений:
Мониторинг: polling активных пропозалов каждые N минут или подписка на события через WebSocket (eth_subscribe logs).
Уведомления: email / Telegram / webhook при: создании нового пропозала в отслеживаемом DAO, приближении дедлайна (например, за 24 часа), исполнении/отклонении пропозала.
Дедлайн расчёт: Governor хранит proposalDeadline как номер блока. Конвертация в timestamp: deadlineBlock * avgBlockTime + referenceTimestamp. Точность ±5 минут — достаточно для уведомлений.
Стек разработки
Backend: Node.js + TypeScript + viem + BullMQ (очереди задач для парсинга и уведомлений) + PostgreSQL (хранение состояния пропозалов и правил).
Indexing: The Graph subgraphs для основных протоколов (Uniswap, Compound, Aave субграфы существуют) + собственный listener для остальных.
Frontend: React + wagmi + Radix UI. Ключевые экраны: дашборд активных пропозалов, управление правилами, история голосований.
Smart contract: Solidity 0.8.x + Foundry для тестирования. Контракт агрегатора — минимальный, основная логика off-chain.
| Функция | Срок |
|---|---|
| Базовый indexer (3–5 протоколов) | 2–3 недели |
| On-chain batch vote контракт | 1 неделя |
| Правила и автоматизация | 2–3 недели |
| Frontend дашборд | 2–3 недели |
| Уведомления | 1 неделя |
MVP с ручным batch voting и дашборд без автоматики — 4–5 недель. Полный агрегатор с правилами — 2–3 месяца.
Стоимость зависит от количества поддерживаемых протоколов и глубины автоматизации.







