Claim Contract Development for Token Distribution
You deployed an airdrop, but the gas to deploy a list of 10,000 addresses ate half your budget. Or worse — someone found a way to claim tokens twice with the same proof. These issues appear in production all the time. A claim contract is a standard tool, but its implementation requires precision. We, a team with over 5 years of blockchain experience, provide turnkey claim contract creation with security guarantees and gas optimization. In this article, we'll break down why the Merkle tree is the industry standard and how to avoid costly mistakes.
Why Merkle Tree Over On-Chain List?
| Parameter | On-chain whitelist | Merkle tree |
|---|---|---|
| Gas on deploy (10k addresses) | ~0.5 ETH | ~0.01 ETH |
| Gas on claim | ~50k (SLOAD) | ~30k (SLOAD + 2x SHA3) |
| Storage | 10k storage slots | 1 bytes32 |
| Updateability | requires contract migration | root change |
Gas savings on deployment up to 90% — for large projects this translates to thousands of dollars.
How to Implement Merkle-Based Claim
Building the Tree (Off-Chain)
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
// Leaves: [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);
// Save the tree for proof generation
fs.writeFileSync("tree.json", JSON.stringify(tree.dump()));
// For a specific address, generate the proof
for (const [i, v] of tree.entries()) {
if (v[0] === "0xAddress1...") {
const proof = tree.getProof(i);
console.log("Proof:", proof);
}
}
Contract
// 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 for gas efficiency instead of 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);
}
// Recover unclaimed tokens after deadline
function recoverUnclaimed() external onlyOwner {
require(block.timestamp > claimDeadline, "Claim period active");
uint256 balance = token.balanceOf(address(this));
token.transfer(owner(), balance);
}
}
Using a bitmap instead of mapping(address => bool) is an important optimization. One storage slot (32 bytes) stores 256 flags. For 10,000 participants, about 40 slots are needed instead of 10,000. The first claim in a slot costs 20,000 gas (SSTORE cold), subsequent ones 5,000 (SSTORE warm). The savings are significant.
How Vesting Claim Works
For team and investors, claim usually works together with vesting. Cliff + linear unlock is the standard scheme:
struct VestingSchedule {
uint256 totalAmount;
uint256 cliffEnd; // timestamp when cliff ends
uint256 vestingEnd; // timestamp when fully unlocked
uint256 claimed; // already 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;
}
Common Vulnerabilities
| Vulnerability | Consequences | Solution |
|---|---|---|
| Double-claim without bitmap | Token loss on repeated claim with different amount | Bitmap with unique index |
| Griefing through claim on behalf | Forced token transfer to unwanted address | Require msg.sender == account |
| Missing recoverUnclaimed | Tokens locked forever | Add recovery function with timelock |
| Frontrunning proof | Theft of tokens by swapping account | Include account in leaf |
Double-Claim Without Bitmap
If you use mapping(address => bool) instead of bitmap, and the same address appears with different amounts — the proof is valid for each variation, the claimed[address] = true flag is set once, but the second claim with a different amount still passes. Bitmap with index as key prevents this: each index is unique.
Griefing Through Claim on Behalf
If claim(account, ...) is called by someone other than account — tokens can be force-sent to an address that hasn't passed KYC or a contract without receive(). For protocols with compliance requirements, restrict: require(msg.sender == account).
No RecoverUnclaimed
Tokens are permanently locked on the contract if the deadline is not handled. Always add a recovery function.
Frontrunning Proof
The proof is public; anyone can see it in the mempool and submit it with their own address. Protection: include account in the leaf (already implemented above) — the proof works only for that specific address.
Multi-Round Claims
For airdrops with multiple rounds (e.g., retroactive + ongoing rewards), use several Merkle roots — one per round, or a mutable root with a timelock on updates:
bytes32[] public merkleRoots; // index = round number
mapping(uint256 => mapping(uint256 => uint256)) private claimedBitMaps; // round => bitmap
function addRound(bytes32 root) external onlyOwner {
merkleRoots.push(root);
}
What’s Included in the Work?
- Smart contract source code (Solidity 0.8.20) with full test coverage (Foundry, including fuzzing).
- Deploy and verification scripts for Etherscan.
- Integration documentation (ABI, interfaces, call examples).
- Post-deployment support: consultations, help with Merkle root updates.
- Optional: external security audit (from 2 weeks).
Claim Contract Development Process
- Requirements analysis — determine the participant list, amounts, vesting schedule, need for multi-round.
- Architecture design — choose Merkle tree, bitmap, vesting, additional functions.
- Implementation — write the smart contract in Solidity 0.8.20 with full test coverage (Foundry, including fuzzing).
- Audit — internal review + optional external audit (from 2 weeks).
- Deployment — deploy with optimized parameters (solid gas efficiency guarantee).
- Documentation and integration — provide integration guide and support.
Timeline: from 5 working days to 3 weeks depending on complexity. Cost is calculated individually.
We have implemented over 30 claim contracts for projects on Ethereum, Arbitrum, and Polygon. Contact us for a consultation — we will evaluate your project in 2 days. Order turnkey development and get a reliable claim contract protected against all typical vulnerabilities.







