Skip to main content

React Hooks

React hooks

Hooks live at @tokenops/sdk/fhe-disperse/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.

For the headless (non-hook) path, see the 30-second example above.

Hook catalogue

FamilyHooks
Reads (11)useIsRegistered, useGetWallets, usePredictWallets, useGetFees, useGetBatchLimits, useCalculateFee, useHasApprovedSubwallets, useHasRole, useIsPaused, useDeploymentBlockNumber, useWalletImplementation
Preflight (1)usePreflightDisperse
User-flow writes (5)useRegister, useApproveTokenOnWallets, useRevokeTokenOnWallets, useRecoverFromWallets, useRecoverERC20FromWallets
Core disperse (1)useDisperse
Encrypted views + disclosure (3)useGetEncryptedFeeReserve, useDiscloseHandleToParty, useBatchDiscloseHandlesToParty
Admin / fee-manager / fee-collector writes (14)usePause, useUnpause, useSetFeeConfig, useSetCustomFee, useDisableCustomFee, useSetMaxBatchSizeHolding, useSetMaxBatchSizeDirect, useSetMaxBatchSizeTokenFee, useWithdrawGasFee, useWithdrawTokenFee, useRescueConfidentialTokens, useRescueERC20, useGrantRole, useRevokeRole

All 35 hooks are exported from @tokenops/sdk/fhe-disperse/react. The following utility types and helpers are re-exported from the same path so component files need only one import statement:

BaseHookOptions, DisperseHookOptions, computeSubtotals, encryptUint64, encryptUint64Batch, resolveEncryptor, Encryptor, EncryptorSource, EncryptUint64Args, EncryptUint64BatchArgs, FheValueInput, DisperseSubwalletNotFoundError, DisperseEncryptedReserveNotGrantedError, TokenOpsSdkError, BatchDiscloseHandlesArgs, BatchLimits, CalculateFeeArgs, CalculatedFee, DiscloseHandleArgs, DisperseArgs, DisperseMode, DisperseResult, EncryptedInput, EncryptedInputs, EncryptedViewResult, FeeConfig, GetEncryptedFeeReserveArgs, PreflightDisperseArgs, PreflightReport, RecipientCheck, RegisterResult, SubwalletApprovalState, UserFees.

useRegister quickstart

useRegister is the first call a new user makes. It deploys their dedicated wallet pair and approves both wallets as ERC-7984 operators for the given token in a single transaction. Registration is a one-time operation — subsequent calls revert with UserAlreadyRegistered. Use useIsRegistered to gate the button.

import { useQueryClient } from "@tanstack/react-query";
import { useIsRegistered, useRegister } from "@tokenops/sdk/fhe-disperse/react";

// Wrap your app in wagmi's <WagmiProvider> + <QueryClientProvider>
// before rendering this component. No extra SDK provider is required
// for non-encrypted hooks like useRegister.

function RegisterButton({
singletonAddress,
token,
user,
}: {
singletonAddress: `0x${string}`;
token: `0x${string}`;
user: `0x${string}`;
}) {
const queryClient = useQueryClient();

const { data: isRegistered } = useIsRegistered({ address: singletonAddress, user });

const register = useRegister({ address: singletonAddress });

return (
<button
onClick={() =>
register.mutate(
{ token },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-disperse"] });
},
},
)
}
disabled={isRegistered === true || register.isPending}
>
{isRegistered ? "Already registered" : register.isPending ? "Registering…" : "Register"}
</button>
);
}

register.data is typed as { hash: Hex; wallets: [Address, Address] } after success.

useDisperse quickstart

useDisperse is the core encrypted-write hook. Pass an encryptor at hook level as a lazy factory so the SDK always reads the live Zama React SDK context at submit time. Plaintext amounts are passed to mutate — the SDK encrypts them. Use useGetFees or useCalculateFee to show the required ETH fee before the user submits.

import { useQueryClient } from "@tanstack/react-query";
import { useZamaSDK } from "@zama-fhe/react-sdk";
import { useCalculateFee, useDisperse } from "@tokenops/sdk/fhe-disperse/react";

// Install @zama-fhe/react-sdk and wrap your app in <ZamaProvider> from that
// package alongside wagmi's <WagmiProvider> + <QueryClientProvider>.

function DisperseButton({
singletonAddress,
token,
recipients,
amounts,
user,
}: {
singletonAddress: `0x${string}`;
token: `0x${string}`;
recipients: `0x${string}`[];
amounts: bigint[];
user: `0x${string}`;
}) {
const queryClient = useQueryClient();

// Show the required ETH fee before submission.
const { data: fee } = useCalculateFee({
address: singletonAddress,
user,
recipients: recipients.length,
mode: "direct",
});

// encryptor is a lazy factory — the SDK calls it per-encryption, picking up
// the live ZamaSDK instance from React context at submit time.
// `useZamaSDK()` returns `ZamaSDK`; the encrypt-capable interface lives on `.relayer`.
const zamaSDK = useZamaSDK();
const disperse = useDisperse({
address: singletonAddress,
encryptor: () => zamaSDK.relayer,
});

return (
<div>
{fee && <p>ETH fee: {fee.ethValue.toString()} wei</p>}
<button
onClick={() =>
disperse.mutate(
{ token, mode: "direct", recipients, amounts },
{
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-disperse"] }),
},
)
}
disabled={disperse.isPending}
>
{disperse.isPending ? "Dispersing…" : "Disperse tokens"}
</button>
</div>
);
}

For "wallet" mode, call useRegister and useApproveTokenOnWallets first, then use usePreflightDisperse to gate the button (see next section).

usePreflightDisperse pattern

Use usePreflightDisperse before rendering the disperse button. data.ready gates the button; data.blockerErrors (a typed TokenOpsSdkError[]) drives an inline diagnostic list so the user knows exactly what to fix — branch on error.code for typed UI.

usePreflightDisperse returns a stock TanStack Query UseQueryResult. Its UsePreflightDisperseArgs type extends BaseHookOptions (address?, chainId?) but does not accept additional useQuery options directly. For live "approve / register / disperse" gating UIs, set refetchInterval: 5_000 via queryClient.setQueryDefaults keyed on the ["tokenops-sdk", "fhe-disperse"] prefix so the report re-fetches every 5 s across the whole component tree:

import { usePreflightDisperse, type PreflightReport } from "@tokenops/sdk/fhe-disperse/react";

// Set in your app's QueryClient setup (once), not per-render:
//
// queryClient.setQueryDefaults(["tokenops-sdk", "fhe-disperse"], {
// refetchInterval: 5_000,
// });

function DisperseReadinessPanel({
singletonAddress,
token,
recipients,
amounts,
user,
onReady,
}: {
singletonAddress: `0x${string}`;
token: `0x${string}`;
recipients: `0x${string}`[];
amounts: bigint[];
user: `0x${string}`;
onReady: () => void;
}) {
// usePreflightDisperse is enabled when all five args are present.
const { data: report, isLoading } = usePreflightDisperse({
address: singletonAddress,
user,
token,
recipients,
amounts,
mode: "wallet",
});

if (isLoading || !report) return <p>Checking readiness…</p>;

return (
<div>
{!report.ready && (
<ul>
{report.blockerErrors.map((err) => (
// `err.code` is the stable machine key (e.g.
// `TOKENOPS_USER_NOT_REGISTERED`) — branch on it for typed UI;
// `err.message` is the prose for a simple list.
<li key={err.code + err.message}>{err.message}</li>
))}
</ul>
)}
<button onClick={onReady} disabled={!report.ready}>
{report.ready ? "Disperse" : "Not ready"}
</button>
</div>
);
}

report.blockerErrors is a TokenOpsSdkError[] — each entry has error.code (machine-readable, e.g. TOKENOPS_USER_NOT_REGISTERED) and error.message (human-readable, e.g. "User not registered"). Branch on error.code for typed UI; the older report.blockers: string[] is still populated for back-compat but will be removed in the next major. report.feeEth is the ETH wei amount to display in the UI before submission.

useGetEncryptedFeeReserve quickstart

useGetEncryptedFeeReserve submits a transaction (costs gas) — it is a useMutation instance, not a read query. The contract calls FHE.allow(handle, msg.sender) on-chain; the SDK extracts the granted handle from the receipt's ACL.Allowed event (not from simulation — simulation returns a divergent handle with no ACL grant). Pass data.handle to the Zama relayer's userDecrypt to read the plaintext reserve amount.

Requires FEE_COLLECTOR_ROLE on the singleton. Throws DisperseEncryptedReserveNotGrantedError when no fees have accrued for the given token yet.

import { useZamaSDK } from "@zama-fhe/react-sdk";
import { useGetEncryptedFeeReserve } from "@tokenops/sdk/fhe-disperse/react";

function FeeReserveDashboard({
singletonAddress,
token,
}: {
singletonAddress: `0x${string}`;
token: `0x${string}`;
}) {
const zamaSDK = useZamaSDK();
const view = useGetEncryptedFeeReserve({ address: singletonAddress });

async function handleReveal() {
const { handle } = await view.mutateAsync({ token });

// `handle` is a euint64 ciphertext bound to the caller (FEE_COLLECTOR_ROLE).
// Pass it to the Zama relayer's userDecrypt to obtain the plaintext reserve.
// See https://docs.zama.ai/protocol/relayer-sdk-guides/fhevm-relayer/user-decrypt
// For React, @zama-fhe/react-sdk exposes useUserDecrypt / userDecryptQueryOptions.
void zamaSDK;
console.log("encrypted fee reserve handle:", handle);
}

return (
<button onClick={handleReveal} disabled={view.isPending}>
{view.isPending ? "Fetching handle…" : "Reveal fee reserve"}
</button>
);
}

Do not call useGetEncryptedFeeReserve speculatively — ACL grants are append-only and cannot be revoked.

Address override

Every hook in this subpath accepts { address?, chainId? } matching BaseHookOptions. When both are omitted, the hook resolves the singleton address via getFheDisperseSingletonAddress(chainId) using the chain id from the connected wagmi client. The canonical singletons are registered for mainnet and Sepolia; pass address explicitly to point at a fork or a redeployment:

// Address override — only needed when targeting a non-canonical deployment.
const register = useRegister({ address: "0xYourSingletonAddress" });
const preflight = usePreflightDisperse({ address: "0xYourSingletonAddress" /* ... */ });

When no address resolves:

  • Mutations (useRegister, useDisperse, etc.) — mutationFn throws DeploymentAddressUnavailableError (code: "TOKENOPS_DEPLOYMENT_ADDRESS_UNAVAILABLE"); error.context names the client (clientLabel: "ConfidentialDisperseClient"), the unknown chain id, and the override knob (overrideName: "address"). Caught by TanStack Query and exposed as mutation.error.
  • Queries (useIsRegistered, useGetFees, etc.) — the query stays gated (status: 'pending', fetchStatus: 'idle') because enabled: !!client is false. No error fires; the hook appears indefinitely loading. Pass address explicitly to enable.

Encryptor source

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

useDisperse, useWithdrawTokenFee

Pass encryptor at the hook level in DisperseHookOptions:

const zamaSDK = useZamaSDK();

const disperse = useDisperse({
address: singletonAddress,
// 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.

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-disperse", "<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.

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 a disperse after a write:

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

Narrow to a specific method and address:

queryClient.invalidateQueries({
queryKey: [
"tokenops-sdk",
"fhe-disperse",
"disperse:isRegistered",
chainId,
singletonAddress.toLowerCase(),
],
});

Status

The DisperseConfidential singleton is deployed on mainnet (0x4fC0d28cBe4B82D512Ad0B42F6787480Cc98cC70) and Sepolia (0x710dD9885Cc9986EfD234E7719483147a6d8DBb4) — both populated in DEPLOYED_ADDRESSES.fheDisperse.disperseConfidentialSingleton. The client resolves the address automatically from publicClient.chain?.id; pass address: explicitly only when targeting a self-deployed singleton.