Skip to main content

Usage

Encryptor setup

Pass a Zama RelayerWeb (browser) or RelayerNode (server) instance. Both satisfy the Encryptor interface structurally.

Browser:

import { RelayerWeb, SepoliaConfig } from "@zama-fhe/sdk";
import { sepolia } from "viem/chains";

const encryptor = new RelayerWeb({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: "https://rpc.sepolia.org" } },
getChainId: async () => sepolia.id,
});

Lazy encryptor (React context that may not be ready at construction):

const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: airdropAddress,
encryptor: () => encryptorContext.current, // resolved per-call
});

For details on the Zama SDK v3 encryptor setup and userDecrypt, see docs.zama.ai/fhevm.

Fee model

Each clone has an immutable GAS_FEE baked in at deploy time. The factory uses either defaultGasFee or a per-creator custom fee at the moment createConfidentialAirdrop is called:

const gasFee = await factory.defaultGasFee();
// Or check for a custom fee:
const { enabled, gasFee: customFee } = await factory.getCustomFee(campaignCreatorAddress);
const effectiveFee = enabled ? customFee : gasFee;

claim() automatically reads GAS_FEE() from the clone and attaches it as msg.value. You do not pass it manually.

Frontend usage

A typical React component: admin issues the claim payload off-chain; user submits it.

import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
import { useAccount, useWalletClient } from "wagmi";
import { RelayerWeb, SepoliaConfig } from "@zama-fhe/sdk";
import { createConfidentialAirdropClient } from "@tokenops/sdk/fhe-airdrop";

const publicClient = createPublicClient({ chain: sepolia, transport: http() });
const encryptor = new RelayerWeb({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: "https://rpc.sepolia.org" } },
getChainId: async () => sepolia.id,
});

export function ClaimButton({
airdropAddress,
claimPayload,
}: {
airdropAddress: `0x${string}`;
// Fetched from your API: the admin-issued payload, encryption bound to the
// recipient at build time. The recipient submits it verbatim — no re-encryption.
claimPayload: {
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
signature: `0x${string}`;
};
}) {
const { address: caller } = useAccount();
const { data: walletClient } = useWalletClient();

async function handleClaim() {
if (!walletClient || !caller) return;

const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: airdropAddress,
});

// Check signature validity before asking the user to pay gas. The on-chain
// check binds to `msg.sender`, so the caller is part of the result.
const valid = await airdrop.isSignatureValid({
encryptedAmountHandle: claimPayload.encryptedInput.handle,
signature: claimPayload.signature,
caller,
});
if (!valid) throw new Error("Claim is no longer valid");

// GAS_FEE() is fetched and attached as msg.value automatically.
// Submit the admin's `encryptedInput` verbatim — never re-encrypt, as that
// would produce a different handle and revert the EIP-712 signature.
// `ClaimArgs` has no `amount` field; `claimPayload.amount` is plaintext
// context for the UI only.
await airdrop.claim({
signature: claimPayload.signature,
encryptedInput: claimPayload.encryptedInput,
});
}

return <button onClick={handleClaim}>Claim tokens</button>;
}

Backend usage

Server-side (Express / Next.js server actions): the admin encrypts allocations and signs claim authorizations, then stores or delivers them to recipients.

import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk/node";
import {
createConfidentialAirdropFactoryClient,
createConfidentialAirdropClient,
encryptUint64,
signClaimAuthorization,
} from "@tokenops/sdk/fhe-airdrop";

const account = privateKeyToAccount(process.env.OPERATOR_PRIVATE_KEY as `0x${string}`);
const rpcUrl = process.env.RPC_URL!;
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });

const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});

// On Sepolia the factory address auto-resolves; pass `address` explicitly only
// on mainnet (slot `null`) or custom chains.
const factory = createConfidentialAirdropFactoryClient({
publicClient,
walletClient,
encryptor,
});

// Build clients once at startup; reuse across requests.
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: process.env.AIRDROP_ADDRESS as `0x${string}`,
});

// ── Operator: issue a signed claim payload for a recipient ──
async function issueClaimPayload(recipient: `0x${string}`, amount: bigint) {
// Bind the input proof to the recipient — see the top of this page.
const encrypted = await encryptUint64({
encryptor,
contractAddress: airdrop.address,
userAddress: recipient,
value: amount,
});
const signature = await signClaimAuthorization({
walletClient,
airdropAddress: airdrop.address,
recipient,
encryptedAmountHandle: encrypted.handle,
});
// Store or deliver { encryptedInput: encrypted, signature } to the recipient.
return { encryptedInput: encrypted, signature };
}

// ── Operator: pause claims (e.g. on security incident) ──
await airdrop.setPaused(true);

// ── Operator: extend the claim window ──
// Only works if canExtendClaimWindow was set to true at deploy time.
const newEndTime = Math.floor(Date.now() / 1000) + 60 * 86400;
await airdrop.extendClaimWindow(newEndTime);

// ── Operator: withdraw remaining tokens after the campaign ends ──
await airdrop.withdraw(account.address);

// ── Fee collector: drain accumulated ETH gas fees ──
// Pass amount = 0n to withdraw the entire balance.
await airdrop.withdrawGasFee(account.address, 0n);