Skip to content

feat: global decryption cache; userDecrypt refactors#195

Closed
enitrat wants to merge 3 commits intoprereleasefrom
feat/unify-decrypt-hook
Closed

feat: global decryption cache; userDecrypt refactors#195
enitrat wants to merge 3 commits intoprereleasefrom
feat/unify-decrypt-hook

Conversation

@enitrat
Copy link
Copy Markdown
Contributor

@enitrat enitrat commented Apr 3, 2026

Summary

Replaces the token-specific balance-cache with a generic, handle-level decrypt cache and lifts userDecrypt to a first-class SDK method. This unifies all decryption paths (token balances, arbitrary handles, delegated decryption) through a single cache-enabled entry point.

  • Generic decrypt cache (decrypt-cache.ts): replaces balance-cache.ts. Any FHE handle from any contract can now be cached, not just token balances. Keyed by (requester, contractAddress, handle).
  • ZamaSDK.userDecrypt(): new top-level method that checks cache → groups uncached handles by contract → batch-decrypts via relayer → writes results back to cache.
  • React simplification: removes useUserDecryptedValue and useUserDecryptedValues hooks. useUserDecrypt is now the single entry point, backed by staleTime: Infinity and the persistent cache.
  • Cache invalidation on revoke: revoke() and revokeSession() now clear cached plaintext alongside credentials, preventing stale access after session/account changes.

Design decisions

Why (requester, contract, handle) as cache key?

The decrypted plaintext for a given handle is always the same regardless of who requests it. However, there is an on-chain ACL system — only the handle owner (or their delegate) is authorized to decrypt. The requester dimension ensures that:

  • Revoking user A's session only wipes user A's cached entries
  • User B cannot get a cache hit on handles they haven't been ACL-allowed to decrypt

Why userDecrypt is a query, not a mutation

Decryption can trigger a wallet signature (via credentials.allow), but the intended UX is that app developers call sdk.allow() upfront as a "global unlock". After that, all decryption happens in the background without re-signing. The operation is idempotent (same handles → same plaintext), so TanStack Query semantics (staleTime: Infinity, retry: false) are the right fit. The wallet prompt is a one-time credential acquisition, not a recurring side effect.

Why remove useUserDecryptedValue / useUserDecryptedValues

These hooks were read-only lookups into the query cache by individual handle. With the persistent decrypt cache now built into sdk.userDecrypt() and staleTime: Infinity on the query, useUserDecrypt itself serves this role — call it with the handles you need, and cached values are returned without RPCs.

DecryptHandle type

Pairs a handle with its originating contractAddress, enabling decryption of arbitrary handles from any contract (not just token balances where the contract was implicit).

What changed

Area Before After
Cache balance-cache.ts — token-specific, keyed by (token, owner, handle) decrypt-cache.ts — generic, keyed by (requester, contract, handle)
SDK decrypt Only via ReadonlyToken.balanceOf() ZamaSDK.userDecrypt() + token methods delegate to shared cache
React hooks useUserDecrypt + useUserDecryptedValue + useUserDecryptedValues useUserDecrypt only
Revoke Cleared credentials only Clears credentials and cached plaintext
Query key Per-handle (decryption.handle) Batch (decryption.batch) with sorted handles for deterministic hashing

Test plan

  • decrypt-cache.test.ts — unit tests for load/save/clear/invalidation
  • zama-sdk.test.tsuserDecrypt cache hit/miss/mixed scenarios
  • use-user-decrypt.test.tsx — React hook integration
  • readonly-token.test.ts — token balance decryption uses new cache
  • Verify useUserDecryptedValue / useUserDecryptedValues removed from exports
  • Size limit updated (48 KB → 48.5 KB)

@cla-bot
Copy link
Copy Markdown

cla-bot Bot commented Apr 3, 2026

Thank you for your pull request. We require contributors to sign our Contributor License Agreement / Terms and Conditions, and we don't seem to have the users @semantic-release-bot on file. In order for us to review and merge your code, please sign:

  • For individual contribution: our CLA
  • for Bounty submission, if you are an individual: our T&C
  • for Bounty submission, if you are a company: our T&C
    to get yourself added.

If you already signed one of this document, just wait to be added to the bot config.

@enitrat enitrat force-pushed the feat/unify-decrypt-hook branch from 3d61921 to 6b899be Compare April 3, 2026 17:32
@cla-bot
Copy link
Copy Markdown

cla-bot Bot commented Apr 3, 2026

Thank you for your pull request. We require contributors to sign our Contributor License Agreement / Terms and Conditions, and we don't seem to have the users @semantic-release-bot on file. In order for us to review and merge your code, please sign:

  • For individual contribution: our CLA
  • for Bounty submission, if you are an individual: our T&C
  • for Bounty submission, if you are a company: our T&C
    to get yourself added.

If you already signed one of this document, just wait to be added to the bot config.

@enitrat enitrat changed the base branch from main to prerelease April 3, 2026 17:32
@enitrat enitrat force-pushed the feat/unify-decrypt-hook branch from 6b899be to b1833f3 Compare April 3, 2026 20:12
@cla-bot cla-bot Bot added the cla-signed label Apr 3, 2026
@enitrat enitrat force-pushed the feat/unify-decrypt-hook branch from b1833f3 to 6726c1e Compare April 3, 2026 20:50
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 3, 2026

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 91.99% (🎯 80%) 3092 / 3361
🔵 Statements 92.1% 3174 / 3446
🔵 Functions 92.45% (🎯 80%) 980 / 1060
🔵 Branches 84.68% (🎯 80%) 1311 / 1548
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/react-sdk/src/authorization/use-revoke-session.ts 100% 100% 100% 100%
packages/react-sdk/src/authorization/use-revoke.ts 100% 100% 100% 100%
packages/react-sdk/src/relayer/use-public-decrypt.ts 100% 100% 100% 100%
packages/react-sdk/src/relayer/use-user-decrypt.ts 100% 100% 100% 100%
packages/sdk/src/decrypt-cache.ts 100% 91.66% 94.73% 100%
packages/sdk/src/zama-sdk.ts 93.61% 73.33% 97.14% 93.4% 122, 198, 328, 340, 366, 396
packages/sdk/src/query/invalidation.ts 100% 90.9% 100% 100%
packages/sdk/src/query/query-keys.ts 96.49% 94.11% 97.67% 96.49% 232-233
packages/sdk/src/query/user-decrypt.ts 100% 100% 100% 100%
packages/sdk/src/token/readonly-token.ts 97.66% 94.54% 100% 97.59% 449-451, 464, 808-810, 817, 893
packages/sdk/src/token/token.ts 96% 91.4% 97.36% 95.85% 91-92, 380, 864, 920, 997-1002, 1030, 1076-1082, 1126-1131
Generated in workflow #1357 for commit ca90c9e by the Vitest Coverage Report Action

@enitrat enitrat force-pushed the feat/unify-decrypt-hook branch from 6726c1e to 464387a Compare April 3, 2026 21:02
@enitrat
Copy link
Copy Markdown
Contributor Author

enitrat commented Apr 4, 2026

you'll likely want to follow-up doing the same thing for public decrypts, btw.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 4, 2026

Public API Changes

Expand to see full diff
diff -ru a/react-sdk.api.md b/react-sdk.api.md
--- a/react-sdk.api.md	2026-04-04 13:27:21.297096669 +0000
+++ b/react-sdk.api.md	2026-04-04 13:27:21.329632793 +0000
@@ -273,10 +273,9 @@
 import { UseMutationOptions } from '@tanstack/react-query';
 import { UseMutationResult } from '@tanstack/react-query';
 import { UseQueryOptions } from '@tanstack/react-query';
-import { UserDecryptCallbacks } from '@zama-fhe/sdk/query';
-import { userDecryptMutationOptions } from '@zama-fhe/sdk/query';
-import { UserDecryptMutationParams } from '@zama-fhe/sdk/query';
 import { UserDecryptParams } from '@zama-fhe/sdk';
+import { UserDecryptQueryConfig } from '@zama-fhe/sdk/query';
+import { userDecryptQueryOptions } from '@zama-fhe/sdk/query';
 import { wrapContract } from '@zama-fhe/sdk';
 import { wrapETHContract } from '@zama-fhe/sdk';
 import { WrappedEvent } from '@zama-fhe/sdk';
@@ -1359,15 +1358,11 @@
 // @public
 export function usePublicParams(bits: number): _$_tanstack_react_query0.UseQueryResult<PublicParamsData | null, Error>;
 
-export { UserDecryptCallbacks as DecryptCallbacks }
-export { UserDecryptCallbacks }
-
-export { userDecryptMutationOptions }
+export { UserDecryptParams }
 
-export { UserDecryptMutationParams as DecryptParams }
-export { UserDecryptMutationParams }
+export { UserDecryptQueryConfig }
 
-export { UserDecryptParams }
+export { userDecryptQueryOptions }
 
 // @public
 export function useReadonlyToken(address: Address): _$_zama_fhe_sdk0.ReadonlyToken;
@@ -1468,19 +1463,20 @@
 export function useUnwrapAll(config: UseZamaConfig, options?: UseMutationOptions<TransactionResult, Error, void, Address>): _$_tanstack_react_query0.UseMutationResult<TransactionResult, Error, void, `0x${string}`>;
 
 // @public
-export function useUserDecrypt(config?: UseUserDecryptConfig): _$_tanstack_react_query0.UseMutationResult<Record<`0x${string}`, ClearValueType>, Error, UserDecryptMutationParams, unknown>;
+export function useUserDecrypt(config: UseUserDecryptConfig, options?: UseUserDecryptOptions): _$_tanstack_react_query0.UseQueryResult<Record<`0x${string}`, ClearValueType>, Error>;
 
 // @public
-export type UseUserDecryptConfig = UserDecryptCallbacks;
+export interface UseUserDecryptConfig {
+    handles: DecryptHandle[];
+}
 
 // @public
-export function useUserDecryptedValue(handle: Handle | undefined): _$_tanstack_react_query0.UseQueryResult<ClearValueType, Error>;
+export interface UseUserDecryptOptions extends Omit<UseQueryOptions<Record<Handle, ClearValueType>>, "queryKey" | "queryFn" | "enabled"> {
+    enabled?: boolean;
+}
 
-// @public
-export function useUserDecryptedValues(handles: Handle[]): {
-    data: Record<`0x${string}`, ClearValueType | undefined>;
-    results: _$_tanstack_react_query0.UseQueryResult<never, Error>[];
-};
+// @public (undocumented)
+export type UseUserDecryptResult = ReturnType<typeof useUserDecrypt>;
 
 // @public
 export function useWrapperDiscovery(config: UseWrapperDiscoveryConfig, options?: Omit<UseQueryOptions<Address | null>, "queryKey" | "queryFn">): _$_tanstack_react_query0.UseQueryResult<`0x${string}` | null, Error>;
diff -ru a/sdk-query.api.md b/sdk-query.api.md
--- a/sdk-query.api.md	2026-04-04 13:27:21.307096647 +0000
+++ b/sdk-query.api.md	2026-04-04 13:27:21.329142279 +0000
@@ -927,6 +927,9 @@
 }
 
 // @public (undocumented)
+export function removeDecryptionQueries(queryClient: QueryClientLike): void;
+
+// @public (undocumented)
 export function requestZKProofVerificationMutationOptions(sdk: ZamaSDK): MutationFactoryOptions<readonly ["zama.requestZKProofVerification"], ZKProofLike, InputProofBytesType>;
 
 // @public (undocumented)
@@ -1343,21 +1346,6 @@
 }
 
 // @public
-export interface UserDecryptCallbacks {
-    onCredentialsReady?: () => void;
-    onDecrypted?: (values: Record<Handle, ClearValueType>) => void;
-}
-
-// @public (undocumented)
-export function userDecryptMutationOptions(sdk: ZamaSDK, callbacks?: UserDecryptCallbacks): MutationFactoryOptions<readonly ["zama.userDecrypt"], UserDecryptMutationParams, Record<Handle, ClearValueType>>;
-
-// @public
-export interface UserDecryptMutationParams {
-    // (undocumented)
-    handles: DecryptHandle[];
-}
-
-// @public
 export interface UserDecryptParams {
     // (undocumented)
     contractAddress: Address;
@@ -1380,6 +1368,19 @@
 }
 
 // @public
+export interface UserDecryptQueryConfig {
+    // (undocumented)
+    handles: DecryptHandle[];
+    // (undocumented)
+    query?: Record<string, unknown>;
+    // (undocumented)
+    requesterAddress: Address;
+}
+
+// @public
+export function userDecryptQueryOptions(sdk: ZamaSDK, config: UserDecryptQueryConfig): QueryFactoryOptions<Record<Handle, ClearValueType>, Error, Record<Handle, ClearValueType>, ReturnType<typeof zamaQueryKeys.decryption.batch>>;
+
+// @public
 export interface WrappedEvent {
     readonly amountIn: bigint;
     // (undocumented)
@@ -1583,6 +1584,16 @@
         readonly handle: (handle: string, contractAddress?: Address) => readonly ["zama.decryption", {
             readonly contractAddress?: `0x${string}` | undefined;
             readonly handle: string;
+        }]; /** Key for a batch decrypt query. Handles are sorted for deterministic hashing. */
+        readonly batch: (handles: readonly {
+            handle: Hex;
+            contractAddress: Address;
+        }[], account: Address) => readonly ["zama.decryption", {
+            readonly account: `0x${string}`;
+            readonly handles: {
+                handle: Hex;
+                contractAddress: `0x${string}`;
+            }[];
         }];
     };
     readonly wrappersRegistry: {
@@ -1660,6 +1671,7 @@
     // (undocumented)
     readonly storage: GenericStorage;
     terminate(): void;
+    userDecrypt(handles: readonly DecryptHandle[], requesterAddress?: Address): Promise<Record<Handle, ClearValueType>>;
 }
 
 // @public
@@ -1723,7 +1735,7 @@
 
 // Warnings were encountered during analysis:
 //
-// dist/esm/activity-DTBvolDB.d.ts:2034:3 - (ae-forgotten-export) The symbol "Handle" needs to be exported by the entry point index.d.ts
+// dist/esm/activity-0AjjsuhC.d.ts:2060:3 - (ae-forgotten-export) The symbol "Handle" needs to be exported by the entry point index.d.ts
 
 // (No @packageDocumentation comment for this package)
 
diff -ru a/sdk.api.md b/sdk.api.md
--- a/sdk.api.md	2026-04-04 13:27:21.315096630 +0000
+++ b/sdk.api.md	2026-04-04 13:27:21.329240292 +0000
@@ -6965,6 +6965,14 @@
 }
 
 // @public
+export interface DecryptHandle {
+    // (undocumented)
+    contractAddress: Address;
+    // (undocumented)
+    handle: Handle;
+}
+
+// @public
 export class DecryptionFailedError extends ZamaError {
     constructor(message: string, options?: ErrorOptions);
 }
@@ -30920,6 +30928,7 @@
     // (undocumented)
     readonly storage: GenericStorage;
     terminate(): void;
+    userDecrypt(handles: readonly DecryptHandle[], requesterAddress?: Address): Promise<Record<Handle, ClearValueType>>;
 }
 
 // @public

@aquint-zama
Copy link
Copy Markdown

@cla-bot check

@cla-bot
Copy link
Copy Markdown

cla-bot Bot commented Apr 4, 2026

The cla-bot has been summoned, and re-checked this pull request!

@enitrat enitrat closed this Apr 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants