js-sdk: CoreFhevm.init() is unbounded and memoizes failure — wedges the client
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 resolution —
ensureResolvedProtocolVersion→resolveProtocolContextdoes an ACL-versionreadContractover RPC (sdk/js-sdk/src/core/runtime/ProtocolVersionResolver-p.ts). A stalled RPC hangs here. - the ~50 MB FHE encryption key fetch —
fetchFheEncryptionKeyBytes(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 load —
initTfheModule/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
- Let
init()accept{ timeout?: number; signal?: AbortSignal }and thread the signal into the protocol-version read, the key fetch, and the WASM load. - Don't memoize a rejected ready-promise — reset
#readyPromise = undefinedin 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).
Source: zama-ai/fhevm