Airdrop Eligibility Status Dashboard Development
An airdrop dashboard isn't just "check if you qualified". A well-made dashboard shows users exactly what they're missing, how many points they have, what actions they can still take before snapshot. It's a retention tool that motivates users to engage more with the protocol. Technically—aggregating data from multiple on-chain sources with caching, because real on-chain queries for every address on every visit kills any RPC.
Architecture: Data Sources
The Graph Subgraph
Primary source for on-chain activity. Subgraph indexes contract events and provides GraphQL API. Dashboard data:
query UserActivity($address: String!) {
user(id: $address) {
totalVolume
transactionCount
firstInteractionTimestamp
liquidityProvisions {
amount
timestamp
pool { id symbol }
}
referrals { count totalVolume }
}
}
Graph queries—free up to limit, fast (< 200ms), don't load RPC nodes.
Snapshot.org API
For tracking governance participation:
const SNAPSHOT_QUERY = `
query Votes($voter: String!, $space: String!) {
votes(where: { voter: $voter, space: $space }) {
id
proposal { id title }
created
}
}
`
async function getSnapshotVotes(address: string, spaceId: string) {
const res = await fetch('https://hub.snapshot.org/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: SNAPSHOT_QUERY, variables: { voter: address.toLowerCase(), space: spaceId } })
})
return res.json()
}
On-Chain Balance Checks
For direct balances—viem multicall batches multiple calls into one RPC request:
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({ chain: mainnet, transport: http() })
const results = await client.multicall({
contracts: [
{ address: TOKEN_ADDRESS, abi: erc20Abi, functionName: 'balanceOf', args: [userAddress] },
{ address: STAKING_ADDRESS, abi: stakingAbi, functionName: 'stakedAmount', args: [userAddress] },
{ address: VESTING_ADDRESS, abi: vestingAbi, functionName: 'vestingInfo', args: [userAddress] },
]
})
One HTTP request instead of three—critical under high traffic.
Points System and Criteria
Eligibility is usually multifactorial. Typical structure:
interface EligibilityScore {
total: number
breakdown: {
volumeScore: number // 0-40 points: trading volume
loyaltyScore: number // 0-20 points: first interaction date
governanceScore: number // 0-20 points: Snapshot votes
referralScore: number // 0-10 points: referred users
holdingScore: number // 0-10 points: token holding
}
tier: 'bronze' | 'silver' | 'gold' | 'platinum'
estimatedAllocation: bigint | null // null until announced
missingCriteria: string[]
}
missingCriteria—most useful for user. "You're 2 votes on Snapshot and $500 volume away from next tier"—actionable info.
Backend: Caching and API
On-chain data doesn't change every second—caching is mandatory.
// Redis cache with TTL
async function getUserEligibility(address: string): Promise<EligibilityScore> {
const cacheKey = `eligibility:${address.toLowerCase()}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
// Parallel fetch from all sources
const [subgraphData, snapshotVotes, onchainBalances] = await Promise.all([
fetchSubgraphData(address),
fetchSnapshotVotes(address),
fetchOnchainBalances(address),
])
const score = calculateScore(subgraphData, snapshotVotes, onchainBalances)
await redis.setex(cacheKey, 300, JSON.stringify(score)) // 5 min TTL
return score
}
5-minute TTL sufficient for most data. For snapshot data after deadline—cache forever (frozen data).
Frontend: React Dashboard
function EligibilityDashboard() {
const { address } = useAccount()
const { data, isLoading } = useQuery({
queryKey: ['eligibility', address],
queryFn: () => fetchEligibility(address!),
enabled: !!address,
staleTime: 60_000, // one minute
})
if (!address) return <ConnectWalletPrompt />
if (isLoading) return <SkeletonDashboard />
return (
<div className="grid grid-cols-2 gap-4">
<ScoreCard score={data.total} tier={data.tier} />
<BreakdownChart breakdown={data.breakdown} />
<MissingCriteriaList items={data.missingCriteria} />
<EstimatedAllocation amount={data.estimatedAllocation} />
</div>
)
}
Progress Bars and Gamification
Tier progress bar motivates users to reach next level:
function TierProgress({ current, next, score }: Props) {
const progress = ((score - current.minScore) / (next.minScore - current.minScore)) * 100
return (
<div>
<div className="flex justify-between text-sm">
<span>{current.name}: {score} pts</span>
<span>{next.minScore - score} pts to {next.name}</span>
</div>
<div className="h-2 bg-gray-800 rounded">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 rounded transition-all duration-500"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
</div>
)
}
Merkle Distribution and Claim
After airdrop announcement—claim interface. Standard scheme: MerkleDistributor contract, proof generated server-side:
import { StandardMerkleTree } from '@openzeppelin/merkle-tree'
// Get proof for address
async function getMerkleProof(address: string): Promise<{ proof: string[], amount: bigint }> {
const tree = await loadMerkleTree() // stored on IPFS or CDN
const [index, [addr, amount]] = tree.entries().find(([, [a]]) => a.toLowerCase() === address.toLowerCase())
return { proof: tree.getProof(index), amount: BigInt(amount) }
}
Frontend: Claim button active if !isClaimed && proof !== null. After claim—show tx hash and update status via useWaitForTransactionReceipt.







