DEV Community

Cover image for Building a Merkle Tree Airdrop System on Starknet: A Complete Guide
Aditya41205
Aditya41205

Posted on

Building a Merkle Tree Airdrop System on Starknet: A Complete Guide

Image description


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:

  1. JavaScript Generator: Creates Merkle trees and generates proofs
  2. Cairo Smart Contract: Verifies proofs and handles claims on-chain
  3. 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Deployment and Usage

Step 1: Generate Your Merkle Tree

npm run generate-tree
Enter fullscreen mode Exit fullscreen mode

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)