Разработка conviction voting (непрерывное голосование)
Стандартное snapshot-голосование в DAO страдает от нескольких проблем: voting period фиксированный (если пропустил — не проголосовал), whale может придержать голоса до последнего момента и перевернуть результат, каждый proposal — отдельное голосовательное событие с низкой явкой. Conviction voting решает эти проблемы через принципиально другую механику: голос накапливает «убеждённость» со временем, а proposal проходит когда накопленная убеждённость пересекает динамический threshold.
Gardens (1Hive), TE Commons, Giveth — протоколы, использующие conviction voting для управления treasury grants. Механику разработал Jeff Emmett, детально описал в Gardens whitepaper.
Математическая модель
Сердце системы — функция накопления убеждённости:
conviction(t) = conviction(t-1) * α + votes * (1 - α)
где:
-
α(alpha) — коэффициент затухания, 0 < α < 1. Типичное значение: 0.9 (медленное накопление) до 0.5 (быстрое) -
votes— текущий вес голосов, поддерживающих proposal -
conviction(t)— накопленная убеждённость в момент t
При α = 0.9: для достижения 100% conviction при постоянных votes нужно ~22 периода (дней, если период = 1 день). Это означает: ранние поддерживатели накапливают больше conviction, поздние манипуляции менее эффективны.
Порог прохождения также динамический, зависит от размера запроса относительно доступного treasury:
threshold = threshold_min + (total_supply² * max_ratio) / (requested_amount * (total_supply - staked_at_this_proposal)²)
Логика: чем больший процент treasury запрашивает proposal, тем выше порог. Это защищает от одобрения огромных transfers при малой явке.
Архитектура контракта
ConvictionVoting контракт
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract ConvictionVoting is ReentrancyGuard {
IERC20 public immutable token;
IERC20 public immutable requestToken; // токен treasury (USDC, WETH)
address public immutable vault; // treasury vault
// Параметры модели (управляются governance)
uint256 public constant PRECISION = 1e7;
uint256 public alpha; // коэффициент затухания * PRECISION
uint256 public maxRatio; // максимальный % treasury за один proposal
uint256 public minThresholdStake; // минимальный % stake для любого proposal
struct Proposal {
uint256 requestedAmount;
address beneficiary;
uint256 stakedTokens; // суммарные голоса за этот proposal
uint256 convictionLast; // накопленная убеждённость на последнем update
uint256 blockLast; // блок последнего обновления
ProposalStatus status;
string title;
string link; // IPFS ссылка на описание
}
enum ProposalStatus { Active, Passed, Rejected, Cancelled }
mapping(uint256 => Proposal) public proposals;
uint256 public proposalCount;
// Распределение голосов пользователя: address => proposalId => amount
mapping(address => mapping(uint256 => uint256)) public voterStake;
// Суммарные голоса пользователя во всех proposals
mapping(address => uint256) public totalVoterStake;
event ProposalAdded(uint256 indexed proposalId, address indexed beneficiary, uint256 amount);
event StakeAdded(uint256 indexed proposalId, address indexed voter, uint256 amount, uint256 conviction);
event StakeWithdrawn(uint256 indexed proposalId, address indexed voter, uint256 amount);
event ProposalExecuted(uint256 indexed proposalId, uint256 amount);
constructor(
address _token,
address _requestToken,
address _vault,
uint256 _alpha, // например, 9000000 (0.9 * PRECISION)
uint256 _maxRatio, // например, 200000 (20% * PRECISION)
uint256 _minThreshold // например, 20000 (2% * PRECISION)
) {
token = IERC20(_token);
requestToken = IERC20(_requestToken);
vault = _vault;
alpha = _alpha;
maxRatio = _maxRatio;
minThresholdStake = _minThreshold;
}
function addProposal(
uint256 requestedAmount,
address beneficiary,
string calldata title,
string calldata link
) external returns (uint256) {
require(requestedAmount > 0, "Amount must be positive");
require(beneficiary != address(0), "Invalid beneficiary");
uint256 vaultBalance = requestToken.balanceOf(vault);
require(
requestedAmount <= vaultBalance * maxRatio / PRECISION,
"Exceeds max ratio"
);
uint256 proposalId = ++proposalCount;
proposals[proposalId] = Proposal({
requestedAmount: requestedAmount,
beneficiary: beneficiary,
stakedTokens: 0,
convictionLast: 0,
blockLast: block.number,
status: ProposalStatus.Active,
title: title,
link: link
});
emit ProposalAdded(proposalId, beneficiary, requestedAmount);
return proposalId;
}
function stakeToProposal(uint256 proposalId, uint256 amount) external nonReentrant {
Proposal storage proposal = proposals[proposalId];
require(proposal.status == ProposalStatus.Active, "Not active");
uint256 availableBalance = token.balanceOf(msg.sender) - totalVoterStake[msg.sender];
require(amount <= availableBalance, "Insufficient unstaked tokens");
_updateConviction(proposalId);
voterStake[msg.sender][proposalId] += amount;
totalVoterStake[msg.sender] += amount;
proposal.stakedTokens += amount;
emit StakeAdded(proposalId, msg.sender, amount, proposal.convictionLast);
}
function withdrawFromProposal(uint256 proposalId, uint256 amount) external nonReentrant {
require(voterStake[msg.sender][proposalId] >= amount, "Insufficient stake");
_updateConviction(proposalId);
voterStake[msg.sender][proposalId] -= amount;
totalVoterStake[msg.sender] -= amount;
proposals[proposalId].stakedTokens -= amount;
emit StakeWithdrawn(proposalId, msg.sender, amount);
}
function executeProposal(uint256 proposalId) external nonReentrant {
Proposal storage proposal = proposals[proposalId];
require(proposal.status == ProposalStatus.Active, "Not active");
_updateConviction(proposalId);
uint256 threshold = calculateThreshold(proposal.requestedAmount);
require(proposal.convictionLast >= threshold, "Insufficient conviction");
proposal.status = ProposalStatus.Passed;
// Выплата из vault через approved allowance
requestToken.transferFrom(vault, proposal.beneficiary, proposal.requestedAmount);
emit ProposalExecuted(proposalId, proposal.requestedAmount);
}
}
Функция обновления убеждённости
Это ключевая функция — вычисляет накопленную conviction с момента последнего обновления:
function _updateConviction(uint256 proposalId) internal {
Proposal storage proposal = proposals[proposalId];
uint256 blocksPassed = block.number - proposal.blockLast;
if (blocksPassed == 0) return;
// conviction(t) = conviction(t-1) * alpha^blocksPassed + stakedTokens * (1 - alpha^blocksPassed)
// Вычисляем alpha^blocksPassed через быстрое возведение в степень
uint256 alphaPow = _pow(alpha, blocksPassed);
proposal.convictionLast =
(proposal.convictionLast * alphaPow / PRECISION) +
(proposal.stakedTokens * (PRECISION - alphaPow) / PRECISION);
proposal.blockLast = block.number;
}
// Быстрое возведение в степень для fixed-point арифметики
function _pow(uint256 base, uint256 exponent) internal pure returns (uint256) {
if (exponent == 0) return PRECISION;
uint256 result = PRECISION;
while (exponent > 0) {
if (exponent % 2 == 1) {
result = result * base / PRECISION;
}
base = base * base / PRECISION;
exponent /= 2;
}
return result;
}
function calculateThreshold(uint256 requestedAmount) public view returns (uint256) {
uint256 vaultBalance = requestToken.balanceOf(vault);
uint256 totalSupply = token.totalSupply();
uint256 stakedTokensTotal = _getTotalStaked(); // сумма всех активных stakes
// Базовый threshold
uint256 baseThreshold = totalSupply * minThresholdStake / PRECISION;
if (requestedAmount == 0) return baseThreshold;
// Динамическая составляющая
uint256 rho = maxRatio * requestedAmount / vaultBalance;
if (rho >= PRECISION) return type(uint256).max; // запрос превышает max ratio
uint256 availableSupply = totalSupply - stakedTokensTotal;
// threshold растёт квадратично при увеличении requestedAmount
uint256 convictionFactor = totalSupply * totalSupply / (availableSupply * availableSupply + 1);
return baseThreshold + convictionFactor * rho / PRECISION;
}
Precision и overflow
Ключевая уязвимость в conviction voting — overflow при вычислении convictionLast * alphaPow. Если conviction хранится в единицах токенов (18 decimals) и alpha тоже в high precision, промежуточные вычисления могут переполнить uint256.
Решение: нормализовать conviction в единицах без decimals, или использовать mulDiv из OpenZeppelin Math:
// Безопасное вычисление с защитой от overflow
proposal.convictionLast = Math.mulDiv(proposal.convictionLast, alphaPow, PRECISION)
+ Math.mulDiv(proposal.stakedTokens, PRECISION - alphaPow, PRECISION);
Math.mulDiv вычисляет (a * b) / c без промежуточного overflow, используя 512-bit arithmetic.
Параметры системы
Выбор параметров — не техническая, а экономическая задача:
| Параметр | Низкое значение | Высокое значение | Влияние |
|---|---|---|---|
| alpha | 0.5-0.7 | 0.9-0.95 | Скорость накопления; низкое = быстро, но легко манипулировать |
| maxRatio | 5-10% | 25-40% | Максимальный размер одного grant |
| minThreshold | 1-2% | 5-10% | Барьер для мелких proposals |
Стандартные параметры Gardens: alpha = 0.9, maxRatio = 20%, minThreshold = 2%.
Alpha — самый чувствительный параметр. При alpha = 0.9 и периоде обновления 1 блок (12 сек) на Ethereum: полное накопление conviction до 95% максимума занимает ~28 дней. Это даёт времени достаточно для нарастания поддержки, но делает систему медленной. Для L2 с быстрыми блоками — нужно нормализовать alpha к реальному времени, а не блокам.
Delegation в conviction voting
Базовая реализация — пользователь голосует своим балансом. Делегирование добавляет сложность:
mapping(address => address) public delegate;
mapping(address => uint256) public delegatedAmount;
function delegateVotes(address to, uint256 amount) external {
require(to != msg.sender, "Cannot self-delegate here"); // используй прямой stake
token.transferFrom(msg.sender, address(this), amount);
delegatedAmount[to] += amount;
delegate[msg.sender] = to;
// delegate теперь может использовать amount при stakeToProposal
}
Полноценное conviction voting с делегированием значительно сложнее: нужно отслеживать delegation chains, предотвращать circular delegation, корректно обновлять conviction при изменении delegation.
Для MVP: начать без delegation. Добавить в v2 при реальном запросе сообщества.
Отличие от стандартного Governor
| Аспект | Governor (snapshot) | Conviction Voting |
|---|---|---|
| Голосование | Фиксированный период | Непрерывное |
| Принятие | Большинство голосов | Накопленный порог |
| Last-minute manipulation | Возможна | Затруднена (нужно время) |
| Voter apathy | Критична (нет явки = нет decision) | Менее критична |
| Voter apathy риск | Низкий порог пропускает всё | Proposals накапливают conviction |
| Параллельные proposals | Каждый отдельно | Конкурируют за stake |
Conviction voting создаёт органическое приоритизирование: proposals, получившие наибольшую поддержку сообщества со временем, проходят первыми. Это особенно хорошо для grants DAO с постоянным потоком запросов.
Фронтенд: визуализация убеждённости
UX conviction voting — отдельная задача. Пользователь должен видеть:
- График нарастания conviction для каждого proposal (линия, приближающаяся к threshold)
- Текущее расстояние до threshold (в процентах и в единицах conviction)
- Сколько дней нужно при текущем количестве votes
- Свои stakes распределённые по proposals (сумма должна быть <= баланс)
// Вычисление conviction в фронтенде для real-time отображения
function computeConviction(
lastConviction: bigint,
lastBlock: number,
currentBlock: number,
stakedTokens: bigint,
alpha: bigint,
precision: bigint
): bigint {
const blocksPassed = BigInt(currentBlock - lastBlock);
const alphaPow = fastPow(alpha, blocksPassed, precision);
return (lastConviction * alphaPow / precision)
+ (stakedTokens * (precision - alphaPow) / precision);
}
// Вычисление времени до прохождения при текущем stake
function timeToPass(
currentConviction: bigint,
threshold: bigint,
stakedTokens: bigint,
alpha: bigint,
precision: bigint,
blocksPerDay: number
): number {
// conviction_max = stakedTokens * precision / (precision - alpha)
const maxConviction = stakedTokens * precision / (precision - alpha);
if (maxConviction < threshold) return Infinity; // никогда не достигнет
// Решаем: threshold = maxConviction - (maxConviction - currentConviction) * alpha^t
// t = log((maxConviction - threshold) / (maxConviction - currentConviction)) / log(alpha)
const numerator = Number(maxConviction - threshold);
const denominator = Number(maxConviction - currentConviction);
const alphaNum = Number(alpha) / Number(precision);
const blocks = Math.log(numerator / denominator) / Math.log(alphaNum);
return blocks / blocksPerDay; // в днях
}
Recharts или D3.js для графика conviction curve — показываем текущую точку и прогнозируемую траекторию.
Процесс работы
Дизайн (1 неделя). Параметры alpha, maxRatio, minThreshold — под конкретную DAO и размер treasury. Интеграция с существующим токеном или новый токен. Vault архитектура.
Разработка контрактов (3-4 недели). ConvictionVoting + Vault + параметры governance (изменяемые через timelock) + comprehensive тесты включая precision edge cases.
Симуляция параметров (3-5 дней). Python/TypeScript симуляция: как быстро проходят proposals при разных параметрах, насколько эффективна защита от whale manipulation.
Аудит (2 недели). Особое внимание: overflow в conviction вычислениях, корректность threshold формулы, reentrancy в executeProposal.
Frontend (2-3 недели). Dashboard proposals, stake management, conviction visualization, мобильная адаптация.
Полный цикл: 2,5-3 месяца. Стоимость — после уточнения параметров и scope.







