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:
Set up the Relayer SDK (@zama-fhe/relayer-sdk) in a React + ethers.js frontend
Create encrypted inputs from the browser and send them to FHEVM contracts
Request decryption of encrypted values for the connected user
Build a complete dApp with a per-user encrypted counter contract
Handle the FHE instance lifecycle (initialization, encryption, decryption)
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:
// 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)
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 ;
}
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
The contract returns an encrypted handle (a uint256 reference to the ciphertext)
The user generates a temporary keypair
The user signs an EIP-712 message to prove ACL access
The gateway re-encrypts the ciphertext with the user’s temporary public key
The frontend decrypts with the temporary private key
React Component Pattern
Here’s a complete React component that ties everything together:
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 Type Internal Type Conversion 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
Contract function parameters: externalEuintXX and bytes calldata inputProof
First line inside function: euintXX val = FHE.fromExternal(externalVal, inputProof);
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 :
Environment SDK Decrypt Method Browser (Frontend) Relayer SDK (@zama-fhe/relayer-sdk) instance.reencrypt() with keypair + EIP-712 signatureHardhat 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.