2.0 RC docsView 1.x docs
Disperse 2.0 · Quickstart

Pay many recipients in one transaction.

Register once, approve the singleton, preflight, then disperse. Headless and React side by side.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.

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.

terminal
bash
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.0

The 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.

disperse.ts
ts
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();
}
One input proof per disperse
Every amount in a disperse is verified against one input proof, which holds at most 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.
A mined receipt does not prove value moved
An ERC-7984 transfer from an insufficient balance moves an encrypted zero instead of reverting. Each 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.

safe.ts
ts
// 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.

encryptor.ts
ts
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