Розроблення системи airdrop-кампанії
Технічно airdrop — це розподіл токенів. На практиці — маркетинговий інструмент, який або створює довгостроків учасників протоколу, або генерує одноразовий тиск продажи. Різниця визначається не сумою розподіляємих токенів, а тим, хто їх отримує та на яких умовах.
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, Arbitrum ARB, Optimism OP — усі були retroactive для ранніх користувачів.
Критерії eligibility визначаються on-chain аналізом:
- Обсяг транзакцій за період
- Кількість унікальних контрактів, з якими взаємодіяв адрес
- Давність першої транзакції
- Утримання позицій (не just-in-time farming)
Sybil filtering — головне технічне завдання. Одна людина з 1000 адрес не повинна отримати в 1000 раз більше.
Індикатори Sybil-кластерів:
- Адреси отримують ETH з одного джерела фінансування
- Транзакції з однаковими паттернами (одна й та ж час доби, одні й ті ж протоколи)
- Пусті адреси між діями (gas station pattern)
- Мінімальні транзакції для виконання мінімальних критеріїв
Інструменти Sybil detection: Chainanalysis Sybil (платний), власний SQL аналіз через 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 {
// Перевірка allocation (якщо перший 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), потім linear vest 9 місяців. Знижує одразу 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 для anti-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): зменшує розрив між крупними та дрібними учасниками. Whale з 10,000 points отримає не в 10x більше ніж користувач з 1,000 points, а тільки ~в 3.16x.
Gas optimization для mass claiming
При мільйонах claimers кожна економлена 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 або опублікованого 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) щоб користувачі могли незалежно верифікувати свою allocation. Непубличний snapshot — червоний прапор для community.
Expiry та unclaimed токени
Завжди встановлюйте expiry на період claim (зазвичай 1 рік). Unclaimed токени повертаються у казначейство або спалюються:
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);
}







