Розробка контракту для 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-контракт із захистом від усіх типових вразливостей.







