userDecrypt fails on mainnet: persisted transport key pair loses tkmsVersion, deserializer defaults to 0.13.20-0 → TkmsVersion mismatch
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/sdk1.1.0-alpha.8(core; observed request header also reported1.1.0-alpha.9)@zama-fhe/sdk3.4.0-alpha.9(wrapper),@zama-fhe/react-sdk3.4.0-alpha.9- Browser (IndexedDB credential storage via
indexedDBStorage) - Chain: Ethereum mainnet (chainId 1), FHEVM protocol
< 0.13.0→ resolves tkms0.13.10/ tfhe1.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) andGET .../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 todecryptKmsSigncryptedShares.
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
- Configure the SDK for a chain whose protocol is
< 0.13.0(Ethereum mainnet), usingindexedDBStorage. - Connect a wallet with a non-zero confidential balance and sign a decryption permit (persists the transport key pair).
- Reload the page (key pair now loaded from storage) and trigger
userDecrypt. - Decrypt fails with
DecryptionFailedError/TkmsVersion mismatch, despite the relayer returningsucceeded.
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+ sepolia0.13.20-0), a single account-scoped key pair cannot be valid on both chains at once.
Suggested fixes
- Persist
tkmsVersionwith the key pair. The storedStoredTransportKeyPairschema (@zama-fhe/sdk+indexedDBStorage) should round-trip thetkmsVersionthatSerializeFn/toJSONalready expose, sotoTransportKeyPaircan recover it. - Don't default an unknown/absent version to a concrete one. In
toTransportKeyPair, treat a missing/unrecognizedtkmsVersionas invalid → regenerate the key pair (or resolve it from the livecontext), rather than silently assuming0.13.20-0and then hard-failing the guard against older-protocol chains. - 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.
- Surface a typed, actionable error. Emit a dedicated error (e.g. a
TransportKeyPairVersionMismatchunderZamaErrorCode) instead of a bareError('TkmsVersion mismatch'), so integrators can auto-recover (evict + regenerate) rather than dead-end.
Source: zama-ai/fhevm