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.encryptandsdk.decryption.decryptValues/sdk.decryption.decryptPublicValues.React — the
useEncryptanduseDecryptValues/useDecryptPublicValueshooks.
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:
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]); // 42nSSR: "window is not defined"
FHE operations use browser APIs. In Next.js or other SSR frameworks, ensure all components using encrypt/decrypt hooks are client components:
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.
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
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:
Check permits — use
useHasPermitto see whether the user has already signed.Show a locked state — display a clear "Decrypt" button so the user understands what they are authorizing.
Decrypt on demand — only mount balance or decrypt components after permits exist.
Never call useConfidentialBalance or useDecryptValues without gating on useHasPermit:
This causes an unexpected wallet popup, user rejection, potential Blockaid flags, and loss of trust.
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).
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
Node.js backend — run the core SDK server-side with per-request storage isolation
ZamaSDK reference —
encrypt,decryption, and the full core APIDecrypt values from event logs — index confidential transfers and decrypt amounts off event logs
Configuration — chains, relayers, authentication, and permit management
Last updated