Разработка системы airdrop-кампании
Технически airdrop — это распределение токенов. На практике — это маркетинговый инструмент, который либо создаёт долгосрочных участников протокола, либо генерирует одноразовую волну sell pressure. Разница определяется не суммой распределяемых токенов, а тем, кто их получает и на каких условиях.
Merkle Distributor: стандарт для массового распределения
Наивный подход — вызвать transfer на каждый адрес. При 100,000 получателей это 100,000 транзакций, огромный gas и точка отказа. Правильный подход — Merkle Distributor, где получатели сами клеймят свои токены.
Off-chain: формируем список (address → amount), строим Merkle tree, публикуем root в контракт.
On-chain: пользователь предоставляет Merkle proof, контракт верифицирует и выдаёт токены.
contract MerkleDistributor {
address public immutable token;
bytes32 public immutable merkleRoot;
// Bitfield для отслеживания claimed — экономия gas vs mapping(address => bool)
mapping(uint256 => uint256) private claimedBitMap;
function isClaimed(uint256 index) public view returns (bool) {
uint256 claimedWordIndex = index / 256;
uint256 claimedBitIndex = index % 256;
uint256 claimedWord = claimedBitMap[claimedWordIndex];
uint256 mask = (1 << claimedBitIndex);
return claimedWord & mask == mask;
}
function _setClaimed(uint256 index) private {
uint256 claimedWordIndex = index / 256;
uint256 claimedBitIndex = index % 256;
claimedBitMap[claimedWordIndex] |= (1 << claimedBitIndex);
}
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external {
require(!isClaimed(index), "Already claimed");
// Верификация proof
bytes32 node = keccak256(abi.encodePacked(index, account, amount));
require(
MerkleProof.verify(merkleProof, merkleRoot, node),
"Invalid proof"
);
_setClaimed(index);
IERC20(token).safeTransfer(account, amount);
emit Claimed(index, account, amount);
}
}
Bitfield vs mapping: хранение claimed status в packed bitfield (256 статусов в одном uint256 slot) экономит ~80% gas на SSTORE/SLOAD по сравнению с mapping(address => bool).
Типы airdrop и их применение
Retroactive airdrop
Распределение для существующих пользователей протокола — самый эффективный тип. Uniswap UNI airdrop, Arbitrum ARB, Optimism OP — все были retroactive для ранних пользователей.
Критерии eligibility определяются on-chain анализом:
- Объём транзакций за период
- Количество уникальных контрактов, с которыми взаимодействовал адрес
- Давность первой транзакции
- Удержание позиций (не just-in-time farming)
Sybil filtering — главная техническая задача. Один человек с 1000 адресов не должен получить в 1000 раз больше.
Признаки Sybil-кластеров:
- Адреса получают ETH с одного funding source
- Транзакции с одинаковыми паттернами (одно и то же время суток, одни и те же протоколы)
- Пустые адреса между действиями (gas station pattern)
- Минимальные транзакции для выполнения минимальных критериев
Инструменты Sybil detection: Chainanalysis Sybil (платный), собственный SQL анализ по onchain данным через Dune Analytics или собственный indexed node.
Задачная (task-based) система
Пользователь выполняет задания → получает allocation. Типичные задания:
- Follow в Twitter, Discord, Telegram
- Testnet транзакции
- Referral новых пользователей
- Участие в governance голосовании
Проблема: эти задания легко фармятся ботами. Задания должны требовать on-chain активности, которую сложно симулировать в масштабе.
Интеграция с Galxe / Layer3 — готовые платформы для task-based кампаний. API для верификации on-chain задач. Минус: платформа берёт fee и пользователи остаются на платформе, а не на твоём сайте.
Vested airdrop
Полученные токены не клеймятся сразу, а вестируются. Linear vesting 6–12 месяцев.
contract VestedAirdrop is MerkleDistributor {
uint256 public immutable vestingStart;
uint256 public immutable vestingDuration;
mapping(address => uint256) public claimed;
mapping(address => uint256) public totalAllocated;
function claimVested(
uint256 index,
address account,
uint256 totalAmount,
bytes32[] calldata merkleProof
) external {
// Верификация аллокации (если первый claim)
if (totalAllocated[account] == 0) {
_verifyAndSetAllocation(index, account, totalAmount, merkleProof);
}
uint256 vested = _vestedAmount(account);
uint256 claimable = vested - claimed[account];
require(claimable > 0, "Nothing to claim");
claimed[account] += claimable;
IERC20(token).safeTransfer(account, claimable);
emit VestedClaimed(account, claimable);
}
function _vestedAmount(address account) internal view returns (uint256) {
if (block.timestamp < vestingStart) return 0;
uint256 elapsed = block.timestamp - vestingStart;
if (elapsed >= vestingDuration) return totalAllocated[account];
return totalAllocated[account] * elapsed / vestingDuration;
}
}
Cliff + linear: первые 3 месяца ничего (cliff), затем линейный вест 9 месяцев. Снижает immediate dump, создаёт долгосрочных holders.
Система начисления баллов
Для сложных кампаний с множеством действий — off-chain система баллов:
interface UserScore {
address: string;
totalPoints: number;
breakdown: {
earlyAdopter: number; // первые 1000 пользователей
volumeScore: number; // на основе торгового объёма
loyaltyScore: number; // длительность использования
referrals: number; // успешные рефералы
governanceVotes: number; // участие в голосованиях
};
}
// Allocation = f(points) с diminishing returns для анти-whale механизма
function calculateAllocation(points: number, totalPoints: number): bigint {
// Квадратный корень для снижения whale доминирования
const sqrtScore = Math.sqrt(points);
const totalSqrtScore = /* sum of sqrt scores for all users */ 0;
const allocation = (TOTAL_AIRDROP_AMOUNT * BigInt(Math.floor(sqrtScore * 1e18)))
/ BigInt(Math.floor(totalSqrtScore * 1e18));
return allocation;
}
Square root formula (используется в quadratic voting и некоторых airdrop системах): уменьшает разрыв между крупными и мелкими участниками. Whale с 10,000 points получит не в 10x больше чем пользователь с 1,000 points, а только в ~3.16x.
Gas optimization для mass claiming
При миллионах claimer-ов каждый сэкономленный gas — это деньги пользователей:
EIP-2612 Permit — вместо отдельной approve транзакции, если пользователю нужно что-то сделать с токенами сразу после claim (например, застейкать):
function claimAndStake(
uint256 index,
uint256 amount,
bytes32[] calldata proof,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// Claim токены
claim(index, msg.sender, amount, proof);
// Permit для approve без отдельной транзакции
IERC20Permit(token).permit(msg.sender, address(staking), amount, deadline, v, r, s);
// Стейкаем сразу
staking.stakeFor(msg.sender, amount);
}
Batch claiming — если один пользователь имеет allocations в нескольких раундах:
function claimMultiple(
uint256[] calldata indices,
uint256[] calldata amounts,
bytes32[][] calldata proofs
) external {
uint256 totalAmount;
for (uint i = 0; i < indices.length; i++) {
// верификация каждого proof
totalAmount += amounts[i];
}
// один transfer вместо N
IERC20(token).safeTransfer(msg.sender, totalAmount);
}
Frontend для airdrop
Eligibility checker — ввод адреса → проверка через API (backend имеет список) или прямо из Merkle tree (если опубликован полностью):
async function checkEligibility(address: string) {
// Нормализация адреса
const normalizedAddress = ethers.getAddress(address);
// Получаем данные из API или из opubликованного snapshot
const allocation = await fetchAllocation(normalizedAddress);
if (!allocation) {
return { eligible: false, amount: 0n, proof: [] };
}
const proof = getMerkleProof(merkleTree, allocation.index, normalizedAddress, allocation.amount);
// Проверяем не клеймил ли уже
const alreadyClaimed = await distributor.isClaimed(allocation.index);
return {
eligible: true,
amount: allocation.amount,
proof,
alreadyClaimed
};
}
Snapshot публикация: Merkle tree данные должны быть публично доступны (GitHub, IPFS) чтобы пользователи могли независимо верифицировать свою аллокацию. Непубличный snapshot — красный флаг для community.
Expiry и unclaimed tokens
Всегда устанавливать expiry на claim период (обычно 1 год). Unclaimed токены возвращаются в treasury или сжигаются:
uint256 public constant EXPIRY = 365 days;
uint256 public immutable deployedAt;
function recoverUnclaimed() external onlyOwner {
require(block.timestamp > deployedAt + EXPIRY, "Not expired");
uint256 remaining = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransfer(treasury, remaining);
}







