Raviole Labs

How to build a secure airdrop claim portal

Anatomy of a token claim portal: eligibility and Merkle proofs, the security holes that bite on launch day, and surviving the traffic spike.

airdropmerklesecurityfrontendblockchain

You announced the airdrop. The snapshot is taken, the allocations are set, and now your entire recipient list is going to hit your infrastructure inside the same hour to grab their tokens. The airdrop claim portal is the one piece of your launch that every recipient touches, and it is the piece most teams underbuild. A broken claim page turns a good distribution into a support fire and a Twitter thread about your contract draining gas. This is a walkthrough of what actually goes into a claim portal that holds up: the moving parts, the security holes, and where the line between build and buy sits.

Anatomy of a claim: eligibility, Merkle proofs, wallet connect

A claim has three jobs: prove the connected wallet is eligible, prove for how much, and move the tokens. You do not want to store every address and amount on-chain. That is expensive and it leaks your full allocation list at deploy time. The standard pattern is a Merkle tree.

Be precise about what the tree does: it proves ELIGIBILITY, meaning this address is entitled to this exact amount. It proves nothing about whether that address already took its tokens. Uniqueness is a separate mechanism, a claimed mapping or a packed bitmap, and it is covered in the security section below. Conflating the two is the most common way teams reason themselves into a double-claim bug.

Off-chain, you build a tree over the recipient list. Two details decide whether your proofs verify on-chain, and both bite silently:

  • The leaf is double-hashed: keccak256(bytes.concat(keccak256(abi.encode(account, amount)))). The outer hash is what makes an internal node impossible to pass off as a leaf. A single keccak256(abi.encodePacked(...)) leaf is the classic version that “works” in your JS tests and then fails against OpenZeppelin’s verifier.
  • Pairs are sorted before hashing at every level. MerkleProof.verify sorts each pair, so your generator has to as well: sortPairs: true if you build the tree with merkletreejs. Unsorted pairs produce a root the contract will never reconstruct.

You publish only the Merkle root in the claim contract. The front end holds the full list plus, for each recipient, the Merkle proof: the sibling hashes needed to walk from their leaf back up to the root.

The flow the user sees:

  1. Connect wallet.
  2. Front end looks up the address in the allocation file, returns amount and proof.
  3. User signs the claim(amount, proof) transaction.
  4. Contract recomputes the leaf, verifies the proof against the stored root, marks the address claimed, transfers tokens.
// OpenZeppelin: import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
function claim(uint256 amount, bytes32[] calldata proof) external {
    // uniqueness: this mapping, not the tree, is what blocks a second claim
    require(!claimed[msg.sender], "already claimed");

    // eligibility: double-hashed leaf, matches MerkleProof.verify (which sorts pairs)
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "bad proof");

    claimed[msg.sender] = true;
    token.safeTransfer(msg.sender, amount);
}

Two independent guarantees sit in those eight lines. The proof plus the msg.sender binding answer “is this wallet owed this amount”. The claimed mapping answers “has it already been paid”. Drop either one and the contract is broken in a different way: no proof and anyone claims anything, no mapping and the same valid proof pays out on every call.

Security: replay, double-claim, front-running, gas griefing

Here is what we do NOT skip, because each of these has burned a real launch.

Double-claim. This is the mapping’s job, not the tree’s. A valid Merkle proof stays valid forever, so the only thing stopping a second call with the same proof is state you write yourself: claimed[msg.sender], or a mapping(uint256 => uint256) bitmap indexed by claim id if you want to pack 256 flags per storage slot. Set the flag before the transfer, never after, or a reentrant token callback claims twice. Standard checks-effects-interactions.

Replay across chains. If you deploy the same claim contract on multiple chains with the same root, a valid proof on chain A is valid on chain B. Bind the leaf or the contract to a chainId, or use separate roots per deployment.

Proof theft / front-running. Because the leaf is keyed to msg.sender, a bot cannot lift your proof from the mempool and claim to its own address. The proof only validates for the address in the leaf. This is why you bind to the recipient and never let the caller pass an arbitrary to address for a standard airdrop claim portal. The moment you add claim(address to, ...), you have handed bots a way to redirect.

Gas griefing and DoS. Watch unbounded loops (batch claims that iterate over caller-supplied arrays), and price your proof verification. A deep tree means longer proofs and higher gas. Balance tree depth against claim cost. Also rate-limit the eligibility API: bots will scrape your allocation endpoint to map the full recipient list before you want it public.

Signature-based variants. Some teams skip Merkle and sign each claim with a backend key (EIP-712). That works, but now your signer key is a single point of failure and your backend has to be live for anyone to claim. Merkle keeps claims trustless once the root is set.

Surviving launch-day traffic spikes

The contract is not your bottleneck. Your RPC and your front end are. On launch minute you get a thundering herd: everyone connects at once, every wallet fires eth_call reads to check eligibility and balances.

  • Serve the allocation file and proofs from a CDN or static host, not a live database query per request. Pre-generate every proof at build time and ship them as static JSON, sharded by address prefix so no single file is huge.
  • Put your own RPC behind the reads. Do not let the whole recipient list hammer a public endpoint that will rate-limit you into failed claims at exactly the wrong minute.
  • Make the claim idempotent from the UI side: if a user refreshes mid-transaction, the page should read on-chain claimed state and show “already claimed” rather than prompting a second signature.

Three claim-specific traps in the front end

Skip the generic checklist. These are the three that actually generate tickets on claim day.

The wallet switches network mid-flow. The user connects on mainnet, you fetch their allocation, they approve a network switch in the wallet, and now your React state still holds the mainnet proof while the wallet is signing against another chain. The claim reverts with “bad proof” and the user reads that as “you stole my airdrop”. Subscribe to the provider’s chainChanged event, and on every fire, drop the cached allocation and proof, re-read the on-chain claimed flag, and re-render from scratch. Never keep proof state across a chain change. Same rule for accountsChanged: a wallet with five accounts will switch under you.

Zero allocation is a state, not an error. A non-eligible address is the single most common outcome on a large distribution, and the naive implementation is a 404 on the proof lookup that bubbles up as a red toast or a blank screen. Make the eligibility endpoint return a normal 200 with { eligible: false } and render a plain sentence: this address is not in the snapshot, here are the rules, here is the snapshot block. Same handling for an address that is in the list with an amount of zero. If your users cannot tell “not eligible” apart from “the site is broken”, they will assume broken and open a ticket.

Proof payloads are heavy on mobile. A proof is one 32-byte hash per level of the tree, so depth drives both calldata gas and download size. The failure mode is shipping the whole allocation JSON to the browser so the client can look itself up: on a phone on mobile data, that is a multi-second stall before anything renders, and often an out-of-memory tab. Shard the pre-generated proofs by address prefix so a browser fetches one small file containing its own proof and nothing else, and never parse the full list client-side. Measure the shard size on a throttled connection before launch, not after.