Merkle Distributor for Mass Payouts Development

We design and develop full-cycle blockchain solutions: from smart contract architecture to launching DeFi protocols, NFT marketplaces and crypto exchanges. Security audits, tokenomics, integration with existing infrastructure.
Showing 1 of 1 servicesAll 1306 services
Merkle Distributor for Mass Payouts Development
Medium
~2-3 business days
FAQ
Blockchain Development Services
Blockchain Development Stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1238
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1167
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    867
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1080
  • image_logo-advance_0.png
    B2B Advance company logo design
    563
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    829

Developing Merkle Distributor for Mass Payouts

The task: pay tokens to 50,000 addresses after a retroactive airdrop. A naive solution is to store mapping(address => uint256) on-chain and iterate through it in a deployment script. Deploying such a mapping costs ~50 SLOAD + 50 SSTORE per recipient, totaling several ETH in storage writes alone for 50K recipients. Merkle Distributor solves this fundamentally differently.

How Merkle Distributor Works

The contract deploys with just one bytes32 merkleRoot — the root of the tree. The entire payout table (address → amount) stays off-chain. The recipient comes to claim their tokens themselves with a merkleProof — a set of hashes proving their inclusion in the tree.

On-chain verification:

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) checks a single bit in mapping(uint256 => uint256) — a packed bitmask. 50,000 recipients = ~1563 uint256 slots instead of 50,000. Gas savings on storage are massive.

Building the Merkle Tree

Off-chain, the tree is built from leaves: leaf = keccak256(abi.encodePacked(index, address, amount)). Double hashing protects against second preimage attacks: leaf vs internal node — different input sizes in abi.encodePacked guarantee this.

Libraries: @openzeppelin/merkle-tree (JS/TS), rs_merkle (Rust). The tree is built from leaves upward, each internal node = keccak256(abi.encodePacked(left, right)) — but in canonical ordering: the smaller hash always goes left. This is important: the verifier in Solidity uses the same ordering.

Typical script:

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 for a specific recipient
const proof = tree.getProof([index, address, amount]);

The root is published in the contract at deployment. Proofs are distributed via API or published on IPFS with the full table.

Common Implementation Mistakes

Double-spend via bit mask. If _setClaimed incorrectly sets the bit — double claim is possible. OpenZeppelin MerkleDistributor uses a proven pattern with claimedBitMap. Don't invent your own.

Collision in leaves. If leaves are formed without index (keccak256(abi.encodePacked(address, amount))), two recipients with the same amount could theoretically prove each other's claim — but with different addresses this would give them the wrong address, so collisions are harmless in the standard scheme. With index — guaranteed unique.

Wrong encoding. abi.encodePacked(address, uint256) and abi.encode(address, uint256) produce different hashes. If the off-chain script uses one encoding and Solidity uses another — no proof verifies. Use abi.encodePacked for leaves (standard for Uniswap/Optimism distributors).

Advanced Patterns

Multi-round distributor. New merkleRoot each week/epoch. Instead of deploying a new contract — update the root through updateMerkleRoot(bytes32) with onlyOwner or governance. The claimed bitmask resets for a new epoch or is indexed by epoch: mapping(uint256 epoch => mapping(uint256 wordIndex => uint256 bitmask)).

Delegated claiming. The recipient signs authorization for claiming on their behalf — useful for gasless UX via relayer or ERC-2771 meta-transactions. Pattern: claimFor(address account, uint256 amount, bytes32[] calldata proof, bytes calldata signature).

Testing on Foundry

function test_ClaimValidProof() public {
    // build tree in test
    bytes32[] memory leaves = new bytes32[](3);
    leaves[0] = keccak256(abi.encodePacked(uint256(0), alice, uint256(100e18)));
    // ... compute merkle proof manually or via FFI to JS script
    distributor.claim(0, alice, 100e18, proof);
    assertEq(token.balanceOf(alice), 100e18);
    vm.expectRevert("Already claimed");
    distributor.claim(0, alice, 100e18, proof); // double claim
}

For proof generation in Foundry tests: vm.ffi with a TypeScript script call via @openzeppelin/merkle-tree. Or write pure Solidity merkle tree builder in setUp() — slower but no external dependencies.

Timeline

Basic Merkle Distributor with single root: 2-3 days including off-chain scripts. Multi-round with governance and gasless claiming: 4-5 days. Deployment and verification on mainnet — a few more hours.

Cost is calculated individually after clarifying recipient count, epoch mechanics, and UX requirements.