Разработка контракта для claim-системы токенов
Вы развернули airdrop, но газ на деплой списка из 10 000 адресов съел половину бюджета. Или ещё хуже — кто-то нашёл способ клеймить токены дважды с одним proof. Такие проблемы встречаются в production постоянно. Claim-контракт — стандартный инструмент, но его реализация требует аккуратности. Мы, команда с 5-летним опытом в блокчейне, предлагаем создание claim-контрактов под ключ с гарантией безопасности и оптимизацией газа. В этой статье разберём, почему Merkle tree — стандарт индустрии, и как избежать дорогих ошибок.
Почему Merkle tree предпочтительнее on-chain списка?
| Параметр | On-chain whitelist | Merkle tree |
|---|---|---|
| Gas при деплое (10k адресов) | ~0.5 ETH | ~0.01 ETH |
| Gas при claim | ~50k (SLOAD) | ~30k (SLOAD + 2x SHA3) |
| Хранилище | 10k storage slots | 1 bytes32 |
| Возможность обновления | требует миграции контракта | смена корня |
Экономия газа на деплое до 90% — для крупных проектов это тысячи долларов в эквиваленте.
Как реализовать Merkle-based claim?
Построение дерева (off-chain)
import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; // Листья: [address, amount] const values = [ ["0xAddress1...", ethers.parseEther("100")], ["0xAddress2...", ethers.parseEther("250")], // ... ]; const tree = StandardMerkleTree.of(values, ["address", "uint256"]); console.log("Merkle Root:", tree.root); // Сохраняем дерево для генерации proofs fs.writeFileSync("tree.json", JSON.stringify(tree.dump())); // Для конкретного адреса генерируем proof for (const [i, v] of tree.entries()) { if (v[0] === "0xAddress1...") { const proof = tree.getProof(i); console.log("Proof:", proof); } } Контракт
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract MerkleClaim is Ownable { IERC20 public immutable token; bytes32 public immutable merkleRoot; uint256 public immutable claimDeadline; // Packed bitmap для газовой эффективности вместо mapping(address => bool) mapping(uint256 => uint256) private claimedBitMap; event Claimed(address indexed account, uint256 amount, uint256 index); constructor( address _token, bytes32 _merkleRoot, uint256 _claimWindowDays ) Ownable(msg.sender) { token = IERC20(_token); merkleRoot = _merkleRoot; claimDeadline = block.timestamp + (_claimWindowDays * 1 days); } 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] = claimedBitMap[claimedWordIndex] | (1 << claimedBitIndex); } function claim( uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof ) external { require(block.timestamp <= claimDeadline, "Claim period ended"); require(!isClaimed(index), "Already claimed"); bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(index, account, amount)))); require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid proof"); _setClaimed(index); token.transfer(account, amount); emit Claimed(account, amount, index); } // Возврат неклеймленных токенов после дедлайна function recoverUnclaimed() external onlyOwner { require(block.timestamp > claimDeadline, "Claim period active"); uint256 balance = token.balanceOf(address(this)); token.transfer(owner(), balance); } } Использование bitmap вместо mapping(address => bool) — важная оптимизация. Один storage slot (32 байта) хранит 256 флагов. Для 10,000 участников нужно ~40 слотов вместо 10,000. Первый claim в слоте стоит 20,000 gas (SSTORE cold), последующие — 5,000 (SSTORE warm). Экономия ощутимая.
Как работает vesting claim?
Для команды и инвесторов claim обычно работает совместно с vesting. Клифф + линейная разблокировка — стандартная схема:
struct VestingSchedule { uint256 totalAmount; uint256 cliffEnd; // timestamp конца клиффа uint256 vestingEnd; // timestamp полной разблокировки uint256 claimed; // уже клеймлено } mapping(address => VestingSchedule) public schedules; function claimVested() external { VestingSchedule storage schedule = schedules[msg.sender]; require(block.timestamp >= schedule.cliffEnd, "Cliff not reached"); uint256 vested = _calculateVested(schedule); uint256 claimable = vested - schedule.claimed; require(claimable > 0, "Nothing to claim"); schedule.claimed += claimable; token.transfer(msg.sender, claimable); } function _calculateVested(VestingSchedule memory s) private view returns (uint256) { if (block.timestamp >= s.vestingEnd) return s.totalAmount; if (block.timestamp < s.cliffEnd) return 0; uint256 vestingDuration = s.vestingEnd - s.cliffEnd; uint256 elapsed = block.timestamp - s.cliffEnd; return (s.totalAmount * elapsed) / vestingDuration; } Какие типичные уязвимости встречаются?
| Уязвимость | Последствия | Решение |
|---|---|---|
| Double-claim без bitmap | Потеря токенов при повторном claim с другим amount | Bitmap с уникальным index |
| Griefing через claim от имени | Принудительная отправка токенов на нежелательный адрес | Проверка msg.sender == account |
| Отсутствие recoverUnclaimed | Токены навсегда заперты в контракте | Добавление функции возврата с таймлоком |
| Frontrunning proof | Кража токенов через подмену account | Включение account в leaf |
Double-claim без bitmap
Если вместо bitmap использовать mapping(address => bool), и в списке один адрес встречается с разными amount — proof валиден для каждого варианта, флаг claimed[address] = true ставится один раз, но второй claim с другим amount тоже пройдёт. Bitmap с index как ключом исключает это: index уникален.
Griefing через claim от имени
Если claim(account, ...) вызывает не сам account — можно принудительно отправить токены на адрес, не прошедший KYC или контракт без receive(). Для протоколов с compliance requirements лучше ограничить: require(msg.sender == account).
Нет recoverUnclaimed
Токены на контракте навсегда, если deadline не обработан. Обязательно добавлять recovery функцию.
Frontrunning proof
Proof публичный, любой может его увидеть в mempool и отправить с account = свой адрес. Защита: в leaf включать account (уже реализовано выше) — proof работает только для конкретного адреса.
Multi-round claims
Для airdrops с несколькими раундами (например, retroactive + ongoing rewards) используют несколько merkle roots — по одному на раунд, или мутируемый root с timelock на обновление:
bytes32[] public merkleRoots; // индекс = номер раунда mapping(uint256 => mapping(uint256 => uint256)) private claimedBitMaps; // round => bitmap function addRound(bytes32 root) external onlyOwner { merkleRoots.push(root); } Что входит в работу?
- Исходный код смарт-контракта (Solidity 0.8.20) с полным покрытием тестами (Foundry, включая fuzzing).
- Скрипты деплоя и верификации на Etherscan.
- Документация по интеграции (ABI, интерфейсы, примеры вызовов).
- Поддержка после деплоя: консультации, помощь в обновлении Merkle root.
- Optional: внешний аудит безопасности (от 2 недель).
Процесс разработки claim-контракта
- Анализ требований — определяем список участников, суммы, vesting-расписание, необходимость multi-round.
- Проектирование архитектуры — выбираем Merkle tree, bitmap, vesting, дополнительные функции.
- Реализация — пишем смарт-контракт на Solidity 0.8.20 с полным покрытием тестами (Foundry, включая fuzzing).
- Аудит — проводим внутренний review + опционально внешний аудит (от 2 недель).
- Развертывание — деплой с оптимизированными параметрами (твердая гарантия gas efficiency).
- Документация и интеграция — предоставляем руководство по интеграции и поддержку.
Сроки: от 5 рабочих дней до 3 недель в зависимости от сложности. Стоимость рассчитывается индивидуально.
Мы реализовали уже более 30 claim-контрактов для проектов на Ethereum, Arbitrum и Polygon. Свяжитесь с нами для консультации — оценим проект за 2 дня. Закажите разработку под ключ и получите надёжный claim-контракт с защитой от всех типичных уязвимостей.







