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 API (token.confidentialTransfer, token.balanceOf, and the useShield / useConfidentialTransfer / useConfidentialBalance hooks) handles 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 encrypt inputs and decrypt outputs yourself:

  • Core SDK — sdk.encrypt and sdk.decryption.decryptValues / sdk.decryption.decryptPublicValues.

  • React — the useEncrypt and useDecryptValues / useDecryptPublicValues hooks.

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.ts
import { createConfig } from "@zama-fhe/sdk/viem";
import { ZamaSDK, SignerNotConfiguredError } from "@zama-fhe/sdk";
import { web } from "@zama-fhe/sdk/web";
import { sepolia } from "@zama-fhe/sdk/chains";

// Minimal ABI for the custom FHE contract this example reads and writes.
const yourContractABI = [
  {
    type: "function",
    name: "store",
    stateMutability: "nonpayable",
    inputs: [{ type: "bytes32" }, { type: "bytes" }],
    outputs: [],
  },
  {
    type: "function",
    name: "getHandle",
    stateMutability: "view",
    inputs: [{ type: "address" }],
    outputs: [{ type: "bytes32" }],
  },
] as const;

// `publicClient`, `walletClient`, and `storage` come from the Configuration guide.
const sdk = new ZamaSDK(
  createConfig({
    chains: [sepolia],
    publicClient,
    walletClient,
    storage,
    relayers: { [sepolia.id]: web() },
  }),
);

const contractAddress = "0xYourContract";
const [userAddress] = await walletClient.getAddresses();

// Writes need a signer; `sdk.signer` is `undefined` on a read-only SDK. Guarding with the
// typed error keeps it catchable via the patterns in Handle errors — unlike `!`, which
// would throw a raw, uncatchable `TypeError`.
const signer = sdk.signer;
if (!signer) throw new SignerNotConfiguredError("writeContract");

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

// 2. Send to contract, then wait for inclusion — writeContract resolves on broadcast, so
//    reading back before the receipt lands can see a stale (zero) handle.
const txHash = await signer.writeContract({
  address: contractAddress,
  abi: yourContractABI,
  functionName: "store",
  args: [encryptedValues[0]!, inputProof],
});
await sdk.provider.waitForTransactionReceipt(txHash);

// 3. Read the encrypted value back
const encryptedValue = (await sdk.provider.readContract({
  address: contractAddress,
  abi: yourContractABI,
  functionName: "getHandle",
  args: [userAddress],
})) as `0x${string}`;

// 4. Decrypt — assembles the transport key pair + EIP-712 permit, then caches
const decrypted = await sdk.decryption.decryptValues([{ encryptedValue, contractAddress }]);
console.log(decrypted[encryptedValue]); // 42n

Running this on a Node.js backend? Swap the web() relayer for node() (from @zama-fhe/sdk/node) and drop the browser wallet client — everything else in the Core SDK flow above is identical. The Node.js backend guide walks through the setup, and examples/node-viem / examples/node-ethers are runnable end-to-end versions.

Recommended: Cross-Origin headers for faster encryption

Encryption runs FHE WASM in the browser. With SharedArrayBuffer available, the runtime spawns a worker pool and encrypts using multiple threads; without it, the SDK falls back to single-threaded mode (slower, but it still works — you'll see a console warning). To enable multi-threaded encryption, set these HTTP headers:

This applies to browser environments only. In Node.js there are no COOP/COEP headers to set; the SDK drives the FHE backend on the calling thread and spawns a worker pool for multi-threaded encryption where the environment allows (opt out process-wide with the runtime.singleThread option). See the Node.js backend guide and the security model for details.

Steps

1. Encrypt values

Encrypt plaintext values into FHE ciphertext that can be passed to any smart contract function that accepts encrypted parameters (e.g. einput + bytes proof). Every call binds the encrypted values to a contractAddress and a userAddress, so pass valid addresses — not undefined.

Encrypting multiple values

Pass multiple values in a single call. Each value needs its FHE type, and they share one input proof.

Encryption returns empty encrypted values? Make sure contractAddress and userAddress are valid addresses, not undefined. In React with wagmi, wait for the account to be connected before encrypting:

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. sdk.decryption.decryptValues (and its React equivalent useDecryptValues) 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.decryptValuesFromPairs, which makes you supply the transport key pair and signed permit yourself, 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. The first decryption for a set of contracts triggers this signature automatically through the configured signer.

decryptValues groups inputs by contract address, assembles credentials (signing the EIP-712 permit through your configured signer on first use), and returns clear-text values keyed by their encrypted value:

On a backend signer (a local private key), the permit is signed silently. In a browser, the first call surfaces a wallet prompt — trigger it from an explicit user action, not on load. Reuse the same sdk instance so cached permits and decrypted values persist across calls.

Hooks like useDecryptValues and useConfidentialBalance trigger the permit signature automatically the first time they run. If your app calls these hooks on render without gating, users see an unsolicited wallet 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

Both sdk.decryption.decryptValues and useDecryptValues automatically group inputs by contract address and issue 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 — decryptValues 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. Grant a fresh permit again (sdk.permits.grantPermit / useGrantPermit) to generate a new transport key pair and permits.

4. Decrypt public values (advanced)

For values marked as publicly decryptable on-chain, no transport key pair or signature is needed — this path works even without a configured signer:

decryptPublicValues also returns decryptionProof and abiEncodedClearValues alongside clearValues, so you can submit on-chain finalization transactions that verify the decryption.

Next steps

Last updated