Разработка системы голосования для держателей fan-токенов
Fan-токены — это особая категория governance-токенов. Chiliz, Socios, Rally — рынок, где спортивные клубы и деятели культуры выпускают токены и дают держателям право голоса в решениях: дизайн комплекта, выбор песни на стадионе, меню в фанзоне. Это не финансовый governance, а engagement механика — и архитектура системы голосования под неё принципиально отличается от DAO протоколов.
Основные требования: высокая throughput (тысячи голосов за часы), простой UX (фанаты — не крипто-нативная аудитория), защита от манипуляций (whale один не должен перекрывать community), прозрачность результатов.
Архитектура: on-chain vs off-chain vs hybrid
Для fan-токен голосований чистый on-chain подход работает плохо: gas costs отпугивают массовую аудиторию, латентность сети создаёт задержки, а опросы вида «какой цвет выбрать для новых кед» не требуют блокчейна для хранения каждого голоса.
Рекомендуемая схема — hybrid:
- Snapshot баланса токенов берётся on-chain (конкретный блок = конкретный момент)
- Сами голоса — off-chain подписи (как в Snapshot Protocol)
- Агрегированный результат и доказательство корректного подсчёта публикуются on-chain
contract FanTokenVoting {
struct Poll {
uint256 id;
string title;
string[] choices;
uint256 snapshotBlock; // блок для snapshot балансов
uint256 startTime;
uint256 endTime;
uint256 minTokenBalance; // минимальный баланс для участия
PollStatus status;
bytes32 resultsHash; // хеш результатов после закрытия
}
enum PollStatus { Draft, Active, Closed, ResultsPublished }
IERC20 public immutable fanToken;
address public operator; // клуб/команда
mapping(uint256 => Poll) public polls;
mapping(uint256 => mapping(uint256 => uint256)) public pollResults; // pollId => choiceIndex => votes
uint256 public pollCount;
event PollCreated(uint256 indexed pollId, string title, uint256 snapshotBlock);
event ResultsPublished(uint256 indexed pollId, bytes32 resultsHash, uint256[] voteCounts);
modifier onlyOperator() {
require(msg.sender == operator, "Not operator");
_;
}
function createPoll(
string calldata title,
string[] calldata choices,
uint256 durationSeconds,
uint256 minTokenBalance
) external onlyOperator returns (uint256 pollId) {
require(choices.length >= 2 && choices.length <= 10, "Invalid choices count");
pollId = ++pollCount;
polls[pollId] = Poll({
id: pollId,
title: title,
choices: choices,
snapshotBlock: block.number, // snapshot прямо сейчас
startTime: block.timestamp,
endTime: block.timestamp + durationSeconds,
minTokenBalance: minTokenBalance,
status: PollStatus.Active,
resultsHash: bytes32(0)
});
emit PollCreated(pollId, title, block.number);
}
function publishResults(
uint256 pollId,
uint256[] calldata voteCounts,
bytes32 resultsHash
) external onlyOperator {
Poll storage poll = polls[pollId];
require(block.timestamp > poll.endTime, "Poll still active");
require(poll.status == PollStatus.Closed || poll.status == PollStatus.Active, "Wrong status");
for (uint256 i = 0; i < voteCounts.length; i++) {
pollResults[pollId][i] = voteCounts[i];
}
poll.resultsHash = resultsHash;
poll.status = PollStatus.ResultsPublished;
emit ResultsPublished(pollId, resultsHash, voteCounts);
}
// Верификация: off-chain сервис агрегирует голоса и публикует хеш
// resultsHash = keccak256(abi.encodePacked(pollId, voteCounts, allSignatures))
}
Off-chain сервис обработки голосов
Голоса приходят как подписанные сообщения. Сервис агрегации верифицирует каждую подпись, сверяет баланс на snapshot-блоке и агрегирует результаты.
const { ethers } = require('ethers');
class VoteAggregator {
constructor(provider, fanTokenAddress) {
this.provider = provider;
this.fanToken = new ethers.Contract(
fanTokenAddress,
['function balanceOf(address) view returns (uint256)'],
provider
);
}
// Структура vote: { pollId, choiceIndex, voter, signature }
async processVote(vote, poll) {
// 1. Верифицируем подпись
const messageHash = ethers.solidityPackedKeccak256(
['uint256', 'uint256', 'address'],
[vote.pollId, vote.choiceIndex, vote.voter]
);
const recoveredAddress = ethers.verifyMessage(
ethers.getBytes(messageHash),
vote.signature
);
if (recoveredAddress.toLowerCase() !== vote.voter.toLowerCase()) {
throw new Error('Invalid signature');
}
// 2. Проверяем баланс на snapshot блоке
const balance = await this.fanToken.balanceOf(
vote.voter,
{ blockTag: poll.snapshotBlock }
);
if (balance < poll.minTokenBalance) {
throw new Error('Insufficient balance at snapshot');
}
// 3. Проверяем временные рамки
const voteTimestamp = vote.timestamp;
if (voteTimestamp < poll.startTime || voteTimestamp > poll.endTime) {
throw new Error('Vote outside poll period');
}
return {
voter: vote.voter,
choice: vote.choiceIndex,
votingPower: balance // или нормализованный вес
};
}
async aggregateResults(pollId, votes, poll) {
const processedVoters = new Set();
const choicePowers = new Array(poll.choices.length).fill(0n);
for (const vote of votes) {
if (processedVoters.has(vote.voter.toLowerCase())) {
continue; // Берём только последний голос пользователя
}
try {
const processed = await this.processVote(vote, poll);
choicePowers[processed.choice] += processed.votingPower;
processedVoters.add(vote.voter.toLowerCase());
} catch (e) {
console.warn(`Invalid vote from ${vote.voter}: ${e.message}`);
}
}
return choicePowers;
}
}
Защита от whale-доминирования
Когда один держатель имеет 30–40% supply, стандартное голосование «1 токен = 1 голос» превращается в диктатуру. Для fan-токенов это особенно болезненно: один крупный спекулятивный держатель перекрывает тысячи реальных фанатов.
Квадратичное голосование (Quadratic Voting): вес голоса = √(баланс токенов). 10,000 токенов дают голосовой вес 100, а не 10,000. Эффективно снижает whale power.
function calculateVotingPower(balance) {
// Квадратный корень из баланса (нормализованного)
// balance в наименьших единицах, нормализуем к "целым токенам"
const normalizedBalance = Number(balance / 10n**18n);
return Math.sqrt(normalizedBalance);
}
Capping: максимальный вес голоса ограничен, например, 1% от общего числа голосов в опросе. Прост в реализации, грубоват.
Time-weighted balance: учитывается не текущий баланс, а средний за последние 30 дней. Препятствует покупке токенов непосредственно перед голосованием.
| Механизм | Сложность | Эффективность против whale | Сложность для UX |
|---|---|---|---|
| 1 токен = 1 голос | Низкая | Нет | Нет |
| Quadratic voting | Средняя | Высокая | Средняя |
| Balance capping | Низкая | Средняя | Нет |
| Time-weighted | Средняя | Средняя | Нет |
| Conviction voting | Высокая | Высокая | Высокая |
Особенности UX для массовой аудитории
Фанаты не разбираются в Web3. Необходим приоритет простоты:
Gasless voting: голоса через подписи (EIP-712) без оплаты gas. Транзакцию при необходимости оплачивает оператор через meta-transactions (ERC-2771, Biconomy).
Social login: интеграция с Magic.link или Privy — кошелёк создаётся автоматически при входе через Google/Apple. Фанат не знает, что у него есть кошелёк.
Уведомления: push-уведомления о новых опросах и результатах через Firebase + мобильное приложение. Snapshot.org как резервный интерфейс для крипто-нативных пользователей.
Прозрачность без сложности: результаты голосования отображаются как простая диаграмма. Ссылка на on-chain результаты доступна для тех, кто хочет верифицировать, но не выставляется на первый план.
Интеграция с реальными решениями клуба
Голосование должно иметь binding nature — иначе теряет смысл. Техническая интеграция: результаты автоматически публикуются через API клуба в официальные каналы, у каждого опроса есть явное описание «как именно будет реализовано решение».
Для критических решений (смена названия команды, дизайн стадиона) часто добавляют двухэтапный процесс: soft poll (Snapshot) → если порог участия достигнут → official poll с публикацией on-chain результатов и юридически обязывающим обязательством клуба.
Сроки разработки
Backend (vote aggregation service, API) + смарт-контракт + интеграция с fan-token: 6–8 недель. Полноценная платформа с мобильным приложением, social login, уведомлениями и аналитикой: 4–5 месяцев.







