What loads, and when#
Nothing from @zama-fhe/sdk runs when you import @tokenops/sdk/fhe/web: the helper imports the peer on its first call, and the heavy work happens on the first encrypt. The bundler still resolves those specifiers at build time, so an app that imports /fhe/web needs the peer installed. /fhe and the product subpaths reference no Zama specifier, so an app that never encrypts in the browser bundles them without it.
| Piece | Where it comes from | Bundler work |
|---|---|---|
| Encrypt worker | @zama-fhe/sdk/dist/esm/encrypt.worker.js (about 4.8 MB, self-contained), spawned with new Worker(new URL(..., import.meta.url), { type: "module" }) | Must emit it; see the per-bundler notes |
| TFHE and KMS WASM | Embedded as base64 inside @fhevm/sdk chunks, compiled with WebAssembly.compile | None: no .wasm file or loader |
| Threaded TFHE sub-workers | blob: URLs inside the encrypt worker, only when cross-origin isolated | None |
| FHE public key and CRS | Downloaded at runtime from the URLs the relayer's v2/keyurl returns (about 4.6 MB on Sepolia) | None; allow the host in CSP |
Server-side rendering#
- Importing
/fhe/web,/fheor any product subpath from a Server Component or SSR module is safe. - Call
createSepoliaEncryptorWebonly in the browser: an effect, an event handler or a lazily created singleton. - Don't read
windowat module scope in a"use client"file: client components also render on the server.
Per bundler#
| Bundler | Setup |
|---|---|
| Next.js App Router | No next.config change. next build (webpack) emits the worker as static/chunks/zama-fhe-encrypt.<hash>.js; Turbopack emits it under static/media/encrypt.worker.<hash>.js. |
| vite build | No configuration: it rewrites the worker URL and emits assets/encrypt.worker-<hash>.js. |
| vite dev | Pre-bundling breaks the worker URL, so the request 404s and encryption falls back to the main thread. Pass offloadWorker from a ?url import (below). optimizeDeps.exclude also works but leaves the peer's own dependencies un-optimized. |
| webpack 5 | No configuration. The worker chunk resolves against output.publicPath; a CDN publicPath must serve it same-origin, or pass a same-origin offloadWorker. |
| webpack 4 | Does not understand the pattern. Copy encrypt.worker.js into your static directory and pass offloadWorker: "/encrypt.worker.js". |
/// <reference types="vite/client" />
import encryptWorkerUrl from "@zama-fhe/sdk/encrypt.worker.js?url";
import { createSepoliaEncryptorWeb } from "@tokenops/sdk/fhe/web";
// vite dev pre-bundles @zama-fhe/sdk into node_modules/.vite/deps, where
// ./encrypt.worker.js does not exist. A ?url import works in dev and build.
const encryptor = await createSepoliaEncryptorWeb({
publicClient,
walletClient,
offloadWorker: encryptWorkerUrl,
});The TokenOps subpaths need "moduleResolution": "bundler" (or node16 / nodenext) in tsconfig.json; the legacy "node" setting cannot see subpath exports.
Content Security Policy#
| Directive | Needs | Why |
|---|---|---|
script-src | 'wasm-unsafe-eval' | WebAssembly.compile of the embedded TFHE / KMS modules |
worker-src | 'self' blob: | The encrypt worker (same origin) and the TFHE sub-workers (blob:) |
connect-src | Your RPC, the relayer (or your proxy origin on mainnet), and the FHE key host | Relayer calls, eth_calls, and the key and CRS download |
connect-src | data: (optional) | Fast base64 decode of the embedded WASM; without it the SDK falls back to atob |
The key host is whatever v2/keyurl returns, so read it from that response (through your proxy on mainnet) rather than hard-coding it. Browsers fall back to script-src for workers when worker-src is absent, so a script-src without blob: also blocks the sub-workers.
import type { NextConfig } from "next";
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'", // use nonces in production
"worker-src 'self' blob:",
"connect-src 'self' data: https://ethereum-rpc.publicnode.com https://*.s3.eu-west-1.amazonaws.com",
].join("; ");
const nextConfig: NextConfig = {
async headers() {
return [{ source: "/:path*", headers: [{ key: "Content-Security-Policy", value: csp }] }];
},
};
export default nextConfig;Troubleshooting#
| Symptom | Cause and fix |
|---|---|
| Encrypt offload unavailable, encryption falls back to the calling thread | Worker not emitted or blocked. Check the network tab for the worker request, then offloadWorker or CSP worker-src. |
| EncryptionFailedError whose cause has code ENCRYPT_OFFLOAD_UNAVAILABLE | Same, with offloadEncrypt: true. |
| InvalidUrlError: Invalid relayerUrl: cannot parse as URL | relayerUrl is a bare path; pass an absolute URL. |
| Relayer 403 on mainnet | No API key. See the relayer API key page. |
| This browser does not support threads | threads > 1 on a page that is not cross-origin isolated. |
| The first encrypt fails and every retry fails the same way | The one-time init failed (key download, WASM) and does not recover. terminate() and build a new encryptor. |
Node servers#
Server code uses createSepoliaEncryptor from /fhe, over the node() transport. An operator endpoint that encrypts and sends a confidential disperse on mainnet:
import express from "express";
import { createPublicClient, createWalletClient, http, isAddress, nonceManager, type Address } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
import { createSepoliaEncryptor, isTokenOpsSdkError, MAINNET_CHAIN_ID } from "@tokenops/sdk/fhe";
import { createConfidentialDisperseClient } from "@tokenops/sdk/fhe-disperse";
const rpcUrl = process.env.MAINNET_RPC_URL!;
const account = privateKeyToAccount(process.env.OPERATOR_PRIVATE_KEY as `0x${string}`, { nonceManager });
const publicClient = createPublicClient({ chain: mainnet, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: mainnet, transport: http(rpcUrl) });
// One encryptor per process, built at startup and reused for every request.
const encryptor = await createSepoliaEncryptor({
chainId: MAINNET_CHAIN_ID,
rpcUrl,
auth: { __type: "ApiKeyHeader", value: process.env.RELAYER_API_KEY! },
});
const disperse = createConfidentialDisperseClient({ publicClient, walletClient, encryptor });
const app = express();
app.use(express.json());
app.post("/disperse", async (req, res) => {
const { token, recipients, amounts } = req.body as { token: string; recipients: string[]; amounts: string[] };
if (!isAddress(token) || !recipients.every((r) => isAddress(r))) {
res.status(400).json({ error: "invalid address" });
return;
}
try {
const { hash } = await disperse.disperse({
token,
mode: "direct",
recipients: recipients as Address[],
amounts: amounts.map((a) => BigInt(a)),
});
res.json({ hash });
} catch (err) {
if (isTokenOpsSdkError(err)) {
res.status(422).json({ code: err.code, error: err.message });
return;
}
throw err;
}
});
const server = app.listen(3001);
process.on("SIGTERM", () => server.close(() => encryptor.terminate()));- Encryption blocks the event loop.
node()generates proofs on the calling thread, so a request that encrypts stalls every other request on that process. For throughput, run several processes or move encryption and sending to a queue worker; raising concurrency inside one process does not help. - One encryptor, built once. The first encrypt loads the WASM and downloads the FHE key and CRS. If that fails it does not recover:
terminate()and build a new one. - Your own RPC. Without
rpcUrlthe encryptor reads through the preset's shared, rate-limited public RPC. - One nonce stream per signer.Concurrent writes from one account race on the nonce: pass viem's
nonceManagertoprivateKeyToAccount, or serialize writes. - User decryption needs a signer. The helper's
instancehas none. On a multi-user server, build your ownZamaSDKwith a signer and give each request its own storage withasyncLocalStoragefrom@zama-fhe/sdk/node. - Shut down cleanly. Call
encryptor.terminate()onSIGTERM, and in afinallyin one-off scripts.
On Sepolia use sepolia from viem/chains, drop chainId and auth, and keep rpcUrl. If the same server fronts a browser app on mainnet, add the relayer proxy.
wagmi v2 and v3#
The wagmi peer is ^2.0.0 || ^3.0.0. wagmi v3 renamed useAccount to useConnection; the TokenOps hooks resolve the right one at runtime, so their API is identical under both majors and they build on both under webpack and Turbopack.
"use client";
import { ZamaProvider } from "@zama-fhe/react-sdk";
import { sepolia as zamaSepolia } from "@zama-fhe/sdk/chains";
import { createConfig as createZamaConfig } from "@zama-fhe/sdk/viem";
import { web } from "@zama-fhe/sdk/web";
import { useMemo, type ReactNode } from "react";
import { usePublicClient, useWalletClient } from "wagmi";
// Render inside <WagmiProvider> and <QueryClientProvider>.
export function ZamaFromWagmi({ children }: { children: ReactNode }) {
const publicClient = usePublicClient();
const { data: walletClient } = useWalletClient();
const config = useMemo(
() =>
publicClient && walletClient
? createZamaConfig({
chains: [zamaSepolia],
publicClient,
walletClient,
relayers: { [zamaSepolia.id]: web() },
})
: undefined,
[publicClient, walletClient],
);
if (!config) return <p>Connect a wallet</p>;
return <ZamaProvider config={config}>{children}</ZamaProvider>;
}Still seeing ERESOLVE after moving to wagmi v3? It almost certainly comes from another package in your tree that still pins wagmi v2. Run pnpm why wagmi (or npm ls wagmi) to find it.
Peers, engines and the package manifest#
Read from the installed package manifest:
| Peer | Range | Optional |
|---|---|---|
@tanstack/react-query | ^5.0.0 | yes |
@zama-fhe/react-sdk | ~3.6.0 | yes |
@zama-fhe/sdk | ~3.6.0 | yes |
react | >=18.0.0 | yes |
viem | ^2.47.0 | no (required) |
wagmi | ^2.0.0 || ^3.0.0 | yes |
- engines:
node >=22.pnpmis no longer declared, so pnpm 9 no longer warns (or fails underengine-strict) on install. @tokenops/sdk/package.jsonis exported, so version probes and bundler plugins that read the manifest resolve it instead of hittingERR_PACKAGE_PATH_NOT_EXPORTED. The installed exports map has 18 entries, the manifest included.skipLibCheck: false:@zama-fhe/sdk3.6's declarations import types fromethersand@tanstack/query-coreand useDisposable. KeepskipLibCheck: true, or addethers@^6and@tanstack/query-core@^5as dev dependencies and includeesnext.disposableinlib.
import pkg from "@tokenops/sdk/package.json" with { type: "json" };
console.log(pkg.version);