1. Viem clients#
Anything you build with viem already has these, bring your own. Otherwise: stand them up like this. Sepolia for free; mainnet only for /fhe-disperse today.
import { createPublicClient, createWalletClient, custom, http } from "viem";
import { sepolia } from "viem/chains";
export const publicClient = createPublicClient({
chain: sepolia,
transport: http(),
});
export const walletClient = createWalletClient({
chain: sepolia,
transport: custom(window.ethereum),
});2. Encryptor#
The browser encryptor wraps Zama's RelayerWeb. The user signs an authorization once; subsequent encryptions reuse that signature for the relayer.
import { createSepoliaEncryptorWeb } from "@tokenops/sdk/fhe";
import { publicClient, walletClient } from "./viem";
// Browser-flavour encryptor wraps Zama RelayerWeb. Resolves the user's
// wallet to sign the per-encrypt authorization.
export const encryptor = await createSepoliaEncryptorWeb({
publicClient,
walletClient,
});3. Deploy + vest#
Two SDK calls, one tx each. The amount is encrypted client-side; the contract stores a 32-byte handle and grants ACL to the operator + the recipient atomically.
import { parseEventLogs } from "viem";
import {
createConfidentialVestingFactoryClient,
createConfidentialVestingManagerClient,
confidentialVestingManagerAbi,
} from "@tokenops/sdk/fhe-vesting";
// Pre-deployed Sepolia factory, DEPLOYED_ADDRESSES resolves it by chainId.
const factory = createConfidentialVestingFactoryClient({
publicClient,
walletClient,
});
// 1. Deploy a manager clone (one tx).
const { hash: deployHash, manager: managerAddress } =
await factory.createManagerAndGetAddress({
token: "0xYOUR_ERC7984_TOKEN_ON_SEPOLIA",
userSalt: "0x".padEnd(66, "0").slice(0, 65) + "a", // any unique 32-byte salt
});
// 2. Mount a per-clone manager client.
const manager = createConfidentialVestingManagerClient({
publicClient,
walletClient,
address: managerAddress,
encryptor,
});
// 3. Create a vesting, the amount is encrypted client-side before submit.
const vestingHash = await manager.createVesting({
params: {
recipient: "0xRECIPIENT_ADDRESS",
startTimestamp: Math.floor(Date.now() / 1000),
endTimestamp: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
cliffSeconds: 0,
releaseIntervalSecs: 86_400, // unlock per-day
timelockSeconds: 0,
initialUnlockBps: 0,
cliffAmountBps: 0,
isRevocable: false,
},
amount: 100_000n, // bigint, encrypted to euint64 before submit
});
// createVesting returns the tx hash only; parse VestingCreated for the vestingId.
const receipt = await publicClient.waitForTransactionReceipt({ hash: vestingHash });
const [vestingEvent] = parseEventLogs({
abi: confidentialVestingManagerAbi,
eventName: "VestingCreated",
logs: receipt.logs,
});
const vestingId = vestingEvent.args.vestingId;
console.log({ deployHash, managerAddress, vestingHash, vestingId });Run it live#
Connect your wallet (top-right pill) on Sepolia, edit the token field if you have your own ERC-7984 deployment, and click Run on Sepolia. The same Monaco editor + runner that powers the stories, the tx broadcasts for real.
What you have now#
deployHash + vestingHash on Etherscan, a vestingId you can pass to useGetVestedAmount / useClaim, and a manager clone you can keep opening vestings inside without re-deploying. The recipient can now call useDecryptedHandle against the vesting's encrypted view handles to see their balance.
Optional: 2–3× faster encryption#
Want 2–3× faster encryption? Set threads: 4–8 on Zama's RelayerWeb — this requires cross-origin isolation, because multi-threaded WASM runs on SharedArrayBuffer, which browsers only enable on cross-origin-isolated pages.
createSepoliaEncryptorWeb keeps its config minimal and stays single-threaded — it exposes no threads option. To opt in, wire Zama's RelayerWeb (which takes threads) as the encryptor instead — the provider setup shown in the Next.js App Router and Vite SPA recipes — and serve your app with the headers below.
// next.config.mjs — cross-origin isolation for threads > 1
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
],
},
];
},
};
export default nextConfig;Two things to know before flipping the switch. Cross-origin isolation is page-wide: Cross-Origin-Embedder-Policy: require-corp blocks cross-origin iframes, scripts, and images that do not send CORP or CORS headers, so audit your embeds first. And the setting fails soft: with threads > 1but no isolation, Zama's SDK logs a warning and encryption keeps working single-threaded — nothing breaks, it is just not faster. React Native is a separate story (no browser worker at all): see the React Native recipe.