Разработка дашборда статуса airdrop-eligibility
Airdrop дашборд — это не просто «проверь, попал ли ты». Хорошо сделанный дашборд показывает пользователю конкретно что ему не хватает, сколько баллов у него есть, какие действия ещё можно совершить до снапшота. Это retention-инструмент, который мотивирует пользователей активнее взаимодействовать с протоколом. Технически — это агрегатор данных из нескольких on-chain источников с кэшированием, потому что реальный on-chain запрос для каждого адреса при каждом посещении убьёт любой RPC.
Архитектура: источники данных
The Graph subgraph
Основной источник для on-chain активности. Subgraph индексирует события контракта и предоставляет GraphQL API. Данные для дашборда:
query UserActivity($address: String!) {
user(id: $address) {
totalVolume
transactionCount
firstInteractionTimestamp
liquidityProvisions {
amount
timestamp
pool { id symbol }
}
referrals { count totalVolume }
}
}
Запросы в The Graph — бесплатны до лимита, быстры (< 200ms), не нагружают RPC ноды.
Snapshot.org API
Для 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
Для прямых балансов — viem multicall батчит несколько вызовов в один RPC запрос:
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] },
]
})
Один HTTP запрос вместо трёх — критично при высоком трафике.
Система баллов и критерии
Eligibility обычно многофакторная. Типичная структура:
interface EligibilityScore {
total: number
breakdown: {
volumeScore: number // 0-40 баллов: торговый объём
loyaltyScore: number // 0-20 баллов: дата первого взаимодействия
governanceScore: number // 0-20 баллов: голосования на Snapshot
referralScore: number // 0-10 баллов: приведённые пользователи
holdingScore: number // 0-10 баллов: удержание токенов
}
tier: 'bronze' | 'silver' | 'gold' | 'platinum'
estimatedAllocation: bigint | null // null до объявления
missingCriteria: string[]
}
missingCriteria — самая полезная часть для пользователя. «Вам не хватает 2 голосований на Snapshot и $500 объёма для следующего тира» — это actionable информация.
Backend: кэширование и API
On-chain данные не меняются каждую секунду — кэшировать обязательно.
// Redis кэш с 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)
// Параллельный fetch из всех источников
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 минут TTL
return score
}
TTL 5 минут для большинства данных достаточно. Для данных снапшота после дедлайна — кэш навсегда (данные frozen).
Frontend: React дашборд
function EligibilityDashboard() {
const { address } = useAccount()
const { data, isLoading } = useQuery({
queryKey: ['eligibility', address],
queryFn: () => fetchEligibility(address!),
enabled: !!address,
staleTime: 60_000, // минута
})
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>
)
}
Прогресс-бары и gamification
Tier прогресс-бар мотивирует пользователя дойти до следующего уровня:
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 до {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 дистрибьюция и claim
После объявления аirdrop — claim интерфейс. Стандартная схема: контракт MerkleDistributor, proof генерируется на сервере:
import { StandardMerkleTree } from '@openzeppelin/merkle-tree'
// Получение proof для адреса
async function getMerkleProof(address: string): Promise<{ proof: string[], amount: bigint }> {
const tree = await loadMerkleTree() // дерево хранится on IPFS или CDN
const [index, [addr, amount]] = tree.entries().find(([, [a]]) => a.toLowerCase() === address.toLowerCase())
return { proof: tree.getProof(index), amount: BigInt(amount) }
}
На фронте кнопка Claim активируется если !isClaimed && proof !== null. После клейма — отображаем tx hash и обновляем статус через useWaitForTransactionReceipt.







