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

Проєктуємо та розробляємо блокчейн-рішення повного циклу: від архітектури смарт-контрактів до запуску DeFi-протоколів, NFT-маркетплейсів та криптобірж. Аудит безпеки, токеноміка, інтеграція з наявною інфраструктурою.
Показано 1 з 1Усі 1306 послуг
Розробка контрактів для claim-системи токенів
Середній
~2-3 дні
Часті запитання

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

Етапи блокчейн-розробки

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

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

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

Claim-контракт — це механізм розподілу токенів по заранее відомому списку адрес: учасники airdrop, переможці whitelist, команда, інвесторі з vesting. Задача здається простою, але типичні реалізації містять кілька уязвимостей та газових неефективностей, які обходяться дорого в production.

Головний вибір при проектуванні: зберігати список адрес on-chain або використовувати Merkle tree. On-chain whitelist — це O(n) gas при деплое, n storage slots. При 10,000 адрес деплой може коштувати десятки ETH. Merkle tree вирішує це: деплоєш один bytes32 merkleRoot, кожен учасник сам доводить своє право, надавши proof.

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);
    }

    // Повернення unclaimed токенів після deadline
    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. Cliff + лінійне розблокування — стандартна схема:

struct VestingSchedule {
    uint256 totalAmount;
    uint256 cliffEnd;       // timestamp кінця cliff
    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 — якщо замість bitmap використовувати mapping(address => bool), та в списку один адрес зустрічається з різними amount — proof валідний для кожного варіанту, флаг claimed[address] = true ставиться один раз, але другий claim з іншим amount теж пройде. Bitmap з index як ключем виключає це: index унікальний.

Griefing через claim від імені: якщо claim(account, ...) викликає не сам account — можна примусово відправити токени на адресу, не пройдену KYC або контракт без receive(). Для протоколів з compliance краще обмежити: 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; // раунд => bitmap

function addRound(bytes32 root) external onlyOwner {
    merkleRoots.push(root);
}

Правильно спроектований claim-контракт — це економія газу для тисяч користувачів та відсутність експлойтів при публічному аудиті. Bitmap, double-index leaf, deadline recovery — не опціональні покращення, а мінімальний baseline для production.