2.0 RC docsView 1.x docs
Concept · Decryption

Decrypt a handle as the account it was granted to

useDecryptedHandle turns an encrypted handle into a bigint through the connected signer's ZamaSDK.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.

Encrypted views across the products (a vesting claimable amount, an airdrop claim preview, a disperse fee reserve) return an EncryptedViewResult: a handle granted by an on-chain ACL Allowed event, and the hash of the transaction that granted it. The handle is opaque. Reading it means asking the Zama relayer to decrypt it for the grantee, which the SDK does through a UserDecryptor.

UserDecryptor mirrors Decryption.decryptValues#

The contract is declared structurally so the optional peer stays out of the SDK's types. ZamaSDK["decryption"] satisfies it without a cast, and the ZamaSDK signer owns the EIP-712 permit and keypair, so the hook takes no keypair or permit parameters.

import type { Address, Hex } from "viem";

interface UserDecryptor {
  decryptValues(
    encryptedInput: { encryptedValue: Hex; contractAddress: Address }[],
    options?: { signal?: AbortSignal; timeout?: number },
  ): Promise<Record<Hex, boolean | number | bigint | string | undefined>>;
}

type UserDecryptorSource = UserDecryptor | (() => UserDecryptor | undefined);

useDecryptedHandle#

ClaimableAmount.tsx
tsx
import { useZamaSDK } from "@zama-fhe/react-sdk";
import { useDecryptedHandle } from "@tokenops/sdk/fhe/react";
import { useConnection } from "wagmi"; // wagmi v2: useAccount

export function ClaimableAmount({ handle, manager }: { handle?: `0x${string}`; manager: `0x${string}` }) {
  const sdk = useZamaSDK();
  const { address } = useConnection();
  const result = useDecryptedHandle({
    handle,
    contractAddress: manager,
    userDecryptor: () => sdk.decryption, // stable across renders
    account: address,                     // re-decrypt on wallet switch
  });

  switch (result.status) {
    case "idle":
      return <span>Encrypted</span>;
    case "loading":
      return <span>Decrypting...</span>;
    case "error":
      return <span>{result.error.message}</span>;
    case "success":
      return <span>{result.value.toString()}</span>; // always a bigint
  }
}
OptionBehaviour
handleundefined keeps the hook idle, for example while the query producing the handle is pending.
contractAddressThe contract that granted ACL access. Decryption is checked against the (handle, contractAddress, signer) triple, so the wrong contract fails.
userDecryptorEager or lazy. A lazy source that resolves to undefined (an SDK still initializing) leaves the hook in error; it decrypts once the source resolves.
accountThe connected signer's address. A change drops the shown value, aborts the in-flight request and decrypts again as the new account.
enabledfalse skips the decrypt, for an Encrypted placeholder until the user opts in. Defaults to true.

The result is a discriminated union on status: idle, loading, success (value: bigint) or error (error: Error). Upstream decrypts euint8 to euint32 to a number; the hook still resolves a bigint, and reports any other value type or a missing entry as a TokenOpsValidationError.

Pass account whenever the wallet can change#

A ZamaSDK keeps the same decryption object across account switches. Without account, a switch that leaves handle and contractAddressas they were keeps showing the previous account's clear value. With it, the hook decrypts again as the new account, which fails with UserDecryptNotAllowedError if that account has no grant. That is the correct outcome.

A stable decryptor, or no value at all#

The hook compares the resolved decryptor by identity on every render. When it becomes a different object (a new ZamaSDK for another signer), the shown value is dropped at once and the handle is decrypted again. sdk.decryption and () => sdk.decryption resolve to the same object across renders; memoize anything else.

Input changes abort the in-flight request#

When the hook's inputs change - a new handle, contractAddress or account - it aborts the running decryption through the signal it passes to decryptValues, so a slow response for the old inputs does not land on screen for the new ones.

Decrypting outside React#

Call the decryptor directly. The instance from createSepoliaEncryptorWeb carries the wallet client as signer; the one from the Node createSepoliaEncryptor has no signer, so build your own ZamaSDK with one to decrypt on a server.

import type { ZamaSDK } from "@zama-fhe/sdk";

// A ZamaSDK used for decryption needs a signer.
const clear = await (sdk as ZamaSDK).decryption.decryptValues([
  { encryptedValue: handle, contractAddress: manager },
]);
const value = clear[handle];

When decryption fails#

Decryptor errors go through the SDK's Zama error mapping, so the hook's error is a typed TokenOpsSdkError, never the raw upstream error. /fhe/react re-exports the classes below for instanceof checks.

ErrorMeansDo
UserDecryptNotAllowedErrorThe relayer refused to decrypt for this signer: no FHE.allow(handle, signer) ever ran. Zama's NOT_ENTITLED maps here. No transaction was sent.Not retryable. Decrypt as the grantee, or request a fresh grant.
AclNotPropagatedErrorThe grant exists on-chain, but the gateway has not observed it yet: a delegation just added, or an FHE.allow mined moments ago. context.statusCode when known.Retry with backoff, typically seconds up to a couple of minutes.
DecryptionFailedErrorAny other decryption failure the mapping could not classify more precisely.Inspect error.cause.
RelayerUnreachableErrorThe relayer call failed (non-2xx, timeout, DNS). context.statusCode when exposed.Retry; check relayerUrl and, on mainnet, the API key.
UserRejectedSignatureErrorThe user cancelled the permit signature.Safe to retry on user action.
import { AclNotPropagatedError } from "@tokenops/sdk/fhe/react";

async function decryptWithBackoff<T>(read: () => Promise<T>, attempts = 6): Promise<T> {
  for (let i = 0; ; i++) {
    try {
      return await read();
    } catch (err) {
      // The grant exists on-chain; the gateway has not observed it yet.
      if (!(err instanceof AclNotPropagatedError) || i >= attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, 2 ** i * 1_000));
    }
  }
}

isAclPropagationError#

For a delegated decrypt the SDK does not run itself, isAclPropagationError (@tokenops/sdk/fhe-airdrop) classifies whatever the relayer client threw. It matches code === "DELEGATION_NOT_PROPAGATED" or name === "DelegationNotPropagatedError" anywhere in the error, or a 400 / 500 status combined with a message that names the propagation condition, searching nested cause, error and info objects up to 6 levels deep. It also returns true for an AclNotPropagatedError. An unrelated 400 or 500 (bad signature, malformed handle, a genuine outage) returns false.

In @zama-fhe/sdk 3.6 the delegated path is sdk.decryption.delegatedDecryptValues, which already retries the propagation window for about 30 seconds by default. Pass { waitForPropagation: false } to get the error at once and run your own backoff.

import type { ZamaSDK } from "@zama-fhe/sdk";
import { isAclPropagationError } from "@tokenops/sdk/fhe-airdrop";

try {
  // waitForPropagation: false fails fast instead of Zama's own ~30s retry.
  await (sdk as ZamaSDK).decryption.delegatedDecryptValues(
    [{ encryptedValue: handle, contractAddress }],
    delegatorAddress,
    undefined,
    { waitForPropagation: false },
  );
} catch (err) {
  if (isAclPropagationError(err)) {
    // retry with backoff: the delegation is fine, the gateway has not caught up
  } else {
    throw err;
  }
}

The on-chain sibling#

FheHandleNotAllowedError is the same logical problem detected on-chain: a contract entry point called FHE.isSenderAllowed(handle) and reverted, so the transaction reached the EVM. UserDecryptNotAllowedError fires before any chain interaction. See the error palette.

  • Most encrypted views grant the caller, but not all: MerkleAirdropClient.getClaimAmountgrants the entry's account, so a third-party sender receives a handle it cannot decrypt.
  • A disclosure to a party (an auditor, say) produces a handle for that party. Hand it to them rather than to your own decryptor.

See also