> For the complete documentation index, see [llms.txt](https://docs.zama.org/protocol/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.zama.org/protocol/sdk/changelog/v3/v3-4.md).

# 3.4.x

An internal FHE backend migration to @fhevm/sdk, FHE runtime and client tuning knobs, shared transport key pairs for B2B2C, automatic decrypt-batch chunking, and a two-signature shield via wrap().

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.

{% hint style="info" %}
**Expect a wallet signature after upgrading.** The new backend persists the full signed [permit](/protocol/sdk/concepts/permit-model.md) (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.
{% endhint %}

### 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`.

```ts
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](/protocol/sdk/guides/encrypt-decrypt.md)).                    |
| `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](/protocol/sdk/guides/authentication.md) for the auth methods.

{% hint style="warning" %}
**`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`.
{% endhint %}

Each transport factory (`web()` / `node()` / `cleartext()`) also takes an optional `options` object that tunes the `@fhevm/sdk` client for that one chain, at construction:

```ts
web({
  // Batch the client's version-resolution RPC reads into one request (default false)
  batchRpcCalls: true,
  // Reuse a pre-fetched public key to skip the ~50 MB fetch on init
  fheEncryptionKey,
  // Pin TFHE/KMS WASM versions, or auto-resolve (default)
  moduleVersions: "auto",
  // Default per-request timeout for this chain's relayer round-trips
  timeout: 60_000,
});
```

`timeout` and `debug` set request defaults for every relayer round-trip on that chain, and a per-call value overrides them. See [Authentication](/protocol/sdk/guides/authentication.md) for the auth methods and [Configuration](/protocol/sdk/guides/configuration.md) 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:

```ts
const config = createConfig({
  chains: [sepolia],
  publicClient,
  walletClient,
  relayers: { [sepolia.id]: web() },
  transportKeyPairScope: "tenant-123", // opaque identifier, e.g. your tenant ID
  storage: myPersistentStorage, // must be shared across every signer in this scope
});

const sdk = new ZamaSDK(config);

// Create the scope's key pair once, before opening concurrent traffic to it.
await sdk.permits.warmTransportKeyPairScope("tenant-123");
```

{% hint style="warning" %}
**Sharing only works if every signer in the scope reads and writes the same storage instance.** `asyncLocalStorage` — the storage recommended for [Node.js servers](/protocol/sdk/guides/configuration.md#6-optional-choose-a-storage-backend) — 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.
{% endhint %}

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](/protocol/sdk/guides/configuration.md) for the setup, [Security Model](/protocol/sdk/concepts/security-model.md#shared-tenant-scope-b2b2c-waas-operators) for when the tradeoff is the right one, and [Permit Model](/protocol/sdk/concepts/permit-model.md#two-revocation-tiers-with-a-shared-scope) 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.

```ts
const { isActive, expiryTimestamp } = await sdk.delegations.getStatus({
  contractAddress: "0xConfidentialToken",
  delegatorAddress: "0xDelegator",
  delegateAddress: "0xDelegate",
});
```

`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`](/protocol/sdk/api-references/react/usedelegationstatus.md) hook already returns this data and needs no changes.

{% hint style="warning" %}
**`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.
{% endhint %}

See [Check delegation status](/protocol/sdk/guides/delegated-decryption.md#4-check-delegation-status-optional) 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`](/protocol/sdk/api-references/react/usewrap.md) alongside [`useApproveUnderlying`](/protocol/sdk/api-references/react/useapproveunderlying.md), 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 }`.

```ts
// 1. Approve the wrapper to spend the underlying ERC-20 (first signature).
await wrappedToken.approveUnderlying(1000n);

// 2. Wrap the approved amount into confidential tokens (second signature).
const { txHash } = await wrappedToken.wrap(1000n);
```

{% hint style="warning" %}
**`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](/protocol/sdk/guides/shield-tokens.md#shielding-paths) 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](/protocol/sdk/guides/shield-tokens.md#manual-approve-wrap-escape-hatch) for the full flow.
{% endhint %}

### 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](/protocol/sdk/changelog/v3/v3-3.md#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:

```ts
import { isRetryable, retryAfterSeconds } from "@zama-fhe/sdk";

try {
  await sdk.decryption.decryptValues([{ encryptedValue, contractAddress }]);
} catch (error) {
  if (isRetryable(error)) {
    const delaySeconds = retryAfterSeconds(error) ?? 2; // backoff when the server gives no hint
    // schedule a retry after delaySeconds
  } else {
    throw error; // terminal — surface it
  }
}
```

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](/protocol/sdk/guides/handle-errors.md#8-retry-transient-failures) for the full guide.

### Bug fixes

* **Correct Hoodi `KMSVerifier` preset address.** The [Hoodi testnet](/protocol/sdk/guides/configuration.md#1-pick-your-chains) 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.

{% hint style="info" %}
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.
{% endhint %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.zama.org/protocol/sdk/changelog/v3/v3-4.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
