2.0 RC docsView 1.x docs
Airdrop v2 · Quickstart

Quickstart: ECDSA and Merkle campaigns.

Install the release candidate, then deploy, fund, and claim, both claim variants shown side by side.

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.

Install

The 2.0 line ships on the next dist-tag; latest stays on 1.x until 2.0.0. The optional Zama peer is pinned to ~3.6.0, because upstream removes APIs in minor releases. Add @zama-fhe/react-sdk@~3.6.0 for the React hooks.

terminal
bash
pnpm add @tokenops/sdk@next viem @zama-fhe/sdk@~3.6.0

The samples below run in Node, so they build the encryptor with createSepoliaEncryptor from @tokenops/sdk/fhe. A browser app uses createSepoliaEncryptorWeb from @tokenops/sdk/fhe/web, or passes its ZamaSDK as encryptor: () => sdk. See Encryptors and the browser encryptor.

Pick a variant

Both extend the same AirdropBaseClient. ECDSA authorizes each claim with an off-chain EIP-712 signature from a SIGNER_ROLE holder; Merkle authorizes by inclusion in a published root. Every create call returns { hash, airdrop, complianceManager, managerImplementation, complianceDelegate }, and refuses before sending what preflightCreate would block, such as an endTime less than 60 seconds past the latest block.

Both samples open the claim window a minute in the past (the contract allows it), so the claim at the end cannot revert ClaimNotStarted. Writes resolve with the transaction hash before it is mined, so the samples wait for the receipt of each write the next one depends on. complianceAdmin: zeroAddressmakes you the compliance clone's admin and seeds you as its first client delegate, with decrypt rights over the campaign.

@tokenops/sdk/fhe-airdrop
ts
import { createPublicClient, createWalletClient, http, zeroAddress, type Address } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { createSepoliaEncryptor } from "@tokenops/sdk/fhe";
import {
  createConfidentialAirdropFactoryClient,
  createEcdsaAirdropClient,
  encryptUint64,
  setOperator,
  signClaimAuthorization,
} from "@tokenops/sdk/fhe-airdrop";

const account = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: sepolia, transport: http(process.env.RPC_URL) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(process.env.RPC_URL) });
const token = "0x..." as Address; // your ERC-7984 token

// Node encryptor over the Zama Sepolia relayer. In a browser, use
// createSepoliaEncryptorWeb from "@tokenops/sdk/fhe/web", or pass a ZamaSDK.
const encryptor = await createSepoliaEncryptor({ rpcUrl: process.env.RPC_URL });

try {
  // The factory address resolves from DEPLOYED_ADDRESSES on mainnet and Sepolia.
  const factory = createConfidentialAirdropFactoryClient({ publicClient, walletClient, encryptor });
  const now = Math.floor(Date.now() / 1000);

  // The per-claim fee is the factory's to set and is frozen into the instance,
  // so you declare the most you accept. Reading it first bounds you at today's
  // fee; UINT96_MAX accepts whatever resolves. See the gas-fees concept page.
  const resolvedGasFee = await factory.resolveGasFee(account.address);

  // common has no admin field, the factory makes the sender admin
  const { airdrop } = await factory.createEcdsaAirdrop({
    params: {
      common: {
        token,
        startTime: now - 60, // already open, so the claim below cannot hit ClaimNotStarted
        endTime: now + 30 * 86400, // at least 60s past the latest block
        canExtendClaimWindow: false,
        unwrappable: false,
        complianceAdmin: zeroAddress, // zero = you; also seeded as a client delegate with decrypt rights
        maxAcceptedGasFee: resolvedGasFee,
      },
      signer: account.address,
      dedupMode: "perAddress", // frozen at create; read it back with readDedupMode()
    },
    mode: "clone",
    userSalt: "0x0000000000000000000000000000000000000000000000000000000000000001",
  });

  // Funding routes through the factory, which needs operator approval on the token.
  await setOperator({ publicClient, walletClient, token, spender: factory.address });
  // Writes return the hash without waiting, and the next write's gas estimate
  // runs against the latest state, so wait for each one the next depends on.
  const fundHash = await factory.fundAirdrop({ airdrop, amount: 1_000_000n });
  await publicClient.waitForTransactionReceipt({ hash: fundHash });

  // Bind the ciphertext to the recipient: a non-empty proof is (instance, claimant).
  const recipient = account.address;
  const encryptedInput = await encryptUint64({
    encryptor,
    contractAddress: airdrop,
    userAddress: recipient,
    value: 1_000_000n,
  });
  const dedupId = "0x0000000000000000000000000000000000000000000000000000000000000001" as const;
  const deadline = BigInt(now + 3600);
  const signature = await signClaimAuthorization({
    walletClient,
    airdrop,
    chainId: sepolia.id,
    recipient,
    encryptedAmountHandle: encryptedInput.handle,
    dedupId,
    deadline,
  });

  const claimant = createEcdsaAirdropClient({ publicClient, walletClient, address: airdrop });
  await claimant.claim({ encryptedInput, dedupId, deadline, signer: account.address, signature });
} finally {
  encryptor.terminate();
}
Merkle claims can be relayed
A Merkle entry.account is the claim identity, whoever it belongs to. It can differ from the address that submits the transaction, which is how one relayer submits on behalf of many recipients. claimAndUnwrap takes no claim identity and always stays sender-bound.
dedupMode is set once, at create
params.dedupMode on createEcdsaAirdropfreezes the instance's replay policy. The instance clients take no dedupMode option; read the policy from the chain with claimant.readDedupMode() or useDedupMode.
Running against mainnet
The same factory address serves chain 1, so the code above only changes its chain and RPC. The Zama-hosted mainnet relayer needs an API key: pass auth: { __type: "ApiKeyHeader", value: process.env.RELAYER_API_KEY! } with chainId: 1 to createSepoliaEncryptor, from server code only. A browser points relayerUrl at a backend proxy instead. See Relayer API key and Deployments.
Gas and Safe signers
Every write estimates its gas and sends it with 25% headroom (DEFAULT_GAS_HEADROOM_PERCENT); set gasHeadroomPercent on a client, or a per-call gas, to change it. A Safe signer passes waitForReceipt: false to a create and gets the committed addresses back with pending: true. See Gas headroom and Receipt-free writes.
The EIP-712 domain behind signClaimAuthorization
signClaimAuthorization signs a Claim(address recipient, bytes32 encryptedAmount, bytes32 dedupId, uint256 deadline) struct under a fixed domain: EIP712_DOMAIN_NAME ("ConfidentialAirdrop") and EIP712_DOMAIN_VERSION ("1"), both exported from @tokenops/sdk/fhe-airdrop for a client that needs to reconstruct or verify the digest itself rather than trust isSignatureValid. Read the live values back with claimant.eip712Domain() (the full ERC-5267 7-tuple) or claimant.domainSeparator() - both should match what these constants predict, on every supported chain.

Where to go next