Skip to main content

Examples

Self-contained, runnable examples for @tokenops/sdk/fhe-disperse. The hardcoded private keys are the well-known Anvil/Hardhat defaults — public, do not use with real funds.

Register a user's wallet pair

Register a user's dedicated wallet pair for wallet-mode disperses and approve the token so the pair is ready to use immediately.

Prerequisites

  1. A funded test wallet (Sepolia ETH for gas; no tokens required for registration).
  2. An ERC-7984 confidential token address.

Registration is one-time per user. Subsequent calls to register() revert with UserAlreadyRegistered; always check isRegistered first. Wallet addresses are deterministic — predictWallets() returns the same addresses register() will deploy.

register.ts
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { createConfidentialDisperseClient } from "@tokenops/sdk/fhe-disperse";

async function main() {
const account = privateKeyToAccount(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
);
const rpcUrl = "https://rpc.sepolia.org";
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });

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

const token = "0xYourERC7984Token" as `0x${string}`;

// Predict wallet addresses deterministically before registering.
const predicted = await client.predictWallets(account.address);
console.log("Will deploy wallet0:", predicted[0]);
console.log("Will deploy wallet1:", predicted[1]);

// Guard against double-registration.
const alreadyRegistered = await client.isRegistered(account.address);
if (alreadyRegistered) {
const wallets = await client.getWallets(account.address);
console.log("Already registered. Wallets:", wallets);
return;
}

// Register: deploys the two ERC-1167 wallet clones and approves them for `token`.
const { hash, wallets } = await client.register({ token });
console.log("Registration tx:", hash);
console.log("Deployed wallet0:", wallets[0]);
console.log("Deployed wallet1:", wallets[1]);

// To use a different token with the same wallet pair later:
// await client.approveTokenOnWallets({ token: otherToken });
}

export { main };

Preflight inspection

Showcase: run preflightDisperse and inspect every field of the PreflightReport. The five failure modes covered:

  1. isUserRegistered — user has not called register() yet
  2. hasApprovedSubwallets — wallets exist but are not approved as operators
  3. feeEth — insufficient ETH attached (pre-compute for UI display)
  4. batchOk — recipient count exceeds the configured batch limit
  5. recipientChecks — one or more recipient addresses are structurally invalid
preflight.ts
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
import { createConfidentialDisperseClient, type PreflightReport } from "@tokenops/sdk/fhe-disperse";

async function main() {
const publicClient = createPublicClient({
chain: sepolia,
transport: http("https://rpc.sepolia.org"),
});

// No walletClient needed — preflightDisperse is read-only.
const client = createConfidentialDisperseClient({ publicClient });

const user = process.env.USER_ADDRESS as `0x${string}`;
const token = process.env.ERC7984_TOKEN_ADDRESS as `0x${string}`;
const recipients: `0x${string}`[] = [
"0x0000000000000000000000000000000000000001",
"0x0000000000000000000000000000000000000002",
"0x0000000000000000000000000000000000000003",
];
const amounts = [1_000_000n, 500_000n, 250_000n]; // 1, 0.5, 0.25 tokens at 6 decimals

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

// ── Failure mode 1: registration ──────────────────────────────────────────
console.log("Registered:", report.isUserRegistered);
console.log("Predicted wallets:", report.predictedWallets);
console.log("On-chain wallets:", report.wallets); // null if not yet registered

// ── Failure mode 2: wallet approvals ──────────────────────────────────────
console.log("Wallet0 approved:", report.hasApprovedSubwallets.wallet0);
console.log("Wallet1 approved:", report.hasApprovedSubwallets.wallet1);
console.log("Both approved:", report.hasApprovedSubwallets.both);
console.log("Sender approved singleton:", report.hasApprovedSingleton); // null for wallet mode

// ── Failure mode 3: ETH fee ───────────────────────────────────────────────
console.log("ETH fee (wei):", report.feeEth);
console.log("Token fee amount:", report.feeTokenAmount); // undefined for "wallet" mode

// ── Failure mode 4: batch limit ───────────────────────────────────────────
console.log("Batch limit:", report.batchLimit, "(0 = no limit)");
console.log("Batch OK:", report.batchOk);

// ── Failure mode 5: recipient validation ──────────────────────────────────
for (const check of report.recipientChecks) {
if (!check.ok) {
console.warn(`Bad recipient ${check.address}: ${check.reason}`);
}
}
console.log("Amounts length matches recipients:", report.amountsOk);

// ── Pre-computed subtotals (wallet modes only) ────────────────────────────
console.log("Group subtotals:", report.subtotals);
// → { group0: 1_500_000n, group1: 250_000n } for this example

// ── Aggregate ─────────────────────────────────────────────────────────────
console.log("\nReady to disperse:", report.ready);
if (!report.ready) {
console.log("Blockers:");
for (const blocker of report.blockers) {
console.log(" -", blocker);
}
}
}

export { main };

Disperse to 50 recipients

Encrypt amounts and disperse to 50 recipients using wallet mode. The SDK encrypts all 50 amounts plus the two group subtotals (52 values total) in a single encryptor.encrypt() call — one KMS input proof covers everything.

Prerequisites

  1. User is already registered (run the register example first).
  2. The registered wallets are approved as ERC-7984 operators for the token.
  3. The sender holds enough of the ERC-7984 token to cover all recipient amounts.
  4. Wallet is funded with Sepolia ETH for gas.
disperse.ts
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";

async function main() {
const account = privateKeyToAccount(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
);
const rpcUrl = "https://rpc.sepolia.org";
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.ERC7984_TOKEN_ADDRESS as `0x${string}`;

// Build a 50-recipient list. In production, load this from your database.
const recipients = Array.from(
{ length: 50 },
(_, i) => `0x${String(i + 1).padStart(40, "0")}` as `0x${string}`,
);
const amounts = recipients.map(() => 1_000_000n); // 1 token at 6 decimals each

// Run preflight to catch any issues before spending gas on encryption.
const report = await client.preflightDisperse({
user: account.address,
token,
recipients,
amounts,
mode: "wallet",
});

if (!report.ready) {
console.error("Disperse not ready:", report.blockers);
process.exit(1);
}

console.log("ETH fee to attach:", report.feeEth, "wei");
console.log("Subtotals (plaintext pre-check):", report.subtotals);

// disperse() encrypts all amounts + subtotals in one batch, attaches the live
// ETH gas fee as msg.value, and calls disperseConfidentialTokens on-chain.
const { hash } = await client.disperse({
token,
mode: "wallet",
recipients,
amounts,
});
console.log("Disperse tx:", hash);

const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log("Status:", receipt.status); // "success"
}

export { main };

Recover residual balances

Sweep residual confidential tokens from subwallets after a subtotal-mismatch disperse. For wallet-mode disperses, the contract pre-loads each wallet with an encrypted subtotal, then distributes individual amounts from that balance. If the subtotal supplied was larger than the sum of the group's actual amounts (e.g. due to a manual override), the leftover tokens remain locked in wallet0 or wallet1.

recoverFromWallets() sweeps those residual confidential tokens back to an address you control. recoverERC20FromWallets() does the same for plain ERC-20 tokens that were accidentally sent to the wallet addresses.

note

The SDK's disperse() always uses computeSubtotals() internally, which matches the contract's exact partition rule. Residual balances should only occur if you bypassed the SDK and called the contract directly with custom subtotal handles.

recover.ts
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { createConfidentialDisperseClient } from "@tokenops/sdk/fhe-disperse";

async function main() {
const account = privateKeyToAccount(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
);
const rpcUrl = "https://rpc.sepolia.org";
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });

const client = createConfidentialDisperseClient({
publicClient,
walletClient,
});

const token = process.env.ERC7984_TOKEN_ADDRESS as `0x${string}`;
const recoveryDestination = account.address;

// Sweep residual confidential (ERC-7984) tokens from both wallets to `to`.
// Both wallet0 and wallet1 are swept in a single transaction.
const hash = await client.recoverFromWallets({
token,
to: recoveryDestination,
});
console.log("recoverFromWallets tx:", hash);

const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log("Status:", receipt.status); // "success"

// If plain ERC-20 tokens landed in the wallets accidentally, sweep those too.
const erc20Token = process.env.ERC20_TOKEN_ADDRESS as `0x${string}`;
const erc20Hash = await client.recoverERC20FromWallets({
token: erc20Token,
to: recoveryDestination,
});
console.log("recoverERC20FromWallets tx:", erc20Hash);
}

export { main };