- Published on
Merkle Airdrop Starter Kit - Hack
- Authors
- Name
- Daksh
- @0xDaksh
Table of Contents
Preface
My aim for this post is to give the reader a starter-kit
which handles most of the concerns and choices made when building a merkle-root based airdrop contract. Most projects these days use such a contract to minimize the cost of executing an airdrop for their token!
This starter kit comes up with the following:
- Latest libs: Foundry, Solidity & OZ 5.0 support
- Upgradability
- Separation of Airdrop contract and Funds
- Not allowed users list
- Pausability
- Timestamp constrained claims
If you want to skip to the codebase and get started: 0xDaksh/merkle-airdrop-starter-kit
Why not do a direct batch transfer?
TLDR: High Cost + Risk of accounting discrepancy
Historically there have been a few tools for doing batch transfers to your users, but they end up being really expensive if you're on ethereum and if the number of transfers is very high, the transaction ends up exceeding block gas limit and you have to split these drops into multiple transactions, leading to a possibility of accounting related issues.
If you have a small list of users and the transfer cost is under your budget constraints, you should think about doing a direct transfer. The best tool for doing so is Gaslite by Pop Punk.
Setup: Dev Tooling
We will use the following tooling:
- Foundry
- OpenZeppelin Libraries
- Bun
To setup the tooling, you have to first ensure foundry is installed on your system:
curl -L https://foundry.paradigm.xyz | bash
foundryup
This should install binaries such as forge
, cast
, anvil
and chisel
We will also install bun which would be used for merkle tree and root generation:
curl -fsSL https://bun.sh/install | bash
Clone the Github Repo:
git clone https://github.com/0xDaksh/merkle-airdrop-starter-kit.git
cd merkle-airdrop-starter-kit
The file tree would look something like this:
merkle-airdrop-starter-kit
├── README.md
├── foundry.toml
├── generator
│ ├── README.md
│ ├── bun.lockb
│ ├── csv2tree.ts
│ ├── data
│ ├── export
│ ├── getProof.ts
│ ├── node_modules
│ ├── package.json
│ └── tsconfig.json
├── lib
│ ├── forge-std
│ ├── openzeppelin-contracts-upgradeable
│ └── openzeppelin-foundry-upgrades
├── remappings.txt
├── script
│ └── DeployAirdrop.s.sol
├── src
│ ├── Airdrop.sol
│ └── MockToken.sol
└── test
└── Airdrop.t.sol
Preparing the Airdrop Data
The starter kit has a generator which takes care of validation & generation of a merkle tree. But before we use it, you need to know how much and to whom are you airdropping.
You need to prepare a csv document, that is in the following format:
account | amount |
---|---|
0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 | 1000000000000000000 |
0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5 | 3074000000000000000 |
... | ... |
... | ... |
The format for this can also be found in the example.csv file, feel free to use it!
Once you have prepared this file, save it in generator/data/values.csv
and run the following command:
cd generator
bun
bun csv2tree.ts
Once you run, it should generate the following files:
The Merkle Tree: export/tree.json
{
"format": "standard-v1",
"tree": [
"0x3fc8090ecab5ab64b34f4d075ee087dbe6a64f7ba054e686152f6defc0503855",
"0x61f0d9754a80fa2abbab4118dd7057fde1d6e41d161994aabd446f2062b78607",
"0x9ff146ab8066c4fd65200349b430d60b02eab11a5979fbb9ecbb2dc5533e6845",
"0x0c2b3cc0e4f76c8ab0e42adb3b1e3f52b9df9aa741ed4b05fe18eeb9e49d281a",
"0x6a69081de7dd029f024f842849f303756bb5a2cc5ffc35212550bce6f3bad325",
"0xd1f792a59f25e595b2f116d65df971970af8ca7b112799a92a3a528d1cb63e66",
"0xc2feca2fc7e048a646a8b6af3a8321c28f39f08c2320e99a6a49990c999932a7",
"0x73d1a0c85445f9285d4564ec4ef41ec1bf7ee8a4b349a50a6e12176eab594b5b",
"0x67c970f2b85d2581eeb84cc581ce3f1264fe15012fb6db5a0997ecd819b6176e",
"0x647099e433f27a21e37e28861090856dba33d932b32703692c5cfc4f6d26ed58",
"0x34574850f97afef1b19d3451acc48ad45909c9ad2c3ae7a216443c7de25c94b0"
],
"values": [
{
"value": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "1000000000000000000"],
"treeIndex": 6
},
{
"value": ["0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5", "3074000000000000000"],
"treeIndex": 8
},
{
"value": ["0x4838B106FCe9647Bdf1E7877BF73cE8B0BAD5f97", "1559000000000000000"],
"treeIndex": 9
},
{
"value": ["0x6b75d8AF000000e20B7a7DDf000Ba900b4009A80", "6969000000000000000"],
"treeIndex": 7
},
{
"value": ["0xdf99A0839818B3f120EBAC9B73f82B617Dc6A555", "4206900000000000000"],
"treeIndex": 5
},
{
"value": ["0x6b75d8AF000000e20B7a7DDf000Ba900b4009A80", "1333000000000000000"],
"treeIndex": 10
}
],
"leafEncoding": ["address", "uint256"]
}
The Merkle Root: export/root.txt
0x3fc8090ecab5ab64b34f4d075ee087dbe6a64f7ba054e686152f6defc0503855
The Contract and How to deploy it?
The contract (Airdrop.sol
) uses the OpenZeppelin StandardMerkleTree format to compute the leaf hash:
function getLeaf(address user, uint256 amt) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(user, amt))));
}
Which is used by the claim function to verify the user's merkle proof and disburse funds, The flow of logic is as such that it:
- Checks if the contract is paused by owner or not
- Checks for active claim period
- Verifies that the user is not in the notAllowed list
- Generates the leaf hash for the user and amount
- Ensures the leaf hasn't been claimed before
- Verifies the Merkle proof
- Marks the leaf as claimed
- Transfers the tokens from the funder to the user
- Emits a Claimed event
function claim(uint256 amt, bytes32[] calldata merkleProof) external whenNotPaused {
if (block.timestamp < start || block.timestamp >= end) revert ClaimNotActive();
if (notAllowed[msg.sender]) revert UserNotAllowed();
bytes32 leaf = getLeaf(msg.sender, amt);
if (leafClaimed[leaf]) revert AlreadyClaimed();
if (!MerkleProof.verifyCalldata(merkleProof, merkleRoot, leaf)) revert InvalidProof();
leafClaimed[leaf] = true;
token.safeTransferFrom(funder, msg.sender, amt);
emit Claimed(msg.sender, leaf, amt);
}
Note: Another nuance that I should probably mention is that funds should not be directly stored in the airdrop contract. Rather a funder (a multisig) can contain them, and this funder approves the amount of funds that can be accessed and transferred by the Airdrop Contract.
Remaining functions in this contract are owner controlled and allow the owner some maneuverability around their plan. Things such as setting a new claim period, pausing claims, disallowing certain users, upgrading the implementation etc.
Deployment
To deploy this contract you should update the placeholder variables in script/Airdrop.s.sol
and set values for the following:
- Token
- Owner
- Funder
- MerkleRoot (can be found in
generator/export/root.txt
) - Start Time
- End Time
The final contract should look something like this:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script} from "forge-std/Script.sol";
import {Airdrop} from "./../src/Airdrop.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
contract DeployAirdrop is Script {
address public token;
address public owner;
address public funder;
bytes32 public merkleRoot;
uint256 public start;
uint256 public end;
function setUp() public {
/// TODO: must set these values, to your intended values
token = 0x912CE59144191C1204E64559FE8253a0e49E6548; // ARB
owner = 0xF3FC178157fb3c87548bAA86F9d24BA38E649B58; // your contract owner multisig
funder = 0xCaD7828a19b363A2B44717AFB1786B5196974D8E; // your token funder multisig, which approves the airdrop contract
// your merkle root, found in root.txt
merkleRoot = bytes32(0x3fc8090ecab5ab64b34f4d075ee087dbe6a64f7ba054e686152f6defc0503855);
start = block.timestamp; // start block = now
end = block.timestamp + 1 days * 365; // end block = now + 365 days
}
function run() public returns (address, address, address) {
vm.startBroadcast();
address proxyAdmin = address(new ProxyAdmin(owner));
address airdropImplementation = address(new Airdrop());
address proxy = address(
new TransparentUpgradeableProxy(
airdropImplementation,
proxyAdmin,
abi.encodeWithSelector(Airdrop.initialize.selector, owner, token, funder, merkleRoot, start, end)
)
);
vm.stopBroadcast();
return (proxyAdmin, airdropImplementation, proxy);
}
}
Once you set the correct values, it is extremely straight forward to deploy this:
export PK=YOUR_PRIVATE_KEY
export RPC_URL=https://arbitrum.drpc.org
forge script script/DeployAirdrop.s.sol --private-key $PK --rpc-url $RPC_URL --optimizer-runs 999 --broadcast
Note: In production please do not use PRIVATE KEY as an environment variable, you should rather use a foundry keystore which is a cleaner option!
Running this script should produce an output like this:
[⠊] Compiling...
[⠃] Compiling 1 files with Solc 0.8.26
[⠊] Solc 0.8.26 finished in 887.79ms
Compiler run successful!
Script ran successfully.
Gas used: 1899516
== Return ==
0: address 0x655A53f74fb2565E67d5FfB0C78663Fc7E5864c8
1: address 0x055970E03F22fAe95FF1F7F7ac02BA7A87AD5feA
2: address 0x6Cc0c2D8F9533dFd2276337DdEaBBCEE9dd0747F
The addresses returned are in the following order:
- ProxyAdmin
- Airdrop Implementation (do not use)
- Airdrop Proxy (use this address)
Usage in Production
Once you have deployed the contracts and used the funder
account to approve spending of the token amount by Airdrop Proxy
contract, we are ready to go to the next step which is finding the proof for a given address and claiming it.
There is some example code on how you can find the Proof for a given account from the Merkle Tree in the generator/getProof.ts
file and it would look something like this:
import { StandardMerkleTree } from '@openzeppelin/merkle-tree'
// read the tree
const treeData = await Bun.file('./export/tree.json').json()
// load the tree
const tree = StandardMerkleTree.load(treeData)
// set this variable to the user who's trying to claim
const userClaiming = '0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5'
for (const [i, v] of tree.entries()) {
// if the user is in the tree entries, their proof will be logged to the console
if (v[0] === userClaiming) {
const proof = tree.getProof(i)
console.log('User:', userClaiming)
console.log('Amount:', v)
console.log('Proof:', proof)
}
}
A sample claim script in solidity looks like this:
uint256 amount = 3074000000000000000;
bytes32[] memory proof = new bytes32[](3);
proof[0] = 0x73d1a0c85445f9285d4564ec4ef41ec1bf7ee8a4b349a50a6e12176eab594b5b;
proof[1] = 0x6a69081de7dd029f024f842849f303756bb5a2cc5ffc35212550bce6f3bad325;
proof[2] = 0x9ff146ab8066c4fd65200349b430d60b02eab11a5979fbb9ecbb2dc5533e6845;
// send transaction from the same user's account
airdrop.claim(amount, proof);
If you're trying to build an ethers/web3.js based pipeline, you can find the ABI for the contract here:
[
{ "type": "constructor", "inputs": [], "stateMutability": "nonpayable" },
{
"type": "function",
"name": "claim",
"inputs": [
{ "name": "amt", "type": "uint256", "internalType": "uint256" },
{ "name": "merkleProof", "type": "bytes32[]", "internalType": "bytes32[]" }
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "disallowUsers",
"inputs": [
{ "name": "users_", "type": "address[]", "internalType": "address[]" },
{ "name": "status_", "type": "bool", "internalType": "bool" }
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "end",
"inputs": [],
"outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "funder",
"inputs": [],
"outputs": [{ "name": "", "type": "address", "internalType": "address" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "initialize",
"inputs": [
{ "name": "owner_", "type": "address", "internalType": "address" },
{ "name": "token_", "type": "address", "internalType": "address" },
{ "name": "funder_", "type": "address", "internalType": "address" },
{ "name": "root_", "type": "bytes32", "internalType": "bytes32" },
{ "name": "start_", "type": "uint256", "internalType": "uint256" },
{ "name": "end_", "type": "uint256", "internalType": "uint256" }
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "leafClaimed",
"inputs": [{ "name": "", "type": "bytes32", "internalType": "bytes32" }],
"outputs": [{ "name": "", "type": "bool", "internalType": "bool" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "merkleRoot",
"inputs": [],
"outputs": [{ "name": "", "type": "bytes32", "internalType": "bytes32" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "notAllowed",
"inputs": [{ "name": "", "type": "address", "internalType": "address" }],
"outputs": [{ "name": "", "type": "bool", "internalType": "bool" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "owner",
"inputs": [],
"outputs": [{ "name": "", "type": "address", "internalType": "address" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "pause",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "paused",
"inputs": [],
"outputs": [{ "name": "", "type": "bool", "internalType": "bool" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "renounceOwnership",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "start",
"inputs": [],
"outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "token",
"inputs": [],
"outputs": [{ "name": "", "type": "address", "internalType": "contract IERC20" }],
"stateMutability": "view"
},
{
"type": "function",
"name": "transferOwnership",
"inputs": [{ "name": "newOwner", "type": "address", "internalType": "address" }],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "unpause",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "updateFunder",
"inputs": [{ "name": "funder_", "type": "address", "internalType": "address" }],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "updateRoot",
"inputs": [{ "name": "merkleRoot_", "type": "bytes32", "internalType": "bytes32" }],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "updateTimestamps",
"inputs": [
{ "name": "start_", "type": "uint256", "internalType": "uint256" },
{ "name": "end_", "type": "uint256", "internalType": "uint256" }
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "verifyProof",
"inputs": [
{ "name": "user", "type": "address", "internalType": "address" },
{ "name": "amt", "type": "uint256", "internalType": "uint256" },
{ "name": "merkleProof", "type": "bytes32[]", "internalType": "bytes32[]" }
],
"outputs": [{ "name": "", "type": "bool", "internalType": "bool" }],
"stateMutability": "view"
},
{
"type": "event",
"name": "Claimed",
"inputs": [
{ "name": "user", "type": "address", "indexed": true, "internalType": "address" },
{ "name": "leaf", "type": "bytes32", "indexed": false, "internalType": "bytes32" },
{ "name": "amount", "type": "uint256", "indexed": false, "internalType": "uint256" }
],
"anonymous": false
},
{
"type": "event",
"name": "Initialized",
"inputs": [{ "name": "version", "type": "uint64", "indexed": false, "internalType": "uint64" }],
"anonymous": false
},
{
"type": "event",
"name": "OwnershipTransferred",
"inputs": [
{ "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" },
{ "name": "newOwner", "type": "address", "indexed": true, "internalType": "address" }
],
"anonymous": false
},
{
"type": "event",
"name": "Paused",
"inputs": [
{ "name": "account", "type": "address", "indexed": false, "internalType": "address" }
],
"anonymous": false
},
{
"type": "event",
"name": "Unpaused",
"inputs": [
{ "name": "account", "type": "address", "indexed": false, "internalType": "address" }
],
"anonymous": false
},
{
"type": "error",
"name": "AddressEmptyCode",
"inputs": [{ "name": "target", "type": "address", "internalType": "address" }]
},
{
"type": "error",
"name": "AddressInsufficientBalance",
"inputs": [{ "name": "account", "type": "address", "internalType": "address" }]
},
{ "type": "error", "name": "AlreadyClaimed", "inputs": [] },
{ "type": "error", "name": "ClaimNotActive", "inputs": [] },
{ "type": "error", "name": "EnforcedPause", "inputs": [] },
{ "type": "error", "name": "ExpectedPause", "inputs": [] },
{ "type": "error", "name": "FailedInnerCall", "inputs": [] },
{ "type": "error", "name": "InvalidFunder", "inputs": [] },
{ "type": "error", "name": "InvalidInitialization", "inputs": [] },
{ "type": "error", "name": "InvalidProof", "inputs": [] },
{ "type": "error", "name": "InvalidTimestamps", "inputs": [] },
{ "type": "error", "name": "InvalidTokenAddress", "inputs": [] },
{ "type": "error", "name": "NotInitializing", "inputs": [] },
{
"type": "error",
"name": "OwnableInvalidOwner",
"inputs": [{ "name": "owner", "type": "address", "internalType": "address" }]
},
{
"type": "error",
"name": "OwnableUnauthorizedAccount",
"inputs": [{ "name": "account", "type": "address", "internalType": "address" }]
},
{
"type": "error",
"name": "SafeERC20FailedOperation",
"inputs": [{ "name": "token", "type": "address", "internalType": "address" }]
},
{ "type": "error", "name": "UserNotAllowed", "inputs": [] }
]