2.0 RC docsView 1.x docs
Concept · Bundling + Node servers

Ship the encryptor in a bundle or a server

What the browser encryptor needs from Next.js, Vite and webpack, the CSP it needs, and how to run it in Node.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.

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.

PieceWhere it comes fromBundler 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 WASMEmbedded as base64 inside @fhevm/sdk chunks, compiled with WebAssembly.compileNone: no .wasm file or loader
Threaded TFHE sub-workersblob: URLs inside the encrypt worker, only when cross-origin isolatedNone
FHE public key and CRSDownloaded 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, /fhe or any product subpath from a Server Component or SSR module is safe.
  • Call createSepoliaEncryptorWeb only in the browser: an effect, an event handler or a lazily created singleton.
  • Don't read window at module scope in a "use client" file: client components also render on the server.

Per bundler#

BundlerSetup
Next.js App RouterNo 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 buildNo configuration: it rewrites the worker URL and emits assets/encrypt.worker-<hash>.js.
vite devPre-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 5No configuration. The worker chunk resolves against output.publicPath; a CDN publicPath must serve it same-origin, or pass a same-origin offloadWorker.
webpack 4Does not understand the pattern. Copy encrypt.worker.js into your static directory and pass offloadWorker: "/encrypt.worker.js".
src/encryptor.ts
ts
/// <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#

DirectiveNeedsWhy
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-srcYour RPC, the relayer (or your proxy origin on mainnet), and the FHE key hostRelayer calls, eth_calls, and the key and CRS download
connect-srcdata: (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.

next.config.ts
ts
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#

SymptomCause and fix
Encrypt offload unavailable, encryption falls back to the calling threadWorker 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_UNAVAILABLESame, with offloadEncrypt: true.
InvalidUrlError: Invalid relayerUrl: cannot parse as URLrelayerUrl is a bare path; pass an absolute URL.
Relayer 403 on mainnetNo API key. See the relayer API key page.
This browser does not support threadsthreads > 1 on a page that is not cross-origin isolated.
The first encrypt fails and every retry fails the same wayThe 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:

server.ts
ts
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 rpcUrl the 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 nonceManager to privateKeyToAccount, or serialize writes.
  • User decryption needs a signer. The helper's instance has none. On a multi-user server, build your own ZamaSDK with a signer and give each request its own storage with asyncLocalStorage from @zama-fhe/sdk/node.
  • Shut down cleanly. Call encryptor.terminate() on SIGTERM, and in a finally in 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.

ZamaFromWagmi.tsx
tsx
"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:

PeerRangeOptional
@tanstack/react-query^5.0.0yes
@zama-fhe/react-sdk~3.6.0yes
@zama-fhe/sdk~3.6.0yes
react>=18.0.0yes
viem^2.47.0no (required)
wagmi^2.0.0 || ^3.0.0yes
  • engines: node >=22. pnpm is no longer declared, so pnpm 9 no longer warns (or fails under engine-strict) on install.
  • @tokenops/sdk/package.json is exported, so version probes and bundler plugins that read the manifest resolve it instead of hitting ERR_PACKAGE_PATH_NOT_EXPORTED. The installed exports map has 18 entries, the manifest included.
  • skipLibCheck: false: @zama-fhe/sdk3.6's declarations import types from ethers and @tanstack/query-core and use Disposable. Keep skipLibCheck: true, or add ethers@^6 and @tanstack/query-core@^5 as dev dependencies and include esnext.disposable in lib.
import pkg from "@tokenops/sdk/package.json" with { type: "json" };

console.log(pkg.version);

See also