2.0 RC docsView 1.x docs
Airdrop v2 · Flows

The five airdrop v2 flows.

Deploy, fund, authorize, claim, and recover, across both the ECDSA and Merkle claim variants.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.
Prerequisite: operator approval

Before creating, funding, or dispersing, the token must approve this contract as an operator: call token.setOperator(<contract address>, deadline) first. Without it the transaction reverts with ERC7984UnauthorizedSpender (0x79f2cb38).

The SDK surfaces the missing approval as: “The token has not approved this contract as an operator. Call setOperator() (see /fhe operators) before this transaction.”

Do not route these calls through Multicall3. aggregate3 makes the Multicall3 contract the msg.sender, which breaks operator approvals and per-user clone authorization. Use the built-in batch functions instead: batchCreateVesting, disperse, batchDiscloseToParty.

Operator helpers live under @tokenops/sdk/fhe
setOperator, revokeOperator, isOperator, and ensureOperator are cross-product - shared by /fhe-vesting, /fhe-airdrop, and /fhe-disperse. Their until deadline is a uint48 unix timestamp; ERC7984_OPERATOR_MAX_DEADLINEis the type's max value, useful for dev loops but scope a real deadline in production. revokeOperator calls the same setOperator entrypoint with until: 0 - the ERC-7984 revoke convention, not a separate on-chain method.
  1. 1

    Deploy a campaign

    Operator calls the factory to create an ECDSA or a Merkle campaign. Params are nested, common fields plus a variant-specific object, and the factory injects admin itself.

    1. factory.createEcdsaAirdrop({ params: { common, signer, dedupMode }, mode, userSalt }) or factory.createMerkleAirdrop({ params: { common, merkleRoot, isMerkleRootMutable }, mode, userSalt })
    2. common has no admin field, the factory injects admin = msg.sender at create time
    3. common.maxAcceptedGasFee is required - read useResolveGasFee({ creator }) to show the fee that would be frozen in, then pass the highest the operator accepts; above it the create reverts GasFeeNotAccepted
    4. Resolves to { hash, airdrop, complianceManager, managerImplementation, complianceDelegate }, a compliance manager clone is wired atomically, no separate deploy step
    5. Every create commits to the instance address, compliance-manager implementation and compliance delegate; drift between quote and send throws CreateCommitmentMismatchError and leaves the salt unused. An immutable-root Merkle campaign is created from planMerkleCampaign by passing plan
    6. The create refuses before sending whatever usePreflightCreateAirdrop would block: a fee above maxGasFee, a zero token or signer, an endTime less than 60 seconds past the latest block's timestamp, an invalid window
    7. Signing with a Safe? Pass waitForReceipt: false (beta) - the create returns a PendingCreateAirdropResult (pending: true) with every address set, instead of waiting for a receipt that only exists once owners co-sign. The React create hooks therefore resolve Mined | Pending: narrow with if (data.pending) return; before reading a receipt-derived field
  2. 2

    Fund the pool

    Operator transfers encrypted tokens into the instance, either in a second call or atomically with create. The pooled balance backs every claim the campaign pays out.

    1. Operator's wallet calls setOperator({ publicClient, walletClient, token, spender: factory.address, deadline }) on the source token first (once per token for the factory spender, until the deadline; from @tokenops/sdk/fhe) - deadline defaults to ERC7984_OPERATOR_MAX_DEADLINE ("forever") if omitted
    2. useFundAirdrop({ encryptor }).mutate({ airdrop, amount }) funds an existing instance; useCreateAndFundEcdsaAirdrop / useCreateAndFundMerkleAirdrop do create + fund in one call
    3. Funding binds the ciphertext to (factory address, funder address), not to the instance
    4. Retiring a factory or rotating in a new one? Call revokeOperator({ publicClient, walletClient, token, spender: staleFactory }) so the old address can no longer pull the caller's confidential balance
  3. 3

    Authorize claims

    ECDSA and Merkle authorize differently. A SIGNER_ROLE holder signs an EIP-712 message per recipient; a Merkle campaign instead publishes one root covering every recipient's cumulative total.

    1. ECDSA: useSignClaimAuthorization signs { recipient, encryptedAmountHandle, dedupId, deadline } off-chain, no on-chain registration
    2. Merkle: useBuildMerkleCampaign encrypts the roster and builds the tree; useSetMerkleRoot (or useRotateMerkleRoot to do both at once) publishes the root
    3. useIsSignatureValid previews an ECDSA signature's validity before handing it to a recipient
  4. 4

    Recipient claims

    ECDSA claims submit the signature; Merkle claims submit a campaign entry. Merkle's entry carries an explicit account, the claim identity, and its input is bound to the instance, so anyone - the recipient or a relayer - can submit it.

    1. useEcdsaClaim({ address }).mutate({ encryptedInput, dedupId, deadline, signer, signature }) or useMerkleClaim({ address }).mutate({ entry })
    2. entry.account picks the leaf and the payout recipient, whoever submits (only entry.account itself may redirect with to); check useClaimedAmount (getClaimedAmount) first - exact on a first root; after a root rotation the recipient decrypts and compares it with their new total - since a leaf someone else already settled pays an encrypted zero and still costs the fee
    3. useEcdsaClaimAndUnwrap / useMerkleClaimAndUnwrap route straight into a plain ERC-20 payout, both are sender-bound, neither takes a claim identity; read the started request with useUnwrapRequest and pass its unwrapRequestId to the wrapper's finalizeUnwrap
    4. usePreflightClaim reports every blocker at once, each carrying the entrypoint it would stop (claim or claimAndUnwrap) as method; a wallet whose ETH is below the gas fee is InsufficientBalanceError (balanceKind: "eth")
    5. Vet the instance before claiming: useIsAirdrop asks the canonical factory's registry whether it deployed this address. A claim against a look-alike succeeds and delivers nothing rather than reverting, so this is the one check no revert will make for you
  5. 5

    Admin recovery + roles

    The factory grants the creator every instance role at create time, except SIGNER_ROLE (params.signer) and FEE_COLLECTOR_ROLE (the factory's fee collector). Pause, extend the window, sweep the pool, or split that bundle across separate holders once the campaign is live.

    1. useAirdropPause (PAUSER_ROLE) halts claims; useExtendClaimWindow (WINDOW_ADMIN_ROLE) only works if canExtendClaimWindow was set at create, and refuses a newEndTime less than 60 seconds after the latest block's timestamp
    2. useWithdrawConfidential (TREASURY_ROLE) sweeps the instance's whole confidential pool back to the operator - pause first while the claim window is open, or it throws ClawbackRequiresPauseError
    3. useGrantInstanceRoles / useAirdropGrantRole split the factory-injected admin bundle across separate keys, non-atomic, reports per-role outcomes named by holder; from a Safe, encodeInstanceRoleSplit turns the same plan into one MultiSend batch. Omit assignment.admin to keep your own admin - naming yourself is refused