Skip to main content

@tokenops/sdk/fhe-disperse

Typed wrapper for the DisperseConfidential singleton. One singleton exists per chain (no factory). The SDK is wired to the chain's singleton address either via explicit address override or via DEPLOYED_ADDRESSES.fheDisperse.disperseConfidentialSingleton[chainId].

Singleton deployments: mainnet 0x4fC0d28cBe4B82D512Ad0B42F6787480Cc98cC70, Sepolia 0x710dD9885Cc9986EfD234E7719483147a6d8DBb4 — resolved automatically from publicClient.chain?.id. The address override below is only needed for self-deployed singletons (forks, custom testnets).

What this product is

@tokenops/sdk/fhe-disperse wraps DisperseConfidential — a singleton contract for confidential bulk token distribution using ERC-7984 tokens. Three dispatch modes cover the most common use cases: a gas-fee wallet mode, a BPS token-fee wallet mode, and a direct sender mode. Unlike /fhe-vesting and /fhe-airdrop which are clone-per-user, the disperse system is a single shared singleton — users interact with it directly, not through their own clone instances.

The key structural difference from other confidential products: 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. A user who disperses three different tokens uses the same wallet pair each time and calls approveTokenOnWallets once per new token.

Architecture

User

├─ register(token)
│ DisperseConfidential singleton
│ └─ Clones.cloneDeterministic(impl, salt0) → wallet0
│ └─ Clones.cloneDeterministic(impl, salt1) → wallet1
│ Wallets are deterministic per user (keccak256(user, 0|1) salt)

└─ disperse(token, mode, recipients, amounts)
SDK encrypts amounts + subtotals → one input proof
Splits recipients into two groups
wallet0.transfer(group0 recipients, handles0, subtotal0Handle)
wallet1.transfer(group1 recipients, handles1, subtotal1Handle)
Both wallets fan out transfers to their recipient groups

For "direct" mode, the sender holds the token balance directly and confidentialTransferFrom is called from the sender — no wallets are involved.

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).
// Pass `address:` explicitly only for self-deployed singletons.
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 { wallets } = await client.register({ token });
console.log("Wallet pair:", wallets);

// Disperse to 3 recipients, wallet mode with ETH gas fee.
// Amounts are plaintext bigints — the SDK encrypts them.
const recipients = [
"0x0000000000000000000000000000000000000001" as `0x${string}`,
"0x0000000000000000000000000000000000000002" as `0x${string}`,
"0x0000000000000000000000000000000000000003" as `0x${string}`,
];
const amounts = [1_000_000n, 500_000n, 250_000n]; // raw token units, ERC-7984 uses 6 decimals

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

Quickstart

1. Register

Call register once per user to deploy their dedicated wallet pair. Each wallet is a deterministic ERC-1167 clone — the address is predictable via predictWallets even before registration.

// Check first if already registered.
const isRegistered = await client.isRegistered(account.address);
if (!isRegistered) {
const { hash, wallets } = await client.register({ token });
console.log("Wallets:", wallets); // [wallet0, wallet1]
}

// Predict deterministic wallet addresses before registering (useful for pre-approvals).
const predicted = await client.predictWallets(account.address);
console.log("Predicted wallets:", predicted);

2. Preflight

Always run preflight before dispersing. It costs a few RPC reads and prevents wasted gas on invalid state.

const report = await client.preflightDisperse({
user: account.address,
token,
recipients,
amounts,
mode: "wallet",
});
if (!report.ready) {
// `blockerErrors` is `TokenOpsSdkError[]` — branch on `error.code` for typed
// UI, or `error.message` for a quick string render.
throw new Error(report.blockerErrors.map((e) => e.message).join("; "));
}

3. Disperse

Pass plaintext amounts. The SDK encrypts all amounts and subtotals in a single batch call (one KMS input proof), then dispatches to the appropriate contract function.

const { hash } = await client.disperse({ token, mode: "wallet", recipients, amounts });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log("Status:", receipt.status); // "success"

4. Recover

Calling disperse() directly is safe — the SDK computes subtotals from your plaintext amounts and they always match, so the wallets are emptied within the same transaction. Recovery only matters if you bypassed disperse() and called the singleton's wallet-mode functions yourself with hand-rolled subtotals, or if a transfer dust accumulated under unusual conditions.

The triggering signal: userDecrypt on a recipient's transferred handle (from the WalletDistribution event) returns 0n when you expected a non-zero amount. That means tokens were pulled into a wallet but never sent out — sweep them with recoverFromWallets.

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

Use recoverERC20FromWallets for plain ERC-20 tokens accidentally sent to the wallets.