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.







