Airdrop Campaign System Development
Technically airdrop is token distribution. Practically—marketing tool that either creates long-term protocol participants or generates one-time dump pressure. Difference determined not by token amount distributed, but by who receives them and on what terms.
Merkle Distributor: Standard for Mass Distribution
Naive approach—call transfer for each address. With 100,000 recipients that's 100,000 transactions, huge gas, guaranteed failure. Right approach—Merkle Distributor, where recipients claim tokens themselves.
Off-chain: form list (address → amount), build Merkle tree, publish root to contract.
On-chain: user provides Merkle proof, contract verifies and issues tokens.
contract MerkleDistributor {
address public immutable token;
bytes32 public immutable merkleRoot;
// Bitfield for tracking claimed—gas savings 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");
// Verify 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: storing claimed status in packed bitfield (256 statuses in one uint256 slot) saves ~80% gas on SSTORE/SLOAD vs mapping(address => bool).
Airdrop Types and Applications
Retroactive Airdrop
Distribution to existing protocol users—most effective type. Uniswap UNI, Arbitrum ARB, Optimism OP—all were retroactive for early users.
Eligibility criteria determined by on-chain analysis:
- Transaction volume during period
- Number of unique contracts interacted with
- Age of first transaction
- Position retention (not just-in-time farming)
Sybil filtering—main technical task. One person with 1000 addresses shouldn't get 1000x more.
Sybil cluster indicators:
- Addresses get ETH from same funding source
- Transactions with identical patterns (same time of day, same protocols)
- Empty addresses between actions (gas station pattern)
- Minimal transactions to meet minimum criteria
Sybil detection tools: Chainanalysis Sybil (paid), own SQL analysis via Dune Analytics or indexed node.
Task-based System
User completes tasks → gets allocation. Typical tasks:
- Follow on Twitter, Discord, Telegram
- Testnet transactions
- Referral of new users
- Governance voting participation
Problem: easily farmed by bots. Tasks should require on-chain activity hard to simulate at scale.
Integration with Galxe / Layer3—ready platforms for task-based campaigns. API for on-chain task verification. Downside: platform takes fee and users stay on platform, not your site.
Vested Airdrop
Received tokens don't claim immediately but vest. Linear vesting 6–12 months.
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 {
// Verify allocation (if first 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: first 3 months nothing (cliff), then linear vest 9 months. Reduces immediate dump, creates long-term holders.
Scoring System
For complex campaigns with many actions—off-chain scoring system:
interface UserScore {
address: string;
totalPoints: number;
breakdown: {
earlyAdopter: number; // first 1000 users
volumeScore: number; // based on trading volume
loyaltyScore: number; // time using protocol
referrals: number; // successful referrals
governanceVotes: number; // governance participation
};
}
// Allocation = f(points) with diminishing returns for anti-whale
function calculateAllocation(points: number, totalPoints: number): bigint {
// Square root for whale mitigation
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 (used in quadratic voting): decreases gap between large and small participants. Whale with 10,000 points gets not 10x more than 1,000-point user, but only ~3.16x.
Gas Optimization for Mass Claiming
With millions of claimers each saved gas is user money:
EIP-2612 Permit—instead of separate approve transaction, if user needs to do something with tokens right after claim (like staking):
function claimAndStake(
uint256 index,
uint256 amount,
bytes32[] calldata proof,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// Claim tokens
claim(index, msg.sender, amount, proof);
// Permit for approve without separate transaction
IERC20Permit(token).permit(msg.sender, address(staking), amount, deadline, v, r, s);
// Stake immediately
staking.stakeFor(msg.sender, amount);
}
Batch claiming—if user has allocations in multiple rounds:
function claimMultiple(
uint256[] calldata indices,
uint256[] calldata amounts,
bytes32[][] calldata proofs
) external {
uint256 totalAmount;
for (uint i = 0; i < indices.length; i++) {
// verify each proof
totalAmount += amounts[i];
}
// one transfer instead of N
IERC20(token).safeTransfer(msg.sender, totalAmount);
}
Frontend for Airdrop
Eligibility checker—enter address → check via API (backend has list) or directly from Merkle tree (if published fully):
async function checkEligibility(address: string) {
// Normalize address
const normalizedAddress = ethers.getAddress(address);
// Get data from API or published snapshot
const allocation = await fetchAllocation(normalizedAddress);
if (!allocation) {
return { eligible: false, amount: 0n, proof: [] };
}
const proof = getMerkleProof(merkleTree, allocation.index, normalizedAddress, allocation.amount);
// Check not already claimed
const alreadyClaimed = await distributor.isClaimed(allocation.index);
return {
eligible: true,
amount: allocation.amount,
proof,
alreadyClaimed
};
}
Snapshot publication: Merkle tree data should be publicly accessible (GitHub, IPFS) so users can independently verify their allocation. Non-public snapshot—red flag for community.
Expiry and Unclaimed Tokens
Always set expiry on claim period (usually 1 year). Unclaimed tokens returned to treasury or burned:
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);
}







