Розробка контрактів для claim-системи токенів

Розробка контракту для claim-системи токенів Ви розгорнули airdrop, але газ на деплой списку з 10 000 адрес з'їв половину бюджету. Або ще гірше — хтось знайшов спосіб клеймити токени двічі з одним proof. Такі проблеми зустрічаються в production постійно. Claim-контракт — стандартний інструмент, а

Напрямки блокчейн-розробки

Часті запитання

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1441
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1301
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    998
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1267
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    713
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    1003

Розробка контракту для 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-контракту

  1. Аналіз вимог — визначаємо список учасників, суми, vesting-розклад, необхідність multi-round.
  2. Проектування архітектури — обираємо Merkle tree, bitmap, vesting, додаткові функції.
  3. Реалізація — пишемо смарт-контракт на Solidity 0.8.20 з повним покриттям тестами (Foundry, включаючи fuzzing).
  4. Аудит — проводимо внутрішній review + опціонально зовнішній аудит (від 2 тижнів).
  5. Розгортання — деплой з оптимізованими параметрами (тверда гарантія gas efficiency).
  6. Документація та інтеграція — надаємо керівництво з інтеграції та підтримку.

Терміни: від 5 робочих днів до 3 тижнів залежно від складності. Вартість розраховується індивідуально.

Ми реалізували вже понад 30 claim-контрактів для проєктів на Ethereum, Arbitrum та Polygon. Зв'яжіться з нами для консультації — оцінимо проєкт за 2 дні. Замовте розробку під ключ і отримайте надійний claim-контракт із захистом від усіх типових вразливостей.