Разработка 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.







