diff --git a/.size-limit.json b/.size-limit.json index e652ec992..b33efe8f5 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,7 +2,7 @@ { "name": "@zama-fhe/sdk (ESM)", "path": "packages/sdk/dist/esm/**/*.js", - "limit": "48 KB" + "limit": "48.5 KB" }, { "name": "@zama-fhe/sdk (CJS)", diff --git a/docs/gitbook/src/guides/encrypt-decrypt.md b/docs/gitbook/src/guides/encrypt-decrypt.md index a81dc8772..3e0356cc6 100644 --- a/docs/gitbook/src/guides/encrypt-decrypt.md +++ b/docs/gitbook/src/guides/encrypt-decrypt.md @@ -16,16 +16,23 @@ Here is a complete flow that encrypts a value, sends it to a custom FHE contract {% code title="ConfidentialRoundTrip.tsx" %} ```tsx -import { useEncrypt, useUserDecrypt, useUserDecryptedValue, useZamaSDK } from "@zama-fhe/react-sdk"; +import { useEncrypt, useUserDecrypt, useZamaSDK } from "@zama-fhe/react-sdk"; import { bytesToHex } from "viem"; import { useState, type FormEvent } from "react"; function ConfidentialRoundTrip() { const sdk = useZamaSDK(); const encrypt = useEncrypt(); - const decrypt = useUserDecrypt(); const [handle, setHandle] = useState(); - const { data: decryptedValue } = useUserDecryptedValue(handle); + const [revealed, setRevealed] = useState(false); + const decrypt = useUserDecrypt( + { + handles: handle + ? [{ handle: handle as `0x${string}`, contractAddress: "0xYourContract" as `0x${string}` }] + : [], + }, + { enabled: revealed }, + ); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -56,12 +63,8 @@ function ConfidentialRoundTrip() { args: [userAddress], })) as string; - // 4. Decrypt - await decrypt.mutateAsync({ - handles: [{ handle: storedHandle, contractAddress }], - }); - setHandle(storedHandle); + setRevealed(true); }; return ( @@ -69,7 +72,9 @@ function ConfidentialRoundTrip() { - {decryptedValue !== undefined && Decrypted: {decryptedValue.toString()}} + {decrypt.data?.[handle as `0x${string}`] !== undefined && ( + Decrypted: {String(decrypt.data[handle as `0x${string}`])} + )} ); } @@ -249,36 +254,38 @@ function ConfidentialAction() { ### 3. Decrypt with useUserDecrypt -`useUserDecrypt` manages the entire orchestration internally — keypair generation, EIP-712 creation, wallet signature, and decryption — so you only need to provide the handles you want to decrypt. All session parameters (`keypairTTL`, credential duration, etc.) are inherited from the SDK configuration. +`useUserDecrypt` is a query hook. It manages the full orchestration internally — keypair generation, EIP-712 creation, wallet signature, caching, and decryption — but you enable it declaratively instead of calling `mutate()`. {% code title="DecryptExample.tsx" %} ```tsx -import { useUserDecrypt, useUserDecryptedValue } from "@zama-fhe/react-sdk"; +import { useUserDecrypt } from "@zama-fhe/react-sdk"; +import { useEffect, useState } from "react"; function DecryptExample() { - const decrypt = useUserDecrypt(); - const { data: decryptedValue } = useUserDecryptedValue("0xabc123..."); + const [revealed, setRevealed] = useState(false); - const handleDecrypt = async () => { - const result = await decrypt.mutateAsync({ + const decrypt = useUserDecrypt( + { handles: [ { - handle: "0xabc123...", // the encrypted handle from the contract + handle: "0xabc123...", contractAddress: "0xYourConfidentialContract", }, ], - }); - // result: { "0xabc123...": 1000n } - }; + }, + { enabled: revealed }, + ); return (
- {decrypt.error &&

Error: {decrypt.error.message}

} - {decryptedValue !== undefined && Value: {decryptedValue.toString()}} + {decrypt.data?.["0xabc123..."] !== undefined && ( + Value: {String(decrypt.data["0xabc123..."])} + )}
); } @@ -291,57 +298,51 @@ function DecryptExample() { `useUserDecrypt` automatically groups handles by contract address and issues one decryption request per contract: ```tsx -const result = await decrypt.mutateAsync({ - handles: [ - { handle: "0xhandle1...", contractAddress: "0xTokenA" }, - { handle: "0xhandle2...", contractAddress: "0xTokenA" }, - { handle: "0xhandle3...", contractAddress: "0xTokenB" }, - ], -}); +const decrypt = useUserDecrypt( + { + handles: [ + { handle: "0xhandle1...", contractAddress: "0xTokenA" }, + { handle: "0xhandle2...", contractAddress: "0xTokenA" }, + { handle: "0xhandle3...", contractAddress: "0xTokenB" }, + ], + }, + { enabled: revealed }, +); // Single wallet signature, but two decryption requests (one per contract) -// result: { "0xhandle1...": 500n, "0xhandle2...": 200n, "0xhandle3...": 1000n } +// decrypt.data: { "0xhandle1...": 500n, "0xhandle2...": 200n, "0xhandle3...": 1000n } ``` -#### Reading decrypted values from cache +#### Reading decrypted values from the query result -After decryption, values are stored in React Query's cache. Use `useUserDecryptedValue` or `useUserDecryptedValues` to read them anywhere in your component tree without triggering a new decryption: +Read plaintext directly from the hook result: ```tsx -import { useUserDecryptedValue, useUserDecryptedValues } from "@zama-fhe/react-sdk"; - -// Single value -function Balance({ handle }: { handle: string }) { - const { data: value } = useUserDecryptedValue(handle); - return {value?.toString() ?? "—"}; -} +const decrypt = useUserDecrypt( + { + handles: [ + { handle: "0xhandle1...", contractAddress: "0xTokenA" }, + { handle: "0xhandle2...", contractAddress: "0xTokenB" }, + ], + }, + { enabled: revealed }, +); -// Multiple values -function Balances({ handles }: { handles: string[] }) { - const { data } = useUserDecryptedValues(handles); - return ( - - ); -} +decrypt.data?.["0xhandle1..."]; +decrypt.data?.["0xhandle2..."]; ``` {% hint style="info" %} -**Decrypted values are `undefined`?** Cache reads only return data after a decryption has populated the cache. Make sure: +**Decrypted values are `undefined`?** The query only populates data after a successful decrypt. Make sure: -1. You have called `useUserDecrypt` first -2. The handle you are reading matches exactly (it is case-sensitive, hex-encoded) -3. The decryption completed successfully (check `decrypt.isSuccess`) +1. `enabled` has been turned on +2. The handle exists in the configured `handles` array +3. The decryption completed successfully (`decrypt.isSuccess`) {% endhint %} #### Showing progress during decryption -Use the `onCredentialsReady` and `onDecrypted` callbacks to show progress: +Model progress in component state around the `enabled` flag and query status: {% code title="DecryptWithProgress.tsx" %} @@ -354,17 +355,17 @@ type DecryptStep = "idle" | "authorizing" | "decrypting" | "done"; function DecryptWithProgress() { const [step, setStep] = useState("idle"); - const decrypt = useUserDecrypt({ - onCredentialsReady: () => setStep("decrypting"), - onDecrypted: () => setStep("done"), - }); - - const handleDecrypt = async () => { - setStep("authorizing"); - await decrypt.mutateAsync({ + const decrypt = useUserDecrypt( + { handles: [{ handle: "0x...", contractAddress: "0xToken" }], - }); - }; + }, + { enabled: step !== "idle" }, + ); + + useEffect(() => { + if (decrypt.isFetching && step === "authorizing") setStep("decrypting"); + if (decrypt.isSuccess && step !== "done") setStep("done"); + }, [decrypt.isFetching, decrypt.isSuccess, step]); const stepLabels: Record = { idle: "Decrypt", @@ -373,7 +374,7 @@ function DecryptWithProgress() { done: "Done!", }; - return ; + return ; } ``` diff --git a/docs/gitbook/src/reference/react/query-keys.md b/docs/gitbook/src/reference/react/query-keys.md index 233fc8d68..d4ccf0f64 100644 --- a/docs/gitbook/src/reference/react/query-keys.md +++ b/docs/gitbook/src/reference/react/query-keys.md @@ -77,6 +77,16 @@ Multi-token batch handles. | `.all` | All batch handle queries | | `.tokens(addrs, owner)` | Batch query for specific tokens and owner | +### `zamaQueryKeys.decryption` + +User decryption queries (used by `useUserDecrypt`). + +| Key | Scope | +| ---------------------------- | -------------------------------------------- | +| `.all` | All decryption queries | +| `.handle(handle, contract?)` | Single handle (used internally by the cache) | +| `.batch(handles[], account)` | Batch decrypt for a specific requester | + ### `zamaQueryKeys.isAllowed` Session signature status. @@ -133,18 +143,6 @@ On-chain wrappers registry queries. | `.isConfidentialTokenValid(registryAddr, confidentialAddr)` | Validity check | | `.listPairs(registryAddr, page, pageSize, metadata)` | Paginated listing | -### `decryptionKeys` - -Cached decrypted values. Populated by [`useUserDecrypt`](/reference/react/useUserDecrypt) and read by `useUserDecryptedValue`. - -```ts -import { decryptionKeys } from "@zama-fhe/react-sdk"; -``` - -| Key | Scope | -| ---------------- | -------------------------------- | -| `.value(handle)` | Single decrypted value by handle | - ## Common Patterns ### Invalidate after an external transaction @@ -170,6 +168,7 @@ queryClient.prefetchQuery({ ```tsx queryClient.removeQueries({ queryKey: zamaQueryKeys.confidentialBalance.all }); queryClient.removeQueries({ queryKey: zamaQueryKeys.confidentialHandle.all }); +queryClient.removeQueries({ queryKey: zamaQueryKeys.decryption.all }); ``` ## Related diff --git a/docs/gitbook/src/reference/react/useUserDecrypt.md b/docs/gitbook/src/reference/react/useUserDecrypt.md index 34fa1a5a6..c001f34fa 100644 --- a/docs/gitbook/src/reference/react/useUserDecrypt.md +++ b/docs/gitbook/src/reference/react/useUserDecrypt.md @@ -1,16 +1,16 @@ --- title: useUserDecrypt -description: High-level mutation hook that orchestrates the full user decryption flow — credential management, wallet signature, and decryption — in a single call. +description: Query hook for user decryption of FHE handles. Requester-scoped, cache-backed, and opt-in by default. --- # useUserDecrypt -High-level orchestration hook for user decryption. Manages the entire flow internally — keypair generation, EIP-712 creation, wallet signature, and decryption — so you only need to provide the handles you want to decrypt. All session parameters (`keypairTTL`, credential duration, etc.) are inherited from the SDK configuration. +`useUserDecrypt` is the query-based hook for user decryption. It resolves the connected signer address, builds a requester-scoped query key, reuses cached plaintext when available, and delegates the actual decryption flow to the SDK. -Reuses cached FHE credentials when available, falling back to generating fresh ones only when no valid credentials exist. This avoids redundant wallet signature prompts across multiple decrypt calls. +Because decrypting may require wallet authorization, the hook defaults to `enabled: false`. Turn it on explicitly when the user chooses to reveal data. {% hint style="info" %} -**This is the recommended way to decrypt.** For token balances, prefer [`useConfidentialBalance`](/reference/react/useConfidentialBalance) which decrypts automatically with two-phase polling. Use `useUserDecrypt` when your smart contract uses FHE types directly (e.g. a confidential voting contract, a sealed-bid auction, or any non-token contract). +For confidential ERC-20 balances, prefer [`useConfidentialBalance`](/reference/react/useConfidentialBalance). Use `useUserDecrypt` for custom contracts that expose FHE handles directly. {% endhint %} ## Import @@ -21,159 +21,81 @@ import { useUserDecrypt } from "@zama-fhe/react-sdk"; ## Usage -{% tabs %} -{% tab title="component.tsx" %} - ```tsx -import { useUserDecrypt, useUserDecryptedValue } from "@zama-fhe/react-sdk"; +import { useUserDecrypt } from "@zama-fhe/react-sdk"; +import { useState } from "react"; -function DecryptHandle() { - const decrypt = useUserDecrypt(); - const { data: decryptedValue } = useUserDecryptedValue("0xhandle..."); +function RevealValue() { + const [revealed, setRevealed] = useState(false); - async function handleDecrypt() { - const result = await decrypt.mutateAsync({ + const decrypt = useUserDecrypt( + { handles: [ { handle: "0xhandle...", contractAddress: "0xYourContract", }, ], - }); - // result: { "0xhandle...": 1000n } - } + }, + { + enabled: revealed, + }, + ); return (
- {decrypt.error &&

Error: {decrypt.error.message}

} - {decryptedValue !== undefined && Value: {decryptedValue.toString()}} + {decrypt.data?.["0xhandle..."] !== undefined && ( + Value: {String(decrypt.data["0xhandle..."])} + )}
); } ``` -{% endtab %} -{% endtabs %} - ## Parameters -```ts -import { type UseUserDecryptConfig } from "@zama-fhe/react-sdk"; -``` - -`UseUserDecryptConfig` extends `DecryptCallbacks` — callbacks are passed directly as top-level properties. - -### onCredentialsReady - -`(() => void) | undefined` - -Fired after credentials are ready — either reused from cache or freshly generated (which may trigger a wallet signature prompt). - -### onDecrypted - -`((values: Record) => void) | undefined` - -Fired after all handles have been decrypted. Receives the full result map. - -{% tabs %} -{% tab title="with callbacks" %} - -```tsx -import { useUserDecrypt } from "@zama-fhe/react-sdk"; -import { useState } from "react"; - -type DecryptStep = "idle" | "authorizing" | "decrypting" | "done"; - -function DecryptWithProgress() { - const [step, setStep] = useState("idle"); - - const decrypt = useUserDecrypt({ - onCredentialsReady: () => setStep("decrypting"), - onDecrypted: () => setStep("done"), - }); - - const handleDecrypt = async () => { - setStep("authorizing"); - await decrypt.mutateAsync({ - handles: [{ handle: "0x...", contractAddress: "0xToken" }], - }); - }; - - const stepLabels: Record = { - idle: "Decrypt", - authorizing: "Authorizing...", - decrypting: "Decrypting...", - done: "Done!", - }; - - return ; -} -``` - -{% endtab %} -{% endtabs %} - -## Mutation Variables - -Passed to `mutate` / `mutateAsync` at call time. +### config ```ts -import { type DecryptParams } from "@zama-fhe/react-sdk"; +import { type UseUserDecryptConfig } from "@zama-fhe/react-sdk"; ``` -### handles - -`DecryptHandle[]` +| Field | Type | Description | +| --------- | ----------------- | ----------------------------------------------------------------- | +| `handles` | `DecryptHandle[]` | Handles to decrypt, each paired with its owning contract address. | -Array of handles to decrypt. Each entry pairs an encrypted handle with the address of the contract that owns it. +### options ```ts -import { type DecryptHandle } from "@zama-fhe/react-sdk"; +import { type UseUserDecryptOptions } from "@zama-fhe/react-sdk"; ``` -| Field | Type | Description | -| ----------------- | --------- | ------------------------------------------------------ | -| `handle` | `Handle` | The encrypted handle (hex string) to decrypt. | -| `contractAddress` | `Address` | Address of the contract that owns the encrypted value. | +`UseUserDecryptOptions` is a `useQuery`-style options object with `queryKey`, `queryFn`, and `enabled` owned by the hook. -Handles from different contracts can be mixed in a single call — `useUserDecrypt` automatically groups them by contract address and issues one decryption request per unique contract: - -```tsx -const result = await decrypt.mutateAsync({ - handles: [ - { handle: "0xhandle1...", contractAddress: "0xContractA" }, - { handle: "0xhandle2...", contractAddress: "0xContractA" }, - { handle: "0xhandle3...", contractAddress: "0xContractB" }, - ], -}); - -// Single wallet signature, two decryption requests (one per contract) -// result: { "0xhandle1...": 500n, "0xhandle2...": 200n, "0xhandle3...": 1000n } -``` +| Field | Type | Description | +| --------- | --------- | ------------------------------------------------------ | +| `enabled` | `boolean` | Whether to run the decrypt query. Defaults to `false`. | ## Return Type -`data` resolves to `Record` — a map from each handle to its decrypted plaintext value (`bigint`, `boolean`, or `string`). - -On success, results are written to the decryption cache so that [`useUserDecryptedValue`](/guides/encrypt-decrypt#reading-decrypted-values-from-cache) and [`useUserDecryptedValues`](/guides/encrypt-decrypt#reading-decrypted-values-from-cache) can read them without re-decrypting. - -{% include ".gitbook/includes/mutation-result.md" %} - -## Credential Caching +`useUserDecrypt` returns a standard TanStack `UseQueryResult, Error>`. -`useUserDecrypt` uses the SDK's credential manager (`sdk.credentials`) to avoid unnecessary wallet prompts: +- `data` is a map from handle to plaintext. +- `isFetching` indicates an active decrypt request. +- `error` contains authorization or relayer failures. -- **First call** — generates a new FHE keypair, creates EIP-712 typed data, and requests a wallet signature. The credentials are then cached. -- **Subsequent calls** — reuses the cached credentials if they are still valid (not expired). -- **Expiry** — credentials expire after `keypairTTL` seconds (default: 2592000 = 30 days, configurable via SDK config). Once expired, the next call generates fresh credentials. +The query uses: -This means users only see a wallet signature prompt once per session (or per TTL window), even if they decrypt multiple times. +- requester-scoped query keys for cache isolation +- `staleTime: Infinity` because decrypted handle values are immutable +- `retry: false` to avoid repeated wallet prompts after rejected signatures ## Related -- [`useConfidentialBalance`](/reference/react/useConfidentialBalance) — high-level hook that decrypts token balances automatically with two-phase polling -- [`useEncrypt`](/reference/react/useEncrypt) — reverse operation, encrypt a plaintext value for on-chain submission -- [Encrypt & Decrypt guide](/guides/encrypt-decrypt) — full walkthrough with end-to-end examples +- [`useConfidentialBalance`](/reference/react/useConfidentialBalance) +- [`useEncrypt`](/reference/react/useEncrypt) +- [Encrypt & Decrypt guide](/guides/encrypt-decrypt) diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 6caa39439..5732518f3 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -811,32 +811,31 @@ const { handles, inputProof } = await encrypt.mutateAsync({ #### Decryption (`useUserDecrypt`) -`useUserDecrypt` manages the full decrypt orchestration — keypair generation, EIP-712, wallet signature — and reuses cached credentials when available, avoiding redundant wallet prompts: +`useUserDecrypt` manages the full decrypt orchestration — keypair generation, EIP-712, wallet signature — and reuses cached credentials when available. Because decryption can prompt the wallet, the query is opt-in and defaults to `enabled: false`: ```tsx -const decrypt = useUserDecrypt({ - onCredentialsReady: () => setStep("decrypting"), - onDecrypted: (values) => setStep("done"), -}); - -const result = await decrypt.mutateAsync({ - handles: [ - { handle: "0xabc...", contractAddress: "0xTokenA" }, - { handle: "0xdef...", contractAddress: "0xTokenB" }, - ], -}); -// result: { "0xabc...": 500n, "0xdef...": 1000n } -// Results are automatically cached for useUserDecryptedValue +const { data, isLoading } = useUserDecrypt( + { + handles: [ + { handle: "0xabc...", contractAddress: "0xTokenA" }, + { handle: "0xdef...", contractAddress: "0xTokenB" }, + ], + }, + { + enabled: shouldDecrypt, + }, +); +// data: { "0xabc...": 500n, "0xdef...": 1000n } ``` #### All Encryption & Decryption Hooks -| Hook | Input | Output | Description | -| --------------------------- | ---------------------------- | ------------------------ | ---------------------------------------------------------------------------- | -| `useEncrypt()` | `EncryptParams` | `EncryptResult` | Encrypt values for smart contract calls. | -| `useUserDecrypt()` | `DecryptParams` | `Record` | Full decrypt orchestration with progress callbacks. Populates cache. | -| `usePublicDecrypt()` | `string[]` (handles) | `PublicDecryptResult` | Public decryption (no authorization needed). Populates the decryption cache. | -| `useDelegatedUserDecrypt()` | `DelegatedUserDecryptParams` | `Record` | Decrypt via delegation. | +| Hook | Input | Output | Description | +| --------------------------- | ---------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------- | +| `useEncrypt()` | `EncryptParams` | `EncryptResult` | Encrypt values for smart contract calls. | +| `useUserDecrypt()` | `{ handles }` | `Record` | Opt-in user decryption query. Defaults to disabled and reuses credentials and persistent cache. | +| `usePublicDecrypt()` | `string[]` (handles) | `PublicDecryptResult` | Public decryption (no authorization needed). Populates the decryption cache. | +| `useDelegatedUserDecrypt()` | `DelegatedUserDecryptParams` | `Record` | Decrypt via delegation. | #### Key Management @@ -854,51 +853,24 @@ const result = await decrypt.mutateAsync({ | `usePublicKey()` | `void` | `{ publicKeyId, publicKey } \| null` | Get the TFHE compact public key. | | `usePublicParams()` | `number` (bits) | `{ publicParams, publicParamsId } \| null` | Get public parameters for encryption. | -### Decryption Cache Hooks - -`useUserDecrypt` and `usePublicDecrypt` populate a shared React Query cache. These hooks read from that cache without triggering new decryption requests. - -```ts -// Single handle -function useUserDecryptedValue(handle: string | undefined): UseQueryResult; - -// Multiple handles -function useUserDecryptedValues(handles: string[]): { - data: Record; - results: UseQueryResult[]; -}; -``` - -```tsx -// First, trigger decryption (populates the cache) -const decrypt = useUserDecrypt(); -await decrypt.mutateAsync({ - handles: [{ handle: "0xHandleHash", contractAddress: "0xToken" }], -}); - -// Then read cached results anywhere in the tree — no new decryption -const { data: value } = useUserDecryptedValue("0xHandleHash"); -``` - ## Query Keys Use `zamaQueryKeys` for manual cache management (invalidation, prefetching, removal). ```ts -import { zamaQueryKeys, decryptionKeys } from "@zama-fhe/react-sdk"; +import { zamaQueryKeys } from "@zama-fhe/react-sdk"; ``` -| Factory | Keys | Description | -| ------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------- | -| `zamaQueryKeys.confidentialBalance` | `.all`, `.token(address)`, `.owner(address, owner)` | Single-token decrypted balance. | -| `zamaQueryKeys.confidentialBalances` | `.all`, `.tokens(addresses, owner)` | Multi-token batch balances. | -| `zamaQueryKeys.confidentialHandle` | `.all`, `.token(address)`, `.owner(address, owner)` | Single-token encrypted handle. | -| `zamaQueryKeys.confidentialHandles` | `.all`, `.tokens(addresses, owner)` | Multi-token batch handles. | -| `zamaQueryKeys.isAllowed` | `.all` | Session signature status. | -| `zamaQueryKeys.underlyingAllowance` | `.all`, `.token(address)`, `.scope(address, owner, wrapper)` | Underlying ERC-20 allowance. | -| `zamaQueryKeys.activityFeed` | `.all`, `.token(address)`, `.scope(address, userAddress, logsKey, decrypt)` | Activity feed items. | -| `zamaQueryKeys.fees` | `.shieldFee(...)`, `.unshieldFee(...)`, `.batchTransferFee(addr)`, `.feeRecipient(addr)` | Fee manager queries. | -| `decryptionKeys` | `.value(handle)` | Individual decrypted handle values. | +| Factory | Keys | Description | +| ------------------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------- | +| `zamaQueryKeys.confidentialBalance` | `.all`, `.token(address)`, `.owner(address, owner)` | Single-token decrypted balance. | +| `zamaQueryKeys.confidentialBalances` | `.all`, `.tokens(addresses, owner)` | Multi-token batch balances. | +| `zamaQueryKeys.confidentialHandle` | `.all`, `.token(address)`, `.owner(address, owner)` | Single-token encrypted handle. | +| `zamaQueryKeys.confidentialHandles` | `.all`, `.tokens(addresses, owner)` | Multi-token batch handles. | +| `zamaQueryKeys.isAllowed` | `.all` | Session signature status. | +| `zamaQueryKeys.underlyingAllowance` | `.all`, `.token(address)`, `.scope(address, owner, wrapper)` | Underlying ERC-20 allowance. | +| `zamaQueryKeys.activityFeed` | `.all`, `.token(address)`, `.scope(address, userAddress, logsKey, decrypt)` | Activity feed items. | +| `zamaQueryKeys.fees` | `.shieldFee(...)`, `.unshieldFee(...)`, `.batchTransferFee(addr)`, `.feeRecipient(addr)` | Fee manager queries. | ```tsx import { useQueryClient } from "@tanstack/react-query"; @@ -1075,7 +1047,7 @@ All public exports from `@zama-fhe/sdk` are re-exported from the main entry poin **Pending unshield:** `savePendingUnshield`, `loadPendingUnshield`, `clearPendingUnshield`. -**Types:** `Address`, `ZamaSDKConfig`, `ReadonlyTokenConfig`, `NetworkType`, `RelayerSDK`, `RelayerSDKStatus`, `EncryptResult`, `EncryptParams`, `UserDecryptParams`, `PublicDecryptResult`, `KeypairType`, `EIP712TypedData`, `DelegatedUserDecryptParams`, `KmsDelegatedUserDecryptEIP712Type`, `ZKProofLike`, `InputProofBytesType`, `BatchTransferData`, `StoredCredentials`, `GenericSigner`, `GenericStorage`, `TransactionReceipt`, `TransactionResult`, `UnshieldCallbacks`. +**Types:** `Address`, `ZamaSDKConfig`, `ReadonlyTokenConfig`, `NetworkType`, `RelayerSDK`, `RelayerSDKStatus`, `EncryptResult`, `EncryptParams`, `PublicDecryptResult`, `KeypairType`, `EIP712TypedData`, `DelegatedUserDecryptParams`, `KmsDelegatedUserDecryptEIP712Type`, `ZKProofLike`, `InputProofBytesType`, `BatchTransferData`, `StoredCredentials`, `GenericSigner`, `GenericStorage`, `TransactionReceipt`, `TransactionResult`, `UnshieldCallbacks`. **Errors:** `ZamaError`, `ZamaErrorCode`, `SigningRejectedError`, `SigningFailedError`, `EncryptionFailedError`, `DecryptionFailedError`, `ApprovalFailedError`, `TransactionRevertedError`, `InvalidKeypairError`, `NoCiphertextError`, `RelayerRequestFailedError`, `matchZamaError`. diff --git a/packages/react-sdk/etc/react-sdk.api.md b/packages/react-sdk/etc/react-sdk.api.md index 3b60cf725..362ca1f1b 100644 --- a/packages/react-sdk/etc/react-sdk.api.md +++ b/packages/react-sdk/etc/react-sdk.api.md @@ -273,10 +273,9 @@ import { UnwrapSubmittedEvent } from '@zama-fhe/sdk'; 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 @@ export function usePublicKey(): _$_tanstack_react_query0.UseQueryResult; -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 useUnwrap(config: UseZamaConfig, options?: UseMutationOptions): _$_tanstack_react_query0.UseMutationResult; // @public -export function useUserDecrypt(config?: UseUserDecryptConfig): _$_tanstack_react_query0.UseMutationResult, Error, UserDecryptMutationParams, unknown>; +export function useUserDecrypt(config: UseUserDecryptConfig, options?: UseUserDecryptOptions): _$_tanstack_react_query0.UseQueryResult, Error>; // @public -export type UseUserDecryptConfig = UserDecryptCallbacks; +export interface UseUserDecryptConfig { + handles: DecryptHandle[]; +} // @public -export function useUserDecryptedValue(handle: Handle | undefined): _$_tanstack_react_query0.UseQueryResult; +export interface UseUserDecryptOptions extends Omit>, "queryKey" | "queryFn" | "enabled"> { + enabled?: boolean; +} -// @public -export function useUserDecryptedValues(handles: Handle[]): { - data: Record<`0x${string}`, ClearValueType | undefined>; - results: _$_tanstack_react_query0.UseQueryResult[]; -}; +// @public (undocumented) +export type UseUserDecryptResult = ReturnType; // @public export function useWrapperDiscovery(config: UseWrapperDiscoveryConfig, options?: Omit, "queryKey" | "queryFn">): _$_tanstack_react_query0.UseQueryResult<`0x${string}` | null, Error>; diff --git a/packages/react-sdk/src/__tests__/use-user-decrypt.test.tsx b/packages/react-sdk/src/__tests__/use-user-decrypt.test.tsx index 0ae576446..e28414c4b 100644 --- a/packages/react-sdk/src/__tests__/use-user-decrypt.test.tsx +++ b/packages/react-sdk/src/__tests__/use-user-decrypt.test.tsx @@ -1,11 +1,24 @@ import { waitFor } from "@testing-library/react"; import type { Address } from "@zama-fhe/sdk"; -import { zamaQueryKeys } from "@zama-fhe/sdk/query"; import { useUserDecrypt } from "../relayer/use-user-decrypt"; import { describe, expect, it, vi } from "../test-fixtures"; describe("useUserDecrypt", () => { - it("runs the full flow: keypair -> EIP712 -> sign -> decrypt", async ({ + it("does not fetch by default", async ({ + relayer, + signer, + tokenAddress, + renderWithProviders, + }) => { + const handles = [{ handle: "0xh1" as `0x${string}`, contractAddress: tokenAddress }]; + + const { result } = renderWithProviders(() => useUserDecrypt({ handles }), { relayer, signer }); + + expect(result.current.fetchStatus).toBe("idle"); + expect(relayer.userDecrypt).not.toHaveBeenCalled(); + }); + + it("decrypts handles when explicitly enabled", async ({ relayer, signer, tokenAddress, @@ -13,66 +26,29 @@ describe("useUserDecrypt", () => { }) => { vi.mocked(relayer.userDecrypt).mockResolvedValue({ "0xhandle1": 100n, - "0xhandle2": true, + "0xhandle2": 200n, }); - const { result, queryClient } = renderWithProviders(() => useUserDecrypt(), { + const handles = [ + { handle: "0xhandle1" as `0x${string}`, contractAddress: tokenAddress }, + { handle: "0xhandle2" as `0x${string}`, contractAddress: tokenAddress }, + ]; + + const { result } = renderWithProviders(() => useUserDecrypt({ handles }, { enabled: true }), { relayer, signer, }); - result.current.mutate({ - handles: [ - { handle: "0xhandle1", contractAddress: tokenAddress }, - { handle: "0xhandle2", contractAddress: tokenAddress }, - ], - }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(relayer.generateKeypair).toHaveBeenCalledOnce(); expect(relayer.createEIP712).toHaveBeenCalledOnce(); expect(signer.signTypedData).toHaveBeenCalledOnce(); expect(relayer.userDecrypt).toHaveBeenCalledOnce(); - expect(result.current.data).toEqual({ "0xhandle1": 100n, - "0xhandle2": true, + "0xhandle2": 200n, }); - - // Verify decryption cache was populated - expect(queryClient.getQueryData(zamaQueryKeys.decryption.handle("0xhandle1"))).toBe(100n); - expect(queryClient.getQueryData(zamaQueryKeys.decryption.handle("0xhandle2"))).toBe(true); - }); - - it("fires callbacks in correct order", async ({ - relayer, - signer, - tokenAddress, - renderWithProviders, - }) => { - vi.mocked(relayer.userDecrypt).mockResolvedValue({ "0xh": 42n }); - - const order: string[] = []; - const onCredentialsReady = vi.fn(() => order.push("credentials")); - const onDecrypted = vi.fn(() => order.push("decrypted")); - - const { result } = renderWithProviders( - () => useUserDecrypt({ onCredentialsReady, onDecrypted }), - { - relayer, - signer, - }, - ); - - result.current.mutate({ - handles: [{ handle: "0xh", contractAddress: tokenAddress }], - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(order).toEqual(["credentials", "decrypted"]); - expect(onDecrypted).toHaveBeenCalledWith({ "0xh": 42n }); }); it("groups handles by contract address", async ({ relayer, signer, renderWithProviders }) => { @@ -83,21 +59,18 @@ describe("useUserDecrypt", () => { .mockResolvedValueOnce({ "0xh1": 10n }) .mockResolvedValueOnce({ "0xh2": 20n }); - const { result } = renderWithProviders(() => useUserDecrypt(), { + const handles = [ + { handle: "0xh1" as `0x${string}`, contractAddress: CONTRACT_A }, + { handle: "0xh2" as `0x${string}`, contractAddress: CONTRACT_B }, + ]; + + const { result } = renderWithProviders(() => useUserDecrypt({ handles }, { enabled: true }), { relayer, signer, }); - result.current.mutate({ - handles: [ - { handle: "0xh1", contractAddress: CONTRACT_A }, - { handle: "0xh2", contractAddress: CONTRACT_B }, - ], - }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - // Should call userDecrypt twice (once per contract) expect(relayer.userDecrypt).toHaveBeenCalledTimes(2); expect(result.current.data).toEqual({ "0xh1": 10n, "0xh2": 20n }); }); @@ -113,110 +86,72 @@ describe("useUserDecrypt", () => { "0xh2": 2n, }); - const { result } = renderWithProviders(() => useUserDecrypt(), { + const handles = [ + { handle: "0xh1" as `0x${string}`, contractAddress: tokenAddress }, + { handle: "0xh2" as `0x${string}`, contractAddress: tokenAddress }, + ]; + + const { result } = renderWithProviders(() => useUserDecrypt({ handles }, { enabled: true }), { relayer, signer, }); - result.current.mutate({ - handles: [ - { handle: "0xh1", contractAddress: tokenAddress }, - { handle: "0xh2", contractAddress: tokenAddress }, - ], - }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - // EIP-712 should be called with deduplicated addresses expect(relayer.createEIP712).toHaveBeenCalledWith( "0xpub", - [tokenAddress], // single address, not duplicated + [tokenAddress], expect.any(Number), - 30, // default durationDays (keypairTTL = 2592000s = 30 days) + 30, ); }); - it("derives durationDays from keypairTTL", async ({ + it("does not fetch when enabled is false", async ({ relayer, signer, tokenAddress, renderWithProviders, }) => { - vi.mocked(relayer.userDecrypt).mockResolvedValue({ "0xh": 1n }); + const handles = [{ handle: "0xh1" as `0x${string}`, contractAddress: tokenAddress }]; - const { result } = renderWithProviders(() => useUserDecrypt(), { + const { result } = renderWithProviders(() => useUserDecrypt({ handles }, { enabled: false }), { relayer, signer, }); - result.current.mutate({ - handles: [{ handle: "0xh", contractAddress: tokenAddress }], - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - // durationDays is derived from keypairTTL (default 2592000s = 30 days) - expect(relayer.createEIP712).toHaveBeenCalledWith( - "0xpub", - [tokenAddress], - expect.any(Number), - 30, - ); + expect(result.current.fetchStatus).toBe("idle"); + expect(relayer.userDecrypt).not.toHaveBeenCalled(); }); - it("reuses cached credentials on second call (no extra wallet prompt)", async ({ + it("does not fetch when handles array is empty", async ({ relayer, signer, - tokenAddress, renderWithProviders, }) => { - vi.mocked(relayer.userDecrypt) - .mockResolvedValueOnce({ "0xh1": 10n }) - .mockResolvedValueOnce({ "0xh2": 20n }); - - const { result } = renderWithProviders(() => useUserDecrypt(), { - relayer, - signer, - }); - - // First call — generates fresh credentials - result.current.mutate({ - handles: [{ handle: "0xh1", contractAddress: tokenAddress }], - }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(relayer.generateKeypair).toHaveBeenCalledTimes(1); - expect(signer.signTypedData).toHaveBeenCalledTimes(1); - - // Second call with same contract — should reuse cached credentials - result.current.mutate({ - handles: [{ handle: "0xh2", contractAddress: tokenAddress }], - }); - await waitFor(() => expect(result.current.data).toEqual({ "0xh2": 20n })); + const { result } = renderWithProviders( + () => useUserDecrypt({ handles: [] }, { enabled: true }), + { relayer, signer }, + ); - // No additional keypair generation or signing - expect(relayer.generateKeypair).toHaveBeenCalledTimes(1); - expect(signer.signTypedData).toHaveBeenCalledTimes(1); - // But userDecrypt was called twice (one per mutation) - expect(relayer.userDecrypt).toHaveBeenCalledTimes(2); + expect(result.current.fetchStatus).toBe("idle"); + expect(relayer.userDecrypt).not.toHaveBeenCalled(); }); it("reports error when keypair generation fails", async ({ relayer, renderWithProviders }) => { vi.mocked(relayer.generateKeypair).mockRejectedValue(new Error("keygen failed")); - const { result } = renderWithProviders(() => useUserDecrypt(), { relayer }); + const handles = [ + { + handle: "0xh" as `0x${string}`, + contractAddress: "0x1a1A1A1A1a1A1A1a1A1a1a1a1a1a1a1A1A1a1a1a" as Address, + }, + ]; - result.current.mutate({ - handles: [ - { - handle: "0xh", - contractAddress: "0x1a1A1A1A1a1A1A1a1A1a1a1a1a1a1a1A1A1a1a1a" as Address, - }, - ], + const { result } = renderWithProviders(() => useUserDecrypt({ handles }, { enabled: true }), { + relayer, }); await waitFor(() => expect(result.current.isError).toBe(true)); - // CredentialsManager wraps the error with context + original message expect(result.current.error?.message).toBe( "Failed to create decrypt credentials: keygen failed", ); diff --git a/packages/react-sdk/src/authorization/__tests__/use-revoke-session.test.tsx b/packages/react-sdk/src/authorization/__tests__/use-revoke-session.test.tsx index 8580901d8..4aac1b692 100644 --- a/packages/react-sdk/src/authorization/__tests__/use-revoke-session.test.tsx +++ b/packages/react-sdk/src/authorization/__tests__/use-revoke-session.test.tsx @@ -1,7 +1,7 @@ import { act } from "@testing-library/react"; import { zamaQueryKeys } from "@zama-fhe/sdk/query"; import { describe, expect, test, vi } from "../../test-fixtures"; -import { expectCacheInvalidated } from "../../test-helpers"; +import { expectCacheInvalidated, expectCacheRemoved } from "../../test-helpers"; import { expectDefaultMutationState } from "../../__tests__/mutation-test-helpers"; import { useRevokeSession } from "../use-revoke-session"; @@ -24,6 +24,21 @@ describe("useRevokeSession", () => { expectCacheInvalidated(queryClient, zamaQueryKeys.isAllowed.all); }); + test("cache: removes decrypted plaintext queries after revokeSession", async ({ + renderWithProviders, + }) => { + const { result, queryClient } = renderWithProviders(() => useRevokeSession()); + const decryptionKey = zamaQueryKeys.decryption.batch( + [{ handle: "0xh1", contractAddress: "0x1a1A1A1A1a1A1A1a1A1a1a1a1a1a1a1A1A1a1a1a" }], + "0x2b2B2B2b2B2b2B2b2B2b2b2b2B2B2b2b2B2b2B2B", + ); + queryClient.setQueryData(decryptionKey, { "0xh1": 1n }); + + await act(() => result.current.mutateAsync()); + + expectCacheRemoved(queryClient, decryptionKey); + }); + test("behavior: forwards onSuccess callback", async ({ renderWithProviders }) => { const onSuccess = vi.fn(); diff --git a/packages/react-sdk/src/authorization/__tests__/use-revoke.test.tsx b/packages/react-sdk/src/authorization/__tests__/use-revoke.test.tsx index 924189538..e03575a6f 100644 --- a/packages/react-sdk/src/authorization/__tests__/use-revoke.test.tsx +++ b/packages/react-sdk/src/authorization/__tests__/use-revoke.test.tsx @@ -1,7 +1,7 @@ import { act } from "@testing-library/react"; import { zamaQueryKeys } from "@zama-fhe/sdk/query"; import { describe, expect, test, vi } from "../../test-fixtures"; -import { expectCacheInvalidated } from "../../test-helpers"; +import { expectCacheInvalidated, expectCacheRemoved } from "../../test-helpers"; import { OTHER_TOKEN, TOKEN, @@ -26,6 +26,21 @@ describe("useRevoke", () => { expectCacheInvalidated(queryClient, zamaQueryKeys.isAllowed.all); }); + test("cache: removes decrypted plaintext queries after revoke", async ({ + renderWithProviders, + }) => { + const { result, queryClient } = renderWithProviders(() => useRevoke()); + const decryptionKey = zamaQueryKeys.decryption.batch( + [{ handle: "0xh1", contractAddress: TOKEN }], + "0x2b2B2B2b2B2b2B2b2B2b2b2b2B2B2b2b2B2b2B2B", + ); + queryClient.setQueryData(decryptionKey, { "0xh1": 1n }); + + await act(() => result.current.mutateAsync([TOKEN])); + + expectCacheRemoved(queryClient, decryptionKey); + }); + test("behavior: forwards onSuccess callback", async ({ renderWithProviders }) => { const onSuccess = vi.fn(); diff --git a/packages/react-sdk/src/authorization/use-revoke-session.ts b/packages/react-sdk/src/authorization/use-revoke-session.ts index 3f4dfeaf4..7886b0a44 100644 --- a/packages/react-sdk/src/authorization/use-revoke-session.ts +++ b/packages/react-sdk/src/authorization/use-revoke-session.ts @@ -1,12 +1,17 @@ "use client"; import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; -import { revokeSessionMutationOptions, zamaQueryKeys } from "@zama-fhe/sdk/query"; +import { + removeDecryptionQueries, + revokeSessionMutationOptions, + zamaQueryKeys, +} from "@zama-fhe/sdk/query"; import { useZamaSDK } from "../provider"; /** * Revoke the session signature for the connected wallet without - * specifying contract addresses. Useful for wallet disconnect handlers. + * specifying contract addresses. Cached plaintext for the connected wallet is + * also cleared. Useful for wallet disconnect handlers. * * @example * ```tsx @@ -22,6 +27,7 @@ export function useRevokeSession(options?: UseMutationOptions) { ...options, onSuccess: (data, variables, onMutateResult, context) => { options?.onSuccess?.(data, variables, onMutateResult, context); + removeDecryptionQueries(context.client); void context.client.invalidateQueries({ queryKey: zamaQueryKeys.isAllowed.all }); }, }); diff --git a/packages/react-sdk/src/authorization/use-revoke.ts b/packages/react-sdk/src/authorization/use-revoke.ts index 1f710d50c..595e9d1c6 100644 --- a/packages/react-sdk/src/authorization/use-revoke.ts +++ b/packages/react-sdk/src/authorization/use-revoke.ts @@ -2,14 +2,15 @@ import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; import type { Address } from "@zama-fhe/sdk"; -import { revokeMutationOptions, zamaQueryKeys } from "@zama-fhe/sdk/query"; +import { removeDecryptionQueries, revokeMutationOptions, zamaQueryKeys } from "@zama-fhe/sdk/query"; import { useZamaSDK } from "../provider"; /** * Revoke stored FHE decrypt credentials for a list of contract addresses. * This is not token-specific — it revokes the EIP-712 authorization for - * any contract that uses FHE-encrypted values. The next decrypt operation - * on these contracts will require a fresh wallet signature. + * any contract that uses FHE-encrypted values. Cached plaintext for the + * current requester is also cleared, so the next decrypt operation on these + * contracts will require a fresh wallet signature. * * @example * ```tsx @@ -27,6 +28,7 @@ export function useRevoke(options?: UseMutationOptions) ...options, onSuccess: (data, variables, onMutateResult, context) => { options?.onSuccess?.(data, variables, onMutateResult, context); + removeDecryptionQueries(context.client); void context.client.invalidateQueries({ queryKey: zamaQueryKeys.isAllowed.all }); }, }); diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index efeef2888..94535c63a 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -15,9 +15,9 @@ export { useEncrypt } from "./relayer/use-encrypt"; export { useUserDecrypt, type DecryptHandle, - type DecryptParams, - type DecryptCallbacks, type UseUserDecryptConfig, + type UseUserDecryptOptions, + type UseUserDecryptResult, } from "./relayer/use-user-decrypt"; export { usePublicDecrypt } from "./relayer/use-public-decrypt"; export { useGenerateKeypair } from "./relayer/use-generate-keypair"; @@ -30,10 +30,6 @@ export { useRequestZKProofVerification } from "./relayer/use-request-zk-proof-ve export { usePublicKey, type PublicKeyData } from "./relayer/use-public-key"; export { usePublicParams, type PublicParamsData } from "./relayer/use-public-params"; -// Read hooks (cached lookups) -export { useUserDecryptedValue } from "./relayer/use-user-decrypted-value"; -export { useUserDecryptedValues } from "./relayer/use-user-decrypted-values"; - // Re-export core classes export { RelayerWeb, @@ -319,9 +315,8 @@ export { delegatedUserDecryptMutationOptions, publicDecryptMutationOptions, requestZKProofVerificationMutationOptions, - userDecryptMutationOptions, - type UserDecryptMutationParams, - type UserDecryptCallbacks, + userDecryptQueryOptions, + type UserDecryptQueryConfig, allowMutationOptions, isAllowedQueryOptions, revokeMutationOptions, diff --git a/packages/react-sdk/src/relayer/__tests__/use-user-decrypted-value.test.tsx b/packages/react-sdk/src/relayer/__tests__/use-user-decrypted-value.test.tsx deleted file mode 100644 index 36f5fa64e..000000000 --- a/packages/react-sdk/src/relayer/__tests__/use-user-decrypted-value.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { hashFn, zamaQueryKeys } from "@zama-fhe/sdk/query"; -import { renderHook } from "@testing-library/react"; -import { describe, expect, it, test } from "../../test-fixtures"; -import { useUserDecryptedValue } from "../use-user-decrypted-value"; - -describe("useUserDecryptedValue", () => { - test("default", ({ createWrapper }) => { - const cached = 1000n; - const ctx = createWrapper(); - ctx.queryClient.setQueryData(zamaQueryKeys.decryption.handle("0xhandle"), cached); - - const { result } = renderHook(() => useUserDecryptedValue("0xhandle"), { - wrapper: ctx.Wrapper, - }); - - const { data, dataUpdatedAt, ...state } = result.current; - const { promise: statePromise, ...stableState } = state; - expect(data).toBe(cached); - expect(dataUpdatedAt).toEqual(expect.any(Number)); - expect(statePromise).toBeDefined(); - expect(stableState).toMatchInlineSnapshot(` - { - "error": null, - "errorUpdateCount": 0, - "errorUpdatedAt": 0, - "failureCount": 0, - "failureReason": null, - "fetchStatus": "idle", - "isEnabled": false, - "isError": false, - "isFetched": true, - "isFetchedAfterMount": false, - "isFetching": false, - "isInitialLoading": false, - "isLoading": false, - "isLoadingError": false, - "isPaused": false, - "isPending": false, - "isPlaceholderData": false, - "isRefetchError": false, - "isRefetching": false, - "isStale": false, - "isSuccess": true, - "refetch": [Function], - "status": "success", - } - `); - }); - - test("behavior: re-render preserves cached data", ({ createWrapper }) => { - const cached = 1000n; - const ctx = createWrapper(); - ctx.queryClient.setQueryData(zamaQueryKeys.decryption.handle("0xhandle"), cached); - - const { result, rerender } = renderHook(() => useUserDecryptedValue("0xhandle"), { - wrapper: ctx.Wrapper, - }); - - const firstData = result.current.data; - rerender(); - - expect(firstData).toBe(cached); - expect(result.current.data).toBe(firstData); - }); -}); - -describe("useUserDecryptedValue (cache behavior)", () => { - it("reads from decryption cache", ({ renderWithProviders }) => { - const { result, queryClient } = renderWithProviders(() => useUserDecryptedValue("0xhandle1")); - - expect(result.current.data).toBeUndefined(); - - queryClient.setQueryData(zamaQueryKeys.decryption.handle("0xhandle1"), 42n); - - const query = queryClient - .getQueryCache() - .find({ queryKey: zamaQueryKeys.decryption.handle("0xhandle1") }); - expect(query?.options.queryKeyHashFn).toBe(hashFn); - }); - - it("handles undefined handle", ({ renderWithProviders }) => { - const { result } = renderWithProviders(() => useUserDecryptedValue(undefined)); - expect(result.current.data).toBeUndefined(); - }); -}); diff --git a/packages/react-sdk/src/relayer/__tests__/use-user-decrypted-values.test.tsx b/packages/react-sdk/src/relayer/__tests__/use-user-decrypted-values.test.tsx deleted file mode 100644 index 1e9b646ff..000000000 --- a/packages/react-sdk/src/relayer/__tests__/use-user-decrypted-values.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { hashFn, zamaQueryKeys } from "@zama-fhe/sdk/query"; -import { describe, expect, it } from "../../test-fixtures"; -import { useUserDecryptedValues } from "../use-user-decrypted-values"; - -describe("useUserDecryptedValues", () => { - it("reads multiple handles from cache", ({ renderWithProviders }) => { - const { result, queryClient } = renderWithProviders(() => - useUserDecryptedValues(["0xh1", "0xh2"]), - ); - - expect(result.current.data).toEqual({ - "0xh1": undefined, - "0xh2": undefined, - }); - expect(result.current.results).toHaveLength(2); - - const query1 = queryClient - .getQueryCache() - .find({ queryKey: zamaQueryKeys.decryption.handle("0xh1") }); - const query2 = queryClient - .getQueryCache() - .find({ queryKey: zamaQueryKeys.decryption.handle("0xh2") }); - expect(query1?.options.queryKeyHashFn).toBe(hashFn); - expect(query2?.options.queryKeyHashFn).toBe(hashFn); - }); - - it("returns empty for empty handles", ({ renderWithProviders }) => { - const { result } = renderWithProviders(() => useUserDecryptedValues([])); - expect(result.current.data).toEqual({}); - expect(result.current.results).toHaveLength(0); - }); -}); diff --git a/packages/react-sdk/src/relayer/use-public-decrypt.ts b/packages/react-sdk/src/relayer/use-public-decrypt.ts index 3bca304f1..a7a070631 100644 --- a/packages/react-sdk/src/relayer/use-public-decrypt.ts +++ b/packages/react-sdk/src/relayer/use-public-decrypt.ts @@ -7,7 +7,7 @@ import { useZamaSDK } from "../provider"; /** * Decrypt FHE ciphertext handles using the network public key (no credential needed). - * On success, populates the decryption cache so {@link useUserDecryptedValue} / {@link useUserDecryptedValues} + * On success, populates the decryption cache so subsequent queries * can read the results. * * @returns A mutation whose `mutate` accepts an array of handle strings. diff --git a/packages/react-sdk/src/relayer/use-user-decrypt.ts b/packages/react-sdk/src/relayer/use-user-decrypt.ts index 8c84f6377..a0a1a7662 100644 --- a/packages/react-sdk/src/relayer/use-user-decrypt.ts +++ b/packages/react-sdk/src/relayer/use-user-decrypt.ts @@ -1,52 +1,72 @@ "use client"; -import { useMutation } from "@tanstack/react-query"; +import { skipToken, type UseQueryOptions } from "@tanstack/react-query"; import type { ClearValueType, Handle } from "@zama-fhe/sdk"; -import type { - DecryptHandle, - UserDecryptCallbacks, - UserDecryptMutationParams, +import type { DecryptHandle } from "@zama-fhe/sdk/query"; +import { + signerAddressQueryOptions, + userDecryptQueryOptions, + zamaQueryKeys, } from "@zama-fhe/sdk/query"; -import { userDecryptMutationOptions } from "@zama-fhe/sdk/query"; import { useZamaSDK } from "../provider"; +import { useQuery } from "../utils/query"; -export type { - UserDecryptCallbacks as DecryptCallbacks, - DecryptHandle, - UserDecryptMutationParams as DecryptParams, -}; +export type { DecryptHandle }; /** Configuration for {@link useUserDecrypt}. */ -export type UseUserDecryptConfig = UserDecryptCallbacks; +export interface UseUserDecryptConfig { + /** Handles to decrypt, each paired with its contract address. */ + handles: DecryptHandle[]; +} + +/** Query options for {@link useUserDecrypt}. */ +export interface UseUserDecryptOptions extends Omit< + UseQueryOptions>, + "queryKey" | "queryFn" | "enabled" +> { + /** + * Whether to run the decrypt query. + * Default: `false`. + */ + enabled?: boolean; +} /** - * High-level orchestration hook for user decryption. - * - * Reuses cached FHE credentials from `sdk.credentials` when available, - * falling back to generating a fresh keypair + EIP-712 signature only when - * no valid credentials exist. This avoids redundant wallet signature prompts. + * Declarative hook for batch user decryption. * - * On success, populates the decryption cache so `useUserDecryptedValue` / `useUserDecryptedValues` - * can read the results. - * - * @param config - Optional callbacks for step-by-step UX feedback. - * @returns A mutation whose `mutate` accepts {@link UserDecryptMutationParams}. + * Decryption can trigger wallet authorization, so this query is opt-in and + * defaults to `enabled: false`. Once enabled, it can reuse cached plaintext + * and previously allowed credentials without prompting again. * * @example * ```tsx - * const decrypt = useUserDecrypt({ - * onCredentialsReady: () => setStep("decrypting"), - * onDecrypted: (values) => console.log(values), - * }); - * decrypt.mutate({ - * handles: [{ handle: "0xHandle", contractAddress: "0xContract" }], - * }); + * const { data, isLoading } = useUserDecrypt( + * { handles: [{ handle: "0xH1", contractAddress: "0xC1" }] }, + * { enabled: isRevealed }, + * ); + * console.log(data?.["0xH1"]); // 100n * ``` */ -export function useUserDecrypt(config?: UseUserDecryptConfig) { +export function useUserDecrypt(config: UseUserDecryptConfig, options?: UseUserDecryptOptions) { const sdk = useZamaSDK(); + const addressQuery = useQuery({ + ...signerAddressQueryOptions(sdk.signer), + }); + + const account = addressQuery.data; + const baseOpts = account + ? userDecryptQueryOptions(sdk, { + ...config, + requesterAddress: account, + query: { ...options, enabled: options?.enabled === true }, + }) + : ({ + queryKey: zamaQueryKeys.decryption.all, + queryFn: skipToken, + enabled: false, + } as const); - return useMutation, Error, UserDecryptMutationParams>( - userDecryptMutationOptions(sdk, config), - ); + return useQuery>(baseOpts); } + +export type UseUserDecryptResult = ReturnType; diff --git a/packages/react-sdk/src/relayer/use-user-decrypted-value.ts b/packages/react-sdk/src/relayer/use-user-decrypted-value.ts deleted file mode 100644 index 345f39826..000000000 --- a/packages/react-sdk/src/relayer/use-user-decrypted-value.ts +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { useQuery } from "../utils/query"; -import type { ClearValueType, Handle } from "@zama-fhe/sdk"; -import { zamaQueryKeys } from "@zama-fhe/sdk/query"; - -/** - * Look up a single cached decrypted value by its handle. - * Values are populated automatically when `useUserDecrypt` or `usePublicDecrypt` succeed. - * You can also populate manually via queryClient.setQueryData(zamaQueryKeys.decryption.handle(handle), value). - */ -export function useUserDecryptedValue(handle: Handle | undefined) { - return useQuery({ - queryKey: zamaQueryKeys.decryption.handle(handle ?? "0x"), - queryFn: () => undefined as never, - enabled: false, - }); -} diff --git a/packages/react-sdk/src/relayer/use-user-decrypted-values.ts b/packages/react-sdk/src/relayer/use-user-decrypted-values.ts deleted file mode 100644 index e2f812514..000000000 --- a/packages/react-sdk/src/relayer/use-user-decrypted-values.ts +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import type { ClearValueType, Handle } from "@zama-fhe/sdk"; -import { zamaQueryKeys } from "@zama-fhe/sdk/query"; -import { useQueries } from "../utils/query"; - -/** - * Look up multiple cached decrypted values by their handles. - * Values are populated automatically when `useUserDecrypt` or `usePublicDecrypt` succeed. - */ -export function useUserDecryptedValues(handles: Handle[]) { - const results = useQueries({ - queries: handles.map((handle) => ({ - queryKey: zamaQueryKeys.decryption.handle(handle), - queryFn: () => undefined as never, - enabled: false, - })), - }); - - const data: Record = {}; - for (let i = 0; i < handles.length; i++) { - data[handles[i]!] = results[i]!.data as ClearValueType | undefined; - } - - return { - data, - results, - }; -} diff --git a/packages/sdk/etc/sdk-query.api.md b/packages/sdk/etc/sdk-query.api.md index 042010a2e..e690e8699 100644 --- a/packages/sdk/etc/sdk-query.api.md +++ b/packages/sdk/etc/sdk-query.api.md @@ -926,6 +926,9 @@ export interface RelayerSDK { userDecrypt(params: UserDecryptParams): Promise>>; } +// @public (undocumented) +export function removeDecryptionQueries(queryClient: QueryClientLike): void; + // @public (undocumented) export function requestZKProofVerificationMutationOptions(sdk: ZamaSDK): MutationFactoryOptions; @@ -1342,21 +1345,6 @@ export interface UnwrapSubmittedEvent extends BaseEvent { type: typeof ZamaSDKEvents.UnwrapSubmitted; } -// @public -export interface UserDecryptCallbacks { - onCredentialsReady?: () => void; - onDecrypted?: (values: Record) => void; -} - -// @public (undocumented) -export function userDecryptMutationOptions(sdk: ZamaSDK, callbacks?: UserDecryptCallbacks): MutationFactoryOptions>; - -// @public -export interface UserDecryptMutationParams { - // (undocumented) - handles: DecryptHandle[]; -} - // @public export interface UserDecryptParams { // (undocumented) @@ -1379,6 +1367,19 @@ export interface UserDecryptParams { startTimestamp: number; } +// @public +export interface UserDecryptQueryConfig { + // (undocumented) + handles: DecryptHandle[]; + // (undocumented) + query?: Record; + // (undocumented) + requesterAddress: Address; +} + +// @public +export function userDecryptQueryOptions(sdk: ZamaSDK, config: UserDecryptQueryConfig): QueryFactoryOptions, Error, Record, ReturnType>; + // @public export interface WrappedEvent { readonly amountIn: bigint; @@ -1583,6 +1584,16 @@ export const zamaQueryKeys: { 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 @@ export class ZamaSDK { // (undocumented) readonly storage: GenericStorage; terminate(): void; + userDecrypt(handles: readonly DecryptHandle[], requesterAddress?: Address): Promise>; } // @public @@ -1723,7 +1735,7 @@ export const ZERO_HANDLE: "0x000000000000000000000000000000000000000000000000000 // 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 --git a/packages/sdk/etc/sdk.api.md b/packages/sdk/etc/sdk.api.md index 98399c657..c6fd76712 100644 --- a/packages/sdk/etc/sdk.api.md +++ b/packages/sdk/etc/sdk.api.md @@ -6964,6 +6964,14 @@ export interface DecryptErrorEvent extends BaseEvent { type: typeof ZamaSDKEvents.DecryptError; } +// @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 @@ export class ZamaSDK { // (undocumented) readonly storage: GenericStorage; terminate(): void; + userDecrypt(handles: readonly DecryptHandle[], requesterAddress?: Address): Promise>; } // @public diff --git a/packages/sdk/src/__tests__/decrypt-cache.test.ts b/packages/sdk/src/__tests__/decrypt-cache.test.ts new file mode 100644 index 000000000..18820d0ff --- /dev/null +++ b/packages/sdk/src/__tests__/decrypt-cache.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "../test-fixtures"; +import { + clearAllCachedDecryptions, + clearCachedDecryptionsForContracts, + clearCachedUserDecryptions, + loadCachedUserDecryption, + saveCachedUserDecryption, +} from "../decrypt-cache"; + +const HANDLE = "0x00000000000000000000000000000000000000000000000000000000000000ab"; +const HANDLE2 = "0x00000000000000000000000000000000000000000000000000000000000000cd"; +const REQUESTER_A = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa" as const; +const REQUESTER_B = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" as const; +const CONTRACT_A = "0x1111111111111111111111111111111111111111" as const; +const CONTRACT_B = "0x2222222222222222222222222222222222222222" as const; + +describe("decrypt-cache", () => { + it("returns null on cache miss", async ({ storage }) => { + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBeNull(); + }); + + it("persists and retrieves a value", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 42n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(42n); + }); + + it("persists typed values directly in storage", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 42n); + expect( + await storage.get( + "zama:decrypt:0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa:0x1111111111111111111111111111111111111111:0x00000000000000000000000000000000000000000000000000000000000000ab", + ), + ).toBe(42n); + }); + + it("different handles produce different cache entries", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 100n); + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE2, 200n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(100n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE2)).toBe(200n); + }); + + it("isolates entries by requester", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 100n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(100n); + expect(await loadCachedUserDecryption(storage, REQUESTER_B, CONTRACT_A, HANDLE)).toBeNull(); + }); + + it("isolates entries by contract address", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 100n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(100n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_B, HANDLE)).toBeNull(); + }); + + it("handles zero value", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 0n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(0n); + }); + + it("persists and retrieves a boolean value", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, true); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(true); + }); + + it("persists and retrieves a false boolean value", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, false); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(false); + }); + + it("persists and retrieves an address value", async ({ storage }) => { + const addr = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa" as `0x${string}`; + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, addr); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(addr); + }); + + it("is case-insensitive for requester, contract, and handle", async ({ storage }) => { + const upperHandle = HANDLE.toUpperCase() as `0x${string}`; + const lowerRequester = REQUESTER_A.toLowerCase() as `0x${string}`; + const lowerContract = CONTRACT_A.toLowerCase() as `0x${string}`; + await saveCachedUserDecryption(storage, lowerRequester, lowerContract, upperHandle, 999n); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBe(999n); + }); + + it("does not throw when storage.get fails", async ({ storage }) => { + storage.get = () => Promise.reject(new Error("storage unavailable")); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBeNull(); + }); + + it("does not throw when storage.set fails", async ({ storage }) => { + storage.set = () => Promise.reject(new Error("storage unavailable")); + await expect( + saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 42n), + ).resolves.toBeUndefined(); + }); + + it("tracks all keys when saves race", async ({ createMockStorage }) => { + const storage = createMockStorage(); + + await Promise.all([ + saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 100n), + saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE2, 200n), + ]); + + expect(await clearAllCachedDecryptions(storage)).toBeUndefined(); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBeNull(); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE2)).toBeNull(); + }); + + it("clearCachedUserDecryptions removes only matching requester entries", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 100n); + await saveCachedUserDecryption(storage, REQUESTER_B, CONTRACT_A, HANDLE2, 200n); + + await clearCachedUserDecryptions(storage, REQUESTER_A); + + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBeNull(); + expect(await loadCachedUserDecryption(storage, REQUESTER_B, CONTRACT_A, HANDLE2)).toBe(200n); + }); + + it("clearCachedDecryptionsForContracts removes only matching contract entries", async ({ + storage, + }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 100n); + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_B, HANDLE2, 200n); + + await clearCachedDecryptionsForContracts(storage, REQUESTER_A, [CONTRACT_A]); + + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBeNull(); + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_B, HANDLE2)).toBe(200n); + }); + + it("clearAllCachedDecryptions removes all cached entries", async ({ storage }) => { + await saveCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE, 100n); + await saveCachedUserDecryption(storage, REQUESTER_B, CONTRACT_B, HANDLE2, 200n); + + await clearAllCachedDecryptions(storage); + + expect(await loadCachedUserDecryption(storage, REQUESTER_A, CONTRACT_A, HANDLE)).toBeNull(); + expect(await loadCachedUserDecryption(storage, REQUESTER_B, CONTRACT_B, HANDLE2)).toBeNull(); + }); + + it("clearAllCachedDecryptions is a no-op on empty storage", async ({ storage }) => { + await expect(clearAllCachedDecryptions(storage)).resolves.toBeUndefined(); + }); + + it("clearAllCachedDecryptions does not throw when storage fails", async ({ storage }) => { + storage.get = () => Promise.reject(new Error("storage unavailable")); + await expect(clearAllCachedDecryptions(storage)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/__tests__/zama-sdk.test.ts b/packages/sdk/src/__tests__/zama-sdk.test.ts index c34c71a13..b207d7e67 100644 --- a/packages/sdk/src/__tests__/zama-sdk.test.ts +++ b/packages/sdk/src/__tests__/zama-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, type Mock } from "../test-fixtures"; import { ZamaSDK } from "../zama-sdk"; +import { loadCachedUserDecryption, saveCachedUserDecryption } from "../decrypt-cache"; import { ReadonlyToken } from "../token/readonly-token"; import { Token } from "../token/token"; import { CredentialsManager } from "../credentials/credentials-manager"; @@ -31,6 +32,80 @@ describe("ZamaSDK", () => { expect(token.address).toBe(tokenAddress); }); + it("userDecrypt caches decrypted handles", async ({ relayer, signer, storage, tokenAddress }) => { + const sdk = new ZamaSDK({ relayer, signer, storage }); + const handle = ("0x" + "01".repeat(32)) as Address; + + vi.mocked(relayer.userDecrypt).mockResolvedValueOnce({ [handle]: 42n }); + + const result = await sdk.userDecrypt([{ handle, contractAddress: tokenAddress }]); + + expect(result).toEqual({ [handle]: 42n }); + expect(relayer.userDecrypt).toHaveBeenCalledOnce(); + expect( + await loadCachedUserDecryption(storage, await signer.getAddress(), tokenAddress, handle), + ).toBe(42n); + }); + + it("userDecrypt keeps same-handle cache entries isolated by contract", async ({ + relayer, + signer, + storage, + }) => { + const sdk = new ZamaSDK({ relayer, signer, storage }); + const handle = ("0x" + "02".repeat(32)) as Address; + const contractA = "0x1111111111111111111111111111111111111111" as Address; + const contractB = "0x2222222222222222222222222222222222222222" as Address; + + vi.mocked(relayer.userDecrypt) + .mockResolvedValueOnce({ [handle]: 10n }) + .mockResolvedValueOnce({ [handle]: 20n }); + + expect(await sdk.userDecrypt([{ handle, contractAddress: contractA }])).toEqual({ + [handle]: 10n, + }); + expect(await sdk.userDecrypt([{ handle, contractAddress: contractB }])).toEqual({ + [handle]: 20n, + }); + + expect( + await loadCachedUserDecryption(storage, await signer.getAddress(), contractA, handle), + ).toBe(10n); + expect( + await loadCachedUserDecryption(storage, await signer.getAddress(), contractB, handle), + ).toBe(20n); + expect(relayer.userDecrypt).toHaveBeenCalledTimes(2); + }); + + it("userDecrypt normalizes contract addresses before grouping", async ({ + relayer, + storage, + createMockSigner, + }) => { + const signer = createMockSigner(); + const sdk = new ZamaSDK({ relayer, signer, storage }); + const handleA = ("0x" + "03".repeat(32)) as Address; + const handleB = ("0x" + "04".repeat(32)) as Address; + const lower = "0x52908400098527886e0f7030069857d2e4169ee7" as Address; + const upper = "0x52908400098527886E0F7030069857D2E4169EE7" as Address; + + vi.mocked(relayer.userDecrypt).mockResolvedValueOnce({ [handleA]: 10n, [handleB]: 20n }); + + const result = await sdk.userDecrypt([ + { handle: handleA, contractAddress: lower }, + { handle: handleB, contractAddress: upper }, + ]); + + expect(result).toEqual({ [handleA]: 10n, [handleB]: 20n }); + expect(relayer.userDecrypt).toHaveBeenCalledOnce(); + expect(relayer.userDecrypt).toHaveBeenCalledWith( + expect.objectContaining({ + contractAddress: "0x52908400098527886E0F7030069857D2E4169EE7", + handles: [handleA, handleB], + }), + ); + }); + for (const method of ["createToken", "createReadonlyToken"] as const) { it(`${method} shares delegatedCredentials with the SDK`, ({ relayer, @@ -138,6 +213,33 @@ describe("ZamaSDK", () => { expect(await sessionStorage.get(storeKey)).toBeNull(); }); + it("revoke clears cached decryptions for the current requester", async ({ + signer, + relayer, + storage, + tokenAddress, + }) => { + const sdk = new ZamaSDK({ relayer, signer, storage }); + const handle = ("0x" + "03".repeat(32)) as Address; + const otherHandle = ("0x" + "13".repeat(32)) as Address; + const requester = await signer.getAddress(); + const otherTokenAddress = "0x3333333333333333333333333333333333333333" as Address; + + await saveCachedUserDecryption(storage, requester, tokenAddress, handle, 99n); + await saveCachedUserDecryption(storage, requester, otherTokenAddress, otherHandle, 199n); + expect(await loadCachedUserDecryption(storage, requester, tokenAddress, handle)).toBe(99n); + expect(await loadCachedUserDecryption(storage, requester, otherTokenAddress, otherHandle)).toBe( + 199n, + ); + + await sdk.revoke(tokenAddress); + + expect(await loadCachedUserDecryption(storage, requester, tokenAddress, handle)).toBeNull(); + expect(await loadCachedUserDecryption(storage, requester, otherTokenAddress, otherHandle)).toBe( + 199n, + ); + }); + it("revokeSession clears session storage", async ({ signer, relayer, @@ -158,6 +260,24 @@ describe("ZamaSDK", () => { expect(await sessionStorage.get(storeKey)).toBeNull(); }); + it("revokeSession clears cached decryptions for the tracked requester", async ({ + signer, + relayer, + storage, + tokenAddress, + }) => { + const sdk = new ZamaSDK({ relayer, signer, storage }); + const handle = ("0x" + "04".repeat(32)) as Address; + const requester = await signer.getAddress(); + + await saveCachedUserDecryption(storage, requester, tokenAddress, handle, 123n); + expect(await loadCachedUserDecryption(storage, requester, tokenAddress, handle)).toBe(123n); + + await sdk.revokeSession(); + + expect(await loadCachedUserDecryption(storage, requester, tokenAddress, handle)).toBeNull(); + }); + it("revokeSession emits CredentialsRevoked event", async ({ relayer, signer, storage }) => { const events: { type: string }[] = []; const sdk = new ZamaSDK({ @@ -433,6 +553,22 @@ describe("ZamaSDK", () => { // Seed account A's session const keyA = await CredentialsManager.computeStoreKey(userAddress, 31337); await sessionStorage.set(keyA, "0xsigA"); + const oldHandle = ("0x" + "21".repeat(32)) as Address; + const newHandle = ("0x" + "22".repeat(32)) as Address; + await saveCachedUserDecryption( + storage, + userAddress, + "0x1111111111111111111111111111111111111111", + oldHandle, + 1n, + ); + await saveCachedUserDecryption( + storage, + NEXT_USER_ADDRESS, + "0x2222222222222222222222222222222222222222", + newHandle, + 2n, + ); // Simulate account change: signer now reports account B (signer.getAddress as Mock).mockResolvedValue(NEXT_USER_ADDRESS); @@ -444,10 +580,28 @@ describe("ZamaSDK", () => { await vi.waitFor(async () => { expect(await sessionStorage.get(keyA)).toBeNull(); }); + await vi.waitFor(async () => { + expect( + await loadCachedUserDecryption( + storage, + userAddress, + "0x1111111111111111111111111111111111111111", + oldHandle, + ), + ).toBeNull(); + }); // Account B's key should be untouched (it was never seeded) const keyB = await CredentialsManager.computeStoreKey(NEXT_USER_ADDRESS, 31337); expect(await sessionStorage.get(keyB)).toBeNull(); + expect( + await loadCachedUserDecryption( + storage, + NEXT_USER_ADDRESS, + "0x2222222222222222222222222222222222222222", + newHandle, + ), + ).toBe(2n); sdk.terminate(); }); @@ -530,6 +684,14 @@ describe("ZamaSDK", () => { const keyA = await CredentialsManager.computeStoreKey(userAddress, 31337); await sessionStorage.set(keyA, "0xsigA"); + const handle = ("0x" + "23".repeat(32)) as Address; + await saveCachedUserDecryption( + storage, + userAddress, + "0x1111111111111111111111111111111111111111", + handle, + 3n, + ); // Signer may throw on getAddress after disconnect (signer.getAddress as Mock).mockRejectedValue(new Error("disconnected")); @@ -538,6 +700,16 @@ describe("ZamaSDK", () => { await vi.waitFor(async () => { expect(await sessionStorage.get(keyA)).toBeNull(); }); + await vi.waitFor(async () => { + expect( + await loadCachedUserDecryption( + storage, + userAddress, + "0x1111111111111111111111111111111111111111", + handle, + ), + ).toBeNull(); + }); sdk.terminate(); }); diff --git a/packages/sdk/src/decrypt-cache.ts b/packages/sdk/src/decrypt-cache.ts new file mode 100644 index 000000000..8c4be2855 --- /dev/null +++ b/packages/sdk/src/decrypt-cache.ts @@ -0,0 +1,132 @@ +import type { ClearValueType } from "@zama-fhe/relayer-sdk/bundle"; +import { getAddress, type Address } from "viem"; +import type { Handle } from "./relayer/relayer-sdk.types"; +import type { GenericStorage } from "./types"; + +const DECRYPT_KEYS_KEY = "zama:decrypt:keys"; +const pendingTrackedKeyWrites = new WeakMap>(); + +function userStoragePrefix(requester: Address): string { + return `zama:decrypt:${getAddress(requester)}`; +} + +function userStorageKey(requester: Address, contractAddress: Address, handle: Handle): string { + return `${userStoragePrefix(requester)}:${getAddress(contractAddress)}:${handle.toLowerCase()}`; +} + +async function getTrackedKeys(storage: GenericStorage): Promise { + return (await storage.get(DECRYPT_KEYS_KEY)) ?? []; +} + +async function trackKey(storage: GenericStorage, key: string): Promise { + const prev = pendingTrackedKeyWrites.get(storage) ?? Promise.resolve(); + const next = prev.then(async () => { + const keys = await getTrackedKeys(storage); + if (!keys.includes(key)) { + await storage.set(DECRYPT_KEYS_KEY, [...keys, key]); + } + }); + pendingTrackedKeyWrites.set( + storage, + next.catch(() => {}), + ); + return next; +} + +export async function loadCachedUserDecryption( + storage: GenericStorage, + requester: Address, + contractAddress: Address, + handle: Handle, +): Promise { + try { + return await storage.get(userStorageKey(requester, contractAddress, handle)); + } catch (error) { + // oxlint-disable-next-line no-console + console.warn("[zama-sdk] User decrypt cache read failed:", error); + return null; + } +} + +export async function saveCachedUserDecryption( + storage: GenericStorage, + requester: Address, + contractAddress: Address, + handle: Handle, + value: ClearValueType, +): Promise { + try { + const key = userStorageKey(requester, contractAddress, handle); + await storage.set(key, value); + await trackKey(storage, key); + } catch { + // Best-effort — never block the caller. + } +} + +async function deleteTrackedKeys( + storage: GenericStorage, + predicate: (key: string) => boolean, +): Promise { + const keys = await getTrackedKeys(storage); + const keysToDelete = keys.filter(predicate); + if (keysToDelete.length === 0) { + return; + } + + await Promise.all(keysToDelete.map((key) => storage.delete(key))); + const remaining = keys.filter((key) => !predicate(key)); + if (remaining.length === 0) { + await storage.delete(DECRYPT_KEYS_KEY); + } else { + await storage.set(DECRYPT_KEYS_KEY, remaining); + } +} + +/** + * Clear cached decryptions for specific contracts, or all of them. + * + * When `contractAddresses` is empty, clears **all** cached decryptions for + * the requester (equivalent to {@link clearCachedUserDecryptions}). + */ +export async function clearCachedDecryptionsForContracts( + storage: GenericStorage, + requester: Address, + contractAddresses: readonly Address[], +): Promise { + if (contractAddresses.length === 0) { + await clearCachedUserDecryptions(storage, requester); + return; + } + + try { + const contractPrefixes = contractAddresses.map( + (addr) => `${userStoragePrefix(requester)}:${getAddress(addr)}:`, + ); + await deleteTrackedKeys(storage, (key) => + contractPrefixes.some((prefix) => key.startsWith(prefix)), + ); + } catch { + // Best-effort — never block the caller. + } +} + +export async function clearCachedUserDecryptions( + storage: GenericStorage, + requester: Address, +): Promise { + try { + const prefix = `${userStoragePrefix(requester)}:`; + await deleteTrackedKeys(storage, (key) => key.startsWith(prefix)); + } catch { + // Best-effort — never block the caller. + } +} + +export async function clearAllCachedDecryptions(storage: GenericStorage): Promise { + try { + await deleteTrackedKeys(storage, () => true); + } catch { + // Best-effort — never block the caller. + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ba57f9756..ef9435c69 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -42,7 +42,7 @@ export { ERC7984_INTERFACE_ID, ERC7984_WRAPPER_INTERFACE_ID } from "./contracts" // Token abstraction layer export { ZamaSDK } from "./zama-sdk"; -export type { ZamaSDKConfig } from "./zama-sdk"; +export type { ZamaSDKConfig, DecryptHandle } from "./zama-sdk"; export { WrappersRegistry, DefaultRegistryAddresses } from "./wrappers-registry"; export type { WrappersRegistryConfig, ListPairsOptions } from "./wrappers-registry"; export { diff --git a/packages/sdk/src/query/__tests__/query-keys.test.ts b/packages/sdk/src/query/__tests__/query-keys.test.ts index c717816d4..2e0a722c5 100644 --- a/packages/sdk/src/query/__tests__/query-keys.test.ts +++ b/packages/sdk/src/query/__tests__/query-keys.test.ts @@ -13,6 +13,8 @@ const WRAPPER_UPPER = "0x27B1FDB04752BBC536007A920D24ACB045561C26"; const SPENDER_LOWER = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; const ERC20_LOWER = "0xd1220a0cf47c7b9be7a2e6ba89f429762e7b9adb"; const TOKEN_B_LOWER = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359"; +const ACCOUNT_LOWER = "0x00000000000000000000000000000000000000aa"; +const ACCOUNT_UPPER = "0x00000000000000000000000000000000000000AA"; const HANDLE_A = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAaaaaaaaaaaaaaaaaaaaaaaaaa"; const HANDLE_B = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbBbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -162,7 +164,7 @@ describe("zamaQueryKeys", () => { zamaQueryKeys.fees.batchTransferFee(TOKEN_LOWER), zamaQueryKeys.fees.feeRecipient(TOKEN_LOWER), zamaQueryKeys.publicParams.bits(2048), - zamaQueryKeys.decryption.handle(HANDLE_A, WRAPPER_LOWER), + zamaQueryKeys.decryption.handle(HANDLE_A, WRAPPER_LOWER, ACCOUNT_LOWER as Address), ]; for (const key of parameterizedKeys) { @@ -247,8 +249,8 @@ describe("zamaQueryKeys", () => { ], [zamaQueryKeys.fees.feeRecipient(TOKEN_LOWER), zamaQueryKeys.fees.feeRecipient(TOKEN_UPPER)], [ - zamaQueryKeys.decryption.handle(HANDLE_A, WRAPPER_LOWER), - zamaQueryKeys.decryption.handle(HANDLE_A, WRAPPER_UPPER), + zamaQueryKeys.decryption.handle(HANDLE_A, WRAPPER_LOWER, ACCOUNT_LOWER as Address), + zamaQueryKeys.decryption.handle(HANDLE_A, WRAPPER_UPPER, ACCOUNT_UPPER as Address), ], ]; @@ -264,6 +266,27 @@ describe("zamaQueryKeys", () => { ]); }); + test("decryption batch key includes normalized requester identity", () => { + expect( + zamaQueryKeys.decryption.batch( + [ + { handle: HANDLE_B, contractAddress: TOKEN_B_LOWER as Address }, + { handle: HANDLE_A, contractAddress: TOKEN_LOWER as Address }, + ], + ACCOUNT_LOWER as Address, + ), + ).toEqual([ + "zama.decryption", + { + account: getAddress(ACCOUNT_LOWER), + handles: [ + { handle: HANDLE_A.toLowerCase(), contractAddress: getAddress(TOKEN_LOWER) }, + { handle: HANDLE_B.toLowerCase(), contractAddress: getAddress(TOKEN_B_LOWER) }, + ], + }, + ]); + }); + test("omits absent optional address fields from query keys", () => { expect(zamaQueryKeys.confidentialHandle.owner(TOKEN_LOWER)).toEqual([ "zama.confidentialHandle", diff --git a/packages/sdk/src/query/__tests__/user-decrypt.test.ts b/packages/sdk/src/query/__tests__/user-decrypt.test.ts index 0c6d90a3d..983157a27 100644 --- a/packages/sdk/src/query/__tests__/user-decrypt.test.ts +++ b/packages/sdk/src/query/__tests__/user-decrypt.test.ts @@ -1,58 +1,101 @@ import { describe, expect, test, vi } from "../../test-fixtures"; -import { userDecryptMutationOptions } from "../user-decrypt"; +import { userDecryptQueryOptions } from "../user-decrypt"; -describe("userDecryptMutationOptions", () => { - test("returns correct mutation key", ({ sdk }) => { - const options = userDecryptMutationOptions(sdk); - expect(options.mutationKey).toEqual(["zama.userDecrypt"]); +describe("userDecryptQueryOptions", () => { + test("returns a requester-scoped query key with sorted handles", ({ sdk }) => { + const handle1 = ("0x" + "02".repeat(32)) as `0x${string}`; + const handle2 = ("0x" + "01".repeat(32)) as `0x${string}`; + const contract = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const requester = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa" as const; + + const options = userDecryptQueryOptions(sdk, { + handles: [ + { handle: handle1, contractAddress: contract }, + { handle: handle2, contractAddress: contract }, + ], + requesterAddress: requester, + }); + + expect(options.queryKey[0]).toBe("zama.decryption"); + expect(options.queryKey[1].account).toBe(requester); + const keyHandles = options.queryKey[1].handles; + const serialized = keyHandles.map((h) => `${h.handle}:${h.contractAddress}`); + expect(serialized).toEqual([...serialized].toSorted((a, b) => a.localeCompare(b))); }); - test("decrypts handles grouped by contract", async ({ sdk, relayer }) => { - const handle1 = ("0x" + "01".repeat(32)) as `0x${string}`; - const handle2 = ("0x" + "02".repeat(32)) as `0x${string}`; - const contract1 = "0x1111111111111111111111111111111111111111" as `0x${string}`; - const contract2 = "0x2222222222222222222222222222222222222222" as `0x${string}`; + test("enabled is false when handles array is empty", ({ sdk }) => { + const options = userDecryptQueryOptions(sdk, { + handles: [], + requesterAddress: "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", + }); + expect(options.enabled).toBe(false); + }); - vi.mocked(relayer.userDecrypt) - .mockResolvedValueOnce({ [handle1]: 100n }) - .mockResolvedValueOnce({ [handle2]: 200n }); + test("enabled is false by default until explicitly opted in", ({ sdk }) => { + const handle = ("0x" + "01".repeat(32)) as `0x${string}`; + const contract = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const options = userDecryptQueryOptions(sdk, { + handles: [{ handle, contractAddress: contract }], + requesterAddress: "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", + }); + expect(options.enabled).toBe(false); + }); - const options = userDecryptMutationOptions(sdk); - const result = await options.mutationFn({ - handles: [ - { handle: handle1, contractAddress: contract1 }, - { handle: handle2, contractAddress: contract2 }, - ], + test("enabled respects query.enabled override", ({ sdk }) => { + const handle = ("0x" + "01".repeat(32)) as `0x${string}`; + const contract = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const options = userDecryptQueryOptions(sdk, { + handles: [{ handle, contractAddress: contract }], + requesterAddress: "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", + query: { enabled: true }, }); + expect(options.enabled).toBe(true); + }); - expect(relayer.userDecrypt).toHaveBeenCalledTimes(2); - expect(result).toEqual({ [handle1]: 100n, [handle2]: 200n }); + test("staleTime is Infinity and retry is disabled", ({ sdk }) => { + const handle = ("0x" + "01".repeat(32)) as `0x${string}`; + const contract = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const options = userDecryptQueryOptions(sdk, { + handles: [{ handle, contractAddress: contract }], + requesterAddress: "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", + }); + expect(options.staleTime).toBe(Infinity); + expect(options.retry).toBe(false); }); - test("groups handles by contract address", async ({ sdk, relayer }) => { + test("queryFn delegates to sdk.userDecrypt", async ({ sdk, relayer }) => { const handle1 = ("0x" + "01".repeat(32)) as `0x${string}`; const handle2 = ("0x" + "02".repeat(32)) as `0x${string}`; - const contract = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const contract1 = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const contract2 = "0x2222222222222222222222222222222222222222" as `0x${string}`; - vi.mocked(relayer.userDecrypt).mockResolvedValueOnce({ - [handle1]: 100n, - [handle2]: 200n, - }); + vi.mocked(relayer.userDecrypt) + .mockResolvedValueOnce({ [handle1]: 100n }) + .mockResolvedValueOnce({ [handle2]: 200n }); - const options = userDecryptMutationOptions(sdk); - await options.mutationFn({ - handles: [ - { handle: handle1, contractAddress: contract }, - { handle: handle2, contractAddress: contract }, - ], + const handles = [ + { handle: handle1, contractAddress: contract1 }, + { handle: handle2, contractAddress: contract2 }, + ]; + const options = userDecryptQueryOptions(sdk, { + handles, + requesterAddress: "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", }); + const result = await options.queryFn({ queryKey: options.queryKey } as never); - // Both handles should be in a single call since same contract - expect(relayer.userDecrypt).toHaveBeenCalledTimes(1); - expect(relayer.userDecrypt).toHaveBeenCalledWith( + expect(result).toEqual({ [handle1]: 100n, [handle2]: 200n }); + expect(relayer.userDecrypt).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + contractAddress: contract1, + signerAddress: "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", + }), + ); + expect(relayer.userDecrypt).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ - handles: [handle1, handle2], - contractAddress: contract, + contractAddress: contract2, + signerAddress: "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", }), ); }); diff --git a/packages/sdk/src/query/index.ts b/packages/sdk/src/query/index.ts index ff7810ffd..8b9d2bb06 100644 --- a/packages/sdk/src/query/index.ts +++ b/packages/sdk/src/query/index.ts @@ -13,6 +13,7 @@ export { invalidateBalanceQueries, invalidateWagmiBalanceQueries, invalidateWalletLifecycleQueries, + removeDecryptionQueries, } from "./invalidation"; export type { QueryClientLike, QueryFilterLike, QueryLike } from "./invalidation"; @@ -131,9 +132,8 @@ export { type DelegateDecryptionParams, } from "./delegate-decryption"; export { - userDecryptMutationOptions, - type UserDecryptMutationParams, - type UserDecryptCallbacks, + userDecryptQueryOptions, + type UserDecryptQueryConfig, type DecryptHandle, } from "./user-decrypt"; export { decryptBalanceAsMutationOptions, type DecryptBalanceAsParams } from "./decrypt-balance-as"; diff --git a/packages/sdk/src/query/invalidation.ts b/packages/sdk/src/query/invalidation.ts index 41af3a5d4..d52558ace 100644 --- a/packages/sdk/src/query/invalidation.ts +++ b/packages/sdk/src/query/invalidation.ts @@ -15,6 +15,10 @@ export interface QueryClientLike { removeQueries(filters: QueryFilterLike): void; } +export function removeDecryptionQueries(queryClient: QueryClientLike): void { + queryClient.removeQueries({ queryKey: zamaQueryKeys.decryption.all }); +} + function invalidateUnderlyingAllowanceQueries( queryClient: QueryClientLike, tokenAddress: Address, @@ -102,7 +106,7 @@ export function invalidateWagmiBalanceQueries(queryClient: QueryClientLike): voi export function invalidateWalletLifecycleQueries(queryClient: QueryClientLike): void { queryClient.removeQueries({ queryKey: zamaQueryKeys.signerAddress.all }); - queryClient.removeQueries({ queryKey: zamaQueryKeys.decryption.all }); + removeDecryptionQueries(queryClient); void queryClient.invalidateQueries({ predicate: isZamaQuery }); invalidateWagmiBalanceQueries(queryClient); } diff --git a/packages/sdk/src/query/query-keys.ts b/packages/sdk/src/query/query-keys.ts index 1101a0d81..220e0daa4 100644 --- a/packages/sdk/src/query/query-keys.ts +++ b/packages/sdk/src/query/query-keys.ts @@ -1,9 +1,7 @@ import { getAddress } from "viem"; -import type { Address } from "viem"; +import type { Address, Hex } from "viem"; import type { Handle } from "../relayer/relayer-sdk.types"; -const normalizeAddresses = (addresses: Address[]): Address[] => - addresses.map((address) => getAddress(address)); const normalizeAddress = (address?: Address): Address | undefined => address === undefined ? undefined : getAddress(address); @@ -60,7 +58,7 @@ export const zamaQueryKeys = { [ "zama.confidentialHandles", { - tokenAddresses: normalizeAddresses(tokenAddresses), + tokenAddresses: tokenAddresses.map((a) => getAddress(a)), ...(owner ? { owner: getAddress(owner) } : {}), }, ] as const, @@ -72,7 +70,7 @@ export const zamaQueryKeys = { [ "zama.confidentialBalances", { - tokenAddresses: normalizeAddresses(tokenAddresses), + tokenAddresses: tokenAddresses.map((a) => getAddress(a)), ...(owner ? { owner: getAddress(owner) } : {}), ...(handles === undefined ? {} : { handles }), }, @@ -261,6 +259,18 @@ export const zamaQueryKeys = { : { contractAddress: getAddress(contractAddress) }), }, ] as const, + /** Key for a batch decrypt query. Handles are sorted for deterministic hashing. */ + batch: (handles: readonly { handle: Hex; contractAddress: Address }[], account: Address) => { + const sorted = [...handles] + .map((h) => ({ + handle: h.handle.toLowerCase() as Hex, + contractAddress: getAddress(h.contractAddress), + })) + .toSorted((a, b) => + `${a.handle}:${a.contractAddress}`.localeCompare(`${b.handle}:${b.contractAddress}`), + ); + return ["zama.decryption", { account: getAddress(account), handles: sorted }] as const; + }, }, wrappersRegistry: { diff --git a/packages/sdk/src/query/user-decrypt.ts b/packages/sdk/src/query/user-decrypt.ts index c1054531d..3280d696d 100644 --- a/packages/sdk/src/query/user-decrypt.ts +++ b/packages/sdk/src/query/user-decrypt.ts @@ -1,75 +1,47 @@ import type { Address } from "viem"; import type { ClearValueType, Handle } from "../relayer/relayer-sdk.types"; -import type { ZamaSDK } from "../zama-sdk"; -import type { MutationFactoryOptions } from "./factory-types"; +import type { DecryptHandle, ZamaSDK } from "../zama-sdk"; +import type { QueryFactoryOptions } from "./factory-types"; import { zamaQueryKeys } from "./query-keys"; +import { filterQueryOptions } from "./utils"; -/** A handle to decrypt, paired with its originating contract address. */ -export interface DecryptHandle { - handle: Handle; - contractAddress: Address; -} +export type { DecryptHandle }; -/** Variables for {@link userDecryptMutationOptions}. */ -export interface UserDecryptMutationParams { +/** Configuration for {@link userDecryptQueryOptions}. */ +export interface UserDecryptQueryConfig { handles: DecryptHandle[]; + requesterAddress: Address; + query?: Record; } -/** Progress callbacks for the decrypt flow. */ -export interface UserDecryptCallbacks { - /** Fired after credentials are ready (either from cache or freshly generated). */ - onCredentialsReady?: () => void; - /** Fired after decryption completes. */ - onDecrypted?: (values: Record) => void; -} - -export function userDecryptMutationOptions( +/** + * Query options factory for batch-decrypting FHE handles. + * + * This factory is requester-scoped and disables retries because decrypt may + * trigger wallet authorization. + */ +export function userDecryptQueryOptions( sdk: ZamaSDK, - callbacks?: UserDecryptCallbacks, -): MutationFactoryOptions< - readonly ["zama.userDecrypt"], - UserDecryptMutationParams, - Record + config: UserDecryptQueryConfig, +): QueryFactoryOptions< + Record, + Error, + Record, + ReturnType > { - return { - mutationKey: ["zama.userDecrypt"] as const, - mutationFn: async ({ handles }) => { - const contractAddresses = [...new Set(handles.map((h) => h.contractAddress))]; - const creds = await sdk.credentials.allow(...contractAddresses); - callbacks?.onCredentialsReady?.(); - - const signerAddress = await sdk.signer.getAddress(); - const allResults: Record = {}; + const { handles, requesterAddress } = config; + const queryEnabled = config.query?.enabled === true; + const queryKey = zamaQueryKeys.decryption.batch(handles, requesterAddress); - const handlesByContract = new Map(); - for (const h of handles) { - const list = handlesByContract.get(h.contractAddress) ?? []; - list.push(h.handle); - handlesByContract.set(h.contractAddress, list); - } - - for (const [contractAddress, contractHandles] of handlesByContract) { - const result = await sdk.relayer.userDecrypt({ - handles: contractHandles, - contractAddress, - signedContractAddresses: creds.contractAddresses, - privateKey: creds.privateKey, - publicKey: creds.publicKey, - signature: creds.signature, - signerAddress, - startTimestamp: creds.startTimestamp, - durationDays: creds.durationDays, - }); - Object.assign(allResults, result); - } - - callbacks?.onDecrypted?.(allResults); - return allResults; - }, - onSuccess: (data, _variables, _onMutateResult, context) => { - for (const [handle, value] of Object.entries(data) as [Handle, ClearValueType][]) { - context.client.setQueryData(zamaQueryKeys.decryption.handle(handle), value); - } + return { + ...filterQueryOptions(config.query ?? {}), + queryKey, + queryFn: async (context) => { + const [, { handles: keyHandles, account }] = context.queryKey; + return sdk.userDecrypt(keyHandles, account); }, + enabled: handles.length > 0 && queryEnabled, + staleTime: Infinity, + retry: false, }; } diff --git a/packages/sdk/src/token/__tests__/balance-cache.test.ts b/packages/sdk/src/token/__tests__/balance-cache.test.ts deleted file mode 100644 index aa4d5551d..000000000 --- a/packages/sdk/src/token/__tests__/balance-cache.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect } from "../../test-fixtures"; -import { loadCachedBalance, saveCachedBalance, clearAllCachedBalances } from "../balance-cache"; - -const OWNER = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa"; -const HANDLE = "0x00000000000000000000000000000000000000000000000000000000000000ab"; -const HANDLE2 = "0x00000000000000000000000000000000000000000000000000000000000000cd"; - -describe("balance-cache", () => { - it("returns null on cache miss", async ({ storage, tokenAddress }) => { - expect( - await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE }), - ).toBeNull(); - }); - - it("persists and retrieves a balance", async ({ storage, tokenAddress }) => { - await saveCachedBalance({ - storage, - tokenAddress, - owner: OWNER, - handle: HANDLE, - value: 42n, - }); - expect(await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE })).toBe( - 42n, - ); - }); - - it("different handles produce different cache entries", async ({ storage, tokenAddress }) => { - await saveCachedBalance({ - storage, - tokenAddress, - owner: OWNER, - handle: HANDLE, - value: 100n, - }); - await saveCachedBalance({ - storage, - tokenAddress, - owner: OWNER, - handle: HANDLE2, - value: 200n, - }); - expect(await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE })).toBe( - 100n, - ); - expect(await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE2 })).toBe( - 200n, - ); - }); - - it("handles zero balance", async ({ storage, tokenAddress }) => { - await saveCachedBalance({ - storage, - tokenAddress, - owner: OWNER, - handle: HANDLE, - value: 0n, - }); - expect(await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE })).toBe( - 0n, - ); - }); - - it("is case-insensitive for token, owner, and handle", async ({ storage, tokenAddress }) => { - const upperToken = tokenAddress.toLowerCase() as `0x${string}`; - const upperOwner = OWNER.toLowerCase() as `0x${string}`; - const upperHandle = HANDLE.toUpperCase() as `0x${string}`; - await saveCachedBalance({ - storage, - tokenAddress: upperToken, - owner: upperOwner, - handle: upperHandle, - value: 999n, - }); - expect(await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE })).toBe( - 999n, - ); - }); - - it("does not throw when storage.getItem fails", async ({ storage, tokenAddress }) => { - storage.get = () => Promise.reject(new Error("storage unavailable")); - expect( - await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE }), - ).toBeNull(); - }); - - it("does not throw when storage.setItem fails", async ({ storage, tokenAddress }) => { - storage.set = () => Promise.reject(new Error("storage unavailable")); - await expect( - saveCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE, value: 42n }), - ).resolves.toBeUndefined(); - }); - - it("clearAllCachedBalances removes all cached entries", async ({ storage, tokenAddress }) => { - await saveCachedBalance({ - storage, - tokenAddress, - owner: OWNER, - handle: HANDLE, - value: 100n, - }); - await saveCachedBalance({ - storage, - tokenAddress, - owner: OWNER, - handle: HANDLE2, - value: 200n, - }); - - await clearAllCachedBalances(storage); - - expect( - await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE }), - ).toBeNull(); - expect( - await loadCachedBalance({ storage, tokenAddress, owner: OWNER, handle: HANDLE2 }), - ).toBeNull(); - }); - - it("clearAllCachedBalances is a no-op on empty storage", async ({ storage }) => { - await expect(clearAllCachedBalances(storage)).resolves.toBeUndefined(); - }); - - it("clearAllCachedBalances does not throw when storage fails", async ({ storage }) => { - storage.get = () => Promise.reject(new Error("storage unavailable")); - await expect(clearAllCachedBalances(storage)).resolves.toBeUndefined(); - }); -}); diff --git a/packages/sdk/src/token/__tests__/batch-decrypt-as.test.ts b/packages/sdk/src/token/__tests__/batch-decrypt-as.test.ts index b82791ea2..07e560554 100644 --- a/packages/sdk/src/token/__tests__/batch-decrypt-as.test.ts +++ b/packages/sdk/src/token/__tests__/batch-decrypt-as.test.ts @@ -1,5 +1,6 @@ import { describe, expect, vi } from "vitest"; import { test, createMockSigner } from "../../test-fixtures"; +import { saveCachedUserDecryption } from "../../decrypt-cache"; import { ReadonlyToken } from "../readonly-token"; import { MemoryStorage } from "../../storage/memory-storage"; import { MAX_UINT64 } from "../../contracts/constants"; @@ -114,10 +115,7 @@ describe("ReadonlyToken.batchDecryptBalancesAs", () => { const storage = new MemoryStorage(); const sessionStorage = new MemoryStorage(); - // Pre-populate cache with the internal key format - const handleLower = HANDLE_A.toLowerCase(); - const cacheKey = `zama:balance:${TOKEN_A}:${DELEGATOR}:${handleLower}`; - await storage.set(cacheKey, "42"); + await saveCachedUserDecryption(storage, DELEGATE, TOKEN_A, HANDLE_A, 42n); vi.mocked(signer.readContract).mockResolvedValueOnce(HANDLE_A); // confidentialBalanceOf diff --git a/packages/sdk/src/token/__tests__/delegation.test.ts b/packages/sdk/src/token/__tests__/delegation.test.ts index 1314c51ed..1372cb9a6 100644 --- a/packages/sdk/src/token/__tests__/delegation.test.ts +++ b/packages/sdk/src/token/__tests__/delegation.test.ts @@ -408,11 +408,8 @@ describe("decryptBalanceAs", () => { [handle]: 42n, }); - // First call populates cache keyed by owner (userAddress), not delegator. - await readonlyToken.decryptBalanceAs({ - delegatorAddress, - owner: userAddress, - }); + // First call populates cache keyed by requester (connected delegate), not delegator. + await readonlyToken.decryptBalanceAs({ delegatorAddress, owner: userAddress }); expect(relayer.delegatedUserDecrypt).toHaveBeenCalledTimes(1); // Second call with same owner should hit cache — no second decrypt call. diff --git a/packages/sdk/src/token/__tests__/readonly-token.test.ts b/packages/sdk/src/token/__tests__/readonly-token.test.ts index 241774bc3..712cdc114 100644 --- a/packages/sdk/src/token/__tests__/readonly-token.test.ts +++ b/packages/sdk/src/token/__tests__/readonly-token.test.ts @@ -3,7 +3,7 @@ import { ReadonlyToken, ZERO_HANDLE } from "../readonly-token"; import { ZamaErrorCode, DecryptionFailedError } from "../../errors"; import type { GenericSigner, GenericStorage } from "../../types"; import type { RelayerSDK } from "../../relayer/relayer-sdk"; -import { saveCachedBalance } from "../balance-cache"; +import { loadCachedUserDecryption, saveCachedUserDecryption } from "../../decrypt-cache"; import { getAddress, type Address } from "viem"; const VALID_HANDLE2 = ("0x" + "cd".repeat(32)) as Address; @@ -484,23 +484,10 @@ describe("ReadonlyToken", () => { address: TOKEN2, }); - const signerAddress = await signer.getAddress(); - // Pre-populate cache for both tokens - await saveCachedBalance({ - storage, - tokenAddress, - owner: signerAddress, - handle: handle, - value: 1000n, - }); - await saveCachedBalance({ - storage, - tokenAddress: TOKEN2, - owner: signerAddress, - handle: VALID_HANDLE2, - value: 2000n, - }); + const signerAddress = await signer.getAddress(); + await saveCachedUserDecryption(storage, signerAddress, tokenAddress, handle, 1000n); + await saveCachedUserDecryption(storage, signerAddress, TOKEN2, VALID_HANDLE2, 2000n); const result = await ReadonlyToken.batchDecryptBalances([token, token2], { handles: [handle, VALID_HANDLE2], @@ -537,16 +524,9 @@ describe("ReadonlyToken", () => { address: TOKEN2, }); - const signerAddress = await signer.getAddress(); - // Pre-populate cache only for token1 - await saveCachedBalance({ - storage, - tokenAddress, - owner: signerAddress, - handle: handle, - value: 1000n, - }); + const signerAddress = await signer.getAddress(); + await saveCachedUserDecryption(storage, signerAddress, tokenAddress, handle, 1000n); vi.mocked(relayer.userDecrypt).mockResolvedValueOnce({ [VALID_HANDLE2]: 2000n, @@ -695,6 +675,37 @@ describe("ReadonlyToken", () => { await token.revoke(); expect(await token.isAllowed()).toBe(false); }); + + it("only clears cached plaintext for the revoked contract scope", async ({ + relayer, + signer, + storage, + sessionStorage, + tokenAddress, + handle, + }) => { + const otherTokenAddress = "0x3333333333333333333333333333333333333333" as Address; + const token = createReadonlyToken({ + relayer, + signer, + storage, + sessionStorage, + tokenAddress, + handle, + }); + const requester = getAddress(await signer.getAddress()); + const otherHandle = ("0x" + "ef".repeat(32)) as Address; + + await saveCachedUserDecryption(storage, requester, tokenAddress, handle, 1000n); + await saveCachedUserDecryption(storage, requester, otherTokenAddress, otherHandle, 2000n); + + await token.revoke(tokenAddress); + + expect(await loadCachedUserDecryption(storage, requester, tokenAddress, handle)).toBeNull(); + expect( + await loadCachedUserDecryption(storage, requester, otherTokenAddress, otherHandle), + ).toBe(2000n); + }); }); }); diff --git a/packages/sdk/src/token/__tests__/token.test.ts b/packages/sdk/src/token/__tests__/token.test.ts index ef19d10b9..9ada68886 100644 --- a/packages/sdk/src/token/__tests__/token.test.ts +++ b/packages/sdk/src/token/__tests__/token.test.ts @@ -1,6 +1,7 @@ import { Topics } from "../../events"; import { Token } from "../token"; import { getAddress, type Address } from "viem"; +import { saveCachedUserDecryption } from "../../decrypt-cache"; import { ZamaError, ZamaErrorCode } from "../../errors"; import { describe, expect, it, vi } from "../../test-fixtures"; @@ -1491,9 +1492,13 @@ describe("Token", () => { handle, storage, }) => { - // Seed the balance cache with a sufficient balance - const cacheKey = `zama:balance:${getAddress(token.address)}:${getAddress(await signer.getAddress())}:${handle.toLowerCase()}`; - await storage.set(cacheKey, "200"); + await saveCachedUserDecryption( + storage, + await signer.getAddress(), + token.address, + handle, + 200n, + ); vi.mocked(signer.readContract).mockResolvedValueOnce(handle); // confidentialBalanceOf @@ -1508,8 +1513,13 @@ describe("Token", () => { handle, storage, }) => { - const cacheKey = `zama:balance:${getAddress(token.address)}:${getAddress(await signer.getAddress())}:${handle.toLowerCase()}`; - await storage.set(cacheKey, "50"); + await saveCachedUserDecryption( + storage, + await signer.getAddress(), + token.address, + handle, + 50n, + ); vi.mocked(signer.readContract).mockResolvedValueOnce(handle); // confidentialBalanceOf diff --git a/packages/sdk/src/token/balance-cache.ts b/packages/sdk/src/token/balance-cache.ts deleted file mode 100644 index ef983feac..000000000 --- a/packages/sdk/src/token/balance-cache.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { getAddress, type Address } from "viem"; -import type { GenericStorage } from "../types"; -import type { Handle } from "../relayer/relayer-sdk.types"; - -const BALANCES_KEY = "zama:balances"; - -export interface BalanceCachePayload { - storage: GenericStorage; - tokenAddress: Address; - owner: Address; - handle: Handle; -} - -/** - * Build a storage key for a cached decrypted balance. - * The handle is embedded in the key so a new on-chain handle automatically - * invalidates the cache entry — no TTL needed. - */ -function storageKey(tokenAddress: Address, owner: Address, handle: Handle): string { - return `zama:balance:${getAddress(tokenAddress)}:${getAddress(owner)}:${handle.toLowerCase()}`; -} - -/** - * Load a cached decrypted balance, or `null` on cache miss. - */ -export async function loadCachedBalance({ - storage, - tokenAddress, - owner, - handle, -}: BalanceCachePayload): Promise { - try { - const raw = await storage.get(storageKey(tokenAddress, owner, handle)); - return raw !== null ? BigInt(raw) : null; - } catch (error) { - console.warn("[zama-sdk] Balance cache read failed:", error); - return null; - } -} - -/** - * Persist a decrypted balance to storage. - */ -export async function saveCachedBalance( - payload: BalanceCachePayload & { value: bigint }, -): Promise { - const { storage, tokenAddress, owner, handle, value } = payload; - const key = storageKey(tokenAddress, owner, handle); - try { - await storage.set(key, value.toString()); - await trackKey(storage, key); - } catch { - // Best-effort — never block the caller. - } -} - -const trackKeyChains = new WeakMap>(); - -async function trackKey(storage: GenericStorage, key: string): Promise { - // Serialize read-modify-write per storage instance to prevent concurrent - // saveCachedBalance calls from overwriting each other's key additions. - const prev = trackKeyChains.get(storage) ?? Promise.resolve(); - const next = prev.then(async () => { - const raw = await storage.get(BALANCES_KEY); - const keys: string[] = raw ? JSON.parse(raw) : []; - if (!keys.includes(key)) { - keys.push(key); - await storage.set(BALANCES_KEY, JSON.stringify(keys)); - } - }); - trackKeyChains.set( - storage, - next.catch(() => {}), - ); // prevent chain poisoning - return next; -} - -/** - * Remove all cached decrypted balances from storage. - * Best-effort — never throws. - */ -export async function clearAllCachedBalances(storage: GenericStorage): Promise { - try { - const raw = await storage.get(BALANCES_KEY); - if (!raw) { - return; - } - const keys: string[] = JSON.parse(raw); - await Promise.all(keys.map((key) => storage.delete(key))); - await storage.delete(BALANCES_KEY); - } catch { - // Best-effort — never block the caller. - } -} diff --git a/packages/sdk/src/token/readonly-token.ts b/packages/sdk/src/token/readonly-token.ts index 0e9f83891..0bcd5bd6e 100644 --- a/packages/sdk/src/token/readonly-token.ts +++ b/packages/sdk/src/token/readonly-token.ts @@ -31,7 +31,11 @@ import type { RelayerSDK } from "../relayer/relayer-sdk"; import type { Handle } from "../relayer/relayer-sdk.types"; import type { GenericSigner, GenericStorage } from "../types"; import { toError } from "../utils"; -import { loadCachedBalance, saveCachedBalance } from "./balance-cache"; +import { + clearCachedDecryptionsForContracts, + loadCachedUserDecryption, + saveCachedUserDecryption, +} from "../decrypt-cache"; import { pLimit } from "./concurrency"; /** 32-byte zero handle, used to detect uninitialized encrypted balances. */ @@ -265,6 +269,7 @@ export class ReadonlyToken { tokens, handles, ownerAddress: signerAddress, + requesterAddress: signerAddress, onError, maxConcurrency, obtainCreds: (uncachedAddresses) => firstToken.credentials.allow(...uncachedAddresses), @@ -321,12 +326,14 @@ export class ReadonlyToken { const { delegatorAddress, handles, owner, onError, maxConcurrency } = options; const ownerAddress = owner ?? delegatorAddress; const firstToken = tokens[0]!; + const requesterAddress = await firstToken.signer.getAddress(); ReadonlyToken.assertSameRelayer(tokens); return ReadonlyToken.#batchDecryptCore({ tokens, handles, ownerAddress, + requesterAddress, onError, maxConcurrency, preFlightCheck: () => firstToken.#assertDelegationActive(delegatorAddress), @@ -353,6 +360,7 @@ export class ReadonlyToken { tokens: ReadonlyToken[]; handles: Handle[] | undefined; ownerAddress: Address; + requesterAddress: Address; onError?: (error: Error, address: Address) => bigint; maxConcurrency?: number; preFlightCheck?: () => Promise; @@ -368,6 +376,7 @@ export class ReadonlyToken { tokens, handles, ownerAddress, + requesterAddress, onError, maxConcurrency, obtainCreds, @@ -396,12 +405,7 @@ export class ReadonlyToken { if (token.isZeroHandle(handle)) { return 0n; } - return loadCachedBalance({ - storage: tokenStorage, - tokenAddress: token.address, - owner: ownerAddress, - handle, - }); + return loadCachedUserDecryption(tokenStorage, requesterAddress, token.address, handle); }), ); @@ -410,7 +414,7 @@ export class ReadonlyToken { const handle = resolvedHandles[i]!; const cached = cachedValues[i]; - if (cached !== null && cached !== undefined) { + if (typeof cached === "bigint") { results.set(token.address, cached); continue; } @@ -448,13 +452,13 @@ export class ReadonlyToken { } results.set(token.address, value); try { - await saveCachedBalance({ - storage: tokenStorage, - tokenAddress: token.address, - owner: ownerAddress, + await saveCachedUserDecryption( + tokenStorage, + requesterAddress, + token.address, handle, value, - }); + ); } catch (cacheError) { // oxlint-disable-next-line no-console console.warn("[zama-sdk] Cache write failed (non-blocking):", cacheError); @@ -593,11 +597,15 @@ export class ReadonlyToken { /** * Revoke the session signature for the connected wallet. - * Stored credentials remain intact, but the next decrypt operation - * will require a fresh wallet signature. + * Cached plaintext is cleared for the current requester so the next decrypt + * operation will require a fresh wallet signature. */ async revoke(...contractAddresses: Address[]): Promise { - await this.credentials.revoke(...contractAddresses); + const address = await this.signer.getAddress(); + await Promise.all([ + this.credentials.revoke(...contractAddresses), + clearCachedDecryptionsForContracts(this.storage, address, contractAddresses), + ]); } /** @@ -715,7 +723,7 @@ export class ReadonlyToken { * The connected signer acts as the delegate who has been granted permission * by the delegator to decrypt their balance. * - * Decrypted values are cached in storage keyed by `(token, owner, handle)`. + * Decrypted values are cached in storage keyed by `(requester, handle)`. * Cache write failures are silently ignored — they do not affect the returned value. * * @param delegatorAddress - The address of the account that delegated decryption rights. @@ -752,16 +760,18 @@ export class ReadonlyToken { return 0n; } - // Check persistent cache keyed by (token, owner, handle). + const delegateAddress = await this.signer.getAddress(); + + // Check persistent cache keyed by (requester, handle). // When the on-chain balance changes the encrypted handle changes too, // so stale entries are never served — no TTL needed. - const cached = await loadCachedBalance({ - storage: this.storage, - tokenAddress: this.address, - owner: normalizedOwner, + const cached = await loadCachedUserDecryption( + this.storage, + delegateAddress, + this.address, handle, - }); - if (cached !== null) { + ); + if (typeof cached === "bigint") { return cached; } @@ -801,13 +811,7 @@ export class ReadonlyToken { } try { - await saveCachedBalance({ - storage: this.storage, - tokenAddress: this.address, - owner: normalizedOwner, - handle, - value, - }); + await saveCachedUserDecryption(this.storage, delegateAddress, this.address, handle, value); } catch (cacheError) { // oxlint-disable-next-line no-console console.warn("[zama-sdk] Cache write failed (non-blocking):", cacheError); @@ -846,14 +850,14 @@ export class ReadonlyToken { const signerAddress = owner ?? (await this.signer.getAddress()); - // Check persistent cache — avoids the 2–5 s decrypt spinner on reload. - const cached = await loadCachedBalance({ - storage: this.storage, - tokenAddress: this.address, - owner: signerAddress, + // Check persistent cache keyed by (requester, handle) — avoids the 2–5 s decrypt spinner on reload. + const cached = await loadCachedUserDecryption( + this.storage, + signerAddress, + this.address, handle, - }); - if (cached !== null) { + ); + if (typeof cached === "bigint") { return cached; } @@ -883,13 +887,7 @@ export class ReadonlyToken { throw new DecryptionFailedError(`Decryption returned no value for handle ${handle}`); } try { - await saveCachedBalance({ - storage: this.storage, - tokenAddress: this.address, - owner: signerAddress, - handle, - value, - }); + await saveCachedUserDecryption(this.storage, signerAddress, this.address, handle, value); } catch (cacheError) { // oxlint-disable-next-line no-console console.warn("[zama-sdk] Cache write failed (non-blocking):", cacheError); diff --git a/packages/sdk/src/token/token.ts b/packages/sdk/src/token/token.ts index 506d3bb21..0c2daa646 100644 --- a/packages/sdk/src/token/token.ts +++ b/packages/sdk/src/token/token.ts @@ -21,7 +21,6 @@ import { findUnwrapRequested } from "../events/onchain-events"; import { ZamaSDKEvents } from "../events/sdk-events"; import type { Handle } from "../relayer/relayer-sdk.types"; import { toError } from "../utils"; -import { loadCachedBalance } from "./balance-cache"; import { ApprovalFailedError, BalanceCheckUnavailableError, @@ -49,6 +48,7 @@ import type { UnshieldCallbacks, UnshieldOptions, } from "../types"; +import { loadCachedUserDecryption } from "../decrypt-cache"; /** * ERC-20-like interface for a single confidential token. @@ -1044,12 +1044,12 @@ export class Token extends ReadonlyToken { // Check the persistent plaintext cache first — if the balance was decrypted // in a previous session, we can validate without credentials or a new decrypt. - const cached = await loadCachedBalance({ - storage: this.storage, - tokenAddress: this.address, - owner: userAddress, + const cached = (await loadCachedUserDecryption( + this.storage, + userAddress, + this.address, handle, - }); + )) as bigint | null; if (cached !== null) { if (cached < amount) { throw new InsufficientConfidentialBalanceError( diff --git a/packages/sdk/src/zama-sdk.ts b/packages/sdk/src/zama-sdk.ts index a657b99ee..6867b6120 100644 --- a/packages/sdk/src/zama-sdk.ts +++ b/packages/sdk/src/zama-sdk.ts @@ -1,9 +1,16 @@ import { getAddress, type Address } from "viem"; import { CredentialsManager } from "./credentials/credentials-manager"; import { DelegatedCredentialsManager } from "./credentials/delegated-credentials-manager"; +import { + clearCachedUserDecryptions, + clearCachedDecryptionsForContracts, + loadCachedUserDecryption, + saveCachedUserDecryption, +} from "./decrypt-cache"; import type { ZamaSDKEventListener } from "./events/sdk-events"; import { ZamaSDKEvents } from "./events/sdk-events"; import type { RelayerSDK } from "./relayer/relayer-sdk"; +import type { ClearValueType, Handle } from "./relayer/relayer-sdk.types"; import { MemoryStorage } from "./storage/memory-storage"; import { ReadonlyToken } from "./token/readonly-token"; import { Token } from "./token/token"; @@ -11,6 +18,12 @@ import type { GenericSigner, GenericStorage, SignerLifecycleCallbacks } from "./ import { toError } from "./utils"; import { WrappersRegistry } from "./wrappers-registry"; +/** A handle to decrypt, paired with its originating contract address. */ +export interface DecryptHandle { + handle: Handle; + contractAddress: Address; +} + /** Configuration for {@link ZamaSDK}. */ export interface ZamaSDKConfig { /** FHE relayer backend (`RelayerWeb` for browser, `RelayerNode` for server). */ @@ -185,7 +198,10 @@ export class ZamaSDK { return; } const storeKey = await CredentialsManager.computeStoreKey(this.#lastAddress, this.#lastChainId); - await this.credentials.revokeByKey(storeKey); + await Promise.all([ + this.credentials.revokeByKey(storeKey), + clearCachedUserDecryptions(this.storage, this.#lastAddress), + ]); } /** @@ -274,7 +290,8 @@ export class ZamaSDK { /** * Revoke the session signature for the current signer. - * The next decrypt operation will require a fresh wallet signature. + * Clears cached plaintext for that signer so the next decrypt operation + * requires a fresh wallet signature. * * @param contractAddresses - Optional addresses included in the * `credentials:revoked` event for observability. @@ -286,13 +303,18 @@ export class ZamaSDK { * ``` */ async revoke(...contractAddresses: Address[]): Promise { - await this.credentials.revoke(...contractAddresses); + const address = await this.#resolveRequesterAddress(); + await Promise.all([ + this.credentials.revoke(...contractAddresses), + clearCachedDecryptionsForContracts(this.storage, address, contractAddresses), + ]); } /** * Revoke the session signature for the current signer without requiring * contract addresses. Uses the tracked identity when available (safe during - * account switches), falling back to querying the signer directly. + * account switches), falling back to querying the signer directly. Clears + * cached plaintext for that signer so future decrypts re-authorize. * * @example * ```ts @@ -301,10 +323,13 @@ export class ZamaSDK { */ async revokeSession(): Promise { await this.#identityReady; - const address = this.#lastAddress ?? (await this.signer.getAddress()); + const address = await this.#resolveRequesterAddress(); const chainId = this.#lastChainId ?? (await this.signer.getChainId()); const storeKey = await CredentialsManager.computeStoreKey(address, chainId); - await this.credentials.revokeByKey(storeKey); + await Promise.all([ + this.credentials.revokeByKey(storeKey), + clearCachedUserDecryptions(this.storage, address), + ]); } /** @@ -315,6 +340,107 @@ export class ZamaSDK { return this.credentials.isAllowed(); } + /** + * Decrypt one or more FHE ciphertext handles into plaintext values. + * + * Checks the persistent decrypt cache first; only uncached handles are sent + * to the relayer. Results are written back to the cache for future calls. + * + * @param handles - Handles to decrypt, each paired with its contract address. + * @returns A record mapping each handle to its decrypted cleartext value. + * + * @example + * ```ts + * const values = await sdk.userDecrypt([ + * { handle: "0xH1", contractAddress: "0xC1" }, + * { handle: "0xH2", contractAddress: "0xC1" }, + * ]); + * console.log(values["0xH1"]); // 100n + * ``` + */ + async userDecrypt( + handles: readonly DecryptHandle[], + requesterAddress?: Address, + ): Promise> { + if (handles.length === 0) { + return {}; + } + + const resolvedRequesterAddress = getAddress( + requesterAddress ?? (await this.#resolveRequesterAddress()), + ); + const normalizedHandles = handles.map(({ handle, contractAddress }) => ({ + handle: handle.toLowerCase() as Handle, + contractAddress: getAddress(contractAddress), + })); + + // Resolve cache hits and misses in parallel. + const cached = await Promise.all( + normalizedHandles.map(async (h) => ({ + ...h, + value: await loadCachedUserDecryption( + this.storage, + resolvedRequesterAddress, + h.contractAddress, + h.handle, + ), + })), + ); + + const hits = Object.fromEntries( + cached.flatMap((entry) => (entry.value === null ? [] : [[entry.handle, entry.value]])), + ) as Record; + + const uncached = cached.filter((e) => e.value === null); + if (uncached.length === 0) { + return hits; + } + + // Group uncached handles by contract address. + const handlesByContract = Map.groupBy(uncached, (e) => e.contractAddress); + const creds = await this.credentials.allow(...handlesByContract.keys()); + + // Decrypt all uncached handles per contract in parallel. + const freshEntries = ( + await Promise.all( + [...handlesByContract.entries()].map(async ([contractAddress, entries]) => { + const result = await this.relayer.userDecrypt({ + handles: entries.map((e) => e.handle), + contractAddress, + signedContractAddresses: creds.contractAddresses, + privateKey: creds.privateKey, + publicKey: creds.publicKey, + signature: creds.signature, + signerAddress: resolvedRequesterAddress, + startTimestamp: creds.startTimestamp, + durationDays: creds.durationDays, + }); + return (Object.entries(result) as [Handle, ClearValueType][]).map( + ([handle, value]) => [handle, value, contractAddress] as const, + ); + }), + ) + ).flat(); + + // Write fresh values to cache (fire-and-forget per entry). + await Promise.all( + freshEntries.map(([handle, value, contractAddress]) => + saveCachedUserDecryption( + this.storage, + resolvedRequesterAddress, + contractAddress, + handle, + value, + ), + ), + ); + + return { + ...hits, + ...Object.fromEntries(freshEntries.map(([handle, value]) => [handle, value])), + } as Record; + } + /** * Unsubscribe from signer lifecycle events without terminating the relayer. * Call this when the SDK instance is being replaced but the relayer is shared @@ -351,4 +477,9 @@ export class ZamaSDK { [Symbol.dispose](): void { this.terminate(); } + + async #resolveRequesterAddress(): Promise
{ + await this.#identityReady; + return this.#lastAddress ?? (await this.signer.getAddress()); + } }