Разработка системы голосования для держателей fan-токенов
Ваш спортивный клуб выпустил fan-токены, чтобы фанаты голосовали за дизайн формы. Первый же опрос провалился из-за высоких газовых комиссий, а один крупный держатель перекрыл мнение тысяч. Нужна система, которая делает голосование доступным и честным. Мы разрабатываем такие системы для держателей fan-токенов. Fan-токены — это governance-токены на платформах Chiliz, Socios и Rally (Chiliz Chain). Спортивные клубы и деятели культуры выпускают их, давая держателям право голоса в нефинансовых вопросах: дизайн комплекта, выбор песни на стадионе, меню в фанзоне. Это engagement-механика, а не финансовый governance, поэтому архитектура голосования отличается.
Преимущества гибридной архитектуры
Для голосований с fan-токенами чистый on-chain подход работает плохо: комиссии за транзакции (газ) отпугивают массовую аудиторию, латентность сети создаёт задержки, а опросы «какой цвет выбрать для новых кед» не требуют блокчейна для хранения каждого голоса. Гибридная схема в 3–5 раз быстрее и дешевле — экономия на газе достигает 80% (это $0.5–$2 за каждый голос).
Рекомендуемая схема — hybrid:
- Снимок балансов токенов берётся 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)) } Защита голосования от манипуляций
Отметим: когда один держатель имеет 30–40% эмиссии, стандартное голосование «1 токен = 1 голос» превращается в диктатуру. Для fan-токенов это особенно болезненно: один крупный спекулянт перекрывает тысячи реальных фанатов.
Мы применяем несколько механизмов. Квадратичное голосование (Quadratic Voting) — вес голоса = квадратный корень из баланса. 10 000 токенов дают вес 100, а не 10 000. Это снижает влияние китов на 60–70%. Также используем capping — максимальный вес голоса ограничен, например, 1% от общего числа голосов в опросе. И time-weighted balance: учитывается средний баланс за последние 30 дней, что препятствует покупке токенов перед голосованием.
function calculateVotingPower(balance) { // Квадратный корень из баланса (нормализованного) const normalizedBalance = Number(balance / 10n**18n); return Math.sqrt(normalizedBalance); } | Механизм | Сложность | Эффективность против whale | Сложность для UX |
|---|---|---|---|
| 1 токен = 1 голос | Низкая | Нет | Нет |
| Quadratic voting | Средняя | Высокая | Средняя |
| Balance capping | Низкая | Средняя | Нет |
| Time-weighted | Средняя | Средняя | Нет |
| Conviction voting | Высокая | Высокая | Высокая |
Как настроить голосование для fan-токенов: пошаговая инструкция
- Создайте смарт-контракт fan-токена (ERC-20) с поддержкой снимков балансов.
- Разверните контракт голосования с квадратичным взвешиванием и capping.
- Реализуйте off-chain сервис для сбора и верификации подписей (как в примере ниже).
- Интегрируйте social login через Magic.link или Privy — кошелёк создаётся автоматически.
- Настройте gasless voting через meta-transactions (ERC-2771).
- Проведите тестовый опрос с реальными пользователями.
Детали реализации gasless voting
Gasless voting реализуется через мета-транзакции: пользователь подписывает сообщение, а оператор отправляет его в сеть. Для этого используется контракт-релей (Forwarder) по стандарту ERC-2771. Стоимость газа берёт на себя клуб — это снижает порог входа для фанатов и повышает вовлечённость на 30–50%.
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; } } Как обеспечить прозрачность голосования?
Результаты голосования должны быть проверяемыми, но не перегружать пользователя. Мы публикуем on-chain хеш результатов и полный набор подписей в IPFS. Любой желающий может верифицировать подсчёт, используя open-source скрипт. Для фанатов итоги отображаются как диаграмма в приложении клуба.
Особенности UX для массовой аудитории
Фанаты не разбираются в Web3. Приоритет — простота:
- Gasless voting: голоса через подписи (EIP-712) без оплаты gas. Транзакцию оплачивает оператор.
- Social login: интеграция с Magic.link или Privy — кошелёк создаётся автоматически при входе через Google/Apple.
- Уведомления: push-уведомления о новых опросах и результатах через Firebase + мобильное приложение.
- Прозрачность без сложности: результаты голосования — простая диаграмма. Ссылка на on-chain данные для тех, кто хочет проверить.
Интеграция с решениями клуба
Голосование должно иметь обязательную силу. Результаты автоматически публикуются через API клуба в официальные каналы. Для критических решений (смена названия, дизайн стадиона) добавляем двухэтапный процесс: soft poll с порогом участия 10% → official poll с on-chain публикацией и юридическим обязательством клуба.
Что входит в работу (deliverables)
- Анализ требований и проектирование архитектуры
- Смарт-контракт голосования с квадратичным голосованием и capping
- Off-chain vote aggregator сервис (Node.js)
- Gasless voting через meta-transactions
- Интеграция с fan-токеном (ERC-20) и клубным API
- Документация и обучение администраторов
- Гарантия безопасности: аудит контрактов через Slither и Mythril
| Этап | Длительность |
|---|---|
| Аналитика и проектирование | 1–2 недели |
| Разработка смарт-контракта | 2–3 недели |
| Backend и API | 2 недели |
| Интеграция и тестирование | 1–2 недели |
| Деплой и документация | 1 неделя |
Закажите разработку — мы подготовим прототип за две недели.
Сроки разработки
Backend (vote aggregation service, API) + смарт-контракт + интеграция с fan-токеном: 6–8 недель. Полноценная платформа с мобильным приложением, social login, уведомлениями и аналитикой: 4–5 месяцев.
Стоимость типового backend-решения — от $12 000, полная платформа — от $40 000. Свяжитесь с нами, чтобы обсудить вашу задачу. Получите консультацию и предварительную оценку.







