Back to Fhevm

RFC003

sdk/js-sdk/notes/rfc/RFC003.md

0.13.0-020.7 KB
Original Source

Status: Implemented (Draft → Implementation complete) Authors: @Aurora Makovac @Alex B. Created: February 23, 2026 Updated: March 30, 2026 — updated to reflect current API surface and naming conventions

Reviewing notes:

  • Currently still in draft mode
  • Reviewed February 26, 2026
    • by @Alex B. → walletClient discussions, user Decrypt flow discussions
    • @Guillaume Hermet → feedback fine grained control needed, nice to haves what is specified here, but not necessary as they are doing a layer on top anyways, need for backwards compatibility
  • Adjusted for ethers to be more backwards compatible and addressed the walletClient questions
  • Waiting on @Clement Danjou review
  • March 24, 2026: Updated to reflect the actual implemented API surface
  • March 30, 2026: Updated naming (EncryptedValue, ClearValue, E2eTransportKeypair, signDecryptionPermit, etc.)

1. Summary

This RFC describes the developer experience for @fhevm/sdk — a client-based SDK for building on FHEVM chains.

  1. Explicit WASM control — Each module owns its WASM and lazy-loads it on first use. Developers who want to control when loading happens can call await client.init() or await client.ready ahead of time.
  2. Client as the unit — Factory functions (createFhevmClient, createFhevmEncryptClient, createFhevmDecryptClient, createFhevmBaseClient) return a single object bound to a chain and provider. No global state beyond the one-time setFhevmRuntimeConfig().
  3. Load only what you use — Client variants pull in only the WASM they need. FhevmEncryptClient → TFHE (~5MB WASM + ~50MB public key). FhevmDecryptClient → TKMS (~600KB WASM). FhevmClient → both.
  4. Framework subpaths@fhevm/sdk/ethers and @fhevm/sdk/viem provide framework-native adapters. The core is framework-agnostic.
  5. Action tier entry points@fhevm/sdk/actions/base, /encrypt, /decrypt, /chain, /host for standalone function imports.

The SDK targets both browser and Node.js environments.

1.1 What changes from @zama-fhe/relayer-sdk

Current (@zama-fhe/relayer-sdk)New (@fhevm/sdk)
initSDK() + createInstance(config)setFhevmRuntimeConfig() + createFhevmClient({ chain, provider }) — extensions lazy-load WASM
Builder pattern: createEncryptedInput().add32(42).encrypt()Declarative: encrypt({ values: [{ type: "uint32", value: 42 }], ... }) — public key auto-fetched
MainnetConfig / SepoliaConfig flat config objectsmainnet / sepolia chain definitions with defineFhevmChain()
@zama-fhe/relayer-sdk/web vs /node entry pointsFramework subpaths: @fhevm/sdk/ethers, @fhevm/sdk/viem (both work in Node.js and browser)
generateKeypair() returns raw key pairgenerateE2eTransportKeypair() returns opaque E2eTransportKeypair
createEIP712() + wallet sign + createSignedPermit()signDecryptionPermit({ signer, e2eTransportKeypair, ... }) — all-in-one

2. Package structure

@fhevm/sdk
├── @fhevm/sdk/ethers            ← Ethers.js v6 adapter: client factories, runtime config
├── @fhevm/sdk/viem              ← Viem adapter: client factories, runtime config
├── @fhevm/sdk/chains            ← FhevmChain definitions (framework-agnostic, no viem/ethers dep)
├── @fhevm/sdk/actions/base      ← Base actions (publicDecrypt, ACL checks, input proofs)
├── @fhevm/sdk/actions/encrypt   ← Encrypt actions (encrypt, generateZkProof)
├── @fhevm/sdk/actions/decrypt   ← Decrypt actions (decrypt, generateE2eTransportKeypair)
├── @fhevm/sdk/actions/chain     ← Chain utility actions (signDecryptionPermit, EIP-712, keypair ops)
├── @fhevm/sdk/actions/host      ← Host contract read actions (resolveFhevmConfig, contract data)

3. Creating a client

All adapters share the same two-step setup:

  1. Configure the runtime (once, before any client creation):
    ts
    setFhevmRuntimeConfig({ numberOfThreads: 4, logger: myLogger });
    
  2. Create a client (as many as needed, bound to a chain + provider):
    ts
    const client = createFhevmClient({ chain, provider });
    

3.1 With viem

ts
import { createPublicClient, createWalletClient, custom, http } from "viem";
import { sepolia as viemSepolia } from "viem/chains";
import {
  setFhevmRuntimeConfig,
  createFhevmClient,
} from "@fhevm/sdk/viem";
import { sepolia } from "@fhevm/sdk/chains";

// 1. Configure runtime (once)
setFhevmRuntimeConfig({ numberOfThreads: 4 });

// 2. Create viem provider
const provider = createPublicClient({
  chain: viemSepolia,
  transport: http(),
});

// 3. Create FHEVM client (full: encrypt + decrypt)
const client = createFhevmClient({ chain: sepolia, provider });

// Encrypt — FHE encryption key is auto-fetched and cached on first call
const encrypted = await client.encrypt({
  values: [{ type: "uint64", value: 1000n }],
  contractAddress: "0x...",
  userAddress: account.address,
});
// encrypted.externalEncryptedValues — array of ExternalEncryptedValue
// encrypted.inputProof — hex string to pass to contract

Encrypt-only client (skips TKMS WASM):

ts
import { createFhevmEncryptClient } from "@fhevm/sdk/viem";

const client = createFhevmEncryptClient({ chain: sepolia, provider });
// client.encrypt() ✓
// client.decrypt() ✗ (type error)

Decrypt-only client (skips TFHE WASM):

ts
import { createFhevmDecryptClient } from "@fhevm/sdk/viem";

const client = createFhevmDecryptClient({ chain: sepolia, provider });
// client.decrypt() ✓
// client.publicDecrypt() ✓
// client.encrypt() ✗ (type error)

Decryption uses signDecryptionPermit() which handles EIP-712 creation and signing in one step:

ts
const walletClient = createWalletClient({
  chain: viemSepolia,
  transport: custom(window.ethereum!),
  account,
});

const e2eTransportKeypair = await client.generateE2eTransportKeypair();

const signedPermit = await client.signDecryptionPermit({
  contractAddresses: ["0x..."],
  startTimestamp: Math.floor(Date.now() / 1000),
  durationDays: 365,
  signerAddress: account.address,
  signer: walletClient,
  e2eTransportKeypair,
});

const results = await client.decrypt({
  e2eTransportKeypair,
  encryptedValues: [
    { encryptedValue: "0x...", contractAddress: "0x..." },
  ],
  signedPermit,
});

3.2 With ethers.js

The ethers adapter follows the exact same factory pattern as viem.

ts
import { ethers } from "ethers";
import {
  setFhevmRuntimeConfig,
  createFhevmClient,
} from "@fhevm/sdk/ethers";
import { sepolia } from "@fhevm/sdk/chains";

// 1. Configure runtime
setFhevmRuntimeConfig({ numberOfThreads: 4 });

// 2. Create ethers provider
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();

// 3. Create FHEVM client
const client = createFhevmClient({ chain: sepolia, provider });

// Encrypt — FHE encryption key auto-fetched and cached on first call
const encrypted = await client.encrypt({
  values: [{ type: "uint64", value: 1000n }],
  contractAddress: "0x...",
  userAddress: await signer.getAddress(),
});

// Decrypt — permit creation and signing in one step
const e2eTransportKeypair = await client.generateE2eTransportKeypair();

const signedPermit = await client.signDecryptionPermit({
  contractAddresses: ["0xContractA"],
  startTimestamp: Math.floor(Date.now() / 1000),
  durationDays: 365,
  signerAddress: await signer.getAddress(),
  signer,
  e2eTransportKeypair,
});

const results = await client.decrypt({
  e2eTransportKeypair,
  encryptedValues: [
    { encryptedValue: "0x...", contractAddress: "0xContractA" },
  ],
  signedPermit,
});

4. Client types and composition

Clients are built internally by composing a base CoreFhevm with decorator actions:

createCoreFhevm() → base client (chain, runtime, trustedClient)
  ↓ .extend() chains decorators:
  ├─ baseActions       → publicDecrypt, signDecryptionPermit, parseE2eTransportKeypair,
  │                      serializeE2eTransportKeypair, fetchFheEncryptionKeyBytes
  ├─ decryptActions    → decrypt, generateE2eTransportKeypair, createUserDecryptEIP712,
  │                      createDelegatedUserDecryptEIP712, publicDecrypt
  └─ encryptActions    → encrypt

4.1 Client variants

FactoryTypeEncryptDecryptWASM cost
createFhevmEncryptClient()FhevmEncryptClientYes~5MB + 50MB key
createFhevmDecryptClient()FhevmDecryptClientYes~600KB
createFhevmClient()FhevmClientYesYes~5.6MB + 50MB key
createFhevmBaseClient()FhevmBaseClientNone

Methods not on the client variant are type errors at compile time.

4.2 WASM loading

WASM modules are lazy-loaded on first use:

  • First encrypt() call → loads TFHE WASM (~5MB) + fetches FHE encryption key (~50MB)
  • First decrypt() call → loads TKMS WASM (~600KB)

Optional eager loading via await client.init() or await client.ready — useful behind a loading spinner at app startup.

4.3 Methods by client type

Base methods (all client types):

MethodSync/AsyncDescription
publicDecrypt(params)asyncDecrypt publicly-decryptable encrypted values
signDecryptionPermit(params)asyncCreate and sign a decrypt permit in one step
parseE2eTransportKeypair(params)asyncRestore a key pair from serialized bytes
serializeE2eTransportKeypair(params)syncSerialize a key pair for storage
fetchFheEncryptionKeyBytes(params?)asyncFetch the network's FHE encryption key

Encrypt methods (FhevmClient, FhevmEncryptClient):

MethodSync/AsyncDescription
encrypt(params)asyncEncrypt values, get encrypted handles + input proof

Decrypt methods (FhevmClient, FhevmDecryptClient):

MethodSync/AsyncDescription
decrypt(params)asyncDecrypt with E2E transport key pair + signed permit
generateE2eTransportKeypair()asyncGenerate a new E2E transport key pair
createUserDecryptEIP712(params)asyncCreate EIP-712 typed data for decrypt permit (lower-level)
createDelegatedUserDecryptEIP712(params)asyncCreate EIP-712 for delegated decrypt (lower-level)
publicDecrypt(params)asyncDecrypt publicly-decryptable encrypted values

5. Encrypting

5.1 API

ts
const encrypted = await client.encrypt({
  values: [
    { type: "uint64", value: 1000n },
    { type: "bool", value: true },
    { type: "address", value: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" },
  ],
  contractAddress: "0x...",
  userAddress: "0x...",
});

// encrypted.externalEncryptedValues — array of ExternalEncryptedValue
// encrypted.inputProof — hex string to pass to contract

The FHE encryption key (~50MB) is automatically fetched and cached on first encrypt() call. To control when the download happens (e.g., behind a loading spinner), call await client.init() at app startup, or pre-fetch with await client.fetchFheEncryptionKeyBytes().

5.2 Capacity limit

A single encrypt() call is limited to 2048 bits total. The SDK validates this before any network call.

TypeBitsTypeBits
bool2uint128128
uint88uint256256
uint1616address160
uint3232
uint6464

6. Decrypting

6.1 Public decrypt

No signature or WASM required. For encrypted values marked as publicly decryptable via the ACL contract.

ts
const result = await client.publicDecrypt({
  encryptedValues: [encryptedValue1, encryptedValue2],
});

// result.orderedClearValues — array of ClearValue
// result.orderedClearValues[0].value — the decrypted plaintext
// result.orderedClearValues[0].fheType — "euint32", "ebool", etc.

Validates: at least one encrypted value, 2048-bit limit, all values on same chain, ACL permissions.

6.2 Private decrypt

Requires an E2eTransportKeypair and a signed decryption permit. The signDecryptionPermit() method handles EIP-712 construction and wallet signing in a single call:

ts
// 1. Generate an E2E transport key pair
const e2eTransportKeypair = await client.generateE2eTransportKeypair();

// 2. Create and sign a decrypt permit in one step
const signedPermit = await client.signDecryptionPermit({
  contractAddresses: ["0xContractA"],
  startTimestamp: Math.floor(Date.now() / 1000),
  durationDays: 365,
  signerAddress: account.address, // or await signer.getAddress() for ethers
  signer,                          // viem WalletClient or ethers Signer
  e2eTransportKeypair,
});

// 3. Decrypt
const results = await client.decrypt({
  e2eTransportKeypair,
  encryptedValues: [
    { encryptedValue: "0x...", contractAddress: "0xContractA" },
  ],
  signedPermit,
});

// results is ClearValue[] — typed based on FHE type
results[0].value;           // number, bigint, boolean, or string
results[0].fheType;         // "euint32", "ebool", "eaddress", etc.
results[0].encryptedValue;  // the original EncryptedValue

6.3 Delegated decrypt

Pass onBehalfOf to signDecryptionPermit():

ts
const signedPermit = await client.signDecryptionPermit({
  contractAddresses: ["0xContract..."],
  startTimestamp: Math.floor(Date.now() / 1000),
  durationDays: 1,
  signerAddress: await signer.getAddress(),
  signer,
  e2eTransportKeypair,
  onBehalfOf: "0xDataOwnerAddress...",
});

For lower-level control, createUserDecryptEIP712() and createDelegatedUserDecryptEIP712() construct the EIP-712 typed data without signing.

6.4 E2E transport key pair management

The E2eTransportKeypair is an opaque object — the TKMS private key is hidden behind #privateFields and never directly accessible.

MethodDescription
generateE2eTransportKeypair()Generate a fresh TKMS key pair
parseE2eTransportKeypair({ ... })Restore a key from serialized bytes
serializeE2eTransportKeypair({ e2eTransportKeypair })Serialize for storage/persistence
e2eTransportKeypair.publicKeyGet the public key hex

7. Chains

ts
export type FhevmChain = {
  readonly id: number;
  readonly fhevm: {
    readonly contracts: FhevmChainContracts; // acl, inputVerifier, kmsVerifier
    readonly relayerUrl: string;
    readonly gateway: FhevmGatewayChain;     // decryption & inputVerification verifier addresses
  };
};

7.1 Built-in chains

ChainImportHost Chain IDGateway Chain ID
Ethereum Mainnetmainnet1261131
Sepoliasepolia1115511110901

7.2 Custom chains

ts
import { defineFhevmChain } from "@fhevm/sdk/chains";

const myChain = defineFhevmChain({
  id: 8453,
  fhevm: {
    contracts: {
      acl: { address: "0x..." },
      inputVerifier: { address: "0x..." },
      kmsVerifier: { address: "0x..." },
    },
    relayerUrl: "https://relayer.mychain.example.com",
    gateway: {
      id: 99999,
      contracts: {
        decryption: { address: "0x..." },
        inputVerification: { address: "0x..." },
      },
    },
  },
});

defineFhevmChain() deep-freezes the chain object for immutability.

7.3 On-chain config fetching

Dynamic on-chain data (InputVerifier.eip712Domain(), KMSVerifier.getKmsSigners(), etc.) is fetched lazily on first use and cached for the client's lifetime. No upfront RPC calls at client creation.

Host contract reading actions are available as standalone functions:

ts
import {
  readFhevmExecutorContractData,
  readInputVerifierContractData,
  readKmsVerifierContractData,
  resolveFhevmConfig,
} from "@fhevm/sdk/actions/host";

8. Runtime configuration

setFhevmRuntimeConfig() must be called once before creating any client. It configures global settings shared across all clients:

ts
type FhevmRuntimeConfig = {
  readonly locateFile?: (file: string) => URL;  // Custom WASM file location
  readonly logger?: Logger;                      // { debug, error } callbacks
  readonly singleThread?: boolean;               // Disable multi-threading
  readonly numberOfThreads?: number;             // Worker pool size
};
  • Idempotent: calling with identical params is safe
  • Throws if called again with different params
  • Must be called before createFhevmClient() / createFhevmEncryptClient() / createFhevmDecryptClient()

9. Action function pattern

Every action exists as a standalone function with client as first argument. Decorators curry this into a method:

ts
// Standalone function (tree-shakeable)
import { encrypt } from "@fhevm/sdk/actions/encrypt";
const result = await encrypt(fhevmClient, { ... });

// Client method (added by decorator)
const result = await fhevmClient.encrypt({ ... });

Each action file exports three things: the function, its Parameters type, and its ReturnType type.

Action tiers determine what Fhevm<> type constraint the first argument must satisfy:

  • actions/hostFhevm (any client, no chain required)
  • actions/baseFhevm<FhevmChain> (needs chain)
  • actions/encryptFhevm<FhevmChain, WithEncrypt> (needs encrypt module)
  • actions/decryptFhevm<FhevmChain, WithDecrypt> (needs decrypt module)
  • actions/chainFhevm<FhevmChain, FhevmRuntime, OptionalNativeClient> (chain only, provider optional)

10. Error handling

Errors extend ErrorBase with structured metadata. Error messages never include sensitive data.

  • ACLError — ACL permission denied
  • EncryptionError — Encryption operation failed
  • FhevmConfigError — Invalid chain or runtime configuration
  • FhevmHandleError — Malformed encrypted value
  • InputProofError — Input proof validation failed
  • TFHEError — TFHE WASM module error
  • ZkProofError — ZK proof generation failed
  • SignersError — Signature verification failed
  • RelayerAbortError / RelayerFetchError / RelayerMaxRetryError / RelayerTimeoutError — Relayer communication errors

11. Security patterns

  1. Opaque TrustedClient sealing — viem/ethers PublicClient/ContractRunner is sealed into a TrustedClient using a symbol-keyed token. Core layer never sees the native client.
  2. Symbol-based access controlCoreFhevmImpl constructor requires a PRIVATE_TOKEN. Only createCoreFhevm() can create instances.
  3. Frozen objectsObject.freeze() on all chain definitions, runtime instances, and client internals.
  4. Opaque E2E transport key pairE2eTransportKeypair hides the TKMS private key behind #privateFields. Only SDK internals can extract raw key material.
  5. ES2015 private fields#field syntax prevents external access to client internals.

12. Migration from @zama-fhe/relayer-sdk

12.1 Before (old SDK)

ts
import { initSDK, createInstance, SepoliaConfig } from "@zama-fhe/relayer-sdk/web";

await initSDK();
const instance = await createInstance({ ...SepoliaConfig, network: "https://sepolia-rpc.com" });

const encrypted = instance.createEncryptedInput(contractAddr, userAddr);
encrypted.add32(42);
encrypted.add8(100);
const { handles, inputProof } = await encrypted.encrypt();

12.2 After (with ethers.js)

ts
import { setFhevmRuntimeConfig, createFhevmClient } from "@fhevm/sdk/ethers";
import { sepolia } from "@fhevm/sdk/chains";

setFhevmRuntimeConfig({});
const client = createFhevmClient({ chain: sepolia, provider });

const encrypted = await client.encrypt({
  values: [{ type: "uint32", value: 42 }, { type: "uint8", value: 100 }],
  contractAddress: contractAddr,
  userAddress: await signer.getAddress(),
});
// encrypted.externalEncryptedValues — array of ExternalEncryptedValue
// encrypted.inputProof — hex string

12.3 After (with viem)

ts
import { createPublicClient, http } from "viem";
import { sepolia as viemSepolia } from "viem/chains";
import { setFhevmRuntimeConfig, createFhevmClient } from "@fhevm/sdk/viem";
import { sepolia } from "@fhevm/sdk/chains";

setFhevmRuntimeConfig({});
const provider = createPublicClient({ chain: viemSepolia, transport: http() });
const client = createFhevmClient({ chain: sepolia, provider });

const encrypted = await client.encrypt({
  values: [
    { type: "uint32", value: 42 },
    { type: "uint8", value: 100 },
  ],
  contractAddress: contractAddr,
  userAddress: account.address,
});

13. Open questions

  1. requestZKProofVerification equivalent: The lower-level generateZkProof() + fetchVerifiedInputProof() flow is available via @fhevm/sdk/actions/encrypt and @fhevm/sdk/actions/base respectively. The high-level encrypt() wraps both.
  2. Mock mode: Local development against Hardhat/Foundry without WASM is not yet implemented. Planned as MockEncryptModule and MockDecryptModule — implementation TBD.