Skip to content

Commit afe091b

Browse files
committed
feat: Add Zama Relayer integration docs
1 parent 0feaa05 commit afe091b

9 files changed

Lines changed: 1467 additions & 29 deletions

File tree

content/relayer/1.4.x/guides/index.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ A complete guide to implementing gasless transactions on Stellar, allowing users
2323
### Stellar x402 Facilitator Guide
2424

2525
[Read the Stellar x402 Facilitator Guide →](./stellar-x402-facilitator-guide.mdx)
26+
27+
### Zama FHEVM Counter Guide
28+
29+
An end-to-end walkthrough of integrating OpenZeppelin Relayer with a Zama FHEVM contract. Covers encrypted transaction submission, public and user decryption (with relayer-signed EIP-712 payloads), and running against Sepolia or Ethereum mainnet.
30+
31+
[Read the Zama FHEVM Counter Guide →](./zama-fhevm-counter-guide.mdx)
Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
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

Comments
 (0)