Разработка Merkle Distributor для массовых выплат

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка Merkle Distributor для массовых выплат
Средняя
~2-3 рабочих дня
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1056
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

Разработка Merkle Distributor для массовых выплат

Задача: выплатить токены 50 000 адресам по итогам ретроактивного airdrop. Наивное решение — хранить mapping(address => uint256) on-chain и итерироваться по нему в скрипте деплоя. Деплой такого маппинга стоит ~50 SLOAD + 50 SSTORE на каждого получателя, итого при 50K получателях — несколько ETH только на запись данных в storage. Merkle Distributor решает это принципиально иначе.

Как работает Merkle Distributor

В контракт деплоится только один bytes32 merkleRoot — корень дерева. Вся таблица выплат (адрес → сумма) остаётся off-chain. Получатель сам приходит за своими токенами с merkleProof — набором хэшей, доказывающих его включение в дерево.

On-chain верификация:

function claim(
    uint256 index,
    address account,
    uint256 amount,
    bytes32[] calldata merkleProof
) external {
    require(!isClaimed(index), "Already claimed");

    bytes32 node = keccak256(abi.encodePacked(index, account, amount));
    require(MerkleProof.verify(merkleProof, merkleRoot, node), "Invalid proof");

    _setClaimed(index);
    require(IERC20(token).transfer(account, amount), "Transfer failed");

    emit Claimed(index, account, amount);
}

isClaimed(index) проверяет один bit в mapping(uint256 => uint256) — packed bitmask. 50 000 получателей = ~1563 uint256 слотов вместо 50 000. Экономия gas на storage в разы.

Построение Merkle Tree

Off-chain дерево строится из листьев: leaf = keccak256(abi.encodePacked(index, address, amount)). Двойное хэширование для защиты от second preimage attack: leaf vs internal node — разные размеры входа в abi.encodePacked гарантируют это.

Библиотеки: @openzeppelin/merkle-tree (JS/TS), rs_merkle (Rust). Дерево строится от листьев вверх, каждый internal node = keccak256(abi.encodePacked(left, right)) — но canonical ordering: меньший хэш всегда слева. Это важно: верификатор на Solidity использует тот же ordering.

Типичный скрипт:

import { StandardMerkleTree } from "@openzeppelin/merkle-tree";

const values = recipients.map(([address, amount], index) => [
    index, address, amount
]);
const tree = StandardMerkleTree.of(values, ["uint256", "address", "uint256"]);
console.log("Root:", tree.root);

// proof для конкретного получателя
const proof = tree.getProof([index, address, amount]);

Root публикуется в контракте при деплое. Proofs раздаются через API или публикуются в IPFS вместе с полной таблицей.

Типичные ошибки реализации

Double-spend через битовую маску. Если _setClaimed некорректно устанавливает бит — возможен double claim. OpenZeppelin MerkleDistributor использует проверенный паттерн с claimedBitMap. Не изобретайте свой.

Collision в листьях. Если листья формируются без index (keccak256(abi.encodePacked(address, amount))), два получателя с одинаковой суммой могут теоретически доказать claim друг друга — но с разными adress это дало бы им чужой адрес, так что на практике collision безвредна в стандартной схеме. С index — гарантированно уникальны.

Неправильное кодирование. abi.encodePacked(address, uint256) и abi.encode(address, uint256) дают разные хэши. Если off-chain скрипт использует одно кодирование, а Solidity другое — ни один proof не верифицируется. Используйте abi.encodePacked для листьев (стандарт Uniswap/Optimism distributors).

Расширенные паттерны

Многораундовый distributor. Новый merkleRoot каждую неделю/эпоху. Вместо деплоя нового контракта — обновляемый root через updateMerkleRoot(bytes32) с onlyOwner или governance. Claimed bitmask сбрасывается для новой эпохи или индексируется по эпохе: mapping(uint256 epoch => mapping(uint256 wordIndex => uint256 bitmask)).

Delegated claiming. Получатель подписывает разрешение на claim от своего имени — полезно для gasless UX через релеер или ERC-2771 meta-transactions. Паттерн: claimFor(address account, uint256 amount, bytes32[] calldata proof, bytes calldata signature).

Тестирование на Foundry

function test_ClaimValidProof() public {
    // строим дерево в тесте
    bytes32[] memory leaves = new bytes32[](3);
    leaves[0] = keccak256(abi.encodePacked(uint256(0), alice, uint256(100e18)));
    // ... merkle proof вычисляем вручную или через FFI к JS скрипту
    distributor.claim(0, alice, 100e18, proof);
    assertEq(token.balanceOf(alice), 100e18);
    vm.expectRevert("Already claimed");
    distributor.claim(0, alice, 100e18, proof); // double claim
}

Для генерации proofs в Foundry тестах: vm.ffi с вызовом TypeScript скрипта через @openzeppelin/merkle-tree. Или писать pure Solidity merkle tree builder в setUp() — медленнее, но без внешних зависимостей.

Сроки

Базовый Merkle Distributor с single root: 2-3 дня включая off-chain скрипты. Многораундовый с governance и gasless claiming: 4-5 дней. Деплой и верификация контракта на mainnet — дополнительно несколько часов.

Стоимость рассчитывается индивидуально после уточнения количества получателей, механики эпох и требований к UX.