|
| 1 | +--- |
| 2 | +title: Zama FHEVM Counter Guide |
| 3 | +--- |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +This guide walks through an end-to-end integration between OpenZeppelin Relayer and a Zama FHEVM contract, using the counter example shipped with the [OpenZeppelin Relayer SDK](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk). The counter contract is deployed from [Zama's fhevm-hardhat-template](https://github.com/zama-ai/fhevm-hardhat-template) and demonstrates the two relayer responsibilities in an FHEVM flow: |
| 8 | + |
| 9 | +- **Transaction submission**: sending an encrypted `increment()` call on-chain. |
| 10 | +- **EIP-712 signing**: signing the typed-data payload that authorizes user decryption of the counter's encrypted state. |
| 11 | + |
| 12 | +By the end of this guide you will have: |
| 13 | + |
| 14 | +- Configured a Zama FHE instance in your application. |
| 15 | +- Read and decrypted an encrypted counter value from the contract. |
| 16 | +- Submitted an encrypted increment through the relayer and waited for confirmation. |
| 17 | +- Re-read and decrypted the updated value. |
| 18 | + |
| 19 | +<Callout> |
| 20 | +The FHE encryption/decryption primitives run in your application using the Zama SDK. The relayer never sees cleartext values or your decryption keypair — it only sends transactions and signs EIP-712 payloads. |
| 21 | +</Callout> |
| 22 | + |
| 23 | +## Prerequisites |
| 24 | + |
| 25 | +- Node.js `>= 22.14.0` |
| 26 | +- `pnpm` (the example repository uses pnpm for installs) |
| 27 | +- A running OpenZeppelin Relayer with: |
| 28 | + - a valid API key |
| 29 | + - an EVM relayer configured on Sepolia (or another FHEVM-enabled network) |
| 30 | +- A Zama FHE counter contract deployed on your target network (e.g. via [fhevm-hardhat-template](https://github.com/zama-ai/fhevm-hardhat-template)) |
| 31 | + |
| 32 | +## Example Setup |
| 33 | + |
| 34 | +The complete working example lives in the OpenZeppelin Relayer SDK repository: |
| 35 | + |
| 36 | +- **Location**: [examples/relayers/zama](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/zama) |
| 37 | +- **Documentation**: [README.md](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/zama/README.md) |
| 38 | + |
| 39 | +The rest of this guide explains the moving parts, references the example files, and shows the minimum code required to adapt the flow to your own contract. |
| 40 | + |
| 41 | +## Architecture |
| 42 | + |
| 43 | +Four components participate in the flow: |
| 44 | + |
| 45 | +- **Your script**: orchestrates the flow and prints progress. |
| 46 | +- **OpenZeppelin Relayer**: signs typed data and submits transactions. |
| 47 | +- **Zama Relayer SDK instance**: encrypts inputs and manages decryption flows. |
| 48 | +- **FHEVM contract**: stores encrypted state on-chain. |
| 49 | + |
| 50 | +The key boundary is that the relayer does not do any encryption. The Zama SDK handles encryption and decryption primitives; the relayer provides transaction execution and signature authorization. |
| 51 | + |
| 52 | +``` |
| 53 | +┌─────────────┐ ┌───────────────────────┐ ┌──────────────┐ |
| 54 | +│ Your app │──────▶│ OpenZeppelin Relayer │──────▶│ FHEVM network│ |
| 55 | +│ (Zama SDK) │ │ sendTransaction + │ │ Sepolia / │ |
| 56 | +│ │◀──────│ signTypedData │ │ Mainnet │ |
| 57 | +└─────────────┘ └───────────────────────┘ └──────────────┘ |
| 58 | + │ |
| 59 | + ▼ |
| 60 | + Zama Gateway |
| 61 | + (public decrypt / |
| 62 | + user decrypt) |
| 63 | +``` |
| 64 | + |
| 65 | +## Relayer Configuration |
| 66 | + |
| 67 | +Zama FHEVM contracts live on standard EVM networks, so the relayer is configured as a regular `evm` relayer. |
| 68 | + |
| 69 | +```json |
| 70 | +{ |
| 71 | + "relayers": [ |
| 72 | + { |
| 73 | + "id": "zama-sepolia", |
| 74 | + "name": "Zama FHEVM Sepolia", |
| 75 | + "network": "sepolia", |
| 76 | + "paused": false, |
| 77 | + "signer_id": "local-signer", |
| 78 | + "network_type": "evm" |
| 79 | + } |
| 80 | + ], |
| 81 | + "signers": [ |
| 82 | + { |
| 83 | + "id": "local-signer", |
| 84 | + "type": "local", |
| 85 | + "config": { |
| 86 | + "path": "config/keys/local-signer.json", |
| 87 | + "passphrase": { |
| 88 | + "type": "env", |
| 89 | + "value": "KEYSTORE_PASSPHRASE" |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | + ] |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +**Important notes:** |
| 98 | + |
| 99 | +- The relayer signer is used both for submitting the encrypted transaction and for signing the EIP-712 payload consumed by the Zama Gateway during user decryption. The same key backs both operations. |
| 100 | +- For production, prefer a hosted signer (AWS KMS, Google Cloud KMS, Turnkey, CDP) over `local`. |
| 101 | +- The relayer must be funded on the target network so it can pay gas for FHEVM contract calls. |
| 102 | + |
| 103 | +## Installation |
| 104 | + |
| 105 | +From the root of the `openzeppelin-relayer-sdk` repository: |
| 106 | + |
| 107 | +```bash |
| 108 | +pnpm install |
| 109 | +``` |
| 110 | + |
| 111 | +The example imports the Zama Relayer SDK (`@zama-fhe/relayer-sdk`) as part of the workspace dependencies. If you are integrating the flow into your own project, install it directly: |
| 112 | + |
| 113 | +```bash |
| 114 | +pnpm add @zama-fhe/relayer-sdk @openzeppelin/relayer-sdk ethers dotenv |
| 115 | +``` |
| 116 | + |
| 117 | +## Environment Configuration |
| 118 | + |
| 119 | +Copy `.env.example` to `.env` in `examples/relayers/zama/`: |
| 120 | + |
| 121 | +```bash |
| 122 | +RELAYER_API_KEY= |
| 123 | +RELAYER_ID= |
| 124 | +ZAMA_CONTRACT_ADDRESS= |
| 125 | +RPC_URL=https://ethereum-sepolia-rpc.publicnode.com |
| 126 | +RELAYER_BASE_PATH=http://localhost:8080 |
| 127 | + |
| 128 | +# Optional: reuse an existing decryption keypair across runs |
| 129 | +# ZAMA_PUBLIC_KEY= |
| 130 | +# ZAMA_PRIVATE_KEY= |
| 131 | +``` |
| 132 | + |
| 133 | +- `RELAYER_BASE_PATH` defaults to `http://localhost:8080` if not set. |
| 134 | +- `RPC_URL` defaults to the public Sepolia RPC if not set. |
| 135 | +- If `ZAMA_PUBLIC_KEY` and `ZAMA_PRIVATE_KEY` are not set, the script generates a fresh decryption keypair on each run. Reusing the same keypair is useful for consistent user-decryption behavior across runs. |
| 136 | + |
| 137 | +## Running the Example |
| 138 | + |
| 139 | +Run the counter example from the SDK repository root: |
| 140 | + |
| 141 | +```bash |
| 142 | +npx ts-node examples/relayers/zama/counter.ts |
| 143 | +``` |
| 144 | + |
| 145 | +The script should: |
| 146 | + |
| 147 | +1. Read the encrypted counter handle from the contract. |
| 148 | +2. Attempt decryption (public first, then user decryption with a relayer-signed EIP-712 payload). |
| 149 | +3. Submit an encrypted `increment()` through the relayer and poll until the transaction is mined or confirmed. |
| 150 | +4. Re-read and decrypt the updated counter value. |
| 151 | + |
| 152 | +## Generating a Reusable Decryption Keypair |
| 153 | + |
| 154 | +To keep the same decryption keypair across runs, run the helper script: |
| 155 | + |
| 156 | +```bash |
| 157 | +npx ts-node examples/relayers/zama/generate-keypair.ts |
| 158 | +``` |
| 159 | + |
| 160 | +Copy the printed `ZAMA_PUBLIC_KEY` and `ZAMA_PRIVATE_KEY` values into your `.env` file. |
| 161 | + |
| 162 | +<Callout> |
| 163 | +The decryption keypair is an application-side secret. Do not pass the private key to the relayer; it is only used by the Zama SDK to decrypt results returned by the Gateway. |
| 164 | +</Callout> |
| 165 | + |
| 166 | +## Walkthrough |
| 167 | + |
| 168 | +The following snippets show the relayer-specific integration points from `counter.ts`. Full code is in the [SDK repository](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/zama). |
| 169 | + |
| 170 | +### 1. Initialize the relayer client and Zama SDK |
| 171 | + |
| 172 | +```typescript |
| 173 | +import { Configuration, RelayersApi } from '@openzeppelin/relayer-sdk'; |
| 174 | +import { SepoliaConfig, createInstance, type FhevmInstanceConfig } from '@zama-fhe/relayer-sdk/node'; |
| 175 | +import { JsonRpcProvider, getAddress } from 'ethers'; |
| 176 | + |
| 177 | +const relayersApi = new RelayersApi( |
| 178 | + new Configuration({ |
| 179 | + basePath: process.env.RELAYER_BASE_PATH ?? 'http://localhost:8080', |
| 180 | + accessToken: process.env.RELAYER_API_KEY!, |
| 181 | + }), |
| 182 | +); |
| 183 | + |
| 184 | +const zamaConfig: FhevmInstanceConfig = { |
| 185 | + ...SepoliaConfig, |
| 186 | + network: process.env.RPC_URL!, |
| 187 | +}; |
| 188 | + |
| 189 | +const provider = new JsonRpcProvider(process.env.RPC_URL!); |
| 190 | +const instance = await createInstance(zamaConfig); |
| 191 | +``` |
| 192 | + |
| 193 | +### 2. Fetch the relayer's on-chain address |
| 194 | + |
| 195 | +The relayer's EVM address is needed both when building encrypted inputs and when authorizing user decryption. |
| 196 | + |
| 197 | +```typescript |
| 198 | +const relayerInfo = await relayersApi.getRelayer(process.env.RELAYER_ID!); |
| 199 | +const relayerAddress = getAddress(relayerInfo.data.data!.address!); |
| 200 | +``` |
| 201 | + |
| 202 | +### 3. Read and decrypt the encrypted counter |
| 203 | + |
| 204 | +The contract exposes a `getCount()` view that returns the encrypted handle. Decoding the handle is a plain EVM call — no relayer involvement. |
| 205 | + |
| 206 | +```typescript |
| 207 | +import { Interface } from 'ethers'; |
| 208 | +import ABI from './abi.json'; |
| 209 | + |
| 210 | +const iface = new Interface(ABI); |
| 211 | + |
| 212 | +async function getCount(contractAddress: string): Promise<string> { |
| 213 | + const data = iface.encodeFunctionData('getCount', []); |
| 214 | + const result = await provider.call({ to: contractAddress, data }); |
| 215 | + return iface.decodeFunctionResult('getCount', result)[0]; |
| 216 | +} |
| 217 | +``` |
| 218 | + |
| 219 | +The script first attempts public decryption. If the handle is not publicly decryptable, it falls back to user decryption (covered in [Decryption Model](#decryption-model) below). |
| 220 | + |
| 221 | +### 4. Submit an encrypted `increment()` via the relayer |
| 222 | + |
| 223 | +Encryption happens locally with the Zama SDK. The encrypted handle plus input proof are encoded into a normal EVM transaction and handed to `sendTransaction`. |
| 224 | + |
| 225 | +```typescript |
| 226 | +import { Speed } from '@openzeppelin/relayer-sdk'; |
| 227 | + |
| 228 | +const encInput = instance.createEncryptedInput(contractAddress, relayerAddress); |
| 229 | +encInput.add32(1); |
| 230 | +const { handles, inputProof } = await encInput.encrypt(); |
| 231 | + |
| 232 | +const data = iface.encodeFunctionData('increment', [handles[0], inputProof]); |
| 233 | + |
| 234 | +const txResponse = await relayersApi.sendTransaction(process.env.RELAYER_ID!, { |
| 235 | + to: contractAddress, |
| 236 | + data, |
| 237 | + value: 0, |
| 238 | + gas_limit: 500000, |
| 239 | + speed: Speed.FAST, |
| 240 | +}); |
| 241 | + |
| 242 | +const transactionId = txResponse.data.data!.id!; |
| 243 | +``` |
| 244 | + |
| 245 | +### 5. Poll for confirmation |
| 246 | + |
| 247 | +Poll `getTransactionById` until the transaction reaches `mined` or `confirmed`: |
| 248 | + |
| 249 | +```typescript |
| 250 | +import type { EvmTransactionResponse } from '@openzeppelin/relayer-sdk'; |
| 251 | + |
| 252 | +async function waitForConfirmation(transactionId: string): Promise<string | undefined> { |
| 253 | + for (let attempt = 1; attempt <= 60; attempt += 1) { |
| 254 | + await new Promise((resolve) => setTimeout(resolve, 2000)); |
| 255 | + |
| 256 | + const status = await relayersApi.getTransactionById(process.env.RELAYER_ID!, transactionId); |
| 257 | + const tx = status.data.data as EvmTransactionResponse | undefined; |
| 258 | + |
| 259 | + if (!tx) throw new Error(`Transaction ${transactionId} not returned by relayer`); |
| 260 | + if (tx.status === 'mined' || tx.status === 'confirmed') return tx.hash; |
| 261 | + if (tx.status === 'failed' || tx.status === 'canceled' || tx.status === 'expired') { |
| 262 | + throw new Error(`Transaction ${tx.status}: ${tx.status_reason ?? 'unknown'}`); |
| 263 | + } |
| 264 | + } |
| 265 | + throw new Error('Transaction confirmation timed out'); |
| 266 | +} |
| 267 | +``` |
| 268 | + |
| 269 | +## Decryption Model |
| 270 | + |
| 271 | +Zama FHEVM supports two decryption paths, and the example tries them in this order: |
| 272 | + |
| 273 | +### 1. Public Decryption |
| 274 | + |
| 275 | +If an encrypted handle is marked as publicly decryptable on-chain, the Zama SDK can decrypt it directly, with no authorization from the relayer. |
| 276 | + |
| 277 | +```typescript |
| 278 | +const result = await instance.publicDecrypt([encryptedHandle]); |
| 279 | +const clear = result.clearValues[encryptedHandle as `0x${string}`]; |
| 280 | +``` |
| 281 | + |
| 282 | +### 2. User Decryption (relayer-signed) |
| 283 | + |
| 284 | +When decryption requires authorization, the Zama SDK builds an EIP-712 payload and the relayer signs it via `signTypedData`. The signature is then passed to `userDecrypt`, which retrieves the cleartext from the Zama Gateway. |
| 285 | + |
| 286 | +```typescript |
| 287 | +import { TypedDataEncoder } from 'ethers'; |
| 288 | +import type { SignDataResponseEvm } from '@openzeppelin/relayer-sdk'; |
| 289 | + |
| 290 | +const keypair = instance.generateKeypair(); |
| 291 | +const contractAddresses = [contractAddress]; |
| 292 | +const startTimeStamp = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000); |
| 293 | +const durationDays = 365; |
| 294 | + |
| 295 | +const eip712 = instance.createEIP712( |
| 296 | + keypair.publicKey, |
| 297 | + contractAddresses, |
| 298 | + startTimeStamp, |
| 299 | + durationDays, |
| 300 | +); |
| 301 | + |
| 302 | +// Hash the domain and struct, then ask the relayer to sign. |
| 303 | +const domainSeparator = TypedDataEncoder.hashDomain(eip712.domain); |
| 304 | +const { EIP712Domain, ...structTypes } = eip712.types; |
| 305 | +const messageHash = TypedDataEncoder.hashStruct( |
| 306 | + eip712.primaryType, |
| 307 | + Object.fromEntries(Object.entries(structTypes).map(([k, v]) => [k, [...v]])), |
| 308 | + eip712.message, |
| 309 | +); |
| 310 | + |
| 311 | +const signResponse = await relayersApi.signTypedData(process.env.RELAYER_ID!, { |
| 312 | + domain_separator: domainSeparator, |
| 313 | + hash_struct_message: messageHash, |
| 314 | +}); |
| 315 | +const signature = (signResponse.data.data as SignDataResponseEvm).sig!; |
| 316 | + |
| 317 | +const decrypted = await instance.userDecrypt( |
| 318 | + [{ handle: encryptedHandle, contractAddress }], |
| 319 | + keypair.privateKey, |
| 320 | + keypair.publicKey, |
| 321 | + signature, |
| 322 | + contractAddresses, |
| 323 | + relayerAddress, |
| 324 | + startTimeStamp, |
| 325 | + durationDays, |
| 326 | +); |
| 327 | +``` |
| 328 | + |
| 329 | +The EIP-712 domain separator and message hash are computed locally using `ethers` so the relayer only has to sign the final hashes via its `signTypedData` endpoint. |
| 330 | + |
| 331 | +## Using on Mainnet |
| 332 | + |
| 333 | +The example defaults to Sepolia. To run against Ethereum mainnet: |
| 334 | + |
| 335 | +1. In `counter.ts`, use `MainnetConfig` instead of `SepoliaConfig` and provide the Zama Gateway API key: |
| 336 | + |
| 337 | + ```typescript |
| 338 | + import { |
| 339 | + MainnetConfig, |
| 340 | + createInstance, |
| 341 | + type FhevmInstanceConfig, |
| 342 | + } from '@zama-fhe/relayer-sdk/node'; |
| 343 | + |
| 344 | + const zamaConfig: FhevmInstanceConfig = { |
| 345 | + ...MainnetConfig, |
| 346 | + network: process.env.RPC_URL!, |
| 347 | + auth: { __type: 'ApiKeyHeader', value: process.env.ZAMA_FHEVM_API_KEY! }, |
| 348 | + }; |
| 349 | + ``` |
| 350 | + |
| 351 | +2. Add the following to your `.env`: |
| 352 | + |
| 353 | + ```bash |
| 354 | + ZAMA_FHEVM_API_KEY= |
| 355 | + RPC_URL=https://ethereum-rpc.publicnode.com |
| 356 | + ``` |
| 357 | + |
| 358 | +3. Point `ZAMA_CONTRACT_ADDRESS` at your mainnet contract and configure the relayer for Ethereum mainnet. |
| 359 | + |
| 360 | +<Callout> |
| 361 | +Mainnet requires API key authentication with the Zama Gateway. See the [Zama mainnet API key guide](https://docs.zama.org/protocol/relayer-sdk-guides/fhevm-relayer/mainnet-api-key) for instructions on obtaining one. |
| 362 | +</Callout> |
| 363 | + |
| 364 | +## Current Limitations |
| 365 | + |
| 366 | +- The example is hardcoded for Sepolia via `SepoliaConfig` plus an explicit Sepolia RPC URL. |
| 367 | +- It assumes a counter contract shape compatible with the included ABI. |
| 368 | +- Logging and error handling are intentionally simple — this is a demo script, not production code. |
| 369 | +- It does not cover relayer creation or contract deployment. |
| 370 | + |
| 371 | +## Troubleshooting |
| 372 | + |
| 373 | +- **`Missing required environment variable`**: one of the required values in `.env` is unset. |
| 374 | +- **`did not return an address`**: the configured relayer id is valid for the API, but the response did not include an EVM address. Check that the relayer is fully provisioned and the signer is reachable. |
| 375 | +- **`Public decryption failed`**: this can be expected depending on the contract and permissions. The script will then try user decryption. |
| 376 | +- **`User decryption failed`**: check that the relayer can sign typed data correctly and that the contract address and decryption keypair are the ones you expect. Confirm the EIP-712 `startTimeStamp`/`durationDays` window is valid. |
| 377 | +- **Transaction polling timeout**: the transaction may still be pending, the relayer may be unhealthy, or the target chain may be slow. Inspect `getTransactionById` directly and check relayer logs. |
| 378 | + |
| 379 | +## Additional Resources |
| 380 | + |
| 381 | +- [Zama Relayer SDK Guides](https://docs.zama.org/protocol/relayer-sdk-guides) |
| 382 | +- [Zama fhevm-hardhat-template](https://github.com/zama-ai/fhevm-hardhat-template) |
| 383 | +- [Zama mainnet API key guide](https://docs.zama.org/protocol/relayer-sdk-guides/fhevm-relayer/mainnet-api-key) |
| 384 | +- [OpenZeppelin Relayer SDK — Zama example](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/zama) |
| 385 | +- [Zama FHEVM Integration](/relayer/zama-fhevm) |
0 commit comments