Skip to main content

React Hooks

React hooks

Hooks live at @tokenops/sdk/fhe-airdrop/react. The optional peer dependencies react, wagmi, and @tanstack/react-query must be installed at consumer-install time (they are not bundled). The encryptor source mirrors the headless EncryptorSource pattern — pass an eager Encryptor instance or a lazy () => Encryptor | undefined factory; both forms are accepted everywhere. @zama-fhe/react-sdk is an optional peer for FHE flows; the SDK never imports it at top level — consumers without FHE encryption flows pay no bundle cost.

Hook catalogue

FamilyHooks
Factory reads (6)useConfidentialAirdropFactoryImplementation, useFactoryDefaultGasFee, useFactoryFeeCollector, useFactoryCustomFee, usePredictAirdropAddress, useFactoryInitCodeHash
Factory writes (8)useCreateConfidentialAirdrop, useCreateConfidentialAirdropAndGetAddress, useCreateAndFundConfidentialAirdrop, useFundConfidentialAirdrop, useSetFeeCollector, useSetDefaultGasFee, useSetCustomFee, useDisableCustomFee
Airdrop clone reads (15)useAirdropToken, useAirdropGasFee, useAirdropStartTime, useAirdropCanExtendClaimWindow, useAirdropEndTime, useAirdropIsPaused, useAirdropDeploymentBlockNumber, useAirdropDomainSeparator, useAirdropIsClaimWindowActive, useAirdropHasClaimStarted, useAirdropHasClaimEnded, useAirdropClaimedSignatures, useAirdropIsSignatureClaimed, useAirdropIsSignatureValid, useAirdropHasRole
Airdrop clone writes (10)useClaim, useGetClaimAmount, useWithdraw, useSetPaused, useExtendClaimWindow, useWithdrawOtherToken, useWithdrawOtherConfidentialToken, useAirdropWithdrawGasFee, useAirdropGrantRole, useAirdropRevokeRole
Signing helper (1)useSignClaimAuthorization

All 40 hooks are exported from @tokenops/sdk/fhe-airdrop/react. The utility types EncryptorSource, Encryptor, AirdropParams, ClaimArgs, GetClaimAmountArgs, EncryptedViewResult, CreateAirdropArgs, CreateAirdropResult, and signClaimAuthorization are re-exported from the same path so component files need only one import.

useCreateConfidentialAirdropAndGetAddress quickstart

The canonical flow: deploy an airdrop clone and learn its address in one call.

import { useQueryClient } from "@tanstack/react-query";
import { useCreateConfidentialAirdropAndGetAddress } from "@tokenops/sdk/fhe-airdrop/react";

// Wagmi providers (<WagmiProvider> + <QueryClientProvider>) must wrap this component.
// No extra SDK provider is required for non-FHE factory hooks.

function DeployAirdropButton({
token,
userSalt,
onDeployed,
}: {
token: `0x${string}`;
userSalt: `0x${string}`;
onDeployed: (airdrop: `0x${string}`) => void;
}) {
const queryClient = useQueryClient();

// Pass no options — the hook resolves the Sepolia factory address from
// DEPLOYED_ADDRESSES using the connected chain. Pass `address` to override.
const create = useCreateConfidentialAirdropAndGetAddress();

const now = Math.floor(Date.now() / 1000);

function handleDeploy() {
create.mutate(
{
params: {
token,
startTimestamp: now + 60,
endTimestamp: now + 30 * 86400,
canExtendClaimWindow: false,
admin: "0xYourAdminAddress" as `0x${string}`,
},
userSalt,
},
{
onSuccess: ({ airdrop }) => {
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-airdrop"] });
onDeployed(airdrop);
},
},
);
}

return (
<button onClick={handleDeploy} disabled={create.isPending}>
{create.isPending ? "Deploying…" : "Deploy airdrop"}
</button>
);
}

create.data is typed as { hash: Hex; airdrop: Address } after success.

useCreateAndFundConfidentialAirdrop / useFundConfidentialAirdrop — operator prerequisite

Both fund-side mutations move ERC-7984 tokens through the factory. Before either mutation succeeds, the caller MUST have set the factory as an operator on the token:

// One-time setup, per (caller, token, factory) triple — done from your own token-side helper.
await token.setOperator(factoryAddress, deadline);

Without this approval the contract reverts at fund time with no actionable message. The same lazy encryptor wiring shown in the useClaim quickstart below applies — both hooks accept the encryptor on FactoryHookOptions.

Pre-claim validation

Unlike fhe-disperse's usePreflightDisperse, fhe-airdrop has no single usePreflight* hook. Compose three read hooks instead to gate a claim button so the user never pays gas on a revertable claim:

import {
useAirdropIsClaimWindowActive,
useAirdropIsSignatureClaimed,
useAirdropIsSignatureValid,
} from "@tokenops/sdk/fhe-airdrop/react";

function CanClaim({
airdropAddress,
user,
encryptedAmountHandle,
signature,
}: {
airdropAddress: `0x${string}`;
user: `0x${string}`;
encryptedAmountHandle: `0x${string}`;
signature: `0x${string}`;
}) {
const windowActive = useAirdropIsClaimWindowActive({ address: airdropAddress });
const alreadyClaimed = useAirdropIsSignatureClaimed({
address: airdropAddress,
user,
encryptedAmountHandle,
});
const sigValid = useAirdropIsSignatureValid({
address: airdropAddress,
encryptedAmountHandle,
signature,
});

const ready =
windowActive.data === true && alreadyClaimed.data === false && sigValid.data === true;

return <button disabled={!ready}>{ready ? "Claim" : "Not ready"}</button>;
}

For "approve / claim"-style UIs, set refetchInterval on each preflight hook so the gate stays live as the claim window opens or as the user's wallet swaps accounts. Default staleTime: 0 already makes them refetch on focus.

useClaim quickstart

The recipient passes the admin-issued { encryptedInput, signature } payload to mutate verbatim. The hook does not encrypt — the signature commits to a specific handle, so any local re-encryption on the recipient side would break the signature. The hook has no encryptor option for the same reason.

import { useQueryClient } from "@tanstack/react-query";
import { useClaim } from "@tokenops/sdk/fhe-airdrop/react";

function ClaimButton({
airdropAddress,
claimPayload,
}: {
airdropAddress: `0x${string}`;
// The admin issued this payload server-side, encrypting with
// `userAddress: recipient` and signing the resulting handle.
claimPayload: {
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
signature: `0x${string}`;
};
}) {
const queryClient = useQueryClient();
const claim = useClaim({ address: airdropAddress });

return (
<button
onClick={() =>
claim.mutate(claimPayload, {
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-airdrop"] }),
})
}
disabled={claim.isPending}
>
{claim.isPending ? "Claiming…" : "Claim tokens"}
</button>
);
}

useGetClaimAmount — encrypted view pattern

Encrypted view hooks submit a transaction — they are useMutation instances, not TanStack read queries. Calling mutate() triggers a wallet popup and costs gas; don't speculatively wire it into a useEffect expecting a free read. The contract performs FHE.allow(handle, msg.sender) on-chain; the SDK extracts the granted handle from the receipt's ACL.Allowed event. Pass data.handle to the Zama relayer's userDecrypt to obtain the plaintext amount.

As with useClaim, the recipient passes the admin-issued { encryptedInput, signature } payload to mutate verbatim. The hook does not encrypt.

import { useZamaSDK } from "@zama-fhe/react-sdk";
import { useGetClaimAmount } from "@tokenops/sdk/fhe-airdrop/react";

function RevealAmountButton({
airdropAddress,
claimPayload,
}: {
airdropAddress: `0x${string}`;
claimPayload: {
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
signature: `0x${string}`;
};
}) {
const zamaSDK = useZamaSDK();
const getClaimAmount = useGetClaimAmount({ address: airdropAddress });

async function handleReveal() {
const { handle } = await getClaimAmount.mutateAsync(claimPayload);

// `handle` is a euint64 ciphertext bound to the caller. Decrypting it
// requires a signed EIP-712 keypair credential — see Zama's relayer
// user-decrypt flow:
// https://docs.zama.ai/protocol/relayer-sdk-guides/fhevm-relayer/user-decrypt
//
// For React, `@zama-fhe/react-sdk` exposes `useUserDecrypt` /
// `userDecryptQueryOptions` that wrap the EIP-712 signing + caching for
// you. The raw `zamaSDK.relayer.userDecrypt({ handles, ... })` takes a
// `UserDecryptParams` object containing the keypair, signature, and
// signed contract addresses.
void zamaSDK; // suppress unused-var while you wire userDecrypt.
console.log("encrypted claim handle:", handle);
}

return (
<button onClick={handleReveal} disabled={getClaimAmount.isPending}>
Reveal claim amount
</button>
);
}

useGetClaimAmount does not consume the claim — the claimedSignatures bit is not set. Call useClaim when the user is ready to transfer tokens. Do not call useGetClaimAmount speculatively — ACL grants are append-only and cannot be revoked.

useSignClaimAuthorization

The admin signs a claim authorization off-chain, then delivers the signature to the recipient (via API, email, or any channel). The recipient then calls useClaim with the signature.

:::note Server-side flow In production, the encrypt + sign steps below typically run server-side (Node, an API route, an admin worker) so recipients never see the admin's private key path. useSignClaimAuthorization is intended for admin-dashboard UIs where an admin wallet is connected in-browser — e.g. a campaign-management console. For headless server flows, call the underlying signClaimAuthorization helper from @tokenops/sdk/fhe-airdrop with an explicit walletClient. :::

import { useZamaSDK } from "@zama-fhe/react-sdk";
import { useSignClaimAuthorization, encryptUint64 } from "@tokenops/sdk/fhe-airdrop/react";

function IssueClaimButton({
airdropAddress,
recipient,
amount,
onIssued,
}: {
airdropAddress: `0x${string}`;
recipient: `0x${string}`;
amount: bigint;
onIssued: (payload: {
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
signature: `0x${string}`;
}) => void;
}) {
const zamaSDK = useZamaSDK();
const sign = useSignClaimAuthorization();

async function handleIssue() {
// 1. Encrypt the allocation BOUND TO THE RECIPIENT — Zama input proofs are
// bound to (contractAddress, userAddress); the contract's
// `FHE.fromExternal` rejects any other binding. The encryptor is resolved
// eagerly here because `encryptUint64` is not a hook.
const encryptor = zamaSDK.relayer;
const encrypted = await encryptUint64({
encryptor,
contractAddress: airdropAddress,
userAddress: recipient,
value: amount,
});

// 2. Sign. The wallet client is pulled from wagmi's useWalletClient()
// internally — no explicit walletClient arg needed.
const signature = await sign.mutateAsync({
airdropAddress,
recipient,
encryptedAmountHandle: encrypted.handle,
});

// 3. Deliver { encryptedInput, signature } to the recipient via your API.
// The recipient submits exactly this pair to useClaim — no re-encryption.
onIssued({ encryptedInput: encrypted, signature });
}

return (
<button onClick={handleIssue} disabled={sign.isPending}>
Issue claim
</button>
);
}

useSignClaimAuthorization throws TokenOpsSdkError if no wallet is connected.

Address override

Every factory hook accepts { address?, chainId? } matching BaseHookOptions. When both are omitted, the hook resolves the factory address from DEPLOYED_ADDRESSES.fheAirdrop.confidentialAirdropFactory[chainId] using the chain id from usePublicClient().chain.id. The Sepolia factory address is pre-populated (0xbE6A3B78B36684fFee48De77d47Bc3393F5Acd4c); the mainnet slot is null.

Airdrop clone hooks require address — there is no chain-level default for per-campaign clones:

// Factory hook — address resolves automatically on Sepolia.
const gasFee = useFactoryDefaultGasFee();

// Factory hook — address override for a custom deployment.
const customFee = useFactoryCustomFee({ address: "0xMyFactory", creator: account });

// Airdrop clone hook — address is always required.
const token = useAirdropToken({ address: airdropAddress });

If no address resolves (chain is not Sepolia, no override was passed), the SDK behaves differently for queries vs mutations:

  • Mutations (useCreate*, useSet*, useDisableCustomFee, etc.) — the mutationFn throws DeploymentAddressUnavailableError (code: "TOKENOPS_DEPLOYMENT_ADDRESS_UNAVAILABLE"); error.context names the client (clientLabel: "ConfidentialAirdropFactoryClient"), the unknown chain id, and the override knob (overrideName: "address"). Caught by TanStack Query and exposed as mutation.error.
  • Queries (useFactoryDefaultGasFee, useFactoryFeeCollector, etc.) — the query stays gated (status: 'pending', fetchStatus: 'idle') because enabled: !!client is false. No error fires. This is the standard TanStack Query "missing args" semantics, but on mainnet (where the slot is null) it can look indefinitely-loading. Pass address explicitly to enable, or branch on usePublicClient().chain.id to render a fallback UI before mounting the hook.

Encryptor source

The following factory mutation hooks require an encryptor to be available at call time:

useCreateAndFundConfidentialAirdrop, useFundConfidentialAirdrop

The airdrop clone hooks useClaim and useGetClaimAmount take no encryptor — they submit the admin-issued { encryptedInput, signature } payload verbatim. Re-encrypting client-side would invalidate the signature, which commits to a specific handle.

Pass encryptor at the hook level in FactoryHookOptions to wire it once for the component:

const zamaSDK = useZamaSDK();

const create = useCreateAndFundConfidentialAirdrop({
// Lazy — called per-encryption, not at hook construction. `useZamaSDK()`
// returns `ZamaSDK`; the encrypt-capable interface lives on `.relayer`.
encryptor: () => zamaSDK.relayer,
});

The lazy form (() => Encryptor | undefined) is recommended in React so the SDK always reads the live context value rather than a stale capture from mount time (CLAUDE.md Pitfall #3). An eager Encryptor instance also type-checks and is appropriate for server-side or test code where the encryptor lifetime matches the component.

If encryptor is absent at both hook level and call time, the SDK throws: Missing encryptor — pass encryptor to the hook config (e.g. encryptor: () => useZamaSDK().relayer) or to the mutation args.

Query keys

The key shape for every hook in this subpath is:

["tokenops-sdk", "fhe-airdrop", "<methodName>", chainId, address?.toLowerCase(), ...primitiveArgs]

bigint args are stringified to base-10 strings. Hex addresses are lowercased. This means structural equality holds across renders even when viem returns checksummed addresses.

Cache behavior. All read hooks ship with staleTime: 0 — chain state is treated as live, so a focus-revalidation re-fires the underlying RPC call.

Invalidate everything for an airdrop after a write:

queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-airdrop"] });

Narrow to a specific method and clone address:

queryClient.invalidateQueries({
queryKey: [
"tokenops-sdk",
"fhe-airdrop",
"airdrop:isPaused",
chainId,
airdropAddress.toLowerCase(),
],
});