Розробка системи голосування для власників 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 місяців.
Зв'яжіться з нами, щоб обговорити ваше завдання. Отримайте консультацію та попередню оцінку.







