Skip to main content

Confidential Disperse

@tokenops/sdk/fhe-disperse is a typed wrapper for DisperseConfidential, a singleton contract for confidential bulk token distribution using ERC-7984 tokens.

Unlike /fhe-vesting and /fhe-airdrop which use clone-per-user patterns, the disperse system is a single shared singleton — users interact with it directly, not through their own clone instances.

Singleton deployments:

  • Mainnet: 0x4fC0d28cBe4B82D512Ad0B42F6787480Cc98cC70
  • Sepolia: 0x710dD9885Cc9986EfD234E7719483147a6d8DBb4

Both resolve automatically from publicClient.chain?.id.

Architecture

Wallets are per-user, not per-token. A user registers once and receives a dedicated pair of ERC-1167 wallet clones. Those same two wallets are reused for every subsequent disperse regardless of which token is being sent.

User

├─ register(token)
│ DisperseConfidential singleton
│ └─ wallet0 (deterministic ERC-1167 clone)
│ └─ wallet1 (deterministic ERC-1167 clone)
│ Wallets are reused for all tokens

└─ disperse(token, mode, recipients, amounts)
SDK encrypts amounts + subtotals → one input proof
Splits recipients into two groups
wallet0.transfer(group0 recipients, handles0, subtotal0)
wallet1.transfer(group1 recipients, handles1, subtotal1)

For "direct" mode, the sender holds the token balance directly — no wallets are involved.

The three disperse modes

ModeFee paid inWallets usedBest for
"wallet"ETH per recipientper-user pairStandard confidential bulk payouts
"wallet-token-fee"BPS on token subtotalsper-user pairProtocol-earning payouts with token fee
"direct"ETH per recipientnoneOne-off transfers, no registration required

If you're unsure, use "wallet" mode. It is the standard path for a single-sender bulk payout. Pick "direct" only for one-off use cases where you don't want a per-user wallet pair.

30-second example

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 { 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 = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});

// Singleton address resolves from publicClient.chain (mainnet / Sepolia)
const client = createConfidentialDisperseClient({ publicClient, walletClient, encryptor });

const token = process.env.TOKEN as `0x${string}`;

// Register: deploy your dedicated wallet pair (once per user, ever)
const isRegistered = await client.isRegistered(account.address);
if (!isRegistered) {
const { wallets } = await client.register({ token });
console.log("Wallet pair:", wallets);
}

const recipients = [
"0x0000000000000000000000000000000000000001" as `0x${string}`,
"0x0000000000000000000000000000000000000002" as `0x${string}`,
];
const amounts = [1_000_000n, 500_000n];

// Preflight checks all five failure modes in one call
const report = await client.preflightDisperse({ user: account.address, token, recipients, amounts, mode: "wallet" });
if (!report.ready) {
throw new Error(report.blockerErrors.map((e) => e.message).join("; "));
}

// Encrypt + disperse. SDK encrypts amounts and subtotals, attaches the ETH gas fee.
const { hash } = await client.disperse({ token, mode: "wallet", recipients, amounts });
console.log("Disperse tx:", hash);

Preflight

Always run preflightDisperse before dispersing. It covers all five failure modes in a single call:

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

const report: PreflightReport = await client.preflightDisperse({
user: account.address,
token,
recipients,
amounts,
mode: "wallet",
});

if (!report.isUserRegistered) console.log("Call register() first");
if (!report.hasApprovedSubwallets.both) console.log("Wallets need token approval");
if (!report.batchOk) console.log(`Too many recipients (limit: ${report.batchLimit})`);

if (!report.ready) {
for (const err of report.blockerErrors) {
console.log(err.code, err.message);
}
}

Frontend usage

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

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 DisperseButton({
token,
recipients,
amounts,
}: {
token: `0x${string}`;
recipients: `0x${string}`[];
amounts: bigint[];
}) {
const { data: walletClient } = useWalletClient();

async function handleDisperse() {
if (!walletClient?.account) return;
const client = createConfidentialDisperseClient({ publicClient, walletClient, encryptor });

const report = await client.preflightDisperse({
user: walletClient.account.address,
token,
recipients,
amounts,
mode: "wallet",
});

if (!report.ready) {
throw new Error(report.blockerErrors.map((e) => e.message).join("; "));
}

const { hash } = await client.disperse({ token, mode: "wallet", recipients, amounts });
console.log("Disperse tx:", hash);
}

return <button onClick={handleDisperse}>Disperse tokens</button>;
}

Backend usage

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 { createConfidentialDisperseClient } from "@tokenops/sdk/fhe-disperse";

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),
});

const client = createConfidentialDisperseClient({ publicClient, walletClient, encryptor });
const token = process.env.TOKEN as `0x${string}`;

const isRegistered = await client.isRegistered(account.address);
if (!isRegistered) {
const { wallets } = await client.register({ token });
console.log("Registered wallets:", wallets);
}

const recipients = process.env.RECIPIENTS!.split(",") as `0x${string}`[];
const amounts = recipients.map(() => 1_000_000n);

const report = await client.preflightDisperse({ user: account.address, token, recipients, amounts, mode: "wallet" });
if (!report.ready) throw new Error(report.blockerErrors.map((e) => e.message).join("; "));

const { hash } = await client.disperse({ token, mode: "wallet", recipients, amounts });
console.log("Disperse tx:", hash);

Recovery

Recovery is only needed if you bypassed disperse() and submitted hand-rolled subtotals that left residual tokens in the wallets:

const txHash = await client.recoverFromWallets({ token, to: account.address });

Subtotal correctness

For wallet modes the SDK supplies encrypted subtotals alongside individual amount handles. The subtotals are computed automatically by disperse() using computeSubtotals, which mirrors the contract's partition rule exactly.

import { computeSubtotals } from "@tokenops/sdk/fhe-disperse";

const { group0, group1 } = computeSubtotals(amounts);
// Verify: group0 + group1 === amounts.reduce((a, b) => a + b, 0n)

FHE pitfalls

getEncryptedFeeReserve is a write transaction, not a free view. The contract calls FHE.allow(handle, msg.sender) inside the tx. The handle is extracted from the receipt's ACL.Allowed event, never from simulation.

ACL grants are append-only. Once getEncryptedFeeReserve grants a handle to an address, that access cannot be revoked. Do not call it speculatively.

euint64 overflow. Plaintext amount values must fit in uint64. The SDK validates each value and throws if it exceeds the range.

Encryptor setup

See Getting Started for RelayerNode, RelayerWeb, mock encryptor, and lazy vs eager injection patterns.