Pay many recipients in one transaction.
Register once, approve the singleton, preflight, then disperse. Headless and React side by side.
Install
The next dist-tag installs the 2.0 line; latest stays on 1.x. The Zama packages are optional peers, pinned to a patch range because upstream removes APIs in minor releases.
pnpm add @tokenops/sdk@next viem @zama-fhe/sdk@~3.6.0
# React hosts also need
pnpm add wagmi @tanstack/react-query @zama-fhe/react-sdk@~3.6.0The samples use bigint literals such as 1_000_000n, which need compilerOptions.target of ES2020 or later. The default Next.js template targets ES2017 and fails with "BigInt literals are not available"; raise the target.
Register, approve, preflight, disperse
Both versions run the same four steps. register is once per user, not per token. The operator approval on step 2 is the sender's grant to the singleton and is needed in every mode; the subwallet approval that register sets up is needed only in the wallet modes.
import { createPublicClient, createWalletClient, http, type Address } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { createSepoliaEncryptor, ensureOperator } from "@tokenops/sdk/fhe";
import { createConfidentialDisperseClient } from "@tokenops/sdk/fhe-disperse";
const account = privateKeyToAccount(process.env.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 = await createSepoliaEncryptor({ rpcUrl });
try {
// The singleton address resolves from DEPLOYED_ADDRESSES on Sepolia and mainnet.
const client = createConfidentialDisperseClient({ publicClient, walletClient, encryptor });
const token = process.env.TOKEN as Address;
// 1. Register once: deploys your two subwallets and approves them for `token`.
if (!(await client.isRegistered(account.address))) {
const { wallets } = await client.register({ token });
console.log("Subwallets:", wallets);
} else if (!(await client.hasApprovedSubwallets({ user: account.address, token })).both) {
// register approved the subwallets only for the token it was given.
await client.approveTokenOnWallets({ token });
}
// 2. Every mode pulls from your balance through the singleton, so approve it as
// an ERC-7984 operator. Sends setOperator only when the grant is missing.
await ensureOperator({
publicClient,
walletClient,
token,
spender: client.address,
deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), // one hour
});
const recipients: Address[] = ["0xRecipient1", "0xRecipient2"];
const amounts = [1_000_000n, 500_000n]; // base units; the test tokens use 6 decimals
// 3. Preflight: registration, both approvals, fee vs ETH balance, batch and
// input-proof limits, per-recipient checks.
const report = await client.preflightDisperse({
user: account.address,
token,
recipients,
amounts,
mode: "wallet",
});
if (!report.ready) {
// TokenOpsSdkError[]: branch on error.code, or render error.message.
throw new Error(report.blockerErrors.map((e) => e.message).join("; "));
}
// 4. Encrypt + disperse. Throws ReceiptEventNotFoundError if the tx reverted.
const { hash, distributions } = await client.disperse({
token,
mode: "wallet",
recipients,
amounts,
});
console.log(hash, distributions.length);
} finally {
encryptor.terminate();
}MAX_EUINT64_PER_INPUT_PROOF (32) values: 32 recipients in "direct", 30 in "wallet" and "wallet-token-fee", which also encrypt two subtotals. A longer list fails preflightDisperse (batchOk: false) and disperse with InvalidArgumentError before any encryption runs. Split it across several calls. The on-chain per-mode cap from getBatchLimits applies on top.DisperseDistribution carries requested and transferred handles; user-decrypt transferred with the token as the contract address to see what actually moved.Signing from a Safe
register, disperse, withdrawTokenFee and both disclosure methods take waitForReceipt: false and return on submission. The operator approval waits for its receipt too, so pass waitForReceipt: false to ensureOperator / setOperator as well and let it execute first.
// A threshold Safe returns a safeTxHash that only executes once owners co-sign.
const result = await client.disperse({
token,
mode: "direct",
recipients,
amounts,
waitForReceipt: false,
});
// PendingDisperseResult: { hash, distributions: undefined, pending: true }
trackSafeTx(result.hash);
// In React, data is DisperseResult | PendingDisperseResult:
// if (disperse.data?.pending) return <Pending hash={disperse.data.hash} />;Every receipt-free write across the SDK: Receipt-free writes. Gas limits and the per-call gas override: Gas headroom.
On mainnet
The singleton is live on mainnet at its own address, resolved the same way. Only the encryptor changes: bind it to mainnet and pass the relayer API key.
import { createSepoliaEncryptor, MAINNET_CHAIN_ID } from "@tokenops/sdk/fhe";
// The Zama-hosted mainnet relayer requires an API key. Keep it server-side.
const encryptor = await createSepoliaEncryptor({
chainId: MAINNET_CHAIN_ID,
rpcUrl: process.env.MAINNET_RPC_URL,
auth: { __type: "ApiKeyHeader", value: process.env.RELAYER_API_KEY! },
});Browser and mock encryptors: Encryptors.
Where to go next
Register, approve, disperse and recover, with the hooks for each step.
Every singleton method as a React hook, filterable by lifecycle and return shape.
What each preflight blocker and write-time error means, and how to clear it.