For the complete documentation index, see llms.txt. This page is also available as Markdown.

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.

Expect a wallet signature after upgrading. The new backend persists the full signed permit (the EIP-712 payload and its signature) where earlier versions stored only the raw signature, and the two formats are not interchangeable. Permits cached by an earlier version fail validation on read and are discarded rather than migrated, so the first decryption on each chain after upgrading prompts the user to sign a permit once more. Subsequent decrypts reuse the stored permit silently.

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:

Option
Type
Default
Purpose

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:

Mode
Behavior

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.

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:

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.

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 }.

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 KMSVerifier preset address. The Hoodi testnet preset shipped a stale KMSVerifier address; it now points at the deployed contract, so decryption on Hoodi works with the built-in preset.

  • Correct ethers-backed integer types to match viem. EthersProvider.readContract now narrows small Solidity integers the same way ViemProvider already did, so reads like Token.decimals() — typed Promise<number> — return a real number under ethers instead of a bigint slipping through.

  • WrappedToken.unwrap() / unwrapAll() now return UnwrapResult. The escape-hatch unshield methods (and useUnwrap / useUnwrapAll) return the decoded unwrapRequestId alongside the transaction result, so you can pass it straight to finalizeUnwrap() without re-decoding the UnwrapRequested event yourself. WrappedToken.unshield / useUnshield already handled this internally and are unaffected.

Only code that constructs low-level relayer objects directly should review its usage against the new backend before upgrading. Staying on createConfig + Token / WrappedToken + the React hooks keeps you fully insulated.

Last updated