Розробка системи голосування для власників fan-токенів
Ваш спортивний клуб випустив fan-токени, щоб фанати голосували за дизайн форми. Перше ж опитування провалилося через високі газові комісії, а один великий власник перекрив думку тисяч. Потрібен механізм опитувань, який робить голосування доступним і чесним. Ми — команда з 5-річним досвідом у Web3, розробили понад 15 систем голосування для власників fan-токенів. Fan-токени — це governance-токени на платформах Chiliz, Socios та Rally (Chiliz Chain). Спортивні клуби та діячі культури випускають їх, даючи власникам право голосу в нефінансових питаннях: дизайн комплекту, вибір пісні на стадіоні, меню в фанзоні. Це engagement-механіка, а не фінансовий governance, тому архітектура опитувань відрізняється.
Переваги гібридної архітектури
Для опитувань з fan-токенами чистий on-chain підхід працює погано: комісії за транзакції (газ) відлякують масову аудиторію, латентність мережі створює затримки, а опитування «який колір вибрати для нових кедів» не вимагають блокчейну для зберігання кожного голосу. Гібридна схема в 3–5 разів швидше і дешевше — економія на газі досягає 80% (це $0.5–$2 за кожен голос). Для опитування з 10 000 учасників економія складає $5,000–$20,000. Квадратичне голосування знижує вплив китів на 60–70% порівняно з голосуванням '1 токен = 1 голос'.
Рекомендована схема — 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. Вартість газу бере на себе клуб — це знижує поріг входу для фанатів і підвищує залученість у 1.3–1.5 рази порівняно з традиційним on-chain голосуванням.
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 тиждень |
Замовте розробку — ми підготуємо прототип за два тижні. Вартість базового рішення — від $15,000.
Терміни розробки
Backend (vote aggregation service, API) + смарт-контракт + інтеграція з fan-токеном: 6–8 тижнів. Повноцінна платформа з мобільним додатком, social login, сповіщеннями та аналітикою: 4–5 місяців.
Зв'яжіться з нами, щоб обговорити ваше завдання. Отримайте консультацію та попередню оцінку.







