3.4.x
Release notes for the 3.4.x line.
This page covers the 3.4.x line.
3.4.0
Released 2026-07-30.
The SDK's internal FHE backend moved from @zama-fhe/relayer-sdk to @fhevm/sdk 1.x. The high-level surface is unchanged — createConfig, Token / WrappedToken, sdk.encrypt, sdk.decryption.*, and the React hooks all behave exactly as before, so Token and hooks apps need no changes. What the new backend adds is a richer set of FHE runtime and per-chain tuning knobs on createConfig, documented below.
FHE runtime and client tuning
createConfig accepts a process-wide runtime object — the @fhevm/sdk runtime config. It configures the FHE engine itself (applied once per process): how the WASM assets load, threading, module versions, and a fallback relayer auth.
const config = createConfig({
chains: [sepolia],
publicClient,
walletClient,
relayers: { [sepolia.id]: web() },
runtime: {
// How the TFHE/KMS WASM is loaded (default "auto")
wasmAssetLoadMode: "auto",
// Set true to force single-threaded WASM (no SharedArrayBuffer)
singleThread: false,
// Size of the multi-threaded WASM worker pool (defaults to hardware concurrency)
numberOfThreads: 8,
// Fallback auth applied to every chain's relayer requests.
auth: { type: "ApiKeyHeader", value: process.env.RELAYER_API_KEY! },
},
});Every field of runtime is optional:
wasmAssetLoadMode
WasmAssetLoadMode
"auto"
How the TFHE/KMS WASM assets are fetched and instantiated (modes below).
singleThread
boolean
false
Force single-threaded WASM. Required when SharedArrayBuffer is unavailable (no cross-origin isolation headers).
numberOfThreads
number
hardware concurrency
Size of the multi-threaded WASM worker pool. Ignored when singleThread is true.
moduleVersions
"auto" | { tfhe?; kms?; checkCompatibility? }
"auto"
Pin the TFHE/KMS WASM module versions instead of auto-resolving from the chain's on-chain protocol version. checkCompatibility is "throw" (default) | "warn" | "off".
locateFile
(file: string) => URL
—
Remap where the WASM assets are served from — set this when self-hosting them.
auth
{ type; value; … }
—
Process-wide fallback relayer authentication, applied to any chain that doesn't set its own auth.
The runtime's logger field is managed by the SDK — pass your logger via createConfig's top-level logger instead.
wasmAssetLoadMode controls how the FHE WASM assets are fetched and instantiated:
auto (default)
Pick the best mode available in the current environment.
embedded-base64
Use the WASM inlined as base64 — no separate network fetch. Useful under strict CSP or offline.
verified-blob
Fetch the WASM, verify its integrity, then instantiate from the verified blob.
precheck-direct-url
Load directly from the asset URL after a precheck request.
trusted-direct-url
Load directly from the asset URL with no precheck (fastest, least defensive).
runtime.auth is a process-wide fallback; a per-chain auth on the chain preset takes precedence for that chain. Note the discriminator differs by scope: runtime.auth uses @fhevm/sdk's native type field ({ type: "ApiKeyHeader", value }), whereas a chain's auth uses the SDK's __type field. See Authentication for the auth methods.
runtime is applied once per process and can't be changed afterward. The @fhevm/sdk runtime is a process-global singleton, applied by the first createConfig call. A later createConfig never reconfigures it — the original stays in effect, and a warning is logged ("runtime configuration is already set and cannot be changed."). Set runtime on your first createConfig; per-chain tuning that must vary belongs in each transport factory's options, not in runtime.
Each transport factory (web() / node() / cleartext()) also takes an optional options object that tunes the @fhevm/sdk client for that one chain, at construction:
timeout and debug set request defaults for every relayer round-trip on that chain, and a per-call value overrides them. See Authentication for the auth methods and Configuration for the full config reference.
Share one transport key pair across signers (B2B2C / WaaS)
Wallet-as-a-Service and B2B2C operators can now collapse per-signer transport key pairs into a single shared one. By default the SDK generates one ML-KEM transport key pair per signer address — the right isolation boundary when signers sit on separate devices behind separate trust boundaries. An operator holding thousands of client wallets behind one operator-controlled key store gets no isolation from that split (a breach of the store exposes every wallet regardless), so the per-signer key pairs are pure overhead. Set transportKeyPairScope on createConfig to an opaque, non-empty identifier — a tenant ID, typically — and every signer configured with that scope reads and creates the same key pair:
Sharing only works if every signer in the scope reads and writes the same storage instance. asyncLocalStorage — the storage recommended for Node.js servers — isolates a fresh, empty store per request by design, which defeats a shared scope entirely: each request would regenerate the "shared" key pair and discard it immediately after. Use one persistent GenericStorage (e.g. a database- or Redis-backed adapter) wired into every ZamaSDK instance that shares the scope instead.
Two operator-level calls manage the scope's lifecycle, and neither needs a connected wallet: sdk.permits.warmTransportKeyPairScope(scopeId) creates the key pair up front so a first wave of end-users doesn't race each other to create it (prefer it over warmTransportKeyPair(), which is gated on a connected wallet account and silently no-ops without one), and sdk.permits.revokeTransportKeyPair(scopeId) deletes it for operator-level rotation. Both require scopeId to match the configured scope and throw ConfigurationError otherwise. Permits stay per-signer whatever the scope, and the signer-level sdk.permits.clear() / revokePermits() never touch the shared key pair — one end-user disconnecting must never invalidate the whole cohort.
There is no breaking change — the scope is opt-in and purely additive. Omitting transportKeyPairScope resolves it to undefined and preserves the existing one-key-pair-per-signer behavior exactly. See Configuration for the setup, Security Model for when the tradeoff is the right one, and Permit Model for how revocation splits into signer-level and operator-level tiers.
Delegation status in one call
Check whether a delegation is live and when it expires in a single call. sdk.delegations.getStatus() returns { isActive, expiryTimestamp } together, so code that renders a delegation panel no longer has to call isActive() and getExpiry() separately and reconcile the two results. It costs one ACL read instead of two, and like both of those methods it is signer-independent — a read-only status check works without a connected wallet. This is an addition: isActive() and getExpiry() are unchanged and still supported.
expiryTimestamp is the raw ACL value and has three states: 0n means no delegation exists, 2n ** 64n - 1n (uint64 max) means the delegation is permanent, and any other value is a UTC Unix timestamp in seconds. getStatus() folds those cases into isActive for you, and only reads the chain's block timestamp to compare when the expiry is a real date.
The result shape is exported as the DelegationStatus type from @zama-fhe/sdk. In React, the existing useDelegationStatus hook already returns this data and needs no changes.
DelegationStatusData was renamed to DelegationStatus on the @zama-fhe/sdk/query subpath, with no compatibility alias kept. The rename is type-only — update the import if you referenced the old name directly.
See Check delegation status for the full flow.
Automatic chunking for large decrypt batches
Decrypting many encrypted values in one call now works no matter how large the batch. The KMS gateway caps each decryption request at 2048 cleartext bits — around 32 euint64 values, but as few as 8 if they are euint256 — and a batch whose values summed past that cap was rejected outright. Anyone batching heavily (a server-side indexer decrypting amounts pulled from event logs, a dashboard resolving a page of balances) had to guess a safe batch size against an undocumented limit, and any guess broke as soon as a batch mixed widths, because the cost of a value depends on its FHE type: ebool costs 2 bits, eaddress 160, euint256 256.
The SDK now sizes the requests for you. sdk.decryption.decryptValues still groups encrypted values by contract, then splits each group into sub-requests whose cumulative bit cost stays under the budget, so an oversized batch becomes several relayer calls instead of one rejected one. delegatedDecryptValues, delegatedBatchDecryptValues, Token.batchDecryptBalancesAs, and the React hooks over them (useDecryptValues, useBatchDecryptBalancesAs) share the same code path and get the same behavior. There is nothing to configure and nothing to change: no new option, no new constant to import, and identical call signatures, results, and caching. Public decryption (decryptPublicValues) and the low-level sdk.relayer.* methods bypass this path and still send exactly the values you hand them.
Two-signature shield with wrap()
WrappedToken.wrap() lets you drive the two-transaction shield path as two separately-triggered calls, so your UI can render distinct "approve" and "shield" steps — or approve well ahead of the wrap — instead of firing both wallet prompts from inside a single shield(). It pairs with the existing approveUnderlying(): the first signature grants the wrapper an allowance on the underlying ERC-20, the second wraps that amount into confidential tokens. In React the same split is available as useWrap alongside useApproveUnderlying, and a successful wrap invalidates the confidential balance and underlying allowance caches for you.
This is purely additive — shield() is unchanged and remains the recommended surface. On a non-ERC-1363 underlying it was always sending these same two transactions; what's new is being able to trigger each one yourself without losing the SDK's checks. wrap() reads your ERC-20 balance and the allowance granted to the wrapper before submitting (public reads, no signing) and throws InsufficientERC20BalanceError or the new InsufficientAllowanceError (code INSUFFICIENT_ALLOWANCE) instead of letting the transaction revert on-chain. WrapOptions accepts to to mint the confidential balance to a different recipient and onWrapSubmitted to observe the transaction hash; the call resolves to the usual { txHash, receipt }.
wrap() is an escape hatch, not a replacement for shield(). It only ever sends the wrapper's wrap call — never transferAndCall — so recreating shield() from approveUnderlying() + wrap() by hand gives up automatic ERC-1363 routing and approvalStrategy handling, and costs an extra transaction on underlyings that do support ERC-1363. Reach for it only when you genuinely need the two signatures as independently-triggered steps. See Manual approve + wrap for the full flow.
Uniform retryability signal across error causes
Every retryable ZamaError now exposes that fact the same way, so you don't have to hardcode which error codes are worth retrying. 3.3.0 introduced typed decryption error causes with a per-error "Retry?" note in the docs; that note is now a real, compiler-checked field. ZamaError carries a readonly retryable: boolean, defaulted per ZamaErrorCode from an exhaustiveness-checked map — a new transient cause can't be added without declaring its retryability, so it can't silently default to the wrong signal.
Two functions read that signal instead of instanceof-checking each subclass yourself:
Five causes are transient today — RpcRateLimitError, RelayerRequestFailedError (only on a 429, or an @fhevm/sdk relayer timeout), DelegationNotPropagatedError, DelegationCooldownError, and WalletAccountNotReadyError — but the point of isRetryable() is that you never need to enumerate them yourself. retryAfterSeconds() unifies the retryAfter field that previously lived only on RelayerRequestFailedError and RpcRateLimitError; it's undefined when the error isn't retryable, or is retryable but carries no server-suggested delay. This is additive — matchZamaError and the error hierarchy from 3.3.0 are unchanged. See Retry transient failures for the full guide.
Bug fixes
Correct Hoodi
KMSVerifierpreset address. The Hoodi testnet preset shipped a staleKMSVerifieraddress; it now points at the deployed contract, so decryption on Hoodi works with the built-in preset.Correct
ethers-backed integer types to matchviem.EthersProvider.readContractnow narrows small Solidity integers the same wayViemProvideralready did, so reads likeToken.decimals()— typedPromise<number>— return a realnumberunderethersinstead of abigintslipping through.WrappedToken.unwrap()/unwrapAll()now returnUnwrapResult. The escape-hatch unshield methods (anduseUnwrap/useUnwrapAll) return the decodedunwrapRequestIdalongside the transaction result, so you can pass it straight tofinalizeUnwrap()without re-decoding theUnwrapRequestedevent yourself.WrappedToken.unshield/useUnshieldalready handled this internally and are unaffected.
Last updated