Fan Token Holder Voting System Development
Fan tokens are special governance tokens. Chiliz, Socios, Rally — markets where sports clubs and cultural figures issue tokens and give holders voting rights on decisions: kit design, stadium songs, food menus. This is engagement mechanics, not financial governance — architecture differs fundamentally from protocol DAOs.
Core requirements: high throughput (thousands of votes in hours), simple UX (fans aren't crypto-native), manipulation protection (one whale can't overpower community), result transparency.
Architecture: On-chain vs Off-chain vs Hybrid
Pure on-chain doesn't work: gas costs deter mass audience; network latency creates delays; polls like "which color for new sneakers" don't need blockchain for every vote.
Recommended: hybrid approach:
- Token balance snapshot taken on-chain (specific block = specific moment)
- Votes themselves — off-chain signatures (like Snapshot Protocol)
- Aggregated results and proof of correct counting published on-chain
contract FanTokenVoting {
struct Poll {
uint256 id;
string title;
string[] choices;
uint256 snapshotBlock; // block for balance snapshot
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;
uint256 public pollCount;
event PollCreated(uint256 indexed pollId, string title, uint256 snapshotBlock);
event ResultsPublished(uint256 indexed pollId, bytes32 resultsHash, uint256[] voteCounts);
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");
pollId = ++pollCount;
polls[pollId] = Poll({
id: pollId,
title: title,
choices: choices,
snapshotBlock: block.number,
startTime: block.timestamp,
endTime: block.timestamp + durationSeconds,
minTokenBalance: minTokenBalance,
status: PollStatus.Active,
resultsHash: bytes32(0)
});
emit PollCreated(pollId, title, block.number);
}
}
Off-chain Vote Aggregation Service
Off-chain service verifies each vote signature, checks snapshot balance, and aggregates results.
class VoteAggregator {
constructor(provider, fanTokenAddress) {
this.provider = provider;
this.fanToken = new ethers.Contract(
fanTokenAddress,
['function balanceOf(address) view returns (uint256)'],
provider
);
}
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');
}
// 3. Check time window
if (vote.timestamp < poll.startTime || vote.timestamp > poll.endTime) {
throw new Error('Vote outside poll period');
}
return {
voter: vote.voter,
choice: vote.choiceIndex,
votingPower: balance
};
}
}
Whale Protection
When one holder has 30–40% supply, standard "1 token = 1 vote" becomes dictatorship. One speculative whale overpowers thousands of real fans.
Quadratic Voting: voting power = √(balance). 10,000 tokens give weight 100, not 10,000. Effectively reduces whale power.
Capping: maximum vote weight is capped (e.g., 1% of total votes). Simple but crude.
Time-weighted balance: counts average balance over 30 days, not current. Prevents pre-vote token buying.
| Mechanism | Complexity | Whale Protection | UX Complexity |
|---|---|---|---|
| 1 token = 1 vote | Low | None | None |
| Quadratic voting | Medium | High | Medium |
| Balance capping | Low | Medium | None |
| Time-weighted | Medium | Medium | None |
UX for Mass Audience
Fans don't understand Web3. Simplicity priority:
Gasless voting: votes via signatures without gas payment. Operator covers via meta-transactions (ERC-2771, Biconomy).
Social login: Magic.link or Privy — wallet auto-created on Google/Apple login. Fan doesn't know they have wallet.
Notifications: push notifications about new polls and results via Firebase + mobile app. Snapshot.org as backup for crypto-native users.
Transparency without complexity: results shown as simple chart. On-chain verification link available but not prominent.
Development Timeline
Backend (vote aggregation, API) + smart contract + integration: 6–8 weeks. Full platform with mobile app, social login, notifications, analytics: 4–5 months.







