Development of Token Claim System Contract
Claim contract is a mechanism for distributing tokens to known address list: airdrop participants, whitelist winners, team, investors with vesting. Task seems simple, but typical implementations contain several vulnerabilities and gas inefficiencies costing real money in production.
Main choice in design: store address list on-chain or use Merkle tree. On-chain whitelist — O(n) gas on deployment, n storage slots. For 10,000 addresses deployment can cost tens of ETH. Merkle tree solves this: deploy one bytes32 merkleRoot, each participant proves their right by providing proof.
Merkle-Based Claim: Implementation
Building 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 tree for proof generation
fs.writeFileSync("tree.json", JSON.stringify(tree.dump()));
// Generate proof for specific address
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);
}
// Return 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);
}
}
Bitmap instead of mapping(address => bool) — important optimization. One storage slot (32 bytes) holds 256 flags. For 10,000 participants need ~40 slots instead of 10,000. First claim in slot costs 20,000 gas (SSTORE cold), subsequent ones — 5,000 (SSTORE warm). Savings noticeable.
Vesting Claim: Scheduled Unlock
For team and investors claim usually works together with vesting. Cliff + linear unlock — standard scheme:
struct VestingSchedule {
uint256 totalAmount;
uint256 cliffEnd; // timestamp cliff end
uint256 vestingEnd; // timestamp full unlock
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
Double-claim without bitmap — if using mapping(address => bool) instead of bitmap, and list contains one address with different amounts — proof valid for each variant, flag claimed[address] = true set once, but second claim with different amount also passes. Bitmap with index as key excludes this: index is unique.
Griefing via claim on behalf: if claim(account, ...) callable not by account itself — can forcibly send tokens to address not passed KYC or contract without receive(). For compliance protocols better restrict: require(msg.sender == account).
No recoverUnclaimed — tokens on contract forever if deadline not handled. Obligatory add recovery function.
Frontrunning proof — proof public, anyone sees it in mempool and can send with account = their address. Protection: include account in leaf (already done above) — proof works only for specific address.
Multi-Round Claims
For airdrops with multiple rounds (e.g., retroactive + ongoing rewards) use multiple merkle roots — one per round, or mutable root with timelock on update:
bytes32[] public merkleRoots; // index = round number
mapping(uint256 => mapping(uint256 => uint256)) private claimedBitMaps; // round => bitmap
function addRound(bytes32 root) external onlyOwner {
merkleRoots.push(root);
}
Properly designed claim contract is gas savings for thousands of users and exploit absence at public audit. Bitmap, double-index leaf, deadline recovery — not optional improvements but minimum baseline for production.







