5/5
## Building Your Merkle Airdrop: The Smart Contract Foundation Welcome to this guide on writing the core Solidity smart contract for a Merkle Airdrop. This lesson focuses on the initial implementation, enabling users on an allowlist, defined by a Merkle Tree, to claim their designated ERC20 tokens. We'll assume you're familiar with the underlying principles of Merkle Trees and Merkle Proofs. Our goal is to construct a secure and efficient contract that verifies claims and distributes tokens. ## Initializing the Contract: The Constructor Every smart contract often needs an initial setup when it's deployed. This is handled by the `constructor`, a special function that runs only once. For our Merkle Airdrop, the constructor will initialize essential, unchanging information. **Constructor Parameters:** To function correctly, our `MerkleAirdrop` contract needs two key pieces of information at deployment: 1. `bytes32 merkleRoot`: This is the root hash of the Merkle Tree. This tree contains the data of all eligible addresses and the amount of tokens they can claim. The `merkleRoot` is crucial for verifying claims later. 2. `IERC20 airdropToken`: This is the contract address of the ERC20 token that will be distributed through this airdrop. **Storing Initialization Data: State Variables** The parameters passed to the constructor must be stored within the contract's state to be accessible by other functions. We'll define two state variables: * `bytes32 private immutable i_merkleRoot;`: This variable will store the Merkle Root. * `immutable`: This keyword signifies that `i_merkleRoot` can only be set within the constructor. Once set, it cannot be changed. This is a gas optimization feature, as reading immutable variables is cheaper than reading from regular storage variables. * `private`: This visibility keyword means the variable is not directly accessible from outside the contract without a dedicated getter function (which we are not adding in this basic version). * `i_`: This prefix is a common Solidity naming convention for immutable state variables. * `IERC20 private immutable i_airdropToken;`: This variable will store the contract address of the airdrop token. It also uses `immutable`, `private`, and the `i_` prefix for the same reasons. **Importing `IERC20` Interface:** To use the `IERC20` type for our `airdropToken` parameter and state variable, we must import its interface definition. OpenZeppelin provides a standard and secure implementation: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract MerkleAirdrop { // ... rest of the contract will go here } ``` **The Constructor Code:** Now, let's put it all together in the constructor's code block: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // We'll add more imports later contract MerkleAirdrop { bytes32 private immutable i_merkleRoot; IERC20 private immutable i_airdropToken; constructor(bytes32 merkleRoot, IERC20 airdropToken) { i_merkleRoot = merkleRoot; i_airdropToken = airdropToken; } // ... claim function and other logic will follow } ``` With this constructor, when the `MerkleAirdrop` contract is deployed, it will be permanently configured with the specific Merkle root of the allowlist and the ERC20 token it's meant to distribute. ## Enabling Token Claims: The `claim` Function The core functionality of our airdrop contract is to allow eligible users to claim their tokens. We'll implement a `claim` function for this purpose. **Function Signature:** The signature defines how the function is called and what parameters it accepts: ```solidity function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external { // ... implementation ... } ``` Let's break this down: * `external`: This visibility specifier means the `claim` function can only be called from outside the contract, not by other functions within the same contract. This is generally appropriate for user-facing actions. * `address account`: This is the address that will receive the airdropped tokens. A key design choice here is to allow a different address (e.g., a relayer) to submit the transaction on behalf of the `account`, potentially covering the gas fees for the recipient. * `uint256 amount`: This is the quantity of tokens the `account` is attempting to claim. * `bytes32[] calldata merkleProof`: This is an array of `bytes32` hashes. It represents the Merkle proof required to demonstrate that the combination of `account` and `amount` is part of the allowlist encoded in the `i_merkleRoot`. * `calldata`: This data location is specified because `merkleProof` is an array passed as an argument to an `external` function. Using `calldata` is more gas-efficient for such parameters compared to `memory` as it avoids unnecessary data copying. **Step 1: Reconstructing and Hashing the Leaf Node** To verify a claim, the contract must first reconstruct the leaf node hash that corresponds to the claimant's data (`account` and `amount`). This on-chain calculated leaf hash will then be used with the provided `merkleProof` to see if it computes back to the known `i_merkleRoot`. The off-chain Merkle tree is constructed from leaves, where each leaf typically represents an address and its associated claimable amount. Our `claim` function parameters (`account`, `amount`) directly mirror this structure. A standard practice for hashing Merkle tree leaves, particularly to prevent "second pre-image attacks" (where an attacker might find different inputs that produce the same hash, potentially leading to invalid claims), is to perform a double hash. Here's how we calculate the leaf: ```solidity // Inside the claim function: // Calculate the leaf node hash // This implementation double-hashes the abi.encoded data. // Consistency between off-chain leaf generation and on-chain verification is paramount. bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount)))); ``` Let's dissect this line: 1. `abi.encode(account, amount)`: This function takes the `account` (an address) and `amount` (a uint256) and encodes them into a single `bytes` string according to ABI (Application Binary Interface) specifications. 2. `keccak256(...)`: The first `keccak256` call hashes the ABI-encoded bytes. 3. `bytes.concat(...)`: `keccak256` expects `bytes` as input. The result of the first hash is `bytes32`. `bytes.concat()` is used to convert this `bytes32` back into a `bytes` type suitable for the next hashing step. 4. `keccak256(...)`: The second `keccak256` call hashes the result of the first hash, producing the final `leaf` hash. **Step 2: Verifying the Merkle Proof** With the `leaf` hash calculated, we can now use OpenZeppelin's `MerkleProof` library to verify its validity against the `i_merkleRoot` using the provided `merkleProof`. First, import the library: ```solidity import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; ``` Next, we'll define a custom error. Custom errors (introduced in Solidity 0.8.4) are more gas-efficient than using `require` statements with string messages and provide clearer error reporting on failure. ```solidity error MerkleAirdrop_InvalidProof(); ``` Now, the verification logic within the `claim` function: ```solidity // Inside the claim function, after calculating 'leaf': if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { revert MerkleAirdrop_InvalidProof(); } ``` The `MerkleProof.verify` function takes three arguments: 1. `merkleProof`: The array of hashes provided by the claimant. 2. `i_merkleRoot`: The root hash stored in our contract during deployment. 3. `leaf`: The leaf hash we just calculated on-chain. If `MerkleProof.verify` returns `false` (meaning the proof is invalid for the given leaf and root), the `if` condition `(!MerkleProof.verify(...))` becomes true, and the transaction reverts with our custom `MerkleAirdrop_InvalidProof` error. **Step 3: Logging Claims with Events** It's a best practice in smart contract development to emit events for significant state changes or actions. This allows off-chain services to listen for and react to these occurrences. For a successful claim, we'll emit a `Claim` event. Define the event: ```solidity event Claim(address indexed account, uint256 amount); ``` (Note: Marking `account` as `indexed` allows for easier filtering of these events off-chain.) Emit the event within the `claim` function after successful proof verification: ```solidity // Inside the claim function, after proof verification: emit Claim(account, amount); ``` **Step 4: Securely Transferring Tokens with `SafeERC20`** The final step in a successful claim is to transfer the tokens to the claimant. The standard `IERC20.transfer(address recipient, uint256 amount)` function has a known quirk: it returns a boolean indicating success or failure, but it doesn't necessarily revert the transaction if the transfer fails (e.g., if the recipient is a contract that cannot receive tokens, or if the token contract itself has an issue). This can lead to unexpected behavior or lost funds in some scenarios. OpenZeppelin provides a robust solution: the `SafeERC20` library. This library includes wrapper functions like `safeTransfer` that ensure the underlying ERC20 call reverts if it's unsuccessful. First, update the imports. The `SafeERC20.sol` file also includes the `IERC20` interface, so we can consolidate: ```solidity // Update the IERC20 import to use SafeERC20 import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; ``` To easily use the `SafeERC20` library functions on our `IERC20` token instance, we use the `using for` directive at the contract level: ```solidity contract MerkleAirdrop { using SafeERC20 for IERC20; // Add this line bytes32 private immutable i_merkleRoot; IERC20 private immutable i_airdropToken; // ... constructor, events, errors ... // ... claim function ... } ``` This directive attaches the functions from `SafeERC20` (like `safeTransfer`) to any variable of type `IERC20`. Now, we can use `safeTransfer` in our `claim` function: ```solidity // Inside the claim function, after emitting the event: i_airdropToken.safeTransfer(account, amount); ``` This line will call the `safeTransfer` function from the `SafeERC20` library on our `i_airdropToken`, sending the specified `amount` to the recipient `account`. If the transfer fails for any reason, the entire `claim` transaction will revert, ensuring atomicity and safety. **Full `claim` function thus far:** ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; contract MerkleAirdrop { using SafeERC20 for IERC20; // State Variables bytes32 private immutable i_merkleRoot; IERC20 private immutable i_airdropToken; // Events event Claim(address indexed account, uint256 amount); // Errors error MerkleAirdrop_InvalidProof(); constructor(bytes32 merkleRoot, IERC20 airdropToken) { i_merkleRoot = merkleRoot; i_airdropToken = airdropToken; } function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external { // 1. Calculate the leaf node hash bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount)))); // 2. Verify the Merkle Proof if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { revert MerkleAirdrop_InvalidProof(); } // (We'll add a check here later to prevent double claims) // 3. Emit event emit Claim(account, amount); // 4. Transfer tokens i_airdropToken.safeTransfer(account, amount); } } ``` ## Compiling Your Smart Contract After writing or modifying your Solidity code, it's essential to compile it to ensure there are no syntax errors and to generate the bytecode for deployment. If you're using a development environment like Foundry, you would typically run a command like: ```bash forge build ``` A successful compilation means your code is syntactically correct and ready for further testing and deployment. The summary indicates this initial version compiles successfully. ## Key Concepts Recap Let's quickly review the important concepts and tools we've utilized in building the foundation of our Merkle Airdrop contract: * **Merkle Tree/Proof/Root:** A cryptographic structure used for efficient on-chain verification of data inclusion. The `merkleRoot` is stored on-chain, and users provide `merkleProof`s to validate their claims. * **ERC20:** The standard interface for fungible tokens on EVM-compatible blockchains, defining common functions like `transfer`. * **`immutable`:** A Solidity keyword for state variables that can only be assigned a value in the constructor. This optimizes gas costs for reading these variables. * **`calldata`:** A data location in Solidity for function arguments, especially for `external` functions. It's read-only and often more gas-efficient for dynamic types like arrays than `memory`. * **`keccak256`:** The standard cryptographic hash function used in Ethereum and other EVM chains. * **`abi.encode(value1, value2, ...)`:** A Solidity function that encodes given arguments into a `bytes` string according to the ABI specification. This is often used before hashing data to form Merkle leaves. * **Double Hashing:** The practice of hashing data twice (e.g., `keccak256(keccak256(data))`). This is a common pattern in Merkle tree construction to enhance security against certain types of attacks like second pre-image attacks. * **Custom Errors:** Introduced in Solidity 0.8.4, custom errors (`error MyError();`) provide a more gas-efficient and descriptive way to handle transaction reverts compared to `require` statements with string messages. * **`SafeERC20`:** An OpenZeppelin library that provides safe wrappers around standard ERC20 token functions. Its `safeTransfer` function, for example, ensures that token transfers revert on failure, preventing potential issues with non-compliant or problematic token contracts. * **`using for`:** A Solidity directive that allows you to attach library functions to a specific data type. For instance, `using SafeERC20 for IERC20;` makes `SafeERC20` functions available as member functions on `IERC20` type variables. This lesson has covered the critical setup and the initial, albeit incomplete, claim logic. Future steps will involve adding checks to prevent double-claiming and other refinements.
A practical guide to Building Your Merkle Airdrop: The Smart Contract Foundation - Create a robust Solidity foundation for your Merkle Airdrop by defining its constructor with essential `merkleRoot` and `airdropToken` parameters. Then, implement a secure `claim` function, complete with leaf calculation, `MerkleProof` validation, event emission, and `SafeERC20` token handling.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025
Solidity Developer
Advanced FoundryDuration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025