Skip to main content

Concepts

Preflight showcase

Before calling disperse, check all five readiness conditions in a single RPC round-trip. preflightDisperse returns a PreflightReport covering every blocker a UI needs to render a "ready to send" checklist.

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

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

// The five failure modes:
// 1. Not registered
if (!report.isUserRegistered) {
console.log("Call register() first");
}
// 2. Wallets not approved as ERC-7984 operators
if (!report.hasApprovedSubwallets.both) {
console.log(`wallet0 approved: ${report.hasApprovedSubwallets.wallet0}`);
console.log(`wallet1 approved: ${report.hasApprovedSubwallets.wallet1}`);
// Call approveTokenOnWallets() or register() with this token
}
// 3. ETH gas fee required
console.log("ETH to attach:", report.feeEth); // bigint, wei

// 4. Batch limit exceeded
if (!report.batchOk) {
console.log(`Too many recipients (limit: ${report.batchLimit})`);
}
// 5. Bad recipient addresses
const badRecipients = report.recipientChecks.filter((rc) => !rc.ok);
if (badRecipients.length > 0) {
console.log(
"Invalid recipients:",
badRecipients.map((rc) => `${rc.address}: ${rc.reason}`),
);
}

// Aggregate: ready is true only when all checks pass. `blockerErrors` is the
// typed `TokenOpsSdkError[]` you can branch on by `error.code`.
if (!report.ready) {
for (const err of report.blockerErrors) {
// Branch on the stable machine code for typed UI, or render `err.message`
// for a quick string list.
if (err.code === "TOKENOPS_USER_NOT_REGISTERED") {
console.log("Register first:", err.message);
} else {
console.log(err.code, err.message);
}
}
return;
}

// Green light — proceed.
const { hash } = await client.disperse({ token, mode: "wallet", recipients, amounts });

The three disperse modes

ModeFee paid inWallets usedBest for
"wallet"ETH per recipient (gas fee)per-user pairStandard confidential bulk payouts
"wallet-token-fee"BPS on token subtotalsper-user pairSponsored payouts where the protocol earns a token fee
"direct"ETH per recipient (gas fee)none — sender's balanceOne-off transfers; no registration required

If you're unsure, use "wallet" mode. It is the standard path for a single-sender bulk payout — one-time register() setup, then any number of disperses. Pick "direct" only if you do not want a per-user wallet pair (one-off use case). Pick "wallet-token-fee" only if you are the protocol earning the BPS fee, not the sender paying it.

For "wallet" and "direct", msg.value = recipients.length * gasFeeWei. The SDK reads the live fee and attaches it automatically.

For "wallet-token-fee", ETH is 0. The token fee is subtotal * tokenFeeBps / 10000 — deducted on-chain from the encrypted subtotals via FHE.min.

Subtotal correctness

For wallet modes the SDK must supply encrypted subtotals alongside the individual amount handles. These subtotals are the sum of amounts for each wallet's group: group0 gets the first ceil(n/2) recipients and group1 gets the remaining floor(n/2).

The SDK computes subtotals automatically inside disperse() using computeSubtotals, which mirrors the contract's _distributeFromWallets partition rule exactly.

If the subtotals are deflated (sum less than what the contract tries to distribute), the FHE underflow protection via FHE.min silently caps each transfer — recipients get less than intended without a revert. computeSubtotals is exported so advanced callers can independently verify the values the SDK will use:

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

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

Never pass custom subtotals that are lower than the true per-group sum — silent partial failures will occur on-chain with no error emitted.

Encrypted fee reserve

getEncryptedFeeReserve is an admin/fee-collector method that grants the caller FHE ACL on the encrypted token fee reserve and returns its handle. Pass the handle to Zama's userDecrypt to read the plaintext total.

This is a write transaction (costs gas) — it calls FHE.allow(handle, msg.sender) on-chain. The handle is extracted from the receipt's ACL.Allowed event, not from simulation. See FHE and ACL pitfalls.

const { handle, hash } = await client.getEncryptedFeeReserve({ token });
// handle is a euint64 handle — pass to Zama relayer userDecrypt to read plaintext reserve.
// See https://docs.zama.ai/fhevm for the userDecrypt flow.

If no token fees have accrued yet, the method throws DisperseEncryptedReserveNotGrantedError.

Address override

On mainnet and Sepolia, omit address — the client resolves it from publicClient.chain?.id via DEPLOYED_ADDRESSES.fheDisperse.disperseConfidentialSingleton. Pass address only when targeting a self-deployed singleton (fork, custom testnet):

const client = createConfidentialDisperseClient({
publicClient,
walletClient,
encryptor,
address: "0xYourSingletonAddress",
});

The constructor throws DeploymentAddressUnavailableError (code: "TOKENOPS_DEPLOYMENT_ADDRESS_UNAVAILABLE") if neither address nor a recognised chain address is available; error.context carries product: "fheDisperse", contract: "disperseConfidentialSingleton", chainId, and reason ("chain-id-missing" / "registry-unknown-chain" / "registry-not-deployed") for UI branching.