Building a Merkle Tree Airdrop System on Starknet: A Complete Guide
Learn how to create an efficient, gas-optimized airdrop system using Merkle trees with JavaScript and Cairo smart contracts
Introduction
Airdrops have become a cornerstone of Web3 projects for distributing tokens to communities. However, traditional airdrop mechanisms can be extremely gas-intensive when dealing with thousands of recipients. Enter Merkle trees – a cryptographic data structure that allows us to verify membership in a large dataset with minimal on-chain storage and computation.
In this comprehensive guide, we'll build a complete Merkle tree-based airdrop system on Starknet, covering everything from generating proofs off-chain to verifying them on-chain with Cairo smart contracts.
What Are Merkle Trees and Why Use Them?
A Merkle tree is a binary tree where:
- Each leaf represents a data element (in our case, an airdrop recipient)
- Each internal node contains the hash of its children
- The root represents the entire dataset
Benefits for airdrops:
- ✅ Gas Efficiency: Store only the root hash on-chain instead of all recipient data
- ✅ Scalability: Handle millions of recipients with minimal on-chain footprint
- ✅ Privacy: Recipients' data isn't publicly visible until they claim
- ✅ Flexibility: Easy to update or modify recipient lists
Project Architecture
Our system consists of three main components:
- JavaScript Generator: Creates Merkle trees and generates proofs
- Cairo Smart Contract: Verifies proofs and handles claims on-chain
- Integration Layer: Connects off-chain computation with on-chain verification
Setting Up the Development Environment
First, let's set up our project structure:
mkdir starknet-merkle-airdrop
cd starknet-merkle-airdrop
npm init -y
npm install starknet-merkle-tree starknet
Update your package.json
:
{
"type": "module",
"scripts": {
"generate-tree": "node generate-tree.js",
"test-verification": "node test-verification.js"
},
"dependencies": {
"starknet-merkle-tree": "^latest",
"starknet": "^6.0.0"
}
}
Building the Off-Chain Merkle Tree Generator
Create generate-tree.js
to handle tree generation and proof creation:
import * as Merkle from "starknet-merkle-tree";
import fs from "fs";
// Define airdrop recipients with their allocation data
const airdropData = [
['0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a', '1000000000000000000', '0'], // 1 ETH
['0x53c615080d35defd55569488bc48c1a91d82f2d2ce6199463e095b4a4ead551', '2000000000000000000', '0'], // 2 ETH
['0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', '500000000000000000', '1'], // 0.5 ETH
['0x5678901234567890abcdef1234567890abcdef1234567890abcdef1234567890', '750000000000000000', '0'] // 0.75 ETH
];
console.log('🌳 Generating Merkle tree...');
// Create tree using Poseidon hash (optimized for Starknet)
const tree = Merkle.StarknetMerkleTree.create(airdropData, Merkle.HashType.Poseidon);
console.log(`✅ Tree created with ${airdropData.length} leaves`);
console.log(`📋 Merkle Root: ${tree.root}`);
// Generate and store proofs for all recipients
const proofs = {};
const claims = [];
for (let i = 0; i {
fn verify_proof(
self: @TContractState,
proof: Array,
leaf: felt252
) -> bool;
fn verify_claim(
self: @TContractState,
proof: Array,
address: ContractAddress,
amount: u256,
additional_data: felt252
) -> bool;
fn get_merkle_root(self: @TContractState) -> felt252;
fn set_merkle_root(ref self: TContractState, new_root: felt252);
}
#[starknet::contract]
mod MerkleVerifier {
use super::IMerkleVerifier;
use starknet::{ContractAddress, get_caller_address};
use core::poseidon::poseidon_hash_span;
use core::array::ArrayTrait;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
merkle_root: felt252,
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
ProofVerified: ProofVerified,
RootUpdated: RootUpdated,
}
#[derive(Drop, starknet::Event)]
struct ProofVerified {
#[key]
leaf: felt252,
verified: bool,
}
#[derive(Drop, starknet::Event)]
struct RootUpdated {
old_root: felt252,
new_root: felt252,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress, merkle_root: felt252) {
self.owner.write(owner);
self.merkle_root.write(merkle_root);
}
#[abi(embed_v0)]
impl MerkleVerifierImpl of IMerkleVerifier {
fn verify_proof(
self: @ContractState,
proof: Array,
leaf: felt252
) -> bool {
let root = self.merkle_root.read();
self._verify_merkle_proof(proof.span(), leaf, root)
}
fn verify_claim(
self: @ContractState,
proof: Array,
address: ContractAddress,
amount: u256,
additional_data: felt252
) -> bool {
let leaf_hash = self._compute_leaf_hash(address, amount, additional_data);
self.verify_proof(proof, leaf_hash)
}
fn get_merkle_root(self: @ContractState) -> felt252 {
self.merkle_root.read()
}
fn set_merkle_root(ref self: ContractState, new_root: felt252) {
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Only owner can update root');
let old_root = self.merkle_root.read();
self.merkle_root.write(new_root);
self.emit(RootUpdated { old_root, new_root });
}
}
#[generate_trait]
impl InternalImpl of InternalTrait {
fn _verify_merkle_proof(
self: @ContractState,
proof: Span,
leaf: felt252,
root: felt252
) -> bool {
let mut computed_hash = leaf;
let mut i = 0;
while i felt252 {
// Hash format: [address, amount_low, amount_high, additional_data]
let mut hash_data = ArrayTrait::new();
hash_data.append(address.into());
hash_data.append(amount.low.into());
hash_data.append(amount.high.into());
hash_data.append(additional_data);
poseidon_hash_span(hash_data.span())
}
fn _is_left_node(self: @ContractState, a: felt252, b: felt252) -> bool {
let a_u256: u256 = a.into();
let b_u256: u256 = b.into();
a_u256 < b_u256
}
}
}
Testing the Integration
Create test-verification.js
to test our complete system:
import { Account, Contract, RpcProvider } from "starknet";
import fs from "fs";
async function testMerkleVerification() {
// Load generated data
const proofs = JSON.parse(fs.readFileSync("./proofs.json", "utf8"));
const treeData = JSON.parse(fs.readFileSync("./merkle_tree.json", "utf8"));
console.log("🧪 Testing Merkle verification...");
console.log(`📋 Root: ${treeData.root}`);
// Test data
const testAddress = "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a";
const testProof = proofs[testAddress];
if (!testProof) {
console.error("❌ No proof found for test address");
return;
}
console.log(`🎯 Testing address: ${testAddress}`);
console.log(`💰 Amount: ${testProof.amount}`);
console.log(`🔐 Proof: [${testProof.proof.join(', ')}]`);
// Here you would call your deployed contract
// const result = await contract.verify_claim(
// testProof.proof,
// testAddress,
// { low: testProof.amount, high: "0x0" },
// testProof.additionalData
// );
console.log("✅ Test data prepared for contract verification");
}
testMerkleVerification().catch(console.error);
Deployment and Usage
Step 1: Generate Your Merkle Tree
npm run generate-tree
Step 2: Deploy the Contract
Use the generated root hash when deploying your Cairo contract.
Step 3: Verify Claims
Users can now claim their airdrops by providing their proof, which the contract will verify against the stored root.
Key Features and Benefits
🚀 Gas Efficiency
- Only stores a single 32-byte root hash on-chain
- Verification requires minimal computation
- Scales to millions of recipients without increasing gas costs
🔒 Security
- Cryptographically secure proof system
- Impossible to forge valid proofs without the original data
- Owner-controlled root updates for flexibility
🎯 User Experience
- Recipients only need their proof to claim
- No need to submit all recipient data on-chain
- Fast verification process
Real-World Applications
This Merkle tree system can be used for:
- Token Airdrops: Distribute governance or utility tokens
- NFT Allowlists: Manage whitelist access for minting
- Reward Systems: Distribute rewards based on participation
- Access Control: Gate access to exclusive features or content
Optimization Tips
For Large Datasets
- Use batch processing for tree generation
- Implement pagination for proof distribution
- Consider using IPFS for storing large proof sets
For Gas Optimization
- Use Poseidon hashing (optimized for Starknet)
- Minimize proof verification loops
- Batch multiple claims when possible
Conclusion
Merkle trees provide an elegant solution for scalable, gas-efficient airdrops on Starknet. By combining off-chain computation with on-chain verification, we can handle massive recipient lists while maintaining security and minimizing costs.
The system we've built demonstrates the power of cryptographic data structures in solving real-world blockchain challenges. Whether you're launching a new token or rewarding your community, this Merkle tree implementation provides a robust foundation for your airdrop needs.
What's Next?
Consider extending this system with:
- Multi-token support for complex airdrop scenarios
- Time-based claiming with expiration dates
- Delegation mechanisms for third-party claiming
- Integration with frontend dApps for seamless user experience
Top comments (0)