Разработка контракта для airdrop
Классическая ошибка — делать airdrop через loop с on-chain отправкой каждому адресу. При 10k получателей это 10k транзакций, десятки тысяч долларов газа и несколько часов работы. Правильный подход — Merkle distributor: один раз on-chain устанавливаете Merkle root, каждый получатель сам клеймит свои токены, платя за своё включение.
Merkle distributor: стандартная реализация
Список адресов и сумм → строите off-chain Merkle tree → публикуете root on-chain → пользователь предоставляет proof своего leaf → контракт верифицирует и переводит токены.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract MerkleAirdrop {
IERC20 public immutable token;
bytes32 public immutable merkleRoot;
mapping(uint256 => uint256) private claimedBitMap;
constructor(address _token, bytes32 _merkleRoot) {
token = IERC20(_token);
merkleRoot = _merkleRoot;
}
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 claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external {
require(!isClaimed(index), "Already claimed");
bytes32 leaf = keccak256(bytes.concat(
keccak256(abi.encode(index, account, amount))
)); // Double-hash против second preimage attack
require(
MerkleProof.verify(merkleProof, merkleRoot, leaf),
"Invalid proof"
);
_setClaimed(index);
require(token.transfer(account, amount), "Transfer failed");
emit Claimed(index, account, amount);
}
}
Почему double-hash leaf. Без него — second preimage attack: злоумышленник может подменить leaf данными, которые совпадают с intermediate node в дереве. OpenZeppelin использует keccak256(bytes.concat(keccak256(abi.encode(...)))) именно по этой причине.
Bit packing для claimed. Вместо mapping(address => bool) используем bit array: 256 статусов в одном uint256 slot. Экономия SLOAD газа существенна при большом количестве кламов.
Генерация Merkle tree off-chain
import { StandardMerkleTree } from "@openzeppelin/merkle-tree"
// Список [index, address, amount]
const values = [
[0, "0xAddress1...", ethers.parseEther("100")],
[1, "0xAddress2...", ethers.parseEther("250")],
// ...тысячи записей
]
const tree = StandardMerkleTree.of(values, ["uint256", "address", "uint256"])
console.log("Merkle Root:", tree.root) // → деплоить в контракт
// Proof для конкретного адреса
for (const [i, v] of tree.entries()) {
if (v[1] === "0xAddress1...") {
const proof = tree.getProof(i)
// proof — массив bytes32, нужен пользователю для claim
}
}
// Сохранить всё дерево для раздачи proofs через API
import fs from "fs"
fs.writeFileSync("tree.json", JSON.stringify(tree.dump()))
Proofs раздаёте через простой API: GET /proof?address=0x... → возвращает { index, amount, proof[] }. Пользователь вставляет эти данные в UI и вызывает claim.
Дополнительные соображения
Expiry. Добавьте deadline после которого unclaimed токены возвращаются владельцу. Иначе токены locked навсегда.
uint256 public immutable claimDeadline;
function claim(...) external {
require(block.timestamp <= claimDeadline, "Airdrop expired");
// ...
}
function recoverExpired() external onlyOwner {
require(block.timestamp > claimDeadline, "Not expired yet");
token.transfer(owner(), token.balanceOf(address(this)));
}
Vesting airdrop. Если токены не должны быть доступны сразу — линейный vesting прямо в distributor контракте: клейм → токены на vesting schedule → claim vested по мере времени.
Газ для пользователей. На mainnet клейм стоит ~$2-10. Рассмотрите деплой на L2 (Arbitrum, Base, Optimism) — клейм стоит центы. Или gasless claim через ERC-2771 meta-transactions (пользователь подписывает, relayer платит газ).
Процесс работы
Список адресов и сумм → off-chain генерация дерева → деплой контракта с root → трансфер токенов на контракт → API для proofs → UI для claim. Стандартный scope: 1-2 недели включая тестирование и деплой.







