Quickstart: ECDSA and Merkle campaigns.
Install the release candidate, then deploy, fund, and claim, both claim variants shown side by side.
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.
pnpm add @tokenops/sdk@next viem @zama-fhe/sdk@~3.6.0The 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.
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();
}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.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.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.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.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
Read the operator flows
Deploy, fund, authorize, claim, and recover, with the hooks that drive each step.
Browse the hooks
Every client method as a React hook, filterable by lifecycle, return shape, and encryptor.
Read the migration guide
Symbol-by-symbol delta, renamed clients, split params, renamed ABIs.