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)