From 86118d7387143edceaf95889280ec5c6c7d689c8 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 27 Apr 2026 13:29:52 +0200 Subject: [PATCH 1/5] feat: add zama sdk example --- examples/relayers/zama/README.md | 218 ++----------- examples/relayers/zama/relayer-sdk/README.md | 217 +++++++++++++ .../relayers/zama/{ => relayer-sdk}/abi.json | 0 .../zama/{ => relayer-sdk}/counter.ts | 10 +- .../{ => relayer-sdk}/generate-keypair.ts | 2 +- .../relayers/zama/{ => relayer-sdk}/types.ts | 0 examples/relayers/zama/zama-sdk/README.md | 141 +++++++++ examples/relayers/zama/zama-sdk/abi.json | 19 ++ examples/relayers/zama/zama-sdk/counter.ts | 154 +++++++++ .../zama-sdk/openzeppelin-relayer-signer.ts | 295 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 28 ++ 12 files changed, 882 insertions(+), 203 deletions(-) create mode 100644 examples/relayers/zama/relayer-sdk/README.md rename examples/relayers/zama/{ => relayer-sdk}/abi.json (100%) rename examples/relayers/zama/{ => relayer-sdk}/counter.ts (97%) rename examples/relayers/zama/{ => relayer-sdk}/generate-keypair.ts (95%) rename examples/relayers/zama/{ => relayer-sdk}/types.ts (100%) create mode 100644 examples/relayers/zama/zama-sdk/README.md create mode 100644 examples/relayers/zama/zama-sdk/abi.json create mode 100644 examples/relayers/zama/zama-sdk/counter.ts create mode 100644 examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts diff --git a/examples/relayers/zama/README.md b/examples/relayers/zama/README.md index 8777cd0..3520895 100644 --- a/examples/relayers/zama/README.md +++ b/examples/relayers/zama/README.md @@ -1,215 +1,37 @@ -# Zama Relayer Example +# Zama FHE Examples -This directory contains a first end-to-end example of using the OpenZeppelin Relayer SDK with a Zama FHE-enabled EVM contract. +This directory contains two end-to-end examples of using the OpenZeppelin Relayer SDK with a Zama FHE-enabled EVM contract. Both examples interact with the same counter contract deployed from Zama's [`fhevm-hardhat-template`](https://github.com/zama-ai/fhevm-hardhat-template), and both share a single `.env` (placed at this directory level) so you only configure your relayer once. -## Objective +| Subdirectory | Zama package | Style | +| ------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`relayer-sdk/`](./relayer-sdk) | `@zama-fhe/relayer-sdk@0.4.x` | Legacy / minimal. All glue lives in user code: hand-roll EIP-712 hashing, call `relayersApi.signTypedData` / `sendTransaction`, poll for status. | +| [`zama-sdk/`](./zama-sdk) | `@zama-fhe/sdk@3.x` + `GenericSigner` | Recommended for new integrations. The relayer is plugged in as a `GenericSigner`; application code uses the high-level `ZamaSDK` API. | -This example shows the minimum viable integration path: +## Which one should I use? -1. Configure a relayer and a deployed FHE contract. -2. Read encrypted state from the contract. -3. Decrypt the state, first via public decryption when possible. -4. Fall back to user decryption authorized with an EIP-712 signature produced by the relayer. -5. Send an encrypted transaction through the relayer. -6. Poll the relayer until the transaction is mined or confirmed. +- **Starting a new integration?** Use [`zama-sdk/`](./zama-sdk). The adapter pattern is reusable across the entire Zama SDK — once the relayer is wrapped as a `GenericSigner`, you get `ZamaSDK`, the ERC-7984 `Token` wrapper, the wrappers registry, decrypt session caching, and event hooks for free. +- **Already on `@zama-fhe/relayer-sdk@0.4.x`?** [`relayer-sdk/`](./relayer-sdk) is the closest reference and stays around for parity. It is also useful if you want to see exactly which raw relayer API calls a Zama FHE flow needs — there is no abstraction in the way. -## Quickstart +The two examples can run side-by-side from the same `.env` and produce equivalent results on the counter contract. -1. Install dependencies from the repository root: +## Shared configuration + +Copy [`.env.example`](./.env.example) to `.env` in this directory: ```bash -pnpm install +cp examples/relayers/zama/.env.example examples/relayers/zama/.env ``` -2. Copy [`.env.example`](./.env.example) to `.env` in this directory. - -3. Fill in: +Required values (used by both examples): - `RELAYER_API_KEY` - `RELAYER_ID` - `ZAMA_CONTRACT_ADDRESS` -- `RPC_URL` -- `RELAYER_BASE_PATH` if you are not using the default - -4. (Optional) Generate a reusable decryption keypair: - -```bash -npx ts-node examples/relayers/zama/generate-keypair.ts -``` - -Copy the output `ZAMA_PUBLIC_KEY` and `ZAMA_PRIVATE_KEY` values into your `.env` file. If you skip this step, the counter script will generate a fresh keypair on each run. - -5. Run the example from the repository root: - -```bash -npx ts-node examples/relayers/zama/counter.ts -``` - -6. Check that the script: - -- reads the encrypted counter value -- decrypts it -- sends an encrypted increment through the relayer -- waits for confirmation -- reads and decrypts the updated value - -## What The Example Does - -The main script is [counter.ts](./counter.ts). It interacts with a counter contract deployed from Zama's `fhevm-hardhat-template`. - -The flow is: - -1. Load configuration from `.env`. -2. Create a Zama FHE instance for Sepolia. -3. Fetch the relayer address from the OpenZeppelin Relayer API. -4. Read the encrypted counter value with `getCount()`. -5. Try to decrypt it. -6. Encrypt an increment input and submit `increment()` through the relayer. -7. Wait for confirmation. -8. Read and decrypt the updated counter again. - -This makes the example useful as both: - -- A functional demo. -- A reference for the relayer-specific parts of an FHE flow. - -## Architecture - -There are four moving parts in this example: - -- Your script: orchestrates the flow and prints progress. -- The OpenZeppelin relayer: signs typed data and submits transactions. -- The Zama FHE SDK instance: encrypts inputs and manages decryption flows. -- The deployed counter contract: stores the encrypted state on-chain. - -The important boundary is that the relayer is not doing the encryption itself. The Zama SDK handles encryption and decryption primitives, while the relayer provides transaction execution and signature authorization. - -## Files - -- [counter.ts](./counter.ts): main end-to-end example. -- [generate-keypair.ts](./generate-keypair.ts): generates a Zama decryption keypair for reuse across runs. -- [types.ts](./types.ts): local typings for the decryption keypair and EIP-712 payload. -- [abi.json](./abi.json): ABI for the example counter contract. -- [.env.example](./.env.example): required environment variables. - -## Prerequisites - -- Node.js `>= 22.14.0` -- Project dependencies installed with `pnpm install` -- Access to an OpenZeppelin relayer with: - - a valid `RELAYER_API_KEY` - - a `RELAYER_ID` - - an EVM address returned by the relayer API -- A Zama FHE counter contract deployed on Sepolia - -## Configuration - -Copy [`.env.example`](./.env.example) to `.env` in this directory and fill in the values: - -```bash -RELAYER_API_KEY= -RELAYER_ID= -ZAMA_CONTRACT_ADDRESS= -RPC_URL=https://ethereum-sepolia-rpc.publicnode.com -RELAYER_BASE_PATH=http://localhost:8080 - -# Optional: reuse an existing keypair for user decryption -# ZAMA_PUBLIC_KEY= -# ZAMA_PRIVATE_KEY= -``` - -Notes: - -- `RELAYER_BASE_PATH` defaults to `http://localhost:8080` in the script. -- `RPC_URL` defaults to `https://ethereum-sepolia-rpc.publicnode.com` in the script. Point it to the RPC for the network you are targeting. -- If `ZAMA_PUBLIC_KEY` and `ZAMA_PRIVATE_KEY` are not set, the script generates a fresh decryption keypair. -- Reusing the same decryption keypair is useful if you want consistent user decryption behavior across runs. - -## Expected Output - -At a high level, the script should: - -- Print the relayer id, contract address, generated or loaded public key, and relayer address. -- Read the encrypted counter handle. -- Attempt decryption and print the clear value if successful. -- Submit an increment transaction through the relayer. -- Poll until the transaction reaches `mined` or `confirmed`. -- Read and decrypt the new counter value. - -## Decryption Model - -The script uses two decryption paths: - -1. Public decryption. -If the encrypted handle can be decrypted publicly, the script uses that result directly. - -2. User decryption. -If public decryption is not available, the script creates an EIP-712 payload through the Zama SDK and asks the relayer to sign it with `signTypedData`. That signature is then used to authorize `userDecrypt`. - -This fallback structure is useful because it shows both the simple path and the relayer-authorized path in one example. - -## Why The Relayer Matters Here - -In this example, the relayer is responsible for two separate concerns: - -- Transaction submission: it sends the encrypted `increment()` call on-chain. -- Authorization: it signs the EIP-712 payload used in the user decryption flow. - -That is the main integration point this example should teach. - -## References - -- [Zama Relayer SDK Guides](https://docs.zama.org/protocol/relayer-sdk-guides) — official documentation for the Zama relayer SDK, including setup, encryption, and decryption flows. - -## Using on Mainnet - -The example defaults to Sepolia. To run against Ethereum mainnet: - -1. In `counter.ts`, replace `SepoliaConfig` with `MainnetConfig` in the Zama instance configuration: - -```ts -import { - MainnetConfig, - createInstance, - type FhevmInstance, - type FhevmInstanceConfig, -} from '@zama-fhe/relayer-sdk/node'; -``` - -```ts -const zamaConfig: FhevmInstanceConfig = { - ...MainnetConfig, - network: configValues.rpcUrl, - auth: { __type: 'ApiKeyHeader', value: process.env.ZAMA_FHEVM_API_KEY! }, -}; -``` - -2. Add the following to your `.env`: - -```bash -# Zama FHE API key for mainnet (required for mainnet access) -ZAMA_FHEVM_API_KEY= -# Mainnet RPC URL -RPC_URL=https://ethereum-rpc.publicnode.com -``` - -3. Update `ZAMA_CONTRACT_ADDRESS` to point to your mainnet contract. - -4. Make sure your relayer is configured for Ethereum mainnet. - -> **Note:** Mainnet requires API key authentication with Zama's gateway. See the [mainnet API key guide](https://github.com/zama-ai/relayer-sdk/blob/main/docs/mainnet-api-key.md) for instructions on obtaining an API key. - -## Current Limitations +- `RPC_URL` (defaults to a public Sepolia RPC) +- `RELAYER_BASE_PATH` (defaults to `http://localhost:8080`) -- The example is hardcoded for Sepolia via `SepoliaConfig`, plus an explicit Sepolia RPC URL. -- It assumes a counter contract shape compatible with the included ABI. -- It is a demo script, so logging and error handling are intentionally simple. -- It does not cover relayer creation or contract deployment. +Optional, only used by the user-decryption flow: -## Troubleshooting +- `ZAMA_PUBLIC_KEY`, `ZAMA_PRIVATE_KEY` — reuse a previously generated decryption keypair across runs. The legacy example ships a [`generate-keypair.ts`](./relayer-sdk/generate-keypair.ts) helper that prints values you can paste back into `.env`. -- `Missing required environment variable`: one of the required values in `.env` is unset. -- `did not return an address`: the configured relayer id is valid for the API, but the response did not include an EVM address. -- `Public decryption failed`: this can be expected depending on the contract and permissions. The script should then try user decryption. -- `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. -- Transaction polling timeout: the transaction may still be pending, the relayer may be unhealthy, or the target chain may be slow. +Each subdirectory's `README.md` has the full quickstart, architecture notes, and troubleshooting for that path. diff --git a/examples/relayers/zama/relayer-sdk/README.md b/examples/relayers/zama/relayer-sdk/README.md new file mode 100644 index 0000000..b5c24bd --- /dev/null +++ b/examples/relayers/zama/relayer-sdk/README.md @@ -0,0 +1,217 @@ +# Zama Relayer Example (`@zama-fhe/relayer-sdk`) + +This directory contains an end-to-end example of using the OpenZeppelin Relayer SDK with a Zama FHE-enabled EVM contract, built against the legacy [`@zama-fhe/relayer-sdk`](https://www.npmjs.com/package/@zama-fhe/relayer-sdk) (v0.4.x). All relayer wiring — EIP-712 hashing, signature requests, transaction submission, and confirmation polling — is done by hand in `counter.ts`. + +If you are starting a new integration, prefer the [`zama-sdk/`](../zama-sdk/) example instead. It uses the newer [`@zama-fhe/sdk`](https://www.npmjs.com/package/@zama-fhe/sdk) (v3.x) and a reusable `OpenZeppelinRelayerSigner` adapter, so the application code is much smaller. + +## Objective + +This example shows the minimum viable integration path: + +1. Configure a relayer and a deployed FHE contract. +2. Read encrypted state from the contract. +3. Decrypt the state, first via public decryption when possible. +4. Fall back to user decryption authorized with an EIP-712 signature produced by the relayer. +5. Send an encrypted transaction through the relayer. +6. Poll the relayer until the transaction is mined or confirmed. + +## Quickstart + +1. Install dependencies from the repository root: + +```bash +pnpm install +``` + +2. Copy [`.env.example`](../.env.example) to `.env` in the parent directory (`examples/relayers/zama/.env`). The `.env` is shared across both the `relayer-sdk/` and `zama-sdk/` examples so you only configure your relayer once. + +3. Fill in: + +- `RELAYER_API_KEY` +- `RELAYER_ID` +- `ZAMA_CONTRACT_ADDRESS` +- `RPC_URL` +- `RELAYER_BASE_PATH` if you are not using the default + +4. (Optional) Generate a reusable decryption keypair: + +```bash +npx ts-node examples/relayers/zama/relayer-sdk/generate-keypair.ts +``` + +Copy the output `ZAMA_PUBLIC_KEY` and `ZAMA_PRIVATE_KEY` values into your `.env` file. If you skip this step, the counter script will generate a fresh keypair on each run. + +5. Run the example from the repository root: + +```bash +npx ts-node examples/relayers/zama/relayer-sdk/counter.ts +``` + +6. Check that the script: + +- reads the encrypted counter value +- decrypts it +- sends an encrypted increment through the relayer +- waits for confirmation +- reads and decrypts the updated value + +## What The Example Does + +The main script is [counter.ts](./counter.ts). It interacts with a counter contract deployed from Zama's `fhevm-hardhat-template`. + +The flow is: + +1. Load configuration from `.env`. +2. Create a Zama FHE instance for Sepolia. +3. Fetch the relayer address from the OpenZeppelin Relayer API. +4. Read the encrypted counter value with `getCount()`. +5. Try to decrypt it. +6. Encrypt an increment input and submit `increment()` through the relayer. +7. Wait for confirmation. +8. Read and decrypt the updated counter again. + +This makes the example useful as both: + +- A functional demo. +- A reference for the relayer-specific parts of an FHE flow. + +## Architecture + +There are four moving parts in this example: + +- Your script: orchestrates the flow and prints progress. +- The OpenZeppelin relayer: signs typed data and submits transactions. +- The Zama FHE SDK instance: encrypts inputs and manages decryption flows. +- The deployed counter contract: stores the encrypted state on-chain. + +The important boundary is that the relayer is not doing the encryption itself. The Zama SDK handles encryption and decryption primitives, while the relayer provides transaction execution and signature authorization. + +## Files + +- [counter.ts](./counter.ts): main end-to-end example. +- [generate-keypair.ts](./generate-keypair.ts): generates a Zama decryption keypair for reuse across runs. +- [types.ts](./types.ts): local typings for the decryption keypair and EIP-712 payload. +- [abi.json](./abi.json): ABI for the example counter contract. +- [.env.example](../.env.example): required environment variables (lives at the parent directory and is shared with the `zama-sdk/` example). + +## Prerequisites + +- Node.js `>= 22.14.0` +- Project dependencies installed with `pnpm install` +- Access to an OpenZeppelin relayer with: + - a valid `RELAYER_API_KEY` + - a `RELAYER_ID` + - an EVM address returned by the relayer API +- A Zama FHE counter contract deployed on Sepolia + +## Configuration + +Copy [`.env.example`](../.env.example) to `.env` in the parent directory (`examples/relayers/zama/.env`) and fill in the values: + +```bash +RELAYER_API_KEY= +RELAYER_ID= +ZAMA_CONTRACT_ADDRESS= +RPC_URL=https://ethereum-sepolia-rpc.publicnode.com +RELAYER_BASE_PATH=http://localhost:8080 + +# Optional: reuse an existing keypair for user decryption +# ZAMA_PUBLIC_KEY= +# ZAMA_PRIVATE_KEY= +``` + +Notes: + +- `RELAYER_BASE_PATH` defaults to `http://localhost:8080` in the script. +- `RPC_URL` defaults to `https://ethereum-sepolia-rpc.publicnode.com` in the script. Point it to the RPC for the network you are targeting. +- If `ZAMA_PUBLIC_KEY` and `ZAMA_PRIVATE_KEY` are not set, the script generates a fresh decryption keypair. +- Reusing the same decryption keypair is useful if you want consistent user decryption behavior across runs. + +## Expected Output + +At a high level, the script should: + +- Print the relayer id, contract address, generated or loaded public key, and relayer address. +- Read the encrypted counter handle. +- Attempt decryption and print the clear value if successful. +- Submit an increment transaction through the relayer. +- Poll until the transaction reaches `mined` or `confirmed`. +- Read and decrypt the new counter value. + +## Decryption Model + +The script uses two decryption paths: + +1. Public decryption. + If the encrypted handle can be decrypted publicly, the script uses that result directly. + +2. User decryption. + If public decryption is not available, the script creates an EIP-712 payload through the Zama SDK and asks the relayer to sign it with `signTypedData`. That signature is then used to authorize `userDecrypt`. + +This fallback structure is useful because it shows both the simple path and the relayer-authorized path in one example. + +## Why The Relayer Matters Here + +In this example, the relayer is responsible for two separate concerns: + +- Transaction submission: it sends the encrypted `increment()` call on-chain. +- Authorization: it signs the EIP-712 payload used in the user decryption flow. + +That is the main integration point this example should teach. + +## References + +- [Zama Relayer SDK Guides](https://docs.zama.org/protocol/relayer-sdk-guides) — official documentation for the Zama relayer SDK, including setup, encryption, and decryption flows. + +## Using on Mainnet + +The example defaults to Sepolia. To run against Ethereum mainnet: + +1. In `counter.ts`, replace `SepoliaConfig` with `MainnetConfig` in the Zama instance configuration: + +```ts +import { + MainnetConfig, + createInstance, + type FhevmInstance, + type FhevmInstanceConfig, +} from '@zama-fhe/relayer-sdk/node'; +``` + +```ts +const zamaConfig: FhevmInstanceConfig = { + ...MainnetConfig, + network: configValues.rpcUrl, + auth: { __type: 'ApiKeyHeader', value: process.env.ZAMA_FHEVM_API_KEY! }, +}; +``` + +2. Add the following to your `.env`: + +```bash +# Zama FHE API key for mainnet (required for mainnet access) +ZAMA_FHEVM_API_KEY= +# Mainnet RPC URL +RPC_URL=https://ethereum-rpc.publicnode.com +``` + +3. Update `ZAMA_CONTRACT_ADDRESS` to point to your mainnet contract. + +4. Make sure your relayer is configured for Ethereum mainnet. + +> **Note:** Mainnet requires API key authentication with Zama's gateway. See the [mainnet API key guide](https://github.com/zama-ai/relayer-sdk/blob/main/docs/mainnet-api-key.md) for instructions on obtaining an API key. + +## Current Limitations + +- The example is hardcoded for Sepolia via `SepoliaConfig`, plus an explicit Sepolia RPC URL. +- It assumes a counter contract shape compatible with the included ABI. +- It is a demo script, so logging and error handling are intentionally simple. +- It does not cover relayer creation or contract deployment. + +## Troubleshooting + +- `Missing required environment variable`: one of the required values in `.env` is unset. +- `did not return an address`: the configured relayer id is valid for the API, but the response did not include an EVM address. +- `Public decryption failed`: this can be expected depending on the contract and permissions. The script should then try user decryption. +- `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. +- Transaction polling timeout: the transaction may still be pending, the relayer may be unhealthy, or the target chain may be slow. diff --git a/examples/relayers/zama/abi.json b/examples/relayers/zama/relayer-sdk/abi.json similarity index 100% rename from examples/relayers/zama/abi.json rename to examples/relayers/zama/relayer-sdk/abi.json diff --git a/examples/relayers/zama/counter.ts b/examples/relayers/zama/relayer-sdk/counter.ts similarity index 97% rename from examples/relayers/zama/counter.ts rename to examples/relayers/zama/relayer-sdk/counter.ts index c2e22e3..68dc86d 100644 --- a/examples/relayers/zama/counter.ts +++ b/examples/relayers/zama/relayer-sdk/counter.ts @@ -19,7 +19,7 @@ * ts-node counter.ts */ import { config as loadEnv } from 'dotenv'; -import { Configuration, EvmTransactionResponse, RelayersApi, SignDataResponseEvm, Speed } from '../../../src'; +import { Configuration, EvmTransactionResponse, RelayersApi, SignDataResponseEvm, Speed } from '../../../../src'; import { DecryptionKeypair } from './types'; import { Interface, JsonRpcProvider, TypedDataEncoder, getAddress } from 'ethers'; import { @@ -33,7 +33,7 @@ import { join } from 'node:path'; import ABI from './abi.json'; -loadEnv({ path: join(__dirname, '.env'), quiet: true }); +loadEnv({ path: join(__dirname, '..', '.env'), quiet: true }); const transactionPollingIntervalMs = 2000; const transactionPollingMaxAttempts = 60; @@ -244,8 +244,10 @@ async function incrementCount(instance: FhevmInstance, contractAddress: string, // Create encrypted input const encInput = instance.createEncryptedInput(contractAddress, relayerAddress); encInput.add32(1); - const { handles, inputProof } = await encInput.encrypt(); - + const { handles, inputProof } = await encInput.encrypt({ + timeout: 90_000, + onProgress: (progress: unknown) => console.log('Encrypt progress:', progress), + }); // Encode transaction data const data = iface.encodeFunctionData('increment', [handles[0], inputProof]); diff --git a/examples/relayers/zama/generate-keypair.ts b/examples/relayers/zama/relayer-sdk/generate-keypair.ts similarity index 95% rename from examples/relayers/zama/generate-keypair.ts rename to examples/relayers/zama/relayer-sdk/generate-keypair.ts index b6cdba4..c61e5b4 100644 --- a/examples/relayers/zama/generate-keypair.ts +++ b/examples/relayers/zama/relayer-sdk/generate-keypair.ts @@ -15,7 +15,7 @@ import { config as loadEnv } from 'dotenv'; import { SepoliaConfig, createInstance, type FhevmInstanceConfig } from '@zama-fhe/relayer-sdk/node'; import { join } from 'node:path'; -loadEnv({ path: join(__dirname, '.env'), quiet: true }); +loadEnv({ path: join(__dirname, '..', '.env'), quiet: true }); async function main() { const rpcUrl = process.env.RPC_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com'; diff --git a/examples/relayers/zama/types.ts b/examples/relayers/zama/relayer-sdk/types.ts similarity index 100% rename from examples/relayers/zama/types.ts rename to examples/relayers/zama/relayer-sdk/types.ts diff --git a/examples/relayers/zama/zama-sdk/README.md b/examples/relayers/zama/zama-sdk/README.md new file mode 100644 index 0000000..d94b26d --- /dev/null +++ b/examples/relayers/zama/zama-sdk/README.md @@ -0,0 +1,141 @@ +# Zama FHE Example (`@zama-fhe/sdk` + `GenericSigner`) + +This directory shows how to integrate the OpenZeppelin Relayer SDK with the new top-level Zama SDK (`@zama-fhe/sdk@3.x`) by implementing Zama's `GenericSigner` interface on top of the relayer. + +If you are looking for the legacy integration that uses `@zama-fhe/relayer-sdk@0.4.x` directly (no signer abstraction, all wiring done in user code), see [`../relayer-sdk/`](../relayer-sdk/). + +## Why a `GenericSigner` + +`@zama-fhe/sdk` ships a framework-agnostic [`GenericSigner`](https://www.npmjs.com/package/@zama-fhe/sdk) interface — wallet-style operations (`getAddress`, `signTypedData`, `readContract`, `writeContract`, `waitForTransactionReceipt`, `getChainId`, `getBlockTimestamp`). The SDK comes with `EthersSigner` and a viem adapter, and any other backend — including a remote relayer — can plug in by implementing the same interface. + +Because the OpenZeppelin Relayer naturally fits this contract — it signs typed data and submits transactions on behalf of an address — the integration reduces to a single adapter class. From there, every other piece of the Zama SDK (decrypt sessions, the `Token` ERC-7984 wrapper, the wrappers registry, etc.) works without any relayer-specific code. + +## Files + +- [openzeppelin-relayer-signer.ts](./openzeppelin-relayer-signer.ts) — `OpenZeppelinRelayerSigner implements GenericSigner`. Hashes EIP-712 payloads with viem, calls `relayersApi.signTypedData` for signatures, encodes calldata + calls `relayersApi.sendTransaction` for writes, then polls `getTransactionById` until the relayer assigns an on-chain hash. Read-only ops (`readContract`, `getChainId`, `getBlockTimestamp`, `waitForTransactionReceipt`) go through a viem `PublicClient`, never through the relayer. +- [counter.ts](./counter.ts) — end-to-end demo that mirrors the legacy example but uses `ZamaSDK` + the adapter. +- [abi.json](./abi.json) — ABI of the example counter contract. + +## Quickstart + +1. Install dependencies from the repository root: + + ```bash + pnpm install + ``` + +2. Copy [`../.env.example`](../.env.example) to `../.env` (the parent `examples/relayers/zama/.env` is shared with the legacy example) and fill in: + - `RELAYER_API_KEY` + - `RELAYER_ID` + - `ZAMA_CONTRACT_ADDRESS` + - `RPC_URL` + - `RELAYER_BASE_PATH` if not running against `http://localhost:8080` + +3. Run the example from the repository root: + + ```bash + npx ts-node examples/relayers/zama/zama-sdk/counter.ts + ``` + +The script reads the encrypted counter, decrypts it (public path first, falling back to user decrypt), submits an encrypted `increment()` through the relayer, waits for confirmation, and decrypts the new value. + +## Architecture + +Four objects are wired together: + +``` +┌─────────────────────────┐ encrypts/decrypts ┌──────────────────┐ +│ RelayerNode │ ◄────────────────────► │ Zama gateway │ +│ (FHE worker pool, WASM) │ └──────────────────┘ +└────────────┬────────────┘ + │ + ▼ composed by +┌─────────────────────────┐ +│ ZamaSDK │ high-level API: +│ - signer │ sdk.publicDecrypt(...) +│ - relayer │ sdk.userDecrypt(...) +│ - storage │ sdk.token(addr).balanceOf(...) +└────────────┬────────────┘ sdk.token(addr).transfer(...) + │ + ▼ delegates signTypedData / writeContract / ... +┌─────────────────────────┐ REST API ┌──────────────────┐ +│ OpenZeppelinRelayer │ ◄────────────► │ OZ Relayer │ +│ Signer (this dir) │ │ (your deployment) │ +└─────────────────────────┘ └──────────────────┘ +``` + +- **Encryption / decryption** stays inside the Zama SDK and never touches the OZ relayer. +- **Signing and submission** flow through the adapter to the OZ relayer. +- **Read-only RPC calls** (chain id, block timestamps, contract reads, receipt confirmation) bypass the relayer entirely and go straight to `RPC_URL`. + +## Mapping `GenericSigner` to OZ Relayer + +| `GenericSigner` method | Backed by | +| ----------------------------- | ---------------------------------------------------------------------------------------------- | +| `getChainId()` | `publicClient.getChainId()` (RPC) | +| `getBlockTimestamp()` | `publicClient.getBlock().timestamp` (RPC) | +| `getAddress()` | `relayersApi.getRelayer(id).address` (cached) | +| `signTypedData(td)` | `viem.hashDomain(td)` + `viem.hashStruct(td)` → `relayersApi.signTypedData({ ... })` | +| `readContract(cfg)` | `publicClient.readContract(cfg)` (RPC) | +| `writeContract(cfg)` | `viem.encodeFunctionData(cfg)` → `relayersApi.sendTransaction(...)` → poll until hash assigned | +| `waitForTransactionReceipt()` | `publicClient.waitForTransactionReceipt({ hash })` (RPC) | +| `subscribe()` | omitted (server-side signer; the SDK guards this method as optional) | + +## Notable implementation details + +- **`writeContract` returns the on-chain hash, not the relayer's transaction id.** The OZ relayer initially returns a transaction id; the on-chain hash is populated only once the relayer broadcasts the transaction. The adapter polls `getTransactionById` until `tx.hash` is set (or a terminal status — `failed`, `canceled`, `expired` — is reached) before returning. This matches the `Promise` contract that `GenericSigner.writeContract` requires, so consumers can pass the result straight into `signer.waitForTransactionReceipt(...)`. +- **EIP-712 hashing happens locally.** The OZ relayer's `signTypedData` endpoint takes a pre-computed `(domain_separator, hash_struct_message)` pair, not the full typed data. The adapter strips the synthetic `EIP712Domain` entry from `types` and runs `hashDomain` / `hashStruct` from viem before posting to the relayer. +- **`value` is currently passed as a JS number.** The OZ relayer's `EvmTransactionRequest.value` schema is `number`. The adapter throws if a `bigint` value larger than `Number.MAX_SAFE_INTEGER` is passed in — for FHE confidential flows the value is virtually always `0`, so this is a non-issue in practice. +- **Storage is in-memory.** `MemoryStorage` is fine for examples but loses cached FHE artifacts and decrypt session signatures across restarts. For a long-running server, swap in a Redis-backed `GenericStorage` to avoid re-fetching the FHE public material and re-prompting the relayer for session signatures on every restart. +- **`subscribe` is intentionally omitted.** The Zama SDK only invokes it when defined (`if (this.signer.subscribe)`), so a server-side adapter can leave it out without breaking lifecycle handling. + +## Using on Mainnet + +The example targets Sepolia by default (`SepoliaConfig` + a Sepolia RPC URL). To run against Ethereum mainnet: + +1. In `counter.ts`, swap the import and config: + + ```ts + import { MainnetConfig, RelayerNode } from '@zama-fhe/sdk/node'; + + const relayer = new RelayerNode({ + transports: { + [chainId]: { + ...MainnetConfig, + network: rpcUrl, + auth: { __type: 'ApiKeyHeader', value: process.env.ZAMA_FHEVM_API_KEY! }, + }, + }, + getChainId: () => Promise.resolve(chainId), + }); + ``` + +2. Add `ZAMA_FHEVM_API_KEY` and a mainnet `RPC_URL` to your `.env`. Mainnet decryption requires an API key with Zama's gateway — see the [mainnet API key guide](https://github.com/zama-ai/relayer-sdk/blob/main/docs/mainnet-api-key.md). + +3. Make sure the OZ relayer (`RELAYER_ID`) is configured for Ethereum mainnet. + +## Comparison with the legacy example + +| Concern | `relayer-sdk/` (legacy) | `zama-sdk/` (this example) | +| ------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------- | +| Zama package | `@zama-fhe/relayer-sdk@0.4.x` | `@zama-fhe/sdk@3.x` | +| EIP-712 hashing | hand-rolled in the script with `ethers.TypedDataEncoder` | inside the adapter, reusable | +| Calling `relayersApi.signTypedData` | hand-rolled in the script | inside the adapter | +| Calling `relayersApi.sendTransaction` | hand-rolled in the script | inside the adapter | +| Confirmation polling | hand-rolled in the script | `signer.waitForTransactionReceipt(hash)` (RPC) | +| Deciding public vs. user decryption | manual fallback chain in the script | `sdk.publicDecrypt` / `sdk.userDecrypt` (cached sessions) | +| Reuse across other Zama integrations | none — every integration repeats the glue | one adapter, plugs into every Zama SDK API | + +## Troubleshooting + +- `Missing required environment variable` — fill in the parent `.env`. +- `Relayer "..." did not return an address` — the relayer id is valid but the API response had no `address` field; check the relayer's signer wiring. +- `Timed out waiting for relayer to assign a transaction hash` — only thrown when `maxSubmitPollAttempts` is set (otherwise the submit phase polls indefinitely). The relayer accepted the transaction but did not broadcast it within the cap; check relayer health, gas funding, and quotas. The confirmation phase inside `waitForTransactionReceipt` is _always_ uncapped — wrap with `Promise.race` if you need a deadline at the call site. +- `Public decryption failed` — expected when the handle is not publicly decryptable. The script then attempts user decryption. +- `User decryption failed` — verify that the relayer can sign typed data, the contract is on the configured chain, and the SDK's `storage` is actually persisting decrypt session signatures across runs (use Redis-backed storage in production). + +## References + +- [Zama SDK docs](https://github.com/zama-ai/sdk) — main repository for `@zama-fhe/sdk`. +- [Zama Relayer SDK guides](https://docs.zama.org/protocol/relayer-sdk-guides) — encryption and decryption flows. +- [OpenZeppelin Relayer docs](https://docs.openzeppelin.com/relayer) — relayer setup and API reference. diff --git a/examples/relayers/zama/zama-sdk/abi.json b/examples/relayers/zama/zama-sdk/abi.json new file mode 100644 index 0000000..e8f786a --- /dev/null +++ b/examples/relayers/zama/zama-sdk/abi.json @@ -0,0 +1,19 @@ +[ + { + "inputs": [], + "name": "getCount", + "outputs": [{ "internalType": "euint32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "externalEuint32", "name": "inputEuint32", "type": "bytes32" }, + { "internalType": "bytes", "name": "inputProof", "type": "bytes" } + ], + "name": "increment", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/examples/relayers/zama/zama-sdk/counter.ts b/examples/relayers/zama/zama-sdk/counter.ts new file mode 100644 index 0000000..e0e7cc0 --- /dev/null +++ b/examples/relayers/zama/zama-sdk/counter.ts @@ -0,0 +1,154 @@ +/** + * Zama FHE Counter Example — `@zama-fhe/sdk` integration + * + * Demonstrates using the OpenZeppelin Relayer SDK with the new top-level Zama + * SDK (`@zama-fhe/sdk`, v3.x). The relayer is plugged in as a `GenericSigner` + * via `OpenZeppelinRelayerSigner`, so the application code only deals with the + * high-level `ZamaSDK` API. EIP-712 hashing, signature requests, transaction + * submission, and confirmation polling are all handled inside the adapter. + * + * Counter contract is deployed with the template at + * https://github.com/zama-ai/fhevm-hardhat-template + * + * Usage: + * ts-node examples/relayers/zama/zama-sdk/counter.ts + */ +import { config as loadEnv } from 'dotenv'; +import { join } from 'node:path'; +import { type Hex, bytesToHex, getAddress } from 'viem'; + +import { MemoryStorage, ZamaSDK } from '@zama-fhe/sdk'; +import { RelayerNode, SepoliaConfig } from '@zama-fhe/sdk/node'; + +import { Configuration, RelayersApi } from '../../../../src'; +import counterAbi from './abi.json'; +import { OpenZeppelinRelayerSigner } from './openzeppelin-relayer-signer'; + +loadEnv({ path: join(__dirname, '..', '.env'), quiet: true }); + +function getRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function main() { + console.log('🚀 Zama FHE Counter Example (zama-sdk)\n'); + + const apiKey = getRequiredEnv('RELAYER_API_KEY'); + const relayerId = getRequiredEnv('RELAYER_ID'); + const contractAddress = getAddress(getRequiredEnv('ZAMA_CONTRACT_ADDRESS')); + const rpcUrl = process.env.RPC_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com'; + const basePath = process.env.RELAYER_BASE_PATH ?? 'http://localhost:8080'; + + console.log(`Relayer: ${relayerId}`); + console.log(`Contract: ${contractAddress}\n`); + + // 1. OZ relayer client + const relayersApi = new RelayersApi(new Configuration({ basePath, accessToken: apiKey })); + + // 2. GenericSigner backed by OZ relayer + const signer = new OpenZeppelinRelayerSigner({ relayersApi, relayerId, rpcUrl }); + const [relayerAddress, chainId] = await Promise.all([signer.getAddress(), signer.getChainId()]); + console.log(`🔑 Relayer address: ${relayerAddress}`); + console.log(`⛓ Chain id: ${chainId}\n`); + + // 3. Zama FHE backend (worker pool, no signer) + const relayer = new RelayerNode({ + transports: { [chainId]: { ...SepoliaConfig, network: rpcUrl } }, + getChainId: () => Promise.resolve(chainId), + }); + + // 4. Compose ZamaSDK — relayer + signer + storage + const sdk = new ZamaSDK({ + relayer, + signer, + storage: new MemoryStorage(), + }); + + try { + const readCount = async (): Promise => { + const encrypted = await signer.readContract({ + address: contractAddress, + abi: counterAbi as never, + functionName: 'getCount', + args: [], + }); + return encrypted as Hex; + }; + + const decrypt = async (handle: Hex): Promise => { + try { + const pub = await sdk.publicDecrypt([handle]); + const value = pub.clearValues[handle]; + if (typeof value === 'bigint') { + console.log(`✅ Decrypted (public): ${value}`); + return value; + } + } catch (err) { + console.warn('Public decryption failed:', err instanceof Error ? err.message : err); + } + try { + const result = await sdk.userDecrypt([{ handle, contractAddress }]); + const value = result[handle]; + if (typeof value === 'bigint') { + console.log(`✅ Decrypted (user): ${value}`); + return value; + } + } catch (err) { + console.warn('User decryption failed:', err instanceof Error ? err.message : err); + } + console.log('❌ Decryption failed'); + return null; + }; + + // STEP 1: read & decrypt initial count + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📊 STEP 1: initial count'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + let handle = await readCount(); + console.log(`Encrypted handle: ${handle}`); + await decrypt(handle); + + // STEP 2: encrypt input + submit increment via the relayer + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📊 STEP 2: increment counter'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + const encrypted = await relayer.encrypt({ + values: [{ value: 1n, type: 'euint32' }], + contractAddress, + userAddress: relayerAddress, + }); + const txHash = await signer.writeContract({ + address: contractAddress, + abi: counterAbi as never, + functionName: 'increment', + args: [bytesToHex(encrypted.handles[0]), bytesToHex(encrypted.inputProof)], + }); + console.log(`📝 Submitted tx: ${txHash}`); + await signer.waitForTransactionReceipt(txHash); + console.log('✅ Confirmed on chain'); + + // STEP 3: read & decrypt final count + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📊 STEP 3: final count'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + handle = await readCount(); + console.log(`Encrypted handle: ${handle}`); + await decrypt(handle); + + console.log('\n✨ Script completed successfully'); + } finally { + relayer.terminate(); + } +} + +main().catch((err) => { + console.error('\n❌ Error:', err); + if (err instanceof Error) { + console.error('Message:', err.message); + } + process.exit(1); +}); diff --git a/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts b/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts new file mode 100644 index 0000000..cf45898 --- /dev/null +++ b/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts @@ -0,0 +1,295 @@ +/** + * `OpenZeppelinRelayerSigner` — `GenericSigner` implementation that backs + * `@zama-fhe/sdk` with an OpenZeppelin Relayer for signing and submission. + * Read-only ops go through a viem `PublicClient`. + */ +import { + type Abi, + type Address, + type ContractFunctionArgs, + type ContractFunctionName, + type ContractFunctionReturnType, + type Hex, + type PublicClient, + type Transport, + createPublicClient, + encodeFunctionData, + getAddress, + hashDomain, + hashStruct, + http, + isHex, +} from 'viem'; + +import type { + EIP712TypedData, + GenericSigner, + ReadContractConfig, + TransactionReceipt, + WriteContractConfig, +} from '@zama-fhe/sdk'; + +import { + type EvmTransactionResponse, + type RelayersApi, + type SignDataResponseEvm, + Speed, + TransactionStatus, +} from '../../../../src'; + +export interface OpenZeppelinRelayerSignerConfig { + /** Configured `RelayersApi` client from `@openzeppelin/relayer-sdk`. */ + relayersApi: RelayersApi; + /** The relayer id to use for signing and submission. */ + relayerId: string; + /** RPC URL for read-only EVM operations (chain id, blocks, contract reads, receipt waiting). */ + rpcUrl: string; + /** How often (ms) to poll the relayer (used for both submit and confirmation phases). Default: 2000. */ + pollIntervalMs?: number; + /** + * Maximum poll attempts while waiting for the relayer to broadcast and assign + * the initial on-chain hash. If omitted, polls indefinitely. + * + * Only applies to the submit phase inside `writeContract`. Confirmation + * inside `waitForTransactionReceipt` is always uncapped — fee-escalation + * cycles can outlast any default. Wrap with `Promise.race` if you need a + * deadline at the call site. + */ + maxSubmitPollAttempts?: number; + /** Speed setting passed to the relayer's `sendTransaction`. Default: `Speed.FAST`. */ + speed?: Speed; + /** Default gas limit if `WriteContractConfig.gas` is not set. Default: 500000. */ + defaultGasLimit?: number; +} + +const DEFAULT_POLL_INTERVAL_MS = 2000; +const DEFAULT_GAS_LIMIT = 500_000; + +type OpenZeppelinPublicClient = PublicClient; +type ReadContractConfigParam = Parameters[0]; +type ReceiptLog = { topics: readonly Hex[]; data: Hex }; +const createOpenZeppelinPublicClient = createPublicClient as (config: { + transport: Transport; +}) => OpenZeppelinPublicClient; + +/** Implements Zama's `GenericSigner` on top of an OpenZeppelin Relayer. */ +export class OpenZeppelinRelayerSigner implements GenericSigner { + readonly #relayersApi: RelayersApi; + readonly #relayerId: string; + readonly #pollIntervalMs: number; + readonly #maxSubmitPollAttempts?: number; + readonly #speed: Speed; + readonly #defaultGasLimit: number; + readonly #publicClient: OpenZeppelinPublicClient; + #cachedAddress?: Address; + /** + * Maps an initial on-chain hash to its relayer transaction id, so + * `waitForTransactionReceipt` can follow fee-bump resubmissions: the relayer + * may broadcast under a new hash while the consumer still holds the original. + */ + readonly #txIdByInitialHash = new Map(); + + constructor(config: OpenZeppelinRelayerSignerConfig) { + this.#relayersApi = config.relayersApi; + this.#relayerId = config.relayerId; + this.#pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.#maxSubmitPollAttempts = config.maxSubmitPollAttempts; + this.#speed = config.speed ?? Speed.FAST; + this.#defaultGasLimit = config.defaultGasLimit ?? DEFAULT_GAS_LIMIT; + this.#publicClient = createOpenZeppelinPublicClient({ transport: http(config.rpcUrl) }); + } + + async getChainId(): Promise { + return this.#publicClient.getChainId(); + } + + async getBlockTimestamp(): Promise { + const block = await this.#publicClient.getBlock(); + return block.timestamp; + } + + async getAddress(): Promise
{ + if (this.#cachedAddress) { + return this.#cachedAddress; + } + const response = await this.#relayersApi.getRelayer(this.#relayerId); + const raw = response.data.data?.address; + if (!raw) { + throw new Error(`Relayer "${this.#relayerId}" did not return an address`); + } + this.#cachedAddress = getAddress(raw); + return this.#cachedAddress; + } + + async signTypedData(typedData: EIP712TypedData): Promise { + const { domain, types, primaryType, message } = typedData; + // viem's hashDomain wants mutable arrays; clone readonly inputs. + const mutableTypes = Object.fromEntries(Object.entries(types).map(([key, fields]) => [key, [...fields]])); + const { EIP712Domain: _ignored, ...structTypes } = mutableTypes; + + // `RelayerNode.createEIP712` in @zama-fhe/sdk@3.0.0 omits primaryType. + // The payload always has exactly one user struct, matching what upstream + // ViemSigner / EthersSigner assume. + const resolvedPrimaryType = primaryType ?? Object.keys(structTypes)[0]; + if (!resolvedPrimaryType) { + throw new Error('signTypedData: typedData has no user-defined struct type'); + } + + const domainSeparator = hashDomain({ domain, types: mutableTypes }); + const hashStructMessage = hashStruct({ + data: message, + primaryType: resolvedPrimaryType, + types: structTypes, + }); + + const response = await this.#relayersApi.signTypedData(this.#relayerId, { + domain_separator: domainSeparator, + hash_struct_message: hashStructMessage, + }); + const rawSig = (response.data.data as SignDataResponseEvm | undefined)?.sig; + if (!rawSig) { + throw new Error('Relayer did not return a signature'); + } + const sig = (rawSig.startsWith('0x') ? rawSig : `0x${rawSig}`) as Hex; + if (!isHex(sig)) { + throw new Error(`Relayer returned non-hex signature: ${rawSig}`); + } + return sig; + } + + async readContract< + const TAbi extends Abi | readonly unknown[], + TFunctionName extends ContractFunctionName, + const TArgs extends ContractFunctionArgs, + >( + config: ReadContractConfig, + ): Promise> { + return this.#publicClient.readContract(config as unknown as ReadContractConfigParam) as Promise< + ContractFunctionReturnType + >; + } + + async writeContract< + const TAbi extends Abi | readonly unknown[], + TFunctionName extends ContractFunctionName, + const TArgs extends ContractFunctionArgs, + >(config: WriteContractConfig): Promise { + const data = encodeFunctionData({ + abi: config.abi, + functionName: config.functionName, + args: config.args, + } as Parameters[0]); + + const value = config.value ?? 0n; + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError( + `OpenZeppelinRelayerSigner: value (${value}) exceeds Number.MAX_SAFE_INTEGER. ` + + `The OZ relayer API encodes 'value' as a JSON number; large values are not currently supported.`, + ); + } + + const txResponse = await this.#relayersApi.sendTransaction(this.#relayerId, { + to: config.address, + data, + value: Number(value), + gas_limit: config.gas !== undefined ? Number(config.gas) : this.#defaultGasLimit, + speed: this.#speed, + }); + const transactionId = txResponse.data.data?.id; + if (!transactionId) { + throw new Error('Relayer did not return a transaction id'); + } + + const initialHash = await this.#waitForRelayerHash(transactionId); + this.#txIdByInitialHash.set(initialHash, transactionId); + return initialHash; + } + + async waitForTransactionReceipt(hash: Hex): Promise { + const transactionId = this.#txIdByInitialHash.get(hash); + if (!transactionId) { + // Hashes the signer didn't issue (e.g. external code) fall through to RPC. + return this.#fetchReceipt(hash, { wait: true }); + } + try { + const finalHash = await this.#waitForRelayerCompletion(transactionId); + // Relayer already confirmed the tx, so a single receipt fetch is enough. + return this.#fetchReceipt(finalHash, { wait: false }); + } finally { + this.#txIdByInitialHash.delete(hash); + } + } + + async #fetchReceipt(hash: Hex, { wait }: { wait: boolean }): Promise { + const receipt = wait + ? await this.#publicClient.waitForTransactionReceipt({ hash }) + : await this.#publicClient.getTransactionReceipt({ hash }); + return { + logs: (receipt.logs as unknown as readonly ReceiptLog[]).map((log) => ({ + topics: log.topics, + data: log.data, + })), + }; + } + + /** + * Poll the relayer until the transaction reaches a terminal status. Returns + * the *current* on-chain hash from the relayer (which may differ from the + * initial hash after a fee-bump resubmission). + * + * Intentionally uncapped — fee escalation cycles can outlast any default. + * Callers needing a deadline should wrap with `Promise.race`. + */ + async #waitForRelayerCompletion(transactionId: string): Promise { + for (;;) { + const tx = await this.#pollTransaction(transactionId); + if (tx.status === TransactionStatus.MINED || tx.status === TransactionStatus.CONFIRMED) { + if (!tx.hash || !isHex(tx.hash)) { + throw new Error(`Relayer reported ${tx.status} but did not return a valid on-chain hash`); + } + return tx.hash; + } + throwIfTerminalFailure(tx); + } + } + + /** + * Poll the relayer until it broadcasts the transaction and assigns an + * on-chain hash. Capped by `maxSubmitPollAttempts` if set, else uncapped. + */ + async #waitForRelayerHash(transactionId: string): Promise { + const cap = this.#maxSubmitPollAttempts; + for (let attempt = 1; cap === undefined || attempt <= cap; attempt += 1) { + const tx = await this.#pollTransaction(transactionId); + if (tx.hash && isHex(tx.hash)) { + return tx.hash; + } + throwIfTerminalFailure(tx); + } + throw new Error( + `Timed out waiting for relayer to assign a transaction hash after ` + + `${(cap ?? 0) * this.#pollIntervalMs}ms (maxSubmitPollAttempts=${cap})`, + ); + } + + async #pollTransaction(transactionId: string): Promise { + await new Promise((resolve) => setTimeout(resolve, this.#pollIntervalMs)); + const status = await this.#relayersApi.getTransactionById(this.#relayerId, transactionId); + const tx = status.data.data as EvmTransactionResponse | undefined; + if (!tx) { + throw new Error(`Relayer transaction "${transactionId}" not found`); + } + return tx; + } +} + +function throwIfTerminalFailure(tx: EvmTransactionResponse): void { + if ( + tx.status === TransactionStatus.FAILED || + tx.status === TransactionStatus.CANCELED || + tx.status === TransactionStatus.EXPIRED + ) { + const reason = tx.status_reason ? ` Reason: ${tx.status_reason}` : ''; + throw new Error(`Relayer transaction ${tx.status}.${reason}`); + } +} diff --git a/package.json b/package.json index 4e30fc8..adda121 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@stellar/stellar-sdk": "^14.3.0", "@types/node": "^22.0.0", "@zama-fhe/relayer-sdk": "0.4.2", + "@zama-fhe/sdk": "^3.0.0", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "dotenv": "^17.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1f57b1..a482f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@zama-fhe/relayer-sdk': specifier: 0.4.2 version: 0.4.2 + '@zama-fhe/sdk': + specifier: ^3.0.0 + version: 3.0.0(ethers@6.15.0)(viem@2.38.6(typescript@5.8.3)) commitizen: specifier: ^4.3.1 version: 4.3.1(@types/node@22.18.6)(typescript@5.8.3) @@ -909,6 +912,21 @@ packages: engines: {node: '>=22'} hasBin: true + '@zama-fhe/sdk@3.0.0': + resolution: {integrity: sha512-43G5css2FWe6+Q4hRlU8DFQvIeC7HybjGY6dYVF3CEb7iv0KAruYGaf0Pf9rMTvY5i7IMGMVhgm1vAl9q1BsZQ==} + engines: {node: '>=22'} + peerDependencies: + '@tanstack/query-core': '>=5' + ethers: '>=6' + viem: '>=2' + peerDependenciesMeta: + '@tanstack/query-core': + optional: true + ethers: + optional: true + viem: + optional: true + '@zkochan/js-yaml@0.0.7': resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} hasBin: true @@ -3998,6 +4016,16 @@ snapshots: - bufferutil - utf-8-validate + '@zama-fhe/sdk@3.0.0(ethers@6.15.0)(viem@2.38.6(typescript@5.8.3))': + dependencies: + '@zama-fhe/relayer-sdk': 0.4.2 + optionalDependencies: + ethers: 6.15.0 + viem: 2.38.6(typescript@5.8.3) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@zkochan/js-yaml@0.0.7': dependencies: argparse: 2.0.1 From 9222f8de8010a093bdc74f649d2f522add2c945b Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 29 Apr 2026 09:38:07 +0200 Subject: [PATCH 2/5] chore: apply PR suggestions --- examples/relayers/zama/zama-sdk/counter.ts | 8 +++++--- .../zama/zama-sdk/openzeppelin-relayer-signer.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/relayers/zama/zama-sdk/counter.ts b/examples/relayers/zama/zama-sdk/counter.ts index e0e7cc0..113d1e6 100644 --- a/examples/relayers/zama/zama-sdk/counter.ts +++ b/examples/relayers/zama/zama-sdk/counter.ts @@ -15,7 +15,7 @@ */ import { config as loadEnv } from 'dotenv'; import { join } from 'node:path'; -import { type Hex, bytesToHex, getAddress } from 'viem'; +import { type Abi, type Hex, bytesToHex, getAddress } from 'viem'; import { MemoryStorage, ZamaSDK } from '@zama-fhe/sdk'; import { RelayerNode, SepoliaConfig } from '@zama-fhe/sdk/node'; @@ -24,6 +24,8 @@ import { Configuration, RelayersApi } from '../../../../src'; import counterAbi from './abi.json'; import { OpenZeppelinRelayerSigner } from './openzeppelin-relayer-signer'; +const typedCounterAbi = counterAbi as Abi; + loadEnv({ path: join(__dirname, '..', '.env'), quiet: true }); function getRequiredEnv(name: string): string { @@ -72,7 +74,7 @@ async function main() { const readCount = async (): Promise => { const encrypted = await signer.readContract({ address: contractAddress, - abi: counterAbi as never, + abi: typedCounterAbi, functionName: 'getCount', args: [], }); @@ -123,7 +125,7 @@ async function main() { }); const txHash = await signer.writeContract({ address: contractAddress, - abi: counterAbi as never, + abi: typedCounterAbi, functionName: 'increment', args: [bytesToHex(encrypted.handles[0]), bytesToHex(encrypted.inputProof)], }); diff --git a/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts b/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts index cf45898..124ab88 100644 --- a/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts +++ b/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts @@ -127,12 +127,14 @@ export class OpenZeppelinRelayerSigner implements GenericSigner { const mutableTypes = Object.fromEntries(Object.entries(types).map(([key, fields]) => [key, [...fields]])); const { EIP712Domain: _ignored, ...structTypes } = mutableTypes; + const structTypeNames = Object.keys(structTypes); // `RelayerNode.createEIP712` in @zama-fhe/sdk@3.0.0 omits primaryType. - // The payload always has exactly one user struct, matching what upstream - // ViemSigner / EthersSigner assume. - const resolvedPrimaryType = primaryType ?? Object.keys(structTypes)[0]; + // Only infer it when the payload has exactly one user-defined struct. + const resolvedPrimaryType = primaryType ?? (structTypeNames.length === 1 ? structTypeNames[0] : undefined); if (!resolvedPrimaryType) { - throw new Error('signTypedData: typedData has no user-defined struct type'); + throw new Error( + `signTypedData: typedData must include primaryType when it has ${structTypeNames.length} user-defined struct types`, + ); } const domainSeparator = hashDomain({ domain, types: mutableTypes }); From 8e172c22f0579d3cc3ddc1d13c47101fa415d1a6 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 14 Jul 2026 12:40:52 +0200 Subject: [PATCH 3/5] chore: updating to latest zma-fhe/sdk version --- examples/relayers/zama/README.md | 14 +- examples/relayers/zama/relayer-sdk/README.md | 2 + examples/relayers/zama/zama-sdk/README.md | 146 ++++++----- examples/relayers/zama/zama-sdk/counter.ts | 73 +++--- .../zama-sdk/openzeppelin-relayer-signer.ts | 232 ++++++------------ package.json | 4 +- pnpm-lock.yaml | 45 ++-- 7 files changed, 247 insertions(+), 269 deletions(-) diff --git a/examples/relayers/zama/README.md b/examples/relayers/zama/README.md index 3520895..e74853d 100644 --- a/examples/relayers/zama/README.md +++ b/examples/relayers/zama/README.md @@ -2,14 +2,14 @@ This directory contains two end-to-end examples of using the OpenZeppelin Relayer SDK with a Zama FHE-enabled EVM contract. Both examples interact with the same counter contract deployed from Zama's [`fhevm-hardhat-template`](https://github.com/zama-ai/fhevm-hardhat-template), and both share a single `.env` (placed at this directory level) so you only configure your relayer once. -| Subdirectory | Zama package | Style | -| ------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`relayer-sdk/`](./relayer-sdk) | `@zama-fhe/relayer-sdk@0.4.x` | Legacy / minimal. All glue lives in user code: hand-roll EIP-712 hashing, call `relayersApi.signTypedData` / `sendTransaction`, poll for status. | -| [`zama-sdk/`](./zama-sdk) | `@zama-fhe/sdk@3.x` + `GenericSigner` | Recommended for new integrations. The relayer is plugged in as a `GenericSigner`; application code uses the high-level `ZamaSDK` API. | +| Subdirectory | Zama package | Style | +| ------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`relayer-sdk/`](./relayer-sdk) | `@zama-fhe/relayer-sdk@0.4.x` | Legacy / minimal. All glue lives in user code: hand-roll EIP-712 hashing, call `relayersApi.signTypedData` / `sendTransaction`, poll for status. | +| [`zama-sdk/`](./zama-sdk) | `@zama-fhe/sdk@3.x` + `GenericSigner` | Recommended for new integrations. The relayer is plugged in as a `GenericSigner`; reads use the built-in `ViemProvider`; application code uses the high-level `ZamaSDK` API. | ## Which one should I use? -- **Starting a new integration?** Use [`zama-sdk/`](./zama-sdk). The adapter pattern is reusable across the entire Zama SDK — once the relayer is wrapped as a `GenericSigner`, you get `ZamaSDK`, the ERC-7984 `Token` wrapper, the wrappers registry, decrypt session caching, and event hooks for free. +- **Starting a new integration?** Use [`zama-sdk/`](./zama-sdk). It uses Zama's officially released top-level SDK. The adapter pattern is reusable across the entire Zama SDK — once the relayer is wrapped as a `GenericSigner`, you get `ZamaSDK`, the ERC-7984 `Token` wrapper, the wrappers registry, decrypt session caching, and event hooks for free. - **Already on `@zama-fhe/relayer-sdk@0.4.x`?** [`relayer-sdk/`](./relayer-sdk) is the closest reference and stays around for parity. It is also useful if you want to see exactly which raw relayer API calls a Zama FHE flow needs — there is no abstraction in the way. The two examples can run side-by-side from the same `.env` and produce equivalent results on the counter contract. @@ -30,8 +30,8 @@ Required values (used by both examples): - `RPC_URL` (defaults to a public Sepolia RPC) - `RELAYER_BASE_PATH` (defaults to `http://localhost:8080`) -Optional, only used by the user-decryption flow: +Optional, only used by the legacy `relayer-sdk/` example's user-decryption flow: -- `ZAMA_PUBLIC_KEY`, `ZAMA_PRIVATE_KEY` — reuse a previously generated decryption keypair across runs. The legacy example ships a [`generate-keypair.ts`](./relayer-sdk/generate-keypair.ts) helper that prints values you can paste back into `.env`. +- `ZAMA_PUBLIC_KEY`, `ZAMA_PRIVATE_KEY` — reuse a previously generated decryption keypair across runs. The legacy example ships a [`generate-keypair.ts`](./relayer-sdk/generate-keypair.ts) helper that prints values you can paste back into `.env`. The `zama-sdk/` example does not need these — `@zama-fhe/sdk` manages decrypt keypairs and sessions internally. Each subdirectory's `README.md` has the full quickstart, architecture notes, and troubleshooting for that path. diff --git a/examples/relayers/zama/relayer-sdk/README.md b/examples/relayers/zama/relayer-sdk/README.md index b5c24bd..dff75d3 100644 --- a/examples/relayers/zama/relayer-sdk/README.md +++ b/examples/relayers/zama/relayer-sdk/README.md @@ -162,6 +162,8 @@ That is the main integration point this example should teach. ## References - [Zama Relayer SDK Guides](https://docs.zama.org/protocol/relayer-sdk-guides) — official documentation for the Zama relayer SDK, including setup, encryption, and decryption flows. +- [Zama Protocol SDK docs](https://docs.zama.org/protocol/sdk) — the newer top-level `@zama-fhe/sdk` used by the [`../zama-sdk/`](../zama-sdk/) example. +- [OpenZeppelin Relayer docs](https://docs.openzeppelin.com/relayer) — relayer setup and API reference. ## Using on Mainnet diff --git a/examples/relayers/zama/zama-sdk/README.md b/examples/relayers/zama/zama-sdk/README.md index d94b26d..d5e8d1b 100644 --- a/examples/relayers/zama/zama-sdk/README.md +++ b/examples/relayers/zama/zama-sdk/README.md @@ -1,19 +1,24 @@ # Zama FHE Example (`@zama-fhe/sdk` + `GenericSigner`) -This directory shows how to integrate the OpenZeppelin Relayer SDK with the new top-level Zama SDK (`@zama-fhe/sdk@3.x`) by implementing Zama's `GenericSigner` interface on top of the relayer. +This directory shows how to integrate the OpenZeppelin Relayer SDK with Zama's officially released top-level FHE SDK ([`@zama-fhe/sdk`](https://www.npmjs.com/package/@zama-fhe/sdk), v3.x) by implementing Zama's `GenericSigner` interface on top of the relayer. -If you are looking for the legacy integration that uses `@zama-fhe/relayer-sdk@0.4.x` directly (no signer abstraction, all wiring done in user code), see [`../relayer-sdk/`](../relayer-sdk/). +If you are looking for the legacy integration that uses [`@zama-fhe/relayer-sdk`](https://www.npmjs.com/package/@zama-fhe/relayer-sdk) (v0.4.x) directly — no signer abstraction, all wiring done in user code — see [`../relayer-sdk/`](../relayer-sdk/). ## Why a `GenericSigner` -`@zama-fhe/sdk` ships a framework-agnostic [`GenericSigner`](https://www.npmjs.com/package/@zama-fhe/sdk) interface — wallet-style operations (`getAddress`, `signTypedData`, `readContract`, `writeContract`, `waitForTransactionReceipt`, `getChainId`, `getBlockTimestamp`). The SDK comes with `EthersSigner` and a viem adapter, and any other backend — including a remote relayer — can plug in by implementing the same interface. +`@zama-fhe/sdk` splits the wallet contract in two framework-agnostic interfaces: -Because the OpenZeppelin Relayer naturally fits this contract — it signs typed data and submits transactions on behalf of an address — the integration reduces to a single adapter class. From there, every other piece of the Zama SDK (decrypt sessions, the `Token` ERC-7984 wrapper, the wrappers registry, etc.) works without any relayer-specific code. +- **`GenericSigner`** — write authority: `signTypedData` (decrypt authorization) and `writeContract` (on-chain submission), plus an observable `walletAccount` store. +- **`GenericProvider`** — read-only RPC: `getChainId`, `readContract`, `getBlockTimestamp`, `waitForTransactionReceipt`. + +The SDK ships built-in adapters (`@zama-fhe/sdk/viem`, `@zama-fhe/sdk/ethers`, `@zama-fhe/react-sdk/wagmi`), and any other backend can plug in by implementing the same interfaces. Because the OpenZeppelin Relayer naturally fits the **signer** contract — it signs typed data and submits transactions on behalf of an address — the integration reduces to a single adapter class. Reads never touch the relayer, so the example wires the SDK's built-in `ViemProvider` (a viem `PublicClient`) for the **provider** half. + +From there, every other piece of the Zama SDK (decrypt sessions, the `Token` ERC-7984 wrapper, the wrappers registry, etc.) works without any relayer-specific code. ## Files -- [openzeppelin-relayer-signer.ts](./openzeppelin-relayer-signer.ts) — `OpenZeppelinRelayerSigner implements GenericSigner`. Hashes EIP-712 payloads with viem, calls `relayersApi.signTypedData` for signatures, encodes calldata + calls `relayersApi.sendTransaction` for writes, then polls `getTransactionById` until the relayer assigns an on-chain hash. Read-only ops (`readContract`, `getChainId`, `getBlockTimestamp`, `waitForTransactionReceipt`) go through a viem `PublicClient`, never through the relayer. -- [counter.ts](./counter.ts) — end-to-end demo that mirrors the legacy example but uses `ZamaSDK` + the adapter. +- [openzeppelin-relayer-signer.ts](./openzeppelin-relayer-signer.ts) — `OpenZeppelinRelayerSigner extends BaseSigner`. Hashes EIP-712 payloads with viem, calls `relayersApi.signTypedData` for signatures, encodes calldata + calls `relayersApi.sendTransaction` for writes, then polls `getTransactionById` until the relayer mines the transaction and returns the final on-chain hash. Extending `BaseSigner` provides the `walletAccount` store and `requireWalletAccount` boilerplate. +- [counter.ts](./counter.ts) — end-to-end demo that mirrors the legacy example but uses `createConfig` + `ZamaSDK` + the adapter. - [abi.json](./abi.json) — ABI of the example counter contract. ## Quickstart @@ -31,6 +36,8 @@ Because the OpenZeppelin Relayer naturally fits this contract — it signs typed - `RPC_URL` - `RELAYER_BASE_PATH` if not running against `http://localhost:8080` + > The `ZAMA_PUBLIC_KEY` / `ZAMA_PRIVATE_KEY` values in `.env.example` are only used by the legacy example — `@zama-fhe/sdk` manages decrypt keypairs and sessions for you. + 3. Run the example from the repository root: ```bash @@ -41,73 +48,97 @@ The script reads the encrypted counter, decrypts it (public path first, falling ## Architecture -Four objects are wired together: +Four objects are wired together with `createConfig`: ``` ┌─────────────────────────┐ encrypts/decrypts ┌──────────────────┐ -│ RelayerNode │ ◄────────────────────► │ Zama gateway │ +│ node() transport │ ◄────────────────────► │ Zama gateway │ │ (FHE worker pool, WASM) │ └──────────────────┘ └────────────┬────────────┘ │ - ▼ composed by + ▼ composed by createConfig() ┌─────────────────────────┐ │ ZamaSDK │ high-level API: -│ - signer │ sdk.publicDecrypt(...) -│ - relayer │ sdk.userDecrypt(...) -│ - storage │ sdk.token(addr).balanceOf(...) -└────────────┬────────────┘ sdk.token(addr).transfer(...) - │ - ▼ delegates signTypedData / writeContract / ... +│ - signer (writes) │ sdk.encrypt(...) +│ - provider (reads) │ sdk.decryption.decryptPublicValues(...) +│ - storage │ sdk.decryption.decryptValues(...) +└──────┬───────────┬──────┘ sdk.createToken(addr).balanceOf(...) + │ │ + │ ▼ reads via viem PublicClient (RPC) + │ ┌──────────────────┐ + │ │ ViemProvider │ ──► RPC_URL + │ └──────────────────┘ + ▼ delegates signTypedData / writeContract ┌─────────────────────────┐ REST API ┌──────────────────┐ │ OpenZeppelinRelayer │ ◄────────────► │ OZ Relayer │ │ Signer (this dir) │ │ (your deployment) │ └─────────────────────────┘ └──────────────────┘ ``` -- **Encryption / decryption** stays inside the Zama SDK and never touches the OZ relayer. +- **Encryption / decryption** stays inside the Zama SDK (the `node()` transport) and never touches the OZ relayer. - **Signing and submission** flow through the adapter to the OZ relayer. -- **Read-only RPC calls** (chain id, block timestamps, contract reads, receipt confirmation) bypass the relayer entirely and go straight to `RPC_URL`. +- **Read-only RPC calls** (chain id, block timestamps, contract reads, receipt confirmation) go through `ViemProvider` straight to `RPC_URL`, bypassing the relayer entirely. + +## Composing the SDK + +```ts +import { MemoryStorage, ZamaSDK, createConfig } from '@zama-fhe/sdk'; +import { sepolia } from '@zama-fhe/sdk/chains'; +import { node } from '@zama-fhe/sdk/node'; +import { ViemProvider } from '@zama-fhe/sdk/viem'; + +const provider = new ViemProvider({ publicClient }); +const chainId = await provider.getChainId(); +const signer = await OpenZeppelinRelayerSigner.create({ relayersApi, relayerId, chainId }); + +const chain = { ...sepolia, network: rpcUrl }; +const sdk = new ZamaSDK( + createConfig({ + chains: [chain], + signer, + provider, + storage: new MemoryStorage(), + relayers: { [chain.id]: node() }, + }), +); +``` -## Mapping `GenericSigner` to OZ Relayer +## Mapping the interfaces to the OZ Relayer -| `GenericSigner` method | Backed by | -| ----------------------------- | ---------------------------------------------------------------------------------------------- | -| `getChainId()` | `publicClient.getChainId()` (RPC) | -| `getBlockTimestamp()` | `publicClient.getBlock().timestamp` (RPC) | -| `getAddress()` | `relayersApi.getRelayer(id).address` (cached) | -| `signTypedData(td)` | `viem.hashDomain(td)` + `viem.hashStruct(td)` → `relayersApi.signTypedData({ ... })` | -| `readContract(cfg)` | `publicClient.readContract(cfg)` (RPC) | -| `writeContract(cfg)` | `viem.encodeFunctionData(cfg)` → `relayersApi.sendTransaction(...)` → poll until hash assigned | -| `waitForTransactionReceipt()` | `publicClient.waitForTransactionReceipt({ hash })` (RPC) | -| `subscribe()` | omitted (server-side signer; the SDK guards this method as optional) | +| Interface method | Backed by | +| --------------------------------------------- | -------------------------------------------------------------------------------------- | +| `GenericProvider.getChainId()` | `publicClient.getChainId()` (RPC, via `ViemProvider`) | +| `GenericProvider.getBlockTimestamp()` | `publicClient.getBlock().timestamp` (RPC, via `ViemProvider`) | +| `GenericProvider.readContract(cfg)` | `publicClient.readContract(cfg)` (RPC, via `ViemProvider`) | +| `GenericProvider.waitForTransactionReceipt()` | `publicClient.waitForTransactionReceipt({ hash })` (RPC, via `ViemProvider`) | +| `GenericSigner.walletAccount` | seeded with `{ address, chainId }` at `OpenZeppelinRelayerSigner.create()` | +| `GenericSigner.signTypedData(td)` | `viem.hashDomain(td)` + `viem.hashStruct(td)` → `relayersApi.signTypedData({ ... })` | +| `GenericSigner.writeContract(cfg)` | `viem.encodeFunctionData(cfg)` → `relayersApi.sendTransaction(...)` → poll until mined | ## Notable implementation details -- **`writeContract` returns the on-chain hash, not the relayer's transaction id.** The OZ relayer initially returns a transaction id; the on-chain hash is populated only once the relayer broadcasts the transaction. The adapter polls `getTransactionById` until `tx.hash` is set (or a terminal status — `failed`, `canceled`, `expired` — is reached) before returning. This matches the `Promise` contract that `GenericSigner.writeContract` requires, so consumers can pass the result straight into `signer.waitForTransactionReceipt(...)`. +- **`writeContract` waits until the transaction is mined and returns the final on-chain hash.** The OZ relayer broadcasts asynchronously and may re-broadcast a fee-bumped replacement under a new hash. The adapter polls `getTransactionById` until the relayer reports `mined`/`confirmed` (or a terminal status — `failed`, `canceled`, `expired`) and returns the final hash. This means the hash handed back is one that `GenericProvider.waitForTransactionReceipt` can resolve directly against the RPC — no relayer-specific bookkeeping leaks into the read path. Polling is uncapped by default (fee-escalation cycles can outlast any default); pass `maxPollAttempts` to bound it, or wrap `writeContract` in `Promise.race` for a deadline at the call site. - **EIP-712 hashing happens locally.** The OZ relayer's `signTypedData` endpoint takes a pre-computed `(domain_separator, hash_struct_message)` pair, not the full typed data. The adapter strips the synthetic `EIP712Domain` entry from `types` and runs `hashDomain` / `hashStruct` from viem before posting to the relayer. -- **`value` is currently passed as a JS number.** The OZ relayer's `EvmTransactionRequest.value` schema is `number`. The adapter throws if a `bigint` value larger than `Number.MAX_SAFE_INTEGER` is passed in — for FHE confidential flows the value is virtually always `0`, so this is a non-issue in practice. -- **Storage is in-memory.** `MemoryStorage` is fine for examples but loses cached FHE artifacts and decrypt session signatures across restarts. For a long-running server, swap in a Redis-backed `GenericStorage` to avoid re-fetching the FHE public material and re-prompting the relayer for session signatures on every restart. -- **`subscribe` is intentionally omitted.** The Zama SDK only invokes it when defined (`if (this.signer.subscribe)`), so a server-side adapter can leave it out without breaking lifecycle handling. +- **The wallet account is seeded once.** `GenericSigner` no longer exposes `getAddress()`; instead the SDK reads a `walletAccount` snapshot (`{ address, chainId }`). Because the relayer resolves its address asynchronously, construct the signer through the static `OpenZeppelinRelayerSigner.create(...)` factory (which fetches the relayer address) rather than `new`. +- **No manual decrypt keypair.** Unlike the legacy example, the SDK generates and caches the decrypt keypair and user-decrypt session signatures in `storage` for you. `MemoryStorage` is fine for examples but loses those artifacts across restarts; for a long-running server, swap in a Redis-backed `GenericStorage` to avoid re-fetching FHE public material and re-prompting the relayer for session signatures on every restart. +- **`value` is passed as a JS number.** The OZ relayer's `EvmTransactionRequest.value` schema is `number`. The adapter throws if a `bigint` value larger than `Number.MAX_SAFE_INTEGER` is passed — for FHE confidential flows the value is virtually always `0`, so this is a non-issue in practice. +- **Tear down the worker pool.** `node()` spawns WASM worker threads that keep the process alive. Call `sdk.terminate()` when done (the example does this in a `finally` block). ## Using on Mainnet -The example targets Sepolia by default (`SepoliaConfig` + a Sepolia RPC URL). To run against Ethereum mainnet: +The example targets Sepolia by default (`sepolia` chain + a Sepolia RPC URL). To run against Ethereum mainnet: -1. In `counter.ts`, swap the import and config: +1. In `counter.ts`, swap the chain import and add API-key auth: ```ts - import { MainnetConfig, RelayerNode } from '@zama-fhe/sdk/node'; - - const relayer = new RelayerNode({ - transports: { - [chainId]: { - ...MainnetConfig, - network: rpcUrl, - auth: { __type: 'ApiKeyHeader', value: process.env.ZAMA_FHEVM_API_KEY! }, - }, - }, - getChainId: () => Promise.resolve(chainId), - }); + import { mainnet } from '@zama-fhe/sdk/chains'; + + const chain = { + ...mainnet, + network: rpcUrl, + auth: { __type: 'ApiKeyHeader', value: process.env.ZAMA_FHEVM_API_KEY! }, + }; + // relayers: { [chain.id]: node() } ``` 2. Add `ZAMA_FHEVM_API_KEY` and a mainnet `RPC_URL` to your `.env`. Mainnet decryption requires an API key with Zama's gateway — see the [mainnet API key guide](https://github.com/zama-ai/relayer-sdk/blob/main/docs/mainnet-api-key.md). @@ -116,26 +147,27 @@ The example targets Sepolia by default (`SepoliaConfig` + a Sepolia RPC URL). To ## Comparison with the legacy example -| Concern | `relayer-sdk/` (legacy) | `zama-sdk/` (this example) | -| ------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------- | -| Zama package | `@zama-fhe/relayer-sdk@0.4.x` | `@zama-fhe/sdk@3.x` | -| EIP-712 hashing | hand-rolled in the script with `ethers.TypedDataEncoder` | inside the adapter, reusable | -| Calling `relayersApi.signTypedData` | hand-rolled in the script | inside the adapter | -| Calling `relayersApi.sendTransaction` | hand-rolled in the script | inside the adapter | -| Confirmation polling | hand-rolled in the script | `signer.waitForTransactionReceipt(hash)` (RPC) | -| Deciding public vs. user decryption | manual fallback chain in the script | `sdk.publicDecrypt` / `sdk.userDecrypt` (cached sessions) | -| Reuse across other Zama integrations | none — every integration repeats the glue | one adapter, plugs into every Zama SDK API | +| Concern | `relayer-sdk/` (legacy) | `zama-sdk/` (this example) | +| ------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------- | +| Zama package | `@zama-fhe/relayer-sdk@0.4.x` | `@zama-fhe/sdk@3.x` | +| EIP-712 hashing | hand-rolled in the script with `ethers.TypedDataEncoder` | inside the adapter, reusable | +| Calling `relayersApi.signTypedData` | hand-rolled in the script | inside the adapter | +| Calling `relayersApi.sendTransaction` | hand-rolled in the script | inside the adapter | +| Confirmation polling | hand-rolled in the script | inside the adapter (`writeContract` returns a mined hash) | +| Decrypt keypair / sessions | generated and passed manually in the script | managed by the SDK in `storage` | +| Deciding public vs. user decryption | manual fallback chain in the script | `sdk.decryption.decryptPublicValues` / `decryptValues` (cached) | +| Reuse across other Zama integrations | none — every integration repeats the glue | one adapter, plugs into every Zama SDK API | ## Troubleshooting - `Missing required environment variable` — fill in the parent `.env`. - `Relayer "..." did not return an address` — the relayer id is valid but the API response had no `address` field; check the relayer's signer wiring. -- `Timed out waiting for relayer to assign a transaction hash` — only thrown when `maxSubmitPollAttempts` is set (otherwise the submit phase polls indefinitely). The relayer accepted the transaction but did not broadcast it within the cap; check relayer health, gas funding, and quotas. The confirmation phase inside `waitForTransactionReceipt` is _always_ uncapped — wrap with `Promise.race` if you need a deadline at the call site. +- `Timed out waiting for relayer to mine the transaction` — only thrown when `maxPollAttempts` is set (otherwise `writeContract` polls indefinitely). The relayer accepted the transaction but did not mine it within the cap; check relayer health, gas funding, and quotas. - `Public decryption failed` — expected when the handle is not publicly decryptable. The script then attempts user decryption. - `User decryption failed` — verify that the relayer can sign typed data, the contract is on the configured chain, and the SDK's `storage` is actually persisting decrypt session signatures across runs (use Redis-backed storage in production). ## References -- [Zama SDK docs](https://github.com/zama-ai/sdk) — main repository for `@zama-fhe/sdk`. +- [Zama Protocol SDK docs](https://docs.zama.org/protocol/sdk) — official documentation for `@zama-fhe/sdk`. - [Zama Relayer SDK guides](https://docs.zama.org/protocol/relayer-sdk-guides) — encryption and decryption flows. - [OpenZeppelin Relayer docs](https://docs.openzeppelin.com/relayer) — relayer setup and API reference. diff --git a/examples/relayers/zama/zama-sdk/counter.ts b/examples/relayers/zama/zama-sdk/counter.ts index 113d1e6..41ce6b2 100644 --- a/examples/relayers/zama/zama-sdk/counter.ts +++ b/examples/relayers/zama/zama-sdk/counter.ts @@ -1,11 +1,13 @@ /** * Zama FHE Counter Example — `@zama-fhe/sdk` integration * - * Demonstrates using the OpenZeppelin Relayer SDK with the new top-level Zama - * SDK (`@zama-fhe/sdk`, v3.x). The relayer is plugged in as a `GenericSigner` - * via `OpenZeppelinRelayerSigner`, so the application code only deals with the - * high-level `ZamaSDK` API. EIP-712 hashing, signature requests, transaction - * submission, and confirmation polling are all handled inside the adapter. + * Demonstrates using the OpenZeppelin Relayer SDK with Zama's top-level FHE SDK + * (`@zama-fhe/sdk`, v3.x). The relayer is plugged in as a `GenericSigner` via + * `OpenZeppelinRelayerSigner`, so application code only deals with the + * high-level `ZamaSDK` API. Reads go through the SDK's built-in `ViemProvider` + * (a viem `PublicClient`); signing and submission flow through the relayer. + * EIP-712 hashing, signature requests, transaction submission, and confirmation + * polling are all handled inside the adapter or the SDK. * * Counter contract is deployed with the template at * https://github.com/zama-ai/fhevm-hardhat-template @@ -15,10 +17,12 @@ */ import { config as loadEnv } from 'dotenv'; import { join } from 'node:path'; -import { type Abi, type Hex, bytesToHex, getAddress } from 'viem'; +import { type Abi, type Hex, createPublicClient, getAddress, http } from 'viem'; -import { MemoryStorage, ZamaSDK } from '@zama-fhe/sdk'; -import { RelayerNode, SepoliaConfig } from '@zama-fhe/sdk/node'; +import { MemoryStorage, ZamaSDK, createConfig } from '@zama-fhe/sdk'; +import { sepolia } from '@zama-fhe/sdk/chains'; +import { node } from '@zama-fhe/sdk/node'; +import { ViemProvider } from '@zama-fhe/sdk/viem'; import { Configuration, RelayersApi } from '../../../../src'; import counterAbi from './abi.json'; @@ -51,28 +55,32 @@ async function main() { // 1. OZ relayer client const relayersApi = new RelayersApi(new Configuration({ basePath, accessToken: apiKey })); - // 2. GenericSigner backed by OZ relayer - const signer = new OpenZeppelinRelayerSigner({ relayersApi, relayerId, rpcUrl }); - const [relayerAddress, chainId] = await Promise.all([signer.getAddress(), signer.getChainId()]); + // 2. GenericProvider — all read-only RPC (chain id, reads, receipts) via viem. + const publicClient = createPublicClient({ transport: http(rpcUrl) }); + const provider = new ViemProvider({ publicClient }); + const chainId = await provider.getChainId(); + + // 3. GenericSigner — signing + submission via the OZ relayer. + const signer = await OpenZeppelinRelayerSigner.create({ relayersApi, relayerId, chainId }); + const relayerAddress = signer.address; console.log(`🔑 Relayer address: ${relayerAddress}`); console.log(`⛓ Chain id: ${chainId}\n`); - // 3. Zama FHE backend (worker pool, no signer) - const relayer = new RelayerNode({ - transports: { [chainId]: { ...SepoliaConfig, network: rpcUrl } }, - getChainId: () => Promise.resolve(chainId), - }); - - // 4. Compose ZamaSDK — relayer + signer + storage - const sdk = new ZamaSDK({ - relayer, - signer, - storage: new MemoryStorage(), - }); + // 4. Compose ZamaSDK — chain, relayer transport, signer, provider, storage. + const chain = { ...sepolia, network: rpcUrl }; + const sdk = new ZamaSDK( + createConfig({ + chains: [chain], + signer, + provider, + storage: new MemoryStorage(), + relayers: { [chain.id]: node() }, + }), + ); try { const readCount = async (): Promise => { - const encrypted = await signer.readContract({ + const encrypted = await provider.readContract({ address: contractAddress, abi: typedCounterAbi, functionName: 'getCount', @@ -82,8 +90,10 @@ async function main() { }; const decrypt = async (handle: Hex): Promise => { + // Public decryption — no signature required. Works when the handle is + // marked publicly decryptable on-chain. try { - const pub = await sdk.publicDecrypt([handle]); + const pub = await sdk.decryption.decryptPublicValues([handle]); const value = pub.clearValues[handle]; if (typeof value === 'bigint') { console.log(`✅ Decrypted (public): ${value}`); @@ -92,8 +102,10 @@ async function main() { } catch (err) { console.warn('Public decryption failed:', err instanceof Error ? err.message : err); } + // User decryption — the SDK creates the EIP-712 request, has the relayer + // (our signer) authorize it, and caches the decrypt session in storage. try { - const result = await sdk.userDecrypt([{ handle, contractAddress }]); + const result = await sdk.decryption.decryptValues([{ encryptedValue: handle, contractAddress }]); const value = result[handle]; if (typeof value === 'bigint') { console.log(`✅ Decrypted (user): ${value}`); @@ -118,7 +130,7 @@ async function main() { console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('📊 STEP 2: increment counter'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - const encrypted = await relayer.encrypt({ + const { encryptedValues, inputProof } = await sdk.encrypt({ values: [{ value: 1n, type: 'euint32' }], contractAddress, userAddress: relayerAddress, @@ -127,10 +139,10 @@ async function main() { address: contractAddress, abi: typedCounterAbi, functionName: 'increment', - args: [bytesToHex(encrypted.handles[0]), bytesToHex(encrypted.inputProof)], + args: [encryptedValues[0], inputProof], }); console.log(`📝 Submitted tx: ${txHash}`); - await signer.waitForTransactionReceipt(txHash); + await provider.waitForTransactionReceipt(txHash); console.log('✅ Confirmed on chain'); // STEP 3: read & decrypt final count @@ -143,7 +155,8 @@ async function main() { console.log('\n✨ Script completed successfully'); } finally { - relayer.terminate(); + // Tear down the FHE worker pool so the process can exit. + sdk.terminate(); } } diff --git a/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts b/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts index 124ab88..f88590a 100644 --- a/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts +++ b/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts @@ -1,33 +1,38 @@ /** - * `OpenZeppelinRelayerSigner` — `GenericSigner` implementation that backs - * `@zama-fhe/sdk` with an OpenZeppelin Relayer for signing and submission. - * Read-only ops go through a viem `PublicClient`. + * `OpenZeppelinRelayerSigner` — a `GenericSigner` implementation that backs + * `@zama-fhe/sdk` with an OpenZeppelin Relayer for signing and transaction + * submission. + * + * In `@zama-fhe/sdk@3.x` the wallet contract is split in two: + * + * - `GenericSigner` — write authority: `signTypedData` (decrypt authorization) + * and `writeContract` (on-chain submission), plus an observable + * `walletAccount` store. This class implements it on top of the relayer. + * - `GenericProvider` — read-only RPC: chain id, contract reads, block + * timestamps, receipt waiting. The relayer is not involved in reads, so the + * example wires the SDK's built-in `ViemProvider` (a viem `PublicClient`) + * for that half. + * + * Extending `BaseSigner` provides the `walletAccount` store and + * `requireWalletAccount` boilerplate; this class only supplies `signTypedData` + * and `writeContract`. Because the relayer resolves its address asynchronously, + * construct instances through the static `create` factory rather than `new`. */ import { type Abi, type Address, type ContractFunctionArgs, type ContractFunctionName, - type ContractFunctionReturnType, type Hex, - type PublicClient, - type Transport, - createPublicClient, + type TypedDataParameter, encodeFunctionData, getAddress, hashDomain, hashStruct, - http, isHex, } from 'viem'; -import type { - EIP712TypedData, - GenericSigner, - ReadContractConfig, - TransactionReceipt, - WriteContractConfig, -} from '@zama-fhe/sdk'; +import { BaseSigner, type EIP712TypedData, type WriteContractConfig } from '@zama-fhe/sdk'; import { type EvmTransactionResponse, @@ -42,20 +47,15 @@ export interface OpenZeppelinRelayerSignerConfig { relayersApi: RelayersApi; /** The relayer id to use for signing and submission. */ relayerId: string; - /** RPC URL for read-only EVM operations (chain id, blocks, contract reads, receipt waiting). */ - rpcUrl: string; - /** How often (ms) to poll the relayer (used for both submit and confirmation phases). Default: 2000. */ + /** How often (ms) to poll the relayer while waiting for a transaction. Default: 2000. */ pollIntervalMs?: number; /** - * Maximum poll attempts while waiting for the relayer to broadcast and assign - * the initial on-chain hash. If omitted, polls indefinitely. - * - * Only applies to the submit phase inside `writeContract`. Confirmation - * inside `waitForTransactionReceipt` is always uncapped — fee-escalation - * cycles can outlast any default. Wrap with `Promise.race` if you need a + * Maximum poll attempts while waiting for the relayer to broadcast and mine a + * transaction. If omitted, polls indefinitely — fee-escalation cycles can + * outlast any default. Wrap `writeContract` in `Promise.race` if you need a * deadline at the call site. */ - maxSubmitPollAttempts?: number; + maxPollAttempts?: number; /** Speed setting passed to the relayer's `sendTransaction`. Default: `Speed.FAST`. */ speed?: Speed; /** Default gas limit if `WriteContractConfig.gas` is not set. Default: 500000. */ @@ -65,71 +65,57 @@ export interface OpenZeppelinRelayerSignerConfig { const DEFAULT_POLL_INTERVAL_MS = 2000; const DEFAULT_GAS_LIMIT = 500_000; -type OpenZeppelinPublicClient = PublicClient; -type ReadContractConfigParam = Parameters[0]; -type ReceiptLog = { topics: readonly Hex[]; data: Hex }; -const createOpenZeppelinPublicClient = createPublicClient as (config: { - transport: Transport; -}) => OpenZeppelinPublicClient; - /** Implements Zama's `GenericSigner` on top of an OpenZeppelin Relayer. */ -export class OpenZeppelinRelayerSigner implements GenericSigner { +export class OpenZeppelinRelayerSigner extends BaseSigner { readonly #relayersApi: RelayersApi; readonly #relayerId: string; readonly #pollIntervalMs: number; - readonly #maxSubmitPollAttempts?: number; + readonly #maxPollAttempts?: number; readonly #speed: Speed; readonly #defaultGasLimit: number; - readonly #publicClient: OpenZeppelinPublicClient; - #cachedAddress?: Address; - /** - * Maps an initial on-chain hash to its relayer transaction id, so - * `waitForTransactionReceipt` can follow fee-bump resubmissions: the relayer - * may broadcast under a new hash while the consumer still holds the original. - */ - readonly #txIdByInitialHash = new Map(); - constructor(config: OpenZeppelinRelayerSignerConfig) { + private constructor(config: OpenZeppelinRelayerSignerConfig, walletAccount: { address: Address; chainId: number }) { + super(walletAccount); this.#relayersApi = config.relayersApi; this.#relayerId = config.relayerId; this.#pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - this.#maxSubmitPollAttempts = config.maxSubmitPollAttempts; + this.#maxPollAttempts = config.maxPollAttempts; this.#speed = config.speed ?? Speed.FAST; this.#defaultGasLimit = config.defaultGasLimit ?? DEFAULT_GAS_LIMIT; - this.#publicClient = createOpenZeppelinPublicClient({ transport: http(config.rpcUrl) }); - } - - async getChainId(): Promise { - return this.#publicClient.getChainId(); } - async getBlockTimestamp(): Promise { - const block = await this.#publicClient.getBlock(); - return block.timestamp; - } - - async getAddress(): Promise
{ - if (this.#cachedAddress) { - return this.#cachedAddress; - } - const response = await this.#relayersApi.getRelayer(this.#relayerId); + /** + * Build a signer, resolving the relayer's on-chain address from the API and + * seeding the wallet-account store with `{ address, chainId }`. `chainId` is + * the chain the SDK operates on (fetch it once from the provider). + */ + static async create( + config: OpenZeppelinRelayerSignerConfig & { chainId: number }, + ): Promise { + const response = await config.relayersApi.getRelayer(config.relayerId); const raw = response.data.data?.address; if (!raw) { - throw new Error(`Relayer "${this.#relayerId}" did not return an address`); + throw new Error(`Relayer "${config.relayerId}" did not return an address`); } - this.#cachedAddress = getAddress(raw); - return this.#cachedAddress; + return new OpenZeppelinRelayerSigner(config, { address: getAddress(raw), chainId: config.chainId }); + } + + /** The relayer's on-chain address (from the seeded wallet account). */ + get address(): Address { + return this.requireWalletAccount('address').address; } async signTypedData(typedData: EIP712TypedData): Promise { const { domain, types, primaryType, message } = typedData; - // viem's hashDomain wants mutable arrays; clone readonly inputs. - const mutableTypes = Object.fromEntries(Object.entries(types).map(([key, fields]) => [key, [...fields]])); + // viem's hashDomain/hashStruct want mutable arrays; clone the readonly inputs. + const mutableTypes = Object.fromEntries( + Object.entries(types as Record).map(([key, fields]) => [key, [...fields]]), + ); const { EIP712Domain: _ignored, ...structTypes } = mutableTypes; const structTypeNames = Object.keys(structTypes); - // `RelayerNode.createEIP712` in @zama-fhe/sdk@3.0.0 omits primaryType. - // Only infer it when the payload has exactly one user-defined struct. + // Some createEIP712 payloads omit primaryType; infer it only when the + // payload has exactly one user-defined struct. const resolvedPrimaryType = primaryType ?? (structTypeNames.length === 1 ? structTypeNames[0] : undefined); if (!resolvedPrimaryType) { throw new Error( @@ -137,6 +123,8 @@ export class OpenZeppelinRelayerSigner implements GenericSigner { ); } + // The OZ relayer's signTypedData endpoint takes a pre-computed + // (domain_separator, hash_struct_message) pair, not the full typed data. const domainSeparator = hashDomain({ domain, types: mutableTypes }); const hashStructMessage = hashStruct({ data: message, @@ -159,18 +147,6 @@ export class OpenZeppelinRelayerSigner implements GenericSigner { return sig; } - async readContract< - const TAbi extends Abi | readonly unknown[], - TFunctionName extends ContractFunctionName, - const TArgs extends ContractFunctionArgs, - >( - config: ReadContractConfig, - ): Promise> { - return this.#publicClient.readContract(config as unknown as ReadContractConfigParam) as Promise< - ContractFunctionReturnType - >; - } - async writeContract< const TAbi extends Abi | readonly unknown[], TFunctionName extends ContractFunctionName, @@ -202,96 +178,44 @@ export class OpenZeppelinRelayerSigner implements GenericSigner { throw new Error('Relayer did not return a transaction id'); } - const initialHash = await this.#waitForRelayerHash(transactionId); - this.#txIdByInitialHash.set(initialHash, transactionId); - return initialHash; + // The relayer broadcasts asynchronously and may re-broadcast a fee-bumped + // replacement under a new hash. Poll until it mines, then return the final + // on-chain hash — one that the SDK's `GenericProvider.waitForTransactionReceipt` + // can resolve directly (no relayer-specific bookkeeping leaks into reads). + return this.#waitForMinedHash(transactionId); } - async waitForTransactionReceipt(hash: Hex): Promise { - const transactionId = this.#txIdByInitialHash.get(hash); - if (!transactionId) { - // Hashes the signer didn't issue (e.g. external code) fall through to RPC. - return this.#fetchReceipt(hash, { wait: true }); - } - try { - const finalHash = await this.#waitForRelayerCompletion(transactionId); - // Relayer already confirmed the tx, so a single receipt fetch is enough. - return this.#fetchReceipt(finalHash, { wait: false }); - } finally { - this.#txIdByInitialHash.delete(hash); - } - } + async #waitForMinedHash(transactionId: string): Promise { + const cap = this.#maxPollAttempts; + for (let attempt = 1; cap === undefined || attempt <= cap; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, this.#pollIntervalMs)); - async #fetchReceipt(hash: Hex, { wait }: { wait: boolean }): Promise { - const receipt = wait - ? await this.#publicClient.waitForTransactionReceipt({ hash }) - : await this.#publicClient.getTransactionReceipt({ hash }); - return { - logs: (receipt.logs as unknown as readonly ReceiptLog[]).map((log) => ({ - topics: log.topics, - data: log.data, - })), - }; - } + const status = await this.#relayersApi.getTransactionById(this.#relayerId, transactionId); + const tx = status.data.data as EvmTransactionResponse | undefined; + if (!tx) { + throw new Error(`Relayer transaction "${transactionId}" not found`); + } - /** - * Poll the relayer until the transaction reaches a terminal status. Returns - * the *current* on-chain hash from the relayer (which may differ from the - * initial hash after a fee-bump resubmission). - * - * Intentionally uncapped — fee escalation cycles can outlast any default. - * Callers needing a deadline should wrap with `Promise.race`. - */ - async #waitForRelayerCompletion(transactionId: string): Promise { - for (;;) { - const tx = await this.#pollTransaction(transactionId); if (tx.status === TransactionStatus.MINED || tx.status === TransactionStatus.CONFIRMED) { if (!tx.hash || !isHex(tx.hash)) { throw new Error(`Relayer reported ${tx.status} but did not return a valid on-chain hash`); } return tx.hash; } - throwIfTerminalFailure(tx); - } - } - /** - * Poll the relayer until it broadcasts the transaction and assigns an - * on-chain hash. Capped by `maxSubmitPollAttempts` if set, else uncapped. - */ - async #waitForRelayerHash(transactionId: string): Promise { - const cap = this.#maxSubmitPollAttempts; - for (let attempt = 1; cap === undefined || attempt <= cap; attempt += 1) { - const tx = await this.#pollTransaction(transactionId); - if (tx.hash && isHex(tx.hash)) { - return tx.hash; + if ( + tx.status === TransactionStatus.FAILED || + tx.status === TransactionStatus.CANCELED || + tx.status === TransactionStatus.EXPIRED + ) { + const reason = tx.status_reason ? ` Reason: ${tx.status_reason}` : ''; + throw new Error(`Relayer transaction ${tx.status}.${reason}`); } - throwIfTerminalFailure(tx); } + throw new Error( - `Timed out waiting for relayer to assign a transaction hash after ` + - `${(cap ?? 0) * this.#pollIntervalMs}ms (maxSubmitPollAttempts=${cap})`, + `Timed out waiting for relayer to mine the transaction after ` + + `${(cap ?? 0) * this.#pollIntervalMs}ms (maxPollAttempts=${cap})`, ); } - - async #pollTransaction(transactionId: string): Promise { - await new Promise((resolve) => setTimeout(resolve, this.#pollIntervalMs)); - const status = await this.#relayersApi.getTransactionById(this.#relayerId, transactionId); - const tx = status.data.data as EvmTransactionResponse | undefined; - if (!tx) { - throw new Error(`Relayer transaction "${transactionId}" not found`); - } - return tx; - } -} - -function throwIfTerminalFailure(tx: EvmTransactionResponse): void { - if ( - tx.status === TransactionStatus.FAILED || - tx.status === TransactionStatus.CANCELED || - tx.status === TransactionStatus.EXPIRED - ) { - const reason = tx.status_reason ? ` Reason: ${tx.status_reason}` : ''; - throw new Error(`Relayer transaction ${tx.status}.${reason}`); - } } diff --git a/package.json b/package.json index adda121..41b742e 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,8 @@ "@solana/kit": "^2.3.0", "@stellar/stellar-sdk": "^14.3.0", "@types/node": "^22.0.0", - "@zama-fhe/relayer-sdk": "0.4.2", - "@zama-fhe/sdk": "^3.0.0", + "@zama-fhe/relayer-sdk": "0.4.4", + "@zama-fhe/sdk": "^3.3.0", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "dotenv": "^17.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a482f60..569c0a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,11 +55,11 @@ importers: specifier: ^22.0.0 version: 22.18.6 '@zama-fhe/relayer-sdk': - specifier: 0.4.2 - version: 0.4.2 + specifier: 0.4.4 + version: 0.4.4 '@zama-fhe/sdk': - specifier: ^3.0.0 - version: 3.0.0(ethers@6.15.0)(viem@2.38.6(typescript@5.8.3)) + specifier: ^3.3.0 + version: 3.3.0(ethers@6.15.0)(viem@2.38.6(typescript@5.8.3)(zod@4.4.3)) commitizen: specifier: ^4.3.1 version: 4.3.1(@types/node@22.18.6)(typescript@5.8.3) @@ -113,7 +113,7 @@ importers: version: 7.15.0 viem: specifier: ^2.38.0 - version: 2.38.6(typescript@5.8.3) + version: 2.38.6(typescript@5.8.3)(zod@4.4.3) packages: @@ -907,13 +907,13 @@ packages: resolution: {integrity: sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==} engines: {node: '>=18.12.0'} - '@zama-fhe/relayer-sdk@0.4.2': - resolution: {integrity: sha512-3Q0tINKxz6YseaDxOrNP/648j5Wr2C4Utnjx5npZESjLAc20tWgWFE7BOfx0Kjhs8cx/ykU+tMZBq40+DsiUvA==} + '@zama-fhe/relayer-sdk@0.4.4': + resolution: {integrity: sha512-N+ateFbi7Fu9JsxExapfn/SdU3Bye3tvCfIaZQsRNXsZ7ep39S0BUnmljh+JhalUKT11xlMoA4+1TKLx72amcw==} engines: {node: '>=22'} hasBin: true - '@zama-fhe/sdk@3.0.0': - resolution: {integrity: sha512-43G5css2FWe6+Q4hRlU8DFQvIeC7HybjGY6dYVF3CEb7iv0KAruYGaf0Pf9rMTvY5i7IMGMVhgm1vAl9q1BsZQ==} + '@zama-fhe/sdk@3.3.0': + resolution: {integrity: sha512-+jiohE7JPwrqeuzS8Qim6q7nu7CDFTF/r9CIEujOm+8E/OpZug7QO/PmhK5D45s3bWT84+qecwe37hsu617Cgw==} engines: {node: '>=22'} peerDependencies: '@tanstack/query-core': '>=5' @@ -2900,6 +2900,9 @@ packages: resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} engines: {node: '>=12.20'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@adraffy/ens-normalize@1.10.1': {} @@ -4001,7 +4004,7 @@ snapshots: js-yaml: 3.14.1 tslib: 2.8.1 - '@zama-fhe/relayer-sdk@0.4.2': + '@zama-fhe/relayer-sdk@0.4.4': dependencies: commander: 14.0.0 ethers: 6.15.0 @@ -4016,12 +4019,13 @@ snapshots: - bufferutil - utf-8-validate - '@zama-fhe/sdk@3.0.0(ethers@6.15.0)(viem@2.38.6(typescript@5.8.3))': + '@zama-fhe/sdk@3.3.0(ethers@6.15.0)(viem@2.38.6(typescript@5.8.3)(zod@4.4.3))': dependencies: - '@zama-fhe/relayer-sdk': 0.4.2 + '@zama-fhe/relayer-sdk': 0.4.4 + zod: 4.4.3 optionalDependencies: ethers: 6.15.0 - viem: 2.38.6(typescript@5.8.3) + viem: 2.38.6(typescript@5.8.3)(zod@4.4.3) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -4035,9 +4039,10 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 - abitype@1.1.0(typescript@5.8.3): + abitype@1.1.0(typescript@5.8.3)(zod@4.4.3): optionalDependencies: typescript: 5.8.3 + zod: 4.4.3 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -5393,7 +5398,7 @@ snapshots: outdent@0.5.0: {} - ox@0.9.6(typescript@5.8.3): + ox@0.9.6(typescript@5.8.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -5401,7 +5406,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.0(typescript@5.8.3) + abitype: 1.1.0(typescript@5.8.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -5875,15 +5880,15 @@ snapshots: v8-compile-cache-lib@3.0.1: {} - viem@2.38.6(typescript@5.8.3): + viem@2.38.6(typescript@5.8.3)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.0(typescript@5.8.3) + abitype: 1.1.0(typescript@5.8.3)(zod@4.4.3) isows: 1.0.7(ws@8.18.3) - ox: 0.9.6(typescript@5.8.3) + ox: 0.9.6(typescript@5.8.3)(zod@4.4.3) ws: 8.18.3 optionalDependencies: typescript: 5.8.3 @@ -5976,3 +5981,5 @@ snapshots: yocto-queue@0.1.0: {} yocto-queue@1.2.1: {} + + zod@4.4.3: {} From 91385af2d155848d45c57821b195a9f5e1dd7fa4 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 14 Jul 2026 12:55:43 +0200 Subject: [PATCH 4/5] chore: fix ci pnpm install --- package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/package.json b/package.json index 41b742e..dd0a023 100644 --- a/package.json +++ b/package.json @@ -96,5 +96,14 @@ "commitizen": { "path": "cz-conventional-changelog" } + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@nestjs/core", + "@openapitools/openapi-generator-cli", + "@stellar/stellar-sdk", + "keccak", + "nx" + ] } } From 222c59a44ee7b8f77b010b850098a89290537a48 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 14 Jul 2026 13:06:19 +0200 Subject: [PATCH 5/5] chore: fix ci pnpm install --- .github/workflows/ci.yaml | 8 ++++++-- package.json | 9 --------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 964e71e..d8141a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,8 +43,11 @@ jobs: run: npm install -g pnpm # Install dependencies + # --ignore-scripts: skip dependency build/postinstall scripts. pnpm 10+ + # otherwise fails with ERR_PNPM_IGNORED_BUILDS on packages with build + # scripts (keccak, nx, etc.) unless they are explicitly approved. - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts # Run linting - name: Run lint @@ -86,8 +89,9 @@ jobs: run: npm install -g pnpm # Install dependencies (this will be fast if cache is hit) + # --ignore-scripts: see note in the lint-format job above. - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts # Build the project - name: Build diff --git a/package.json b/package.json index dd0a023..41b742e 100644 --- a/package.json +++ b/package.json @@ -96,14 +96,5 @@ "commitizen": { "path": "cz-conventional-changelog" } - }, - "pnpm": { - "onlyBuiltDependencies": [ - "@nestjs/core", - "@openapitools/openapi-generator-cli", - "@stellar/stellar-sdk", - "keccak", - "nx" - ] } }