Why it moved#
createSepoliaEncryptorWeb loads @zama-fhe/sdk through literal import() calls so bundlers can include it. Webpack, Vite/Rollup and esbuild resolve those specifiers when they build the module graph, before any code runs. While the helper lived in /fhe, a browser app that imported anything from /fhe - even scaleRatio - failed to build without the peer.
Now only @tokenops/sdk/fhe/webreferences the peer's specifiers. Coming from the 2.0 alphas, only the import path changed. Coming from 1.6, auth and the offload* options are new, and the behaviour changes below apply: an unsupported chainId rejects instead of throwing synchronously, threads is process-global, relayer reads use an http(url)public client's RPC, and a failed init no longer retries. /fhe/web also re-exports SEPOLIA_CHAIN_ID, MAINNET_CHAIN_ID, RelayerAuth, EncryptorInitPhase, SepoliaEncryptorLogger and the Encryptor types. The peer loads on the first call, so Node without a bundler can import /fhe/web without it; the call then rejects with MissingPeerDependencyError.
import { createSepoliaEncryptorWeb } from "@tokenops/sdk/fhe/web";
import { createConfidentialVestingManagerClient } from "@tokenops/sdk/fhe-vesting";
const encryptor = await createSepoliaEncryptorWeb({ publicClient, walletClient });
const manager = createConfidentialVestingManagerClient({
publicClient,
walletClient,
address: managerAddress,
encryptor,
});
await manager.createVesting({ params, amount: 1_000_000n });Options#
| Option | Notes |
|---|---|
publicClient | Required. Host-chain reads, forwarded to the @zama-fhe/sdk/viem createConfig. Use the client you pass to the product clients. |
walletClient | Required. Becomes the ZamaSDK signer. Encryption needs no signature; the signer serves decryption on instance. |
chainId | SEPOLIA_CHAIN_ID (default) or MAINNET_CHAIN_ID. Any other value rejects with InvalidArgumentError. |
relayerUrl | Defaults to the chain preset's relayer. On mainnet, an absolute URL to your key-injecting proxy. |
auth | RelayerAuth. Leave unset in a browser, or use ApiKeyCookie for the browser-to-proxy hop. Never the mainnet API key. |
logger | Same shape as the Node helper. Missing methods become no-ops. |
onPhase | Initialization lifecycle, for a loading state around the first encryption. |
threads | Opt-in WASM thread count, forwarded as runtime.numberOfThreads. Process-global; needs a cross-origin isolated page. |
offloadEncrypt | "auto" (upstream default) uses a Web Worker and falls back to the main thread with a console.warn; true rejects instead; false always encrypts on the main thread. |
offloadWorker | A same-origin string or URL of a served copy of @zama-fhe/sdk/encrypt.worker.js, or a factory returning a fresh module Worker on every call. |
offloadTimeouts | { spawn, init } in milliseconds. Upstream defaults: spawn 10 000, init 300 000. |
The result is a SepoliaEncryptorWeb: an Encryptor plus instance (the ZamaSDK, built with the wallet client as signer and typed unknown), chainId and terminate(). Terminating is optional in a page, which frees everything on unload, but a long-lived SPA that swaps encryptors on chain switch or logout should call it.
Create it in the browser only#
Importing /fhe/web from a Server Component or SSR module is safe: nothing from @zama-fhe/sdk runs at import time. Call createSepoliaEncryptorWeb in an effect, an event handler or a lazily created singleton, since the worker, IndexedDB and WASM paths do not exist during SSR.
"use client";
import { useEffect, useState } from "react";
import type { PublicClient, WalletClient } from "viem";
import {
createSepoliaEncryptorWeb,
type EncryptorInitPhase,
type SepoliaEncryptorWeb,
} from "@tokenops/sdk/fhe/web";
export function useWebEncryptor(
publicClient: PublicClient | undefined,
walletClient: WalletClient | undefined,
) {
const [encryptor, setEncryptor] = useState<SepoliaEncryptorWeb>();
const [phase, setPhase] = useState<EncryptorInitPhase>();
useEffect(() => {
if (!publicClient || !walletClient) return;
let disposed = false;
let built: SepoliaEncryptorWeb | undefined;
void createSepoliaEncryptorWeb({ publicClient, walletClient, onPhase: setPhase }).then((e) => {
built = e;
if (disposed) e.terminate();
else setEncryptor(e);
});
return () => {
disposed = true;
built?.terminate();
setEncryptor(undefined);
};
}, [publicClient, walletClient]);
return { encryptor, phase };
}Hand the result to a hook lazily, as encryptor: () => encryptor, so the hook sees the instance once it resolves.
threads#
threads maps to runtime.numberOfThreads, which is process-global in @zama-fhe/sdk: it is set once, and setting it again with a different value throws, so every encryptor in a page must agree on it. 4 to 8 threads give about 2-3x faster encryption, but only on a cross-origin isolated page (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp). Without isolation the browser hides SharedArrayBuffer and encryption runs single-threaded; the SDK logs a warning when threads > 1 is set on a non-isolated page. Omit it for the default single-threaded mode, which needs no headers.
The encrypt worker#
@zama-fhe/sdk spawns its encrypt worker with new Worker(new URL("./encrypt.worker.js", import.meta.url)) from node_modules. Most bundlers emit it; Vite dev pre-bundling and webpack 4 do not, and a strict worker-src CSP may only allow a path you serve. That is what the three offload* options are for.
import { createSepoliaEncryptorWeb } from "@tokenops/sdk/fhe/web";
const encryptor = await createSepoliaEncryptorWeb({
publicClient,
walletClient,
// A same-origin path you serve, or a factory returning a fresh module Worker per call.
offloadWorker: () => new Worker("/encrypt.worker.js", { type: "module" }),
offloadEncrypt: true, // reject instead of falling back to the main thread
offloadTimeouts: { spawn: 10_000, init: 300_000 },
});EncryptWorkerLike#
A factory passed as offloadWorker must return every member @zama-fhe/sdk 3.6 calls. It waits for the ready message and watches for crashes through the listener methods, sends requests with postMessage(message, transfer) to transfer the key buffers, and calls terminate() on shutdown. The type is structural, so /fhe/web does not require the DOM lib; a real Worker satisfies it, and a hand-written object without the listener methods does not compile.
interface EncryptWorkerMessageEvent { readonly data: unknown }
interface EncryptWorkerErrorEvent { readonly message: string }
interface EncryptWorkerLike {
addEventListener(type: "message", listener: (event: EncryptWorkerMessageEvent) => void): void;
addEventListener(type: "error", listener: (event: EncryptWorkerErrorEvent) => void): void;
removeEventListener(type: "message", listener: (event: EncryptWorkerMessageEvent) => void): void;
removeEventListener(type: "error", listener: (event: EncryptWorkerErrorEvent) => void): void;
postMessage(message: unknown, transfer: unknown[]): void;
terminate(): void;
}Which RPC the relayer reads use#
The relayer's own encryption-init reads and its encrypt worker take an RPC URL, not a client. When the publicClient transport is http(url), that URL replaces the chain preset's public RPC for them. Any other transport (custom, webSocket, fallback) leaves them on the preset's public endpoint, which is shared and can throttle the first encryption of a busy app.
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
// http(url): the encryption-init reads and the encrypt worker use this URL too.
const publicClient = createPublicClient({ chain: sepolia, transport: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC) });onPhase#
"initializing"fires synchronously when the helper is called, before it loads the peer."downloading-params"fires immediately before the firstencrypt, which loads the WASM and fetches several MB of FHE public material. Near-instant on a warm browser cache."ready"fires once that first encrypt resolves. On failure it is not fired, the next attempt re-fires"downloading-params", and a failed init does not self-recover: callterminate()and build a new encryptor.
Phases carry no percentages, byte counts or timers: upstream exposes no download-progress API. Exceptions thrown by the callback are swallowed, so a UI observer cannot break encryption. For the bundler-specific setup, see Bundling + Node servers.