- Merkle Airdrop with EIP-712 Signatures
A gas-efficient airdrop distribution system using Merkle trees to verify claim eligibility and EIP-712 typed signatures for secure claim authorization. This implementation allows users to claim pre-allocated airdrop tokens with minimal on-chain verification overhead.
- Merkle Tree Verification: Gas-efficient eligibility verification using Merkle proofs
- EIP-712 Signatures: Typed signature scheme for secure claim authorization
- Immutable Token Contract: Secure ERC20 token for airdrop distribution
- Claim Tracking: Prevents duplicate claims with one-time claim verification per address
- Multi-network Support: Deployable on Ethereum, Arbitrum, and Base networks
Tech Stack:
- Solidity 0.8.33
- Foundry (Forge for building and testing)
- forge-std version (v1.11.0)
- OpenZeppelin Contracts (ERC20, EIP-712, Merkle proof utilities, ECDSA signature recovery)
- openzeppelin-contracts version (v5.5.0)
- Murky (Merkle tree generation for testing)
- murky version (v0.1.0)
┌──────────────────────────────────────────────────────────┐
│ Whitelisted Users/EOAs │
└──────────┬─────────────────────────────┬─────────────────┘
│ │
│ Direct claim │ Authorize signature
│ with signature │ (delegate claim)
│ ▼
│ ┌──────────────────────────┐
│ │ Authorized Claimer │
│ │ (Non-whitelisted EOA) │
│ └────────┬─────────────────┘
│ │
│ claim(address, │ claim(address,
│ amount, proof, │ amount, proof,
│ v, r, s) │ v, r, s)
│ │
└─────────────┬───────────────┘
▼
┌──────────────────────────────────────────┐
│ │
│ MerkleAirdrop Contract │
│ │
│ ┌──────────────────┐ ┌────────────────┐ │
│ │ Merkle Root │ │ EIP-712 Domain │ │
│ │ (Eligibility) │ │(Signature Ver) │ │
│ └──────────────────┘ └────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Claim Status Tracking (per address)│ │
│ │(Prevents duplicate claims) │ │
│ └────────────────────────────────────┘ │
│ │
└───────────────┬──────────────────────────┘
│ safeTransfer()
│
▼
┌─────────────────────────┐
│ AirdropToken (ERC20) │
│ Token Distribution │
└─────────────────────────┘
Repository Structure:
merkle-airdrop-eip712/
├── src/
│ ├── AirdropToken.sol # ERC20 token for airdrop
│ └── MerkleAirdrop.sol # Core airdrop claim contract with EIP-712
├── script/
│ ├── Deploy.s.sol # Deployment script
│ ├── Interactions.s.sol # Claim interaction scripts
│ ├── GenerateInput.s.sol # Generate merkle tree input data
│ ├── MerkleBuilder.s.sol # Build merkle tree from input
│ ├── HelperConfig.s.sol # Network configuration
│ ├── SplitSignature.s.sol # Split full signature into v,r,s
│ └── target/ # Generated merkle tree outputs
├── test/
│ ├── unit/
│ │ └── MerkleBuilderTest.t.sol # Merkle tree generation tests
│ └── integration/
│ ├── DeployTest.t.sol # Deployment tests
│ ├── MerkleAirdropTest.t.sol # Airdrop claim functionality tests
│ └── InteractionsTest.t.sol # Full integration tests
├── lib/ # Dependencies
├── foundry.toml # Foundry configuration
├── Makefile # Convenient make targets
└── README.md # This file
git clone https://github.com/0xGearhart/merkle-airdrop-eip712
cd merkle-airdrop-eip712
make-
Copy the environment template:
cp .env.example .env
-
Configure your
.envfile:ETH_SEPOLIA_RPC_URL=your_sepolia_rpc_url_here ETH_MAINNET_RPC_URL=your_mainnet_rpc_url_here ARB_SEPOLIA_RPC_URL=your_arbitrum_sepolia_rpc_url_here ARB_MAINNET_RPC_URL=your_arbitrum_mainnet_rpc_url_here BASE_SEPOLIA_RPC_URL=your_base_sepolia_rpc_url_here BASE_MAINNET_RPC_URL=your_base_mainnet_rpc_url_here ETHERSCAN_API_KEY=your_etherscan_api_key_here DEFAULT_KEY_ADDRESS=public_address_of_your_encrypted_private_key_here SECONDARY_ADDRESS=secondary_address_for_whitelisting
-
Get testnet ETH:
- Ethereum Sepolia: cloud.google.com/application/web3/faucet/ethereum/sepolia
- Base Sepolia & Arbitrum Sepolia (requires mainnet Chainlink balance): faucets.chain.link
-
Configure Makefile
- Change account name in Makefile to the name of your desired encrypted key
- Change
--account defaultKeyto--account <YOUR_ENCRYPTED_KEY_NAME> - Check encrypted key names stored locally with:
cast wallet list
- If no encrypted keys found, encrypt private key to be used securely within foundry:
cast wallet import <account_name> --interactive
- Never commit your
.envfile - Never use your mainnet private key for testing
- Use a separate wallet with only testnet funds
Compile the contracts:
forge buildRun the test suite:
forge testRun tests with verbosity:
forge test -vvvRun specific test:
forge test --mt testFunctionNameGenerate coverage report:
forge coverageCreate test coverage report and save to .txt file:
make coverage-reportStart a local Anvil node:
make anvilDeploy to local node (in another terminal):
make deployGenerate Merkle Tree: Build the merkle tree from input data and generate the root:
make merkleGet Claim Digest: Get the EIP-712 typed hash digest for signing:
make get-digestSign Digest: Sign the digest with your private key:
make sign-digestClaim Airdrop (Streamlined): Automatically create digest, sign, and claim in one script:
make claim-airdropClaim with Full Signature: Use a pre-generated full signature (requires splitting first):
make claim-airdrop-with-full-sigSplit Full Signature: Split a full signature into v, r, and s components:
make split-signatureDeploy to Sepolia:
make deploy ARGS="--network eth sepolia"Deploy to Arbitrum Sepolia:
make deploy ARGS="--network arb sepolia"Deploy to Base Sepolia:
make deploy ARGS="--network base sepolia"Or using forge directly:
forge script script/Deploy.s.sol:Deploy --rpc-url $ETH_SEPOLIA_RPC_URL --account defaultKey --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY -vvvvIf automatic verification fails:
forge verify-contract <CONTRACT_ADDRESS> src/MerkleAirdrop.sol:MerkleAirdrop --chain-id 11155111 --etherscan-api-key $ETHERSCAN_API_KEY| Network | MerkleAirdrop Address | AirdropToken Address | Block |
|---|---|---|---|
| Arbitrum Sepolia | 0x0292d5E5A58a1BE1603a6fAb2D893eb1b6039D6F |
0x04CD94DE2733Bff4A359c3D595573F479430cac9 |
View on Arbiscan |
For production use, consider:
- Professional security audit
- Bug bounty program
- Gradual rollout with monitoring
This protocol does not implement role-based access control through OpenZeppelin's AccessControl. The contracts follow a stateless, permissionless design:
MerkleAirdrop Contract:
- No Owner: The contract is deployed without an owner. Once deployed, it operates autonomously with no administrative functions.
- Permissionless Claims: Any address with a valid Merkle proof and corresponding EIP-712 signature can claim their airdrop.
- One-Time Claims Per Address: Uses
s_hasClaimedmapping to prevent duplicate claims, but does not restrict who can call the function.
AirdropToken Contract:
- Initial Minter: The deployer of the contract receives the initial token supply upon deployment.
- Standard ERC20: No special roles; functions follow standard ERC20 behavior (transfer, approve, etc.).
- Tokens Transferred to MerkleAirdrop: All airdrop tokens are transferred to the
MerkleAirdropcontract during deployment for distribution.
Key Security Properties:
- ✅ Immutable Parameters: Merkle root and token address are immutable once set
- ✅ EIP-712 Compliance: Signature verification uses standardized typed data hashing
- ✅ Gas Optimization: Merkle proofs minimize on-chain computation
⚠️ No Recovery: Once tokens are in theMerkleAirdropcontract, unclaimed tokens cannot be withdrawn (by design)⚠️ Whitelist Immutability: The Merkle root (whitelist) cannot be updated after deployment
Centralization Risks:
- Whitelist Generation: The Merkle tree whitelist is generated off-chain. A malicious whitelist could be deployed, but users can verify their own eligibility before claiming.
- Signature Verification: Claims require valid EIP-712 signatures. If the signer's private key is compromised, unauthorized claims are possible for addresses in the Merkle proof.
Dependencies:
- OpenZeppelin Contracts: Uses audited libraries for ERC20, EIP-712, ECDSA, and Merkle proof verification
- Murky: External library for Merkle tree generation in tests
- Merkle Root Immutability: Cannot update the whitelist after deployment. A new contract must be deployed to change eligible addresses.
- No Batch Claims: Users can claim their own airdrop, or on behalf of others with their signature.
- Merkle Leaf Hashing: Current implementation uses
keccak256(bytes.concat(keccak256(abi.encode(account, amount))))which is less gas-efficient than assembly-based methods. Consider using Solady'sEfficientHashLibfor production. - No Claim Deadline: Users can claim their airdrop at any time after deployment. Consider adding a deadline in production environments.
| Function | Description | Optimizations |
|---|---|---|
claim() |
Main airdrop claim function | Uses Merkle proofs (log n verification), EIP-712 signature verification, one-time claim tracking |
getDigest() |
Returns EIP-712 typed data hash | Lazy evaluation, no storage reads |
getClaimStatus() |
Check if address has claimed | Single storage read |
Key Gas Optimizations Implemented:
- Merkle Trees: Instead of storing all eligible addresses (O(n) storage), uses Merkle root (O(1) storage) with O(log n) verification
- Immutable Variables: Token and Merkle root use
immutablekeyword, reducing SSTOREs and optimizing reads - SafeERC20: Uses OpenZeppelin's
SafeERC20for safe transfers with minimal overhead - No Loop-Based Operations: Merkle proof verification is non-iterative
- Single Storage Slot for Claims:
s_hasClaimedmapping efficiently tracks claimed status
Gas Report:
Generate a gas report for this project:
make gas-reportGenerate gas snapshot:
forge snapshotCompare gas changes:
forge snapshot --diffContributions are welcome! Please follow these steps:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
Disclaimer: This software is provided "as is", without warranty of any kind. Use at your own risk.
Built with Foundry