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/examples/relayers/zama/README.md b/examples/relayers/zama/README.md index 8777cd0..e74853d 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`; reads use the built-in `ViemProvider`; 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). 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. -## 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 legacy `relayer-sdk/` example's 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`. The `zama-sdk/` example does not need these — `@zama-fhe/sdk` manages decrypt keypairs and sessions internally. -- `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..dff75d3 --- /dev/null +++ b/examples/relayers/zama/relayer-sdk/README.md @@ -0,0 +1,219 @@ +# 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. +- [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 + +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..d5e8d1b --- /dev/null +++ b/examples/relayers/zama/zama-sdk/README.md @@ -0,0 +1,173 @@ +# Zama FHE Example (`@zama-fhe/sdk` + `GenericSigner`) + +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`](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` splits the wallet contract in two framework-agnostic interfaces: + +- **`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 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 + +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` + + > 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 + 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 with `createConfig`: + +``` +┌─────────────────────────┐ encrypts/decrypts ┌──────────────────┐ +│ node() transport │ ◄────────────────────► │ Zama gateway │ +│ (FHE worker pool, WASM) │ └──────────────────┘ +└────────────┬────────────┘ + │ + ▼ composed by createConfig() +┌─────────────────────────┐ +│ ZamaSDK │ high-level API: +│ - 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 (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) 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 the interfaces to the OZ Relayer + +| 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` 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. +- **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 (`sepolia` chain + a Sepolia RPC URL). To run against Ethereum mainnet: + +1. In `counter.ts`, swap the chain import and add API-key auth: + + ```ts + 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). + +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 | 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 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 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/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..41ce6b2 --- /dev/null +++ b/examples/relayers/zama/zama-sdk/counter.ts @@ -0,0 +1,169 @@ +/** + * Zama FHE Counter Example — `@zama-fhe/sdk` integration + * + * 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 + * + * Usage: + * ts-node examples/relayers/zama/zama-sdk/counter.ts + */ +import { config as loadEnv } from 'dotenv'; +import { join } from 'node:path'; +import { type Abi, type Hex, createPublicClient, getAddress, http } from 'viem'; + +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'; +import { OpenZeppelinRelayerSigner } from './openzeppelin-relayer-signer'; + +const typedCounterAbi = counterAbi as Abi; + +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. 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`); + + // 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 provider.readContract({ + address: contractAddress, + abi: typedCounterAbi, + functionName: 'getCount', + args: [], + }); + return encrypted as Hex; + }; + + 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.decryption.decryptPublicValues([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); + } + // 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.decryption.decryptValues([{ encryptedValue: 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 { encryptedValues, inputProof } = await sdk.encrypt({ + values: [{ value: 1n, type: 'euint32' }], + contractAddress, + userAddress: relayerAddress, + }); + const txHash = await signer.writeContract({ + address: contractAddress, + abi: typedCounterAbi, + functionName: 'increment', + args: [encryptedValues[0], inputProof], + }); + console.log(`📝 Submitted tx: ${txHash}`); + await provider.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 { + // Tear down the FHE worker pool so the process can exit. + sdk.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..f88590a --- /dev/null +++ b/examples/relayers/zama/zama-sdk/openzeppelin-relayer-signer.ts @@ -0,0 +1,221 @@ +/** + * `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 Hex, + type TypedDataParameter, + encodeFunctionData, + getAddress, + hashDomain, + hashStruct, + isHex, +} from 'viem'; + +import { BaseSigner, type EIP712TypedData, type 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; + /** 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 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. + */ + 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. */ + defaultGasLimit?: number; +} + +const DEFAULT_POLL_INTERVAL_MS = 2000; +const DEFAULT_GAS_LIMIT = 500_000; + +/** Implements Zama's `GenericSigner` on top of an OpenZeppelin Relayer. */ +export class OpenZeppelinRelayerSigner extends BaseSigner { + readonly #relayersApi: RelayersApi; + readonly #relayerId: string; + readonly #pollIntervalMs: number; + readonly #maxPollAttempts?: number; + readonly #speed: Speed; + readonly #defaultGasLimit: number; + + 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.#maxPollAttempts = config.maxPollAttempts; + this.#speed = config.speed ?? Speed.FAST; + this.#defaultGasLimit = config.defaultGasLimit ?? DEFAULT_GAS_LIMIT; + } + + /** + * 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 "${config.relayerId}" did not return an address`); + } + 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/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); + // 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( + `signTypedData: typedData must include primaryType when it has ${structTypeNames.length} user-defined struct types`, + ); + } + + // 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, + 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 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'); + } + + // 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 #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)); + + 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`); + } + + 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; + } + + 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}`); + } + } + + throw new Error( + `Timed out waiting for relayer to mine the transaction after ` + + `${(cap ?? 0) * this.#pollIntervalMs}ms (maxPollAttempts=${cap})`, + ); + } +} diff --git a/package.json b/package.json index 4e30fc8..41b742e 100644 --- a/package.json +++ b/package.json @@ -62,7 +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/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 c1f57b1..569c0a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,8 +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.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) @@ -110,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: @@ -904,11 +907,26 @@ 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.3.0': + resolution: {integrity: sha512-+jiohE7JPwrqeuzS8Qim6q7nu7CDFTF/r9CIEujOm+8E/OpZug7QO/PmhK5D45s3bWT84+qecwe37hsu617Cgw==} + 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 @@ -2882,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': {} @@ -3983,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 @@ -3998,6 +4019,17 @@ snapshots: - bufferutil - utf-8-validate + '@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.4 + zod: 4.4.3 + optionalDependencies: + ethers: 6.15.0 + viem: 2.38.6(typescript@5.8.3)(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@zkochan/js-yaml@0.0.7': dependencies: argparse: 2.0.1 @@ -4007,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: @@ -5365,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 @@ -5373,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 @@ -5847,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 @@ -5948,3 +5981,5 @@ snapshots: yocto-queue@0.1.0: {} yocto-queue@1.2.1: {} + + zod@4.4.3: {}