#3076·fhevm

js-sdk: CoreFhevm.init() is unbounded and memoizes failure — wedges the client

Author: ghermetCreated Jul 9, 2026Updated Jul 22, 2026

Summary

CoreFhevm.init() cannot be bounded by a caller, and it memoizes a failed init — so a slow or flaky setup phase hangs indefinitely and permanently wedges the client. Verified against the current js-sdk HEAD (0c30b139d) and the published @fhevm/[email protected].

Details

init() (sdk/js-sdk/src/core/runtime/CoreFhevm-p.ts:266-282) takes no arguments, and ready is just a getter delegating to it (same memoization):

init: {
  value: (): Promise<void> => {
    this.#readyPromise ??= Promise.all(
      [...this.#initFns].map((fn) => fn(this)),
    ).then(() => {});
    return this.#readyPromise;
  },
  ...
},
ready: {
  get: (): Promise<void> => this.init(),
  ...
},

So no timeout/signal can reach the work the init fns drive on first encrypt/decrypt:

  • on-chain protocol-version resolutionensureResolvedProtocolVersionresolveProtocolContext does an ACL-version readContract over RPC (sdk/js-sdk/src/core/runtime/ProtocolVersionResolver-p.ts). A stalled RPC hangs here.
  • the ~50 MB FHE encryption key fetchfetchFheEncryptionKeyBytes(f, {}) is called with empty options (sdk/js-sdk/src/core/clients/decorators/encrypt-p.ts:18), so no signal is threaded even internally; the underlying fetch has no default timeout.
  • the WASM module loadinitTfheModule / initTkmsModule.

The timeout/signal on relayer operations only arm RelayerAsyncRequest's deadline, which is started inside run() — strictly after init() has resolved, and only for the four relayer-request kinds (input-proof, public-decrypt, user-decrypt, delegated-user-decrypt). The init phase is entirely outside any bound.

Worse than a missing bound: the ready-promise is memoized with ??= and never cleared on rejection (the only resets are the constructor and extend()). A rejected init() is therefore returned forever — every later call on that client instance gets the same stuck/rejected promise. The only recovery is constructing a brand-new client.

Impact

Realistic, not theoretical: a slow/flaky CDN mid-WASM-download, a stalled 50 MB key fetch on a bad connection, or an RPC endpoint down during protocol-version resolution all hang indefinitely today, regardless of any timeout the caller set. A wrapping SDK can't fix this from the outside — it can't reach the private #readyPromise to reset it, and racing init() from the calling thread leaves the underlying memoized promise stuck.

Requests

  1. Let init() accept { timeout?: number; signal?: AbortSignal } and thread the signal into the protocol-version read, the key fetch, and the WASM load.
  2. Don't memoize a rejected ready-promise — reset #readyPromise = undefined in a .catch (or expose a reset) so a transient init failure is retryable without recreating the client.

Context

Surfaced while migrating @zama-fhe/sdk onto @fhevm/sdk as its FHE backend (zama-ai/sdk#458).