#3363·fhevm

userDecrypt fails on mainnet: persisted transport key pair loses tkmsVersion, deserializer defaults to 0.13.20-0 → TkmsVersion mismatch

Author: ghermetCreated Jul 29, 2026Updated Jul 29, 2026

Summary

userDecrypt always fails on Ethereum mainnet with DecryptionFailedError: Failed to decrypt encrypted values, caused internally by Error: TkmsVersion mismatch thrown in decryptKmsSigncryptedShares. The relayer/KMS round-trip succeeds and returns valid signcrypted shares — the failure is purely client-side, when the SDK reconstructs the cleartext.

Root cause: the persisted transport key pair loses its tkmsVersion, and on reload the deserializer defaults a versionless key pair to 0.13.20-0. Ethereum mainnet's on-chain protocol (< 0.13.0) resolves tkms 0.13.10, so the reloaded key pair (0.13.20-0) never matches the decrypt context (0.13.10) and the version guard throws.

Environment

  • @fhevm/sdk 1.1.0-alpha.8 (core; observed request header also reported 1.1.0-alpha.9)
  • @zama-fhe/sdk 3.4.0-alpha.9 (wrapper), @zama-fhe/react-sdk 3.4.0-alpha.9
  • Browser (IndexedDB credential storage via indexedDBStorage)
  • Chain: Ethereum mainnet (chainId 1), FHEVM protocol < 0.13.0 → resolves tkms 0.13.10 / tfhe 1.5.3
  • Multi-chain config: mainnet + sepolia enabled together (sepolia resolves the newer 0.13.20-0 / 1.6.2)

Symptom

  • All confidential balances render "Unable to decrypt".
  • The POST .../v2/user-decrypt (202) and GET .../v2/user-decrypt/{id} (200) both succeed; the relayer returns {"status":"succeeded", result: {...}} with well-formed signcrypted shares + KMS signatures.
  • Zero-value handles decrypt fine to 0n (they short-circuit before tkms); only non-zero ciphertext fails — pinpointing the failure to decryptKmsSigncryptedShares.

Observed error chain (from the app's TanStack Query cache):

DecryptionFailedError: Failed to decrypt encrypted values   (code: DECRYPTION_FAILED, retryable: false)
  └─ caused by: Error: TkmsVersion mismatch
       at decryptKmsSigncryptedShares  (@fhevm/sdk esm/core/kms)
       at decryptValuesFromPairs

Root cause

Three facts combine:

1. The version guard. core/kms/decryptKmsSigncryptedShares-p.js:

if (context.tkmsVersion !== kmsSigncryptedShares.tkmsVersion) throw new Error('TkmsVersion mismatch');
if (context.tkmsVersion !== transportKeyPair.tkmsVersion)     throw new Error('TkmsVersion mismatch');

kmsSigncryptedShares.tkmsVersion is set from resolveFhevmTkmsVersion(context) in fetchKmsSigncryptedSharesV2 — the same source as context.tkmsVersion — so the shares branch can never fire. The failing branch is the key pair comparison.

2. The persisted key pair drops tkmsVersion. The core SerializeFn includes it, but the value actually stored via @zama-fhe/sdk + indexedDBStorage is only:

{ "publicKey": "0x…", "privateKey": "0x…", "createdAt": 1785317470, "expiresAt": 1787909470 }

No tkmsVersion. (Verified directly by reading the CredentialStore / renamed IndexedDB object store — storage key keypair:{account}, with no chainId.)

3. The deserializer defaults to the newer version. core/kms/TransportKeyPair-p.js toTransportKeyPair:

const rawTkmsVersion = value.tkmsVersion;      // undefined for a persisted key pair
let resolvedTkmsVersion = '0.13.20-0';         // default = the NEWER version
if (rawTkmsVersion === '0.13.10') resolvedTkmsVersion = rawTkmsVersion;
// → reloaded key pair.tkmsVersion === '0.13.20-0'

So on mainnet (context.tkmsVersion === '0.13.10', per HyperWasmSolver rule for protocol < 0.13.0), a reloaded key pair is 0.13.20-0'0.13.10' !== '0.13.20-0'TkmsVersion mismatch on every real decrypt.

A freshly generated key pair does not hit this (it takes the live context.tkmsVersion), so the bug only manifests after the key pair has been persisted and reloaded — i.e. every returning session on mainnet.

Compatibility table for reference (HyperWasmSolver)

protocol tfhe canonical tfhe compatible kms canonical kms compatible
< 0.13.0 (mainnet) 1.5.3 ['1.5.3'] 0.13.10 ['0.13.10']
>= 0.13.0 (sepolia) 1.6.2 ['1.5.3','1.6.2'] 0.13.20-0 ['0.13.10','0.13.20-0']

Note the newer protocol lists the older crypto as backward-compatible, but the older protocol does not accept the newer crypto — so the 0.13.20-0 default is exactly the wrong direction for any < 0.13.0 chain.

Reproduction

  1. Configure the SDK for a chain whose protocol is < 0.13.0 (Ethereum mainnet), using indexedDBStorage.
  2. Connect a wallet with a non-zero confidential balance and sign a decryption permit (persists the transport key pair).
  3. Reload the page (key pair now loaded from storage) and trigger userDecrypt.
  4. Decrypt fails with DecryptionFailedError / TkmsVersion mismatch, despite the relayer returning succeeded.

Impact

  • Mainnet decryption is broken for any returning user (anyone whose key pair was persisted in a prior session). First-session/in-memory key pairs mask it, so it can pass a clean-state E2E and only surface in production.
  • The failure is silent to the app layer (DecryptionFailedError, retryable: false) with no signal that a stale/mis-versioned key pair is the cause.
  • In a multi-chain config spanning protocol generations (mainnet 0.13.10 + sepolia 0.13.20-0), a single account-scoped key pair cannot be valid on both chains at once.

Suggested fixes

  1. Persist tkmsVersion with the key pair. The stored StoredTransportKeyPair schema (@zama-fhe/sdk + indexedDBStorage) should round-trip the tkmsVersion that SerializeFn/toJSON already expose, so toTransportKeyPair can recover it.
  2. Don't default an unknown/absent version to a concrete one. In toTransportKeyPair, treat a missing/unrecognized tkmsVersion as invalid → regenerate the key pair (or resolve it from the live context), rather than silently assuming 0.13.20-0 and then hard-failing the guard against older-protocol chains.
  3. Scope the transport key pair per protocol/chain (or re-stamp it from the live context on load) so a multi-chain config across protocol generations is supported.
  4. Surface a typed, actionable error. Emit a dedicated error (e.g. a TransportKeyPairVersionMismatch under ZamaErrorCode) instead of a bare Error('TkmsVersion mismatch'), so integrators can auto-recover (evict + regenerate) rather than dead-end.