For the complete documentation index, see llms.txt. This page is also available as Markdown.

Encrypt & decrypt

How to encrypt values and decrypt FHE encrypted values for custom confidential smart contracts that are not wrapped ERC-20 tokens.

The high-level token hooks (useShield, useConfidentialTransfer, useConfidentialBalance) handle encryption and decryption automatically for wrapped confidential ERC-20 tokens. This guide is for a different scenario: your smart contract uses FHE types directly (e.g. a confidential voting contract, a sealed-bid auction, or any non-token contract that stores euint values). In that case, you need useEncrypt and useDecryptValues to interact with your contract's encrypted parameters and return values.

Before starting, make sure your project is set up following the Configuration guide.

Example

Here is a complete flow that encrypts a value, sends it to a custom FHE contract, reads back the encrypted value, and decrypts it:

ConfidentialRoundTrip.tsx
import { useEncrypt, useDecryptValues, useZamaSDK } from "@zama-fhe/react-sdk";
import { useAccount } from "wagmi";
import { useState, type FormEvent } from "react";

function ConfidentialRoundTrip() {
  const sdk = useZamaSDK();
  const encrypt = useEncrypt();
  const { address: userAddress } = useAccount();
  const [inputs, setInputs] = useState<
    { encryptedValue: string; contractAddress: `0x${string}` }[]
  >([]);

  // Disabled by default — opt in with `enabled`. The hook still waits for
  // non-empty inputs and a connected wallet before it decrypts.
  const { data: decrypted } = useDecryptValues(inputs, { enabled: true });

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const contractAddress = "0xYourContract" as `0x${string}`;

    // 1. Encrypt
    const encrypted = await encrypt.mutateAsync({
      values: [{ value: 42n, type: "euint64" }],
      contractAddress,
      userAddress: userAddress!,
    });

    // 2. Send to contract
    await sdk.signer!.writeContract({
      address: contractAddress,
      abi: yourContractABI,
      functionName: "store",
      args: [encrypted.encryptedValues[0]!, encrypted.inputProof],
    });

    // 3. Read the encrypted value back — setting inputs triggers decryption
    const encryptedValue = (await sdk.provider.readContract({
      address: contractAddress,
      abi: yourContractABI,
      functionName: "getHandle",
      args: [userAddress],
    })) as string;

    setInputs([{ encryptedValue, contractAddress }]);
  };

  return (
    <form onSubmit={handleSubmit}>
      <button type="submit" disabled={encrypt.isPending}>
        Encrypt → Store → Decrypt
      </button>
      {decrypted && inputs[0] && (
        <output>Decrypted: {decrypted[inputs[0].encryptedValue]?.toString()}</output>
      )}
    </form>
  );
}

Steps

1. Encrypt values with useEncrypt

useEncrypt encrypts plaintext values into FHE ciphertext that can be passed to any smart contract function that accepts encrypted parameters (e.g. einput + bytes proof).

Encrypting multiple values

Pass multiple values in a single call. Each value needs its FHE type.

Encryption returns empty encrypted values? Make sure contractAddress and userAddress are valid addresses, not undefined. If using wagmi, wait for the account to be connected:

2. Use encrypted values in contract calls

After encryption, pass the encrypted values and proof to your custom FHE contract. Both are 0x-prefixed hex, so they go straight into a writeContract call — no conversion needed:

3. Decryption of the encrypted data

Use the high-level decryption path. useDecryptValues (and its core-SDK equivalent sdk.decryption.decryptValues) is the canonical way to decrypt: it assembles the decryption credentials — transport key pair and EIP-712 permit — for you, caches results, and wraps relayer errors. The low-level sdk.relayer.userDecrypt, which makes you build the credential bundle by hand, is an escape hatch — reach for it only when you genuinely need that control.

Decrypting on-chain data requires the user to sign an EIP-712 message that grants your app a reusable permit for the relevant contracts. Hooks like useDecryptValues and useConfidentialBalance trigger this signature automatically the first time they run. If your app calls these hooks on render without gating, users see an unsolicited MetaMask popup before they have taken any action — a confusing experience that often leads to rejection.

A good decryption UX follows three steps:

  1. Check permits — use useHasPermit to see whether the user has already signed.

  2. Show a locked state — display a clear "Decrypt" button so the user understands what they are authorizing.

  3. Decrypt on demand — only mount balance or decrypt components after permits exist.

Gating useConfidentialBalance

Split the gate and the balance display into separate components. The gate checks credentials and shows a decrypt button; the balance component only mounts once credentials exist, so it never triggers a wallet popup.

DecryptGate only renders its children once useHasPermit returns true. This means ConfidentialBalance never mounts without permits — no enabled guard needed, no wallet popup on render. Returning users skip the prompt entirely because permits persist in IndexedDB (default TTL: 30 days).

The same pattern works with useDecryptValues and any other decrypt hook — anything nested inside DecryptGate can decrypt freely without triggering a wallet prompt.

When contract addresses come from the chain (e.g. useListPairs), DecryptGate automatically detects new addresses and prompts the user once to extend their authorization:

Decrypting encrypted values from multiple contracts

useDecryptValues automatically groups inputs by contract address and issues one decryption request per contract:

Persistent caching

Decrypted values are stored through the SDK's internal CachingService, scoped by signer and contract address. Cached values survive page reloads — useDecryptValues returns them instantly without hitting the relayer.

The cache is cleared on permits.revokePermits(), permits.clear(), or wallet lifecycle events (disconnect, account/chain change).

Decryption fails with an invalid or expired transport key pair? The transport key pair has a TTL (default: 30 days). If the key pair was generated more than transportKeyPairTTL seconds ago, the relayer rejects it. Call useGrantPermit again to generate a fresh transport key pair and permits.

4. Decrypt with useDecryptPublicValues (advanced)

For values marked as publicly decryptable on-chain, no transport key pair or signature is needed:

Last updated