Who is affected: anyone who installs the Zama peers, passes a raw relayer or a custom encryptor, calls useDecryptedHandle, or tunes createSepoliaEncryptor. Every product client signature is unchanged, and so are the SDK's own encryptUint64 / encryptUint64Batch outputs. If you only call product clients with an encryptor built by createSepoliaEncryptor, createSepoliaEncryptorWeb or createMockEncryptor, the peer bump and the runtime notes are all that apply.
Peer ranges#
| Package | 1.x and every 2.0 alpha | 2.0 release candidate |
|---|---|---|
@zama-fhe/sdk | ^3.0.0 | ~3.6.0 |
@zama-fhe/react-sdk | ^3.0.0 | ~3.6.0 |
pnpm add @zama-fhe/sdk@~3.6.0
# React hooks:
pnpm add @zama-fhe/react-sdk@~3.6.0Only 3.0.x ever worked under ^3.0.0: 3.1 dropped the root RelayerWeb, SepoliaConfig and MainnetConfig exports the 1.x /fhe helpers import. No single range covers 3.0 and 3.6, so both peers move in one jump. The range is ~, not ^, because upstream removes APIs in minor releases. 3.6 also drops HardhatConfig, so a local-test setup built on it moves to createConfig too.
wagmi v2 and v3#
The wagmi peer stays ^2.0.0 || ^3.0.0 and the TokenOps hooks build on both. If you build your ZamaProvider config with createConfig from @zama-fhe/react-sdk/wagmi, note that 3.6.0's adapter reads getConnection and watchConnection from wagmi/actions, which only wagmi v3 exports. On wagmi v2 a webpack build (next build on Next.js 15, next build --webpack on 16) fails with Attempted import error; Turbopack builds it. Move to wagmi v3, or build the config with @zama-fhe/sdk/viem.
Encryptor#
The Encryptor contract, one definition re-exported by /fhe, /fhe-vesting, /fhe-airdrop and /fhe-disperse, now mirrors ZamaSDK.encrypt:
// Before (3.0): mirrored RelayerSDK.encrypt
encrypt(params: {
values: FheValueInput[];
contractAddress: Address;
userAddress: Address;
}): Promise<{ handles: Uint8Array[]; inputProof: Uint8Array }>;// After (3.6): mirrors ZamaSDK.encrypt
encrypt(params: {
readonly values: readonly FheValueInput[];
contractAddress: Address;
userAddress: Address;
}): Promise<{ readonly encryptedValues: readonly Hex[]; inputProof: Hex }>;A custom encryptor returns one hex bytes32 handle per input value, in input order, plus the hex input proof:
import { bytesToHex, type Address } from "viem";
import type { Encryptor } from "@tokenops/sdk/fhe";
// A backend that still produces bytes: convert to one bytes32 hex handle per
// input value, in input order, plus the hex input proof.
export const encryptor: Encryptor = {
async encrypt({ values, contractAddress, userAddress }) {
const { handles, inputProof } = await myBackend.encrypt(values, contractAddress, userAddress);
return {
encryptedValues: handles.map((h: Uint8Array) => bytesToHex(h)),
inputProof: bytesToHex(inputProof),
};
},
};FheValueInput#
FheValueInput narrows to upstream's EncryptInput, and every field is readonly:
| Type | Value |
|---|---|
| euint8, euint16, euint32, euint64, euint128, euint256 | bigint |
| ebool | boolean | 1n | 0n |
| eaddress | Address |
| euint160, ebytes64, ebytes128, ebytes256 | Removed |
ebool no longer takes an arbitrary bigint; pass a boolean, 1n or 0n.
Passing a ZamaSDK#
sdk.relayer no longer has encrypt, so the old React hint fails to compile. Pass the ZamaSDK itself, eagerly or lazily; it satisfies Encryptor with no cast:
const zamaSDK = useZamaSDK();
// Before (3.0)
const create = useCreateVesting({ address, encryptor: () => zamaSDK.relayer });
// After (3.6)
const create = useCreateVesting({ address, encryptor: () => zamaSDK });Node: RelayerNode to createConfig#
// Before (3.0)
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk";
const relayer = new RelayerNode({
transports: { [SepoliaConfig.chainId]: /* ... */ },
});Replace a hand-built RelayerNode with the SDK helper, or build your own ZamaSDK:
// After: the SDK helper
import { createSepoliaEncryptor } from "@tokenops/sdk/fhe";
const encryptor = await createSepoliaEncryptor({ rpcUrl: process.env.RPC_URL! });
// ... product clients take { encryptor } as before
encryptor.terminate();// After: build your own ZamaSDK
import { createConfig, ZamaSDK } from "@zama-fhe/sdk";
import { node, sepolia } from "@zama-fhe/sdk/node";
import { ViemProvider } from "@zama-fhe/sdk/viem";
import { createConfidentialVestingManagerClient } from "@tokenops/sdk/fhe-vesting";
const sdk = new ZamaSDK(
createConfig({
chains: [{ ...sepolia, network: rpcUrl }],
provider: new ViemProvider({ publicClient }),
relayers: { [sepolia.id]: node() },
}),
);
// A ZamaSDK satisfies Encryptor with no cast.
const vesting = createConfidentialVestingManagerClient({ publicClient, walletClient, address, encryptor: sdk });UserDecryptor and useDecryptedHandle#
UserDecryptor (/fhe/react) mirrors Decryption.decryptValues instead of userDecrypt(params). The ZamaSDKassembles the EIP-712 permit and keypair through its own signer, so the hook's relayerParams option is removed.
interface UserDecryptor {
decryptValues(
encryptedInput: { encryptedValue: Hex; contractAddress: Address }[],
options?: { signal?: AbortSignal; timeout?: number },
): Promise<Record<Hex, boolean | number | bigint | string | undefined>>;
}const sdk = useZamaSDK();
// Before (3.0)
const before = useDecryptedHandle({
handle,
contractAddress,
userDecryptor: () => sdk.relayer,
relayerParams: { ...keypair, requestValidity, contractsChainId },
});
// After (3.6)
const { status, value } = useDecryptedHandle({
handle,
contractAddress,
userDecryptor: () => sdk.decryption,
account: address, // useConnection().address on wagmi v3, useAccount().address on v2
});- Upstream decrypts
euint8toeuint32to a number. The hook still resolves abigintand reports any other value type, or a missing entry, asTokenOpsValidationError. - Decryptor failures go through the SDK's Zama error mapping:
NOT_ENTITLEDsurfaces asUserDecryptNotAllowedError, re-exported from/fhe/reactwithDecryptionFailedError. - Pass
account. AZamaSDKkeeps the samedecryptionobject across wallet switches, so without it the hook keeps showing the previous account's value whilehandleandcontractAddressstay the same. - Pass a stable decryptor:
sdk.decryptionor() => sdk.decryption. An object literal built inline is a new decryptor every render, and the hook then fails closed with aTokenOpsValidationErroruntil it keeps its identity. - The hook aborts its in-flight request when its inputs change. Outside React, call
sdk.decryption.decryptValues(...)directly; aZamaSDKused for decryption needs a signer.
createSepoliaEncryptor and createSepoliaEncryptorWeb#
The names, the return shape (an Encryptor plus instance, chainId and terminate) and onPhase are unchanged. The browser helper moved to its own subpath:
// Before: the browser helper lived in the /fhe subpath.
// After:
import { createSepoliaEncryptorWeb } from "@tokenops/sdk/fhe/web";That move is what lets a browser app bundle @tokenops/sdk/fhe without the optional @zama-fhe/sdk peer; see Browser encryptor. The Node helper drops its pool and artifact-cache options:
// Before (1.x)
const encryptor = await createSepoliaEncryptor({
poolSize: 4,
fheArtifactStorage: myStorage,
fheArtifactCacheTTL: 86_400,
relayerUrl: "https://relayer.testnet.zama.org/v2",
});
// After (2.0): relayerUrl defaults to https://relayer.testnet.zama.org (no /v2)
const encryptor = await createSepoliaEncryptor({
rpcUrl: process.env.RPC_URL!,
});| Change | Detail |
|---|---|
| instance is a ZamaSDK | Still typed unknown; cast to import("@zama-fhe/sdk").ZamaSDK. The Node helper builds it without a signer, so user decryption through it throws. |
| New rpcUrl (Node) | Sets the chain's network and the read provider. The preset's default public RPC is shared and rate-limited, so pass your own. |
| Removed poolSize, fheArtifactStorage, fheArtifactCacheTTL | node() has no worker pool or artifact cache. SepoliaEncryptorStorage is removed too. |
| chainId is validated | Anything other than 1 or 11155111 throws InvalidArgumentError instead of silently using the Sepolia config. |
| Web helper uses its clients | createSepoliaEncryptorWeb builds the ZamaSDK from the /viem createConfig with publicClient and walletClient; the wallet client is the signer. |
| logger | Forwarded to createConfig; any subset of debug / info / warn / error works. |
| New auth (both), relayer API key | Set as the chain's relayer auth (RelayerAuth). The Zama-hosted mainnet relayer requires an API key; pass it from server code only. |
| New offload* (web), bundling | offloadEncrypt, offloadWorker and offloadTimeouts forward to web(), for bundlers that do not emit the encrypt worker or CSPs that restrict worker sources. |
Runtime behaviour#
- Relayer URL. The Sepolia preset's
relayerUrlishttps://relayer.testnet.zama.org, with no/v2suffix. Drop the suffix if you pass it explicitly. - node() encrypts on the calling thread. 3.0's
RelayerNoderan encryption in a worker pool sized bypoolSize; 3.6 has no pool, so each encrypt blocks the event loop while it runs. A server that encrypts per request should scale out across processes or move encryption to a queue of its own. - A failed init does not recover. If the first encrypt fails to load the WASM or fetch the key material, the
ZamaSDKkeeps the failure: callterminate()and build a new encryptor. runtime.numberOfThreadsis process-global.createSepoliaEncryptorWeb({ threads })maps to it. It is set once per page; setting a different value later throws.
Local testing with createMockEncryptor#
@fhevm/mock-utils@0.4.2 imports @zama-fhe/relayer-sdk at runtime, and @zama-fhe/sdk 3.6 no longer installs it. Pin it yourself next to the mock; without it, createMockEncryptor throws MissingPeerDependencyError naming the package.
pnpm add -D @fhevm/mock-utils@^0.4.2 @zama-fhe/relayer-sdk@0.4.1 @zama-fhe/sdk@~3.6.0 ethers/fhe-vesting, /fhe-airdrop and /fhe-disperse client keeps its signature. Once the encryptor and decryptor are on the 3.6 shape, the product code around them compiles as before.