Skip to main content

Overview

Learn how to build a complete web frontend that interacts with FHEVM contracts. This module covers client-side encryption, transaction submission, and decrypting encrypted on-chain data for display to users.
Level: Intermediate
Duration: 3 hours
Prerequisites: Modules 01-09

Learning Objectives

By the end of this module, you will be able to:
  1. Set up the Relayer SDK (@zama-fhe/relayer-sdk) in a React + ethers.js frontend
  2. Create encrypted inputs from the browser and send them to FHEVM contracts
  3. Request decryption of encrypted values for the connected user
  4. Build a complete dApp with a per-user encrypted counter contract
  5. Handle the FHE instance lifecycle (initialization, encryption, decryption)
  6. Understand the trust model between frontend, gateway, and chain

Architecture Overview

The FHEVM frontend integration follows this flow:
Browser (React + Relayer SDK)
    |
    |-- 1. Initialize FHE instance (fetches public key from chain)
    |-- 2. Encrypt plaintext inputs client-side
    |-- 3. Send encrypted inputs as tx params (externalEuintXX)
    |
    v
FHEVM Contract
    |
    |-- 4. FHE.fromExternal(input, inputProof) converts to euintXX
    |-- 5. Perform FHE operations
    |-- 6. Store encrypted results with ACL
    |
    v
Gateway (for decryption)
    |
    |-- 7. User requests decryption via Relayer SDK
    |-- 8. Gateway re-encrypts for user's keypair
    |-- 9. Frontend decrypts and displays

Installing the Relayer SDK

For a React project with Vite:
npm create vite@latest my-fhevm-app -- --template react-ts
cd my-fhevm-app
npm install @zama-fhe/relayer-sdk ethers

Initializing the FHE Instance

The FHE instance must be created once and reused. It fetches the network’s FHE public key.
import { createInstance } from "@zama-fhe/relayer-sdk/web";
import { BrowserProvider } from "ethers";

let fheInstance: Awaited<ReturnType<typeof createInstance>> | null = null;

async function initFhevm(): Promise<typeof fheInstance> {
  if (fheInstance) return fheInstance;

  const provider = new BrowserProvider(window.ethereum);

  fheInstance = await createInstance({
    network: await provider.send("eth_chainId", []),
    relayerUrl: "https://gateway.zama.ai",
  });

  return fheInstance;
}
The instance caches the FHE public key. You only need to initialize once per page load.

The SimpleCounter Contract

Here is the contract we’ll connect to from the frontend:
SimpleCounter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {FHE, euint32, externalEuint32} from "@fhevm/solidity/lib/FHE.sol";
import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol";

contract SimpleCounter is ZamaEthereumConfig {
    mapping(address => euint32) private _counts;

    event CountIncremented(address indexed user);

    function increment(externalEuint32 encValue, bytes calldata inputProof) external {
        euint32 value = FHE.fromExternal(encValue, inputProof);
        _counts[msg.sender] = FHE.add(_counts[msg.sender], value);
        FHE.allowThis(_counts[msg.sender]);
        FHE.allow(_counts[msg.sender], msg.sender);
        emit CountIncremented(msg.sender);
    }

    function getMyCount() external view returns (euint32) {
        return _counts[msg.sender];
    }
}

Key Points

  • Uses a per-user mapping(address => euint32) so each user has their own private counter
  • increment accepts externalEuint32 and bytes calldata inputProof from the frontend
  • FHE.fromExternal(encValue, inputProof) converts the external type to a usable euint32
  • getMyCount() returns the caller’s encrypted count handle for re-encryption/decryption
  • ACL is set for both the contract (allowThis) and the user (allow)

Creating Encrypted Inputs

Use the Relayer SDK to encrypt a plaintext value before sending it in a transaction:
async function encryptAmount(
  amount: number,
  contractAddress: string,
  userAddress: string
) {
  const instance = await initFhevm();

  // Create an encrypted input bound to this contract and user
  const input = instance.createEncryptedInput(contractAddress, userAddress);
  input.add32(amount); // add a 32-bit encrypted value

  const encrypted = await input.encrypt();
  // encrypted.handles[0] = the encrypted handle (bytes32)
  // encrypted.inputProof = the ZK proof (bytes)
  return encrypted;
}

Available Input Methods

  • input.addBool(value) — encrypt a boolean
  • input.add8(value) — encrypt a uint8
  • input.add16(value) — encrypt a uint16
  • input.add32(value) — encrypt a uint32
  • input.add64(value) — encrypt a uint64
  • input.add128(value) — encrypt a uint128
  • input.add256(value) — encrypt a uint256
  • input.addAddress(value) — encrypt an address
The createEncryptedInput method binds the encrypted value to a specific contract address and user address. This prevents replay attacks.

Sending Encrypted Transactions

import { Contract, BrowserProvider } from "ethers";

const COUNTER_ABI = [
  "function increment(bytes32 encValue, bytes calldata inputProof) external",
  "function getMyCount() external view returns (uint256)",
];

async function incrementCounter(amount: number) {
  const provider = new BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();
  const userAddress = await signer.getAddress();

  const contract = new Contract(COUNTER_ADDRESS, COUNTER_ABI, signer);

  // Encrypt the amount
  const encrypted = await encryptAmount(amount, COUNTER_ADDRESS, userAddress);

  // Send the transaction with the encrypted handle and input proof
  const tx = await contract.increment(encrypted.handles[0], encrypted.inputProof);
  await tx.wait();

  console.log("Counter incremented!");
}
On the ABI level, externalEuint32 appears as bytes32 (the handle), and the proof is bytes.

Requesting Decryption

To read an encrypted value, the user must request decryption through the relayer:
async function readCounter(): Promise<number> {
  const provider = new BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();
  const userAddress = await signer.getAddress();

  const contract = new Contract(COUNTER_ADDRESS, COUNTER_ABI, signer);
  const instance = await initFhevm();

  // Get the encrypted handle from the contract
  const encryptedHandle = await contract.getMyCount();

  // Request re-encryption for this user
  // The user must sign a message to prove they have ACL access
  const { publicKey, privateKey } = instance.generateKeypair();
  const eip712 = instance.createEIP712(publicKey, COUNTER_ADDRESS);
  const signature = await signer.signTypedData(
    eip712.domain,
    eip712.types,
    eip712.message
  );

  const decryptedValue = await instance.reencrypt(
    encryptedHandle,
    privateKey,
    publicKey,
    signature,
    COUNTER_ADDRESS,
    userAddress
  );

  return Number(decryptedValue);
}

The Decryption Flow

  1. The contract returns an encrypted handle (a uint256 reference to the ciphertext)
  2. The user generates a temporary keypair
  3. The user signs an EIP-712 message to prove ACL access
  4. The gateway re-encrypts the ciphertext with the user’s temporary public key
  5. The frontend decrypts with the temporary private key

React Component Pattern

Here’s a complete React component that ties everything together:
CounterApp.tsx
import { useState, useEffect } from "react";

function CounterApp() {
  const [counter, setCounter] = useState<number | null>(null);
  const [amount, setAmount] = useState<number>(1);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    refreshCounter();
  }, []);

  async function refreshCounter() {
    try {
      const value = await readCounter();
      setCounter(value);
    } catch (err) {
      console.error("Failed to read counter:", err);
    }
  }

  async function handleIncrement() {
    setLoading(true);
    try {
      await incrementCounter(amount);
      await refreshCounter();
    } catch (err) {
      console.error("Increment failed:", err);
    }
    setLoading(false);
  }

  return (
    <div>
      <h1>Encrypted Counter</h1>
      <p>Your counter value: {counter !== null ? counter : "Loading..."}</p>
      <input
        type="number"
        value={amount}
        onChange={(e) => setAmount(Number(e.target.value))}
        min={1}
      />
      <button onClick={handleIncrement} disabled={loading}>
        + Increment
      </button>
      <button onClick={refreshCounter} disabled={loading}>
        Refresh
      </button>
    </div>
  );
}

External Types: The Bridge

When encrypted data crosses the contract boundary (from frontend to contract), it uses external types:
External TypeInternal TypeConversion
externalEbooleboolFHE.fromExternal(val, inputProof)
externalEuint8euint8FHE.fromExternal(val, inputProof)
externalEuint16euint16FHE.fromExternal(val, inputProof)
externalEuint32euint32FHE.fromExternal(val, inputProof)
externalEuint64euint64FHE.fromExternal(val, inputProof)
externalEuint128euint128FHE.fromExternal(val, inputProof)
externalEuint256euint256FHE.fromExternal(val, inputProof)
externalEaddresseaddressFHE.fromExternal(val, inputProof)

The Pattern

  1. Contract function parameters: externalEuintXX and bytes calldata inputProof
  2. First line inside function: euintXX val = FHE.fromExternal(externalVal, inputProof);
  3. Then use val normally with FHE operations

Best Practices

Always Initialize Before Encrypting

// BAD: might fail if instance not ready
const encrypted = instance.createEncryptedInput(...);

// GOOD: ensure initialization
const instance = await initFhevm();
const encrypted = instance.createEncryptedInput(...);

Handle Wallet Connection

async function connectWallet(): Promise<string> {
  if (!window.ethereum) {
    throw new Error("MetaMask not detected");
  }
  const accounts = await window.ethereum.request({
    method: "eth_requestAccounts",
  });
  return accounts[0];
}

Cache Decrypted Values

Decryption requests go through the relayer and require a signature. Cache results when appropriate:
const decryptionCache = new Map<string, number>();

async function cachedDecrypt(handle: bigint): Promise<number> {
  const key = handle.toString();
  if (decryptionCache.has(key)) {
    return decryptionCache.get(key)!;
  }
  const value = await readCounter();
  decryptionCache.set(key, value);
  return value;
}

Clear Cache on State Changes

After any write transaction, invalidate cached values:
async function handleIncrement() {
  await incrementCounter(amount);
  decryptionCache.clear(); // Invalidate cache
  await refreshCounter();
}

Frontend vs. Hardhat Test: Decryption Differences

The encryption flow is the same in both environments, but decryption differs:
EnvironmentSDKDecrypt Method
Browser (Frontend)Relayer SDK (@zama-fhe/relayer-sdk)instance.reencrypt() with keypair + EIP-712 signature
Hardhat Tests@fhevm/hardhat-pluginfhevm.userDecryptEuint(FhevmType.euint32, handle, contractAddr, signer)
In Hardhat tests:
import { ethers, fhevm } from "hardhat";
import { FhevmType } from "@fhevm/hardhat-plugin";

// Decrypt in tests
const clear = await fhevm.userDecryptEuint(
  FhevmType.euint32,
  encryptedHandle,
  contractAddress,
  signer
);

Working dApp

Full Frontend Example

The /frontend/ directory contains a complete React + Vite + Relayer SDK demo with SimpleCounter and ConfidentialERC20 integration.

Summary

  • The Relayer SDK (@zama-fhe/relayer-sdk) provides client-side tools for encrypting inputs and decrypting outputs
  • Use createEncryptedInput() to encrypt values bound to a specific contract and user
  • Contract parameters use externalEuintXX and bytes calldata inputProof types; convert with FHE.fromExternal(val, inputProof)
  • Decryption requires EIP-712 signatures to prove ACL access
  • The relayer re-encrypts ciphertexts for the user’s temporary keypair
  • Always initialize the FHE instance before performing any operations
  • Cache decryption results and invalidate on state changes

Next Steps

Module 11: Confidential ERC-20

Build a privacy-preserving ERC-20 token with fully encrypted balances.

Build docs developers (and LLMs) love