DEV Community

Cover image for Building for Trust: A Guide to Gasless Transactions with ERC-2771
Obinna Duru
Obinna Duru

Posted on

Building for Trust: A Guide to Gasless Transactions with ERC-2771

How to use ERC-2771 and a Merkle Airdrop contract to build thoughtful, user-first dApps by separating the gas-payer from the transaction authorizer.

As smart contract engineers, we build systems that handle real value. This demands a philosophy built on reliability, thoughtfulness, and excellence. Every line of code we write should communicate trust.

But what happens when the very design of the network creates friction that breaks this trust?

The Human Problem: Gas Fees
Imagine a common scenario: Alice is excited to claim her airdrop, a reward for being an early community member. She goes to the claim page, connects her wallet, but when she clicks "Claim", the transaction fails. The reason? She has no ETH in her wallet to pay for gas.

This is a critical failure in user experience. We’ve presented a user with a "gift" they cannot open.

As engineers, our first instinct might be to build a "relayer service." We could have a server, let's call it "Bob" that pays the gas on Alice's behalf.

The Technical Problem: msg.sender
This "thoughtful" solution immediately hits a technical wall: authorization.

In a standard transaction, the msg.sender (the address that initiated the call and is paying the gas) is the ultimate source of truth for authorization.

If Bob (the relayer) calls the claim() function and pays the gas, the contract sees: msg.sender = Bob.address

The contract now believes Bob is the one claiming the airdrop, not Alice. This breaks the entire authorization model.

How do we build a reliable system that separates "who is paying for gas" from "who is authorizing the action"?

The Principled Solution: ERC-2771
The community solved this through a standard: ERC-2771.

ERC-2771 is a standard for "meta-transactions" that provides a trusted, on-chain path to solve this very problem. It introduces a special "middleman" contract called a Trusted Forwarder.

Instead of a custom, off-chain solution, we use a clear, on-chain protocol. The new flow looks like this:

  1. Alice (No ETH): Creates a signed message containing her desired transaction (e.g., "I, Alice, want to call claim()").
  2. Relayer (Bob): Receives this signed message from Alice.
  3. Relayer (Bob): Wraps Alice's message in a new transaction and sends it to the Trusted Forwarder, paying the gas.
  4. Trusted Forwarder: Verifies Alice's signature is valid.
  5. Trusted Forwarder: Calls our Airdrop contract, appending Alice's original address (Alice.address) to the end of the calldata.
  6. Airdrop Contract: Receives the call from the Trusted Forwarder. Because it's a trusted contract, it knows to look at the end of the calldata to find the true authorizer (Alice).

This is the core of reliable, gasless design. Our contract's logic is no longer concerned with msg.sender (the gas payer) but with the true authorizer of the request.

A Practical Example: A Secure Merkle Airdrop
To demonstrate this, I built a MerkleAirdrop contract. The design goals were reliability, gas efficiency, and security with native support for gasless claims.

Here is the full contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Context} from "@openzeppelin/contracts/utils/Context.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {IAirdrop} from "./interfaces/IAirdrop.sol";

/**
 * @title MerkleAirdrop
 * @author BinnaDev
 * @notice A modular, gas-efficient, and secure contract for airdrops
 * verified by Merkle proofs.
 * @dev This contract implements the `IAirdrop` interface, uses OpenZeppelin's
 * `BitMaps` for gas-efficient claim tracking, `ReentrancyGuard` for security,
 * and `ERC2771Context` to natively support gasless claims via a trusted
 * forwarder. The Merkle root is immutable, set at deployment for
 * maximum trust.
 */
contract MerkleAirdrop is IAirdrop, ERC2771Context, ReentrancyGuard {
    using BitMaps for BitMaps.BitMap;

    /**
     * @notice The MerMonitor's root of the Merkle tree containing all allocations.
     * @dev This is immutable, meaning it can only be set once at deployment.
     * This is a critical security feature to build trust with users,
     * as the rules of the airdrop can never change.
     */
    bytes32 public immutable MERKLE_ROOT;

    /**
     * @notice The maximum allowed depth for a Merkle proof.
     * @dev This is a security measure to prevent gas-griefing (DOS) attacks
     * where an attacker might submit an excessively long (but valid) proof.
     * 32 is a safe and generous default.
     */
    uint256 public constant MAX_PROOF_DEPTH = 32;

    /**
     * @notice The bitmap storage that tracks all claimed indices.
     * @dev We use the `BitMaps` library (composition) rather than inheriting
     * (inheritance) for better modularity and clarity.
     */
    BitMaps.BitMap internal claimedBitmap;

    /**
     * @notice Initializes the contract with the airdrop's Merkle root
     * and the trusted forwarder for gasless transactions.
     * @param merkleRoot The `bytes32` root of the Merkle tree.
     * @param trustedForwarder The address of the ERC-2771 trusted forwarder.
     * Pass `address(0)` if gasless support is not needed.
     */
    constructor(bytes32 merkleRoot, address trustedForwarder) ERC2771Context(trustedForwarder) {
        MERKLE_ROOT = merkleRoot;
    }

    /**
     * @notice Claims an airdrop allocation by providing a valid Merkle proof.
     * @dev This function follows the Checks-Effects-Interactions pattern.
     * It uses `_msgSender()` to support both direct calls and gasless claims.
     * @param index The unique claim index for this user (from the Merkle tree data).
     * @param claimant The address that is eligible for the claim.
     * @param tokenContract The address of the ERC20 or ERC721 token.
     * @param tokenId The ID of the token (for ERC721); must be 0 for ERC20.
     * @param amount The amount of tokens (for ERC20); typically 1 for ERC721.
     * @param proof The Merkle proof (`bytes32[]`) showing the leaf is in the tree.
     */
    function claim(
        uint256 index,
        address claimant,
        address tokenContract,
        uint256 tokenId,
        uint256 amount,
        bytes32[] calldata proof
    ) external nonReentrant {
        // --- CHECKS ---

        // 1. Check if this index is already claimed (Bitmap check).
        // This is the cheapest check and should come first.
        if (claimedBitmap.get(index)) {
            revert MerkleAirdrop_AlreadyClaimed(index);
        }

        // 2. Check for proof length (Gas-griefing DOS protection).
        if (proof.length > MAX_PROOF_DEPTH) {
            revert MerkleAirdrop_ProofTooLong(proof.length, MAX_PROOF_DEPTH);
        }

        // 3. Check that the sender is the rightful claimant.
        // We use `_msgSender()` to transparently support ERC-2771.
        address sender = _msgSender();
        if (claimant != sender) {
            revert MerkleAirdrop_NotClaimant(claimant, sender);
        }

        // 4. Reconstruct the leaf on-chain.
        // This is a critical security step. We NEVER trust the client
        // to provide the leaf hash directly.
        bytes32 leaf = _hashLeaf(index, claimant, tokenContract, tokenId, amount);

        // 5. Verify the proof (Most expensive check).
        if (!MerkleProof.verify(proof, MERKLE_ROOT, leaf)) {
            revert MerkleAirdrop_InvalidProof();
        }

        // --- EFFECTS ---

        // 6. Mark the index as claimed *before* the interaction.
        // This satisfies the Checks-Effects-Interactions pattern and
        // mitigates reentrancy risk.
        claimedBitmap.set(index);

        // --- INTERACTIONS ---

        // 7. Dispatch the token.
        _dispatchToken(tokenContract, claimant, tokenId, amount);

        // 8. Emit the standardized event.
        emit Claimed("Merkle", index, claimant, tokenContract, tokenId, amount);
    }

    /**
     * @notice Public view function to check if an index has been claimed.
     * @param index The index to check.
     * @return bool True if the index is claimed, false otherwise.
     */
    function isClaimed(uint256 index) public view returns (bool) {
        return claimedBitmap.get(index);
    }

    /**
     * @notice Internal function to hash the leaf data.
     * @dev Must match the exact hashing scheme used in the off-chain
     * generator script. We use a double-hash (H(H(data))) pattern
     * with `abi.encode` for maximum security and standardization.
     * `abi.encode` is safer than `abi.encodePacked` as it pads elements.
     */
    function _hashLeaf(uint256 index, address claimant, address tokenContract, uint256 tokenId, uint256 amount)
        internal
        pure
        returns (bytes32)
    {
        // First hash: abi.encode() is safer than abi.encodePacked()
        // as it pads all elements, preventing ambiguity.
        bytes32 innerHash = keccak256(abi.encode(index, claimant, tokenContract, tokenId, amount));

        // Second hash: This is a standard pattern to ensure all leaves
        // are a uniform hash-of-a-hash.
        return keccak256(abi.encode(innerHash));
    }

    /**
     * @notice Internal function to dispatch the tokens (ERC20 or ERC721).
     * @dev Assumes this contract holds the full supply of airdrop tokens.
     */
    function _dispatchToken(address tokenContract, address to, uint256 tokenId, uint256 amount) internal {
        if (tokenId == 0) {
            // This is an ERC20 transfer.
            if (amount == 0) revert Airdrop_InvalidAllocation();
            bool success = IERC20(tokenContract).transfer(to, amount);
            if (!success) revert Airdrop_TransferFailed();
        } else {
            // This is an ERC721 transfer.
            // The `amount` parameter is ignored (implicitly 1).
            // `safeTransferFrom` is used for security, and our `nonReentrant`
            // guard on `claim()` protects against reentrancy attacks.
            IERC721(tokenContract).safeTransferFrom(address(this), to, tokenId);
        }
    }

    /**
     * @dev Overrides the `_msgSender()` from `ERC2771Context` to enable
     * meta-transactions. This is the heart of our gasless support.
     *
     * Why do this? Because we're also inheriting from other contracts (ReentrancyGuard, IAirdrop) and Solidity requires you to explicitly choose which parent's `_msgSender()` to use when there's potential ambiguity.
     */
    function _msgSender() internal view override(ERC2771Context) returns (address) {
        return ERC2771Context._msgSender();
    }
}
Enter fullscreen mode Exit fullscreen mode

Dissecting the Thoughtful Design
This contract is more than just a piece of code; it's a system designed for trust. Let's look at the key decisions.

  1. Enabling Gasless Claims Enabling ERC-2771 is a deliberate choice made at deployment.

A. Inheritance: We inherit from OpenZeppelin's ERC2771Context.

contract MerkleAirdrop is IAirdrop, ERC2771Context, ReentrancyGuard {...}
Enter fullscreen mode Exit fullscreen mode

B. The Constructor: We tell the contract the address of the one-and-only trustedForwarder it will ever listen to.

constructor(
    bytes32 merkleRoot,
    address trustedForwarder
) ERC2771Context(trustedForwarder) {
    MERKLE_ROOT = merkleRoot;
}
Enter fullscreen mode Exit fullscreen mode
  1. The Core: _msgSender() vs. msg.sender This is where the magic happens. The ERC2771Context contract provides a function: _msgSender().

Here is a simplified view of what it does:

  • It checks, "Is the msg.sender (the gas payer) my trustedForwarder?"
  • If YES: It knows this is a meta-transaction. It reads the real sender's address from the end of the calldata and returns it.
  • If NO: It knows this is a normal transaction. It simply returns the msg.sender as usual.

This provides a single, reliable function that transparently handles both gasless calls and regular calls.

Look at our claim function's authorization check. It doesn't use msg.sender. It uses _msgSender():

// 3. (Authorization) Check that the sender is the rightful claimant.
// This is the core of our ERC-2771 integration.
address sender = _msgSender();
if (claimant != sender) {
    revert MerkleAirdrop_NotClaimant(claimant, sender);
}
Enter fullscreen mode Exit fullscreen mode

With this in place:

  • If Alice calls claim() directly and pays gas:
    • _msgSender() returns Alice.address.
    • The check passes.
  • If Bob the Relayer calls via the Trusted Forwarder:
    • _msgSender() returns Alice.address.
    • The check passes. We have successfully separated the gas payer from the authorizer.
  1. A Note on the override You'll notice this specific function at the end of the contract:
function _msgSender() internal view override(Context, ERC2771Context) returns (address) {
    return ERC2771Context._msgSender();
}
Enter fullscreen mode Exit fullscreen mode

This may look redundant, but it's essential for clarity and correctness in Solidity.

Our contract inherits from both ERC2771Context and ReentrancyGuard. ReentrancyGuard also has a dependency on _msgSender() (via the Context contract). Solidity sees this "ambiguity" (which _msgSender should I use?) and requires us to be explicit.

Here, we are being deliberate: "When I call _msgSender(), I am explicitly stating that I want to use the version provided by ERC2771Context." It's a hallmark of excellent, precise engineering.

  1. Beyond Gasless: Other Pillars of Trust A reliable contract is secure in all aspects. Thoughtful design extends beyond one feature.
  2. Checks-Effects-Interactions: We mark the claim as used (claimedBitmap.set(index)) before we make the external call (_dispatchToken). This is a fundamental pattern to prevent reentrancy attacks.
  3. Gas-Griefing Protection: We enforce a MAX_PROOF_DEPTH. This prevents an attacker from sending a valid but excessively long proof designed to waste gas and block others.
  4. On-Chain Hashing: We never trust the client to provide a leaf hash. We reconstruct it on-chain (_hashLeaf(...)) to ensure the proof is for the exact data we expect.
  5. Safe Hashing: We use abi.encode instead of abi.encodePacked in our hashing function. encodePacked can be ambiguous and lead to collisions; abi.encode is safer. This is a small, thoughtful choice that enhances reliability.

Conclusion: Engineering with Purpose
ERC-2771 is more than a technical standard; it's a design philosophy. It empowers us to build thoughtful, human-centered applications that remove the greatest point of friction in Web3: the gas fee.

By combining this standard with other principles of secure and reliable design, we can build systems that users can truly trust.

Let's build something you can trust with clarity, purpose, and excellence.

Thanks for reading! If you found this article thoughtful and reliable, I'd appreciate a like or a comment.

You can find the full GitHub repo for this project here: https://github.com/obinnafranklinduru/tailored-airdrop/blob/main/src/MerkleAirdrop.sol

To see more of my work on secure and efficient on-chain systems, feel free to visit my portfolio at https://binnadev.vercel.app

Top comments (0)