Fan Token Voting System Development
Your sports club issued fan tokens so fans could vote on jersey design. The first poll failed due to high gas fees, and one large holder overwhelmed thousands of opinions. You need a system that makes voting accessible and fair. We develop such systems for fan token holders. Our hybrid voting architecture combines on-chain snapshots and off-chain voting, enabling token holder polling with quadratic weighting, whale protection, and gasless transactions. Fan tokens are governance tokens on platforms like Chiliz, Socios, and Rally (Chiliz Chain). Sports clubs and cultural figures issue them, giving holders voting rights on non-financial matters: kit design, stadium song selection, fan zone menu. This is an engagement mechanic, not financial governance, so the polling architecture differs.
Benefits of Hybrid Architecture
For fan token polls, a pure on-chain approach works poorly: transaction fees (gas) deter mass audience, network latency causes delays, and polls like "what color for new sneakers" don't need blockchain for every vote. A hybrid scheme is 3–5 times faster and cheaper than pure on-chain voting — gas savings up to 80% ($0.5–$2 per vote compared to $2–$5 on-chain). With 1,000 voters, you save over $2,000 per poll.
Recommended hybrid scheme:
- Token balance snapshot taken on-chain (specific block = specific moment)
- Votes themselves are off-chain signatures (like Snapshot Protocol)
- Aggregated result and proof of correct counting published on-chain
contract FanTokenVoting {
struct Poll {
uint256 id;
string title;
string[] choices;
uint256 snapshotBlock; // block for snapshot balances
uint256 startTime;
uint256 endTime;
uint256 minTokenBalance; // minimum balance to participate
PollStatus status;
bytes32 resultsHash; // hash of results after closing
}
enum PollStatus { Draft, Active, Closed, ResultsPublished }
IERC20 public immutable fanToken;
address public operator; // club/team
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 right now
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);
}
// Verification: off-chain service aggregates votes and publishes hash
// resultsHash = keccak256(abi.encodePacked(pollId, voteCounts, allSignatures))
}
How Do We Protect Voting from Manipulation?
When one holder has 30–40% of the supply, standard "1 token = 1 vote" becomes a dictatorship. For fan tokens, this is especially painful: one large speculator drowns out thousands of real fans.
We apply several mechanisms. Quadratic Voting — vote weight equals square root of balance. Compared to standard 1-token-1-vote, quadratic voting reduces whale influence by 60–70%. We also use capping — maximum vote weight limited, e.g., 1% of total votes in the poll. And time-weighted balance: average balance over last 30 days, which prevents buying tokens right before voting.
function calculateVotingPower(balance) {
// Square root of balance (normalized)
const normalizedBalance = Number(balance / 10n**18n);
return Math.sqrt(normalizedBalance);
}
| Mechanism | Complexity | Effectiveness against whale | UX complexity |
|---|---|---|---|
| 1 token = 1 vote | Low | None | None |
| Quadratic voting | Medium | High | Medium |
| Balance capping | Low | Medium | None |
| Time-weighted | Medium | Medium | None |
| Conviction voting | High | High | High |
Step-by-Step: Setting Up Voting for Fan Tokens
- Create a fan token smart contract (ERC-20) with snapshot support.
- Deploy the voting contract with quadratic weighting and capping.
- Implement an off-chain service for collecting and verifying signatures (as in example below).
- Integrate social login via Magic.link or Privy — wallet created automatically.
- Configure gasless voting via meta-transactions (ERC-2771).
- Conduct a test poll with real users.
Details of gasless voting implementation
Gasless voting is implemented via meta-transactions: the user signs a message, and the operator sends it to the network. This uses a relay contract (Forwarder) per ERC-2771. The club pays for gas, lowering the entry barrier for fans. Compared to requiring users to pay gas fees, gasless voting increases fan participation by 30-50%.
Off-chain Vote Processing Service
Votes arrive as signed messages. The aggregation service verifies each signature, checks balance at the snapshot block, and aggregates results.
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 structure: { pollId, choiceIndex, voter, signature }
async processVote(vote, poll) {
// 1. Verify signature
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. Check balance at snapshot block
const balance = await this.fanToken.balanceOf(
vote.voter,
{ blockTag: poll.snapshotBlock }
);
if (balance < poll.minTokenBalance) {
throw new Error('Insufficient balance at snapshot');
}
// 3. Check time bounds
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 // or normalized weight
};
}
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; // Take only the last vote from a user
}
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;
}
}
How Do We Ensure Voting Transparency?
Voting results must be verifiable but not overwhelm the user. We publish an on-chain hash of results and the full set of signatures on IPFS. Anyone can verify the count using an open-source script. For fans, results are shown as a chart in the club's app.
UX Considerations for Mass Audience
Fans don't understand Web3. Priority is simplicity:
- Gasless voting: votes via signatures (EIP-712) without paying gas. Transaction paid by operator.
- Social login: integration with Magic.link or Privy — wallet created automatically on login via Google/Apple.
- Notifications: push notifications for new polls and results via Firebase + mobile app.
- Transparency without complexity: voting results shown as a simple chart. Link to on-chain data for those who want to verify.
Integration with Club Solutions
Voting must be binding. Results are automatically published via the club's API to official channels. For critical decisions (name change, stadium design), we add a two-step process: soft poll with 10% participation threshold → official poll with on-chain publication and legal commitment from the club.
What's Included (Deliverables)
- Requirements analysis and architecture design
- Voting smart contract with quadratic voting and capping
- Off-chain vote aggregator service (Node.js)
- Gasless voting via meta-transactions
- Integration with fan token (ERC-20) and club API
- Documentation and administrator training
- Security guarantee: contract audits via Slither and Mythril
With over 5 years of experience in Web3 development and 20+ successful governance projects, we ensure robust and scalable voting systems. Typical project cost ranges from $30,000 to $80,000 depending on complexity.
| Phase | Duration |
|---|---|
| Analysis and design | 1–2 weeks |
| Smart contract development | 2–3 weeks |
| Backend and API | 2 weeks |
| Integration and testing | 1–2 weeks |
| Deployment and documentation | 1 week |
Contact us to discuss your project — we'll prepare a prototype in two weeks.
Development Timelines
Backend (vote aggregation service, API) + smart contract + fan token integration: 6–8 weeks. Full platform with mobile app, social login, notifications, and analytics: 4–5 months.
Get a consultation and preliminary estimate. Contact us.







