sdk/js-sdk/notes/rfc/RFC003.md
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:
This RFC describes the developer experience for @fhevm/sdk — a client-based SDK for building on FHEVM chains.
await client.init() or await client.ready ahead of time.createFhevmClient, createFhevmEncryptClient, createFhevmDecryptClient, createFhevmBaseClient) return a single object bound to a chain and provider. No global state beyond the one-time setFhevmRuntimeConfig().FhevmEncryptClient → TFHE (~5MB WASM + ~50MB public key). FhevmDecryptClient → TKMS (~600KB WASM). FhevmClient → both.@fhevm/sdk/ethers and @fhevm/sdk/viem provide framework-native adapters. The core is framework-agnostic.@fhevm/sdk/actions/base, /encrypt, /decrypt, /chain, /host for standalone function imports.The SDK targets both browser and Node.js environments.
@zama-fhe/relayer-sdkCurrent (@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 objects | mainnet / sepolia chain definitions with defineFhevmChain() |
@zama-fhe/relayer-sdk/web vs /node entry points | Framework subpaths: @fhevm/sdk/ethers, @fhevm/sdk/viem (both work in Node.js and browser) |
generateKeypair() returns raw key pair | generateE2eTransportKeypair() returns opaque E2eTransportKeypair |
createEIP712() + wallet sign + createSignedPermit() | signDecryptionPermit({ signer, e2eTransportKeypair, ... }) — all-in-one |
@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)
All adapters share the same two-step setup:
setFhevmRuntimeConfig({ numberOfThreads: 4, logger: myLogger });
const client = createFhevmClient({ chain, provider });
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):
import { createFhevmEncryptClient } from "@fhevm/sdk/viem";
const client = createFhevmEncryptClient({ chain: sepolia, provider });
// client.encrypt() ✓
// client.decrypt() ✗ (type error)
Decrypt-only client (skips TFHE WASM):
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:
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,
});
The ethers adapter follows the exact same factory pattern as viem.
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,
});
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
| Factory | Type | Encrypt | Decrypt | WASM cost |
|---|---|---|---|---|
createFhevmEncryptClient() | FhevmEncryptClient | Yes | — | ~5MB + 50MB key |
createFhevmDecryptClient() | FhevmDecryptClient | — | Yes | ~600KB |
createFhevmClient() | FhevmClient | Yes | Yes | ~5.6MB + 50MB key |
createFhevmBaseClient() | FhevmBaseClient | — | — | None |
Methods not on the client variant are type errors at compile time.
WASM modules are lazy-loaded on first use:
encrypt() call → loads TFHE WASM (~5MB) + fetches FHE encryption key (~50MB)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.
Base methods (all client types):
| Method | Sync/Async | Description |
|---|---|---|
publicDecrypt(params) | async | Decrypt publicly-decryptable encrypted values |
signDecryptionPermit(params) | async | Create and sign a decrypt permit in one step |
parseE2eTransportKeypair(params) | async | Restore a key pair from serialized bytes |
serializeE2eTransportKeypair(params) | sync | Serialize a key pair for storage |
fetchFheEncryptionKeyBytes(params?) | async | Fetch the network's FHE encryption key |
Encrypt methods (FhevmClient, FhevmEncryptClient):
| Method | Sync/Async | Description |
|---|---|---|
encrypt(params) | async | Encrypt values, get encrypted handles + input proof |
Decrypt methods (FhevmClient, FhevmDecryptClient):
| Method | Sync/Async | Description |
|---|---|---|
decrypt(params) | async | Decrypt with E2E transport key pair + signed permit |
generateE2eTransportKeypair() | async | Generate a new E2E transport key pair |
createUserDecryptEIP712(params) | async | Create EIP-712 typed data for decrypt permit (lower-level) |
createDelegatedUserDecryptEIP712(params) | async | Create EIP-712 for delegated decrypt (lower-level) |
publicDecrypt(params) | async | Decrypt publicly-decryptable encrypted values |
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().
A single encrypt() call is limited to 2048 bits total. The SDK validates this before any network call.
| Type | Bits | Type | Bits |
|---|---|---|---|
bool | 2 | uint128 | 128 |
uint8 | 8 | uint256 | 256 |
uint16 | 16 | address | 160 |
uint32 | 32 | ||
uint64 | 64 |
No signature or WASM required. For encrypted values marked as publicly decryptable via the ACL contract.
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.
Requires an E2eTransportKeypair and a signed decryption permit. The signDecryptionPermit() method handles EIP-712 construction and wallet signing in a single call:
// 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
Pass onBehalfOf to signDecryptionPermit():
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.
The E2eTransportKeypair is an opaque object — the TKMS private key is hidden behind #privateFields and never directly accessible.
| Method | Description |
|---|---|
generateE2eTransportKeypair() | Generate a fresh TKMS key pair |
parseE2eTransportKeypair({ ... }) | Restore a key from serialized bytes |
serializeE2eTransportKeypair({ e2eTransportKeypair }) | Serialize for storage/persistence |
e2eTransportKeypair.publicKey | Get the public key hex |
export type FhevmChain = {
readonly id: number;
readonly fhevm: {
readonly contracts: FhevmChainContracts; // acl, inputVerifier, kmsVerifier
readonly relayerUrl: string;
readonly gateway: FhevmGatewayChain; // decryption & inputVerification verifier addresses
};
};
| Chain | Import | Host Chain ID | Gateway Chain ID |
|---|---|---|---|
| Ethereum Mainnet | mainnet | 1 | 261131 |
| Sepolia | sepolia | 11155111 | 10901 |
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.
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:
import {
readFhevmExecutorContractData,
readInputVerifierContractData,
readKmsVerifierContractData,
resolveFhevmConfig,
} from "@fhevm/sdk/actions/host";
setFhevmRuntimeConfig() must be called once before creating any client. It configures global settings shared across all clients:
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
};
createFhevmClient() / createFhevmEncryptClient() / createFhevmDecryptClient()Every action exists as a standalone function with client as first argument. Decorators curry this into a method:
// 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/host — Fhevm (any client, no chain required)actions/base — Fhevm<FhevmChain> (needs chain)actions/encrypt — Fhevm<FhevmChain, WithEncrypt> (needs encrypt module)actions/decrypt — Fhevm<FhevmChain, WithDecrypt> (needs decrypt module)actions/chain — Fhevm<FhevmChain, FhevmRuntime, OptionalNativeClient> (chain only, provider optional)Errors extend ErrorBase with structured metadata. Error messages never include sensitive data.
ACLError — ACL permission deniedEncryptionError — Encryption operation failedFhevmConfigError — Invalid chain or runtime configurationFhevmHandleError — Malformed encrypted valueInputProofError — Input proof validation failedTFHEError — TFHE WASM module errorZkProofError — ZK proof generation failedSignersError — Signature verification failedRelayerAbortError / RelayerFetchError / RelayerMaxRetryError / RelayerTimeoutError — Relayer communication errorsPublicClient/ContractRunner is sealed into a TrustedClient using a symbol-keyed token. Core layer never sees the native client.CoreFhevmImpl constructor requires a PRIVATE_TOKEN. Only createCoreFhevm() can create instances.Object.freeze() on all chain definitions, runtime instances, and client internals.E2eTransportKeypair hides the TKMS private key behind #privateFields. Only SDK internals can extract raw key material.#field syntax prevents external access to client internals.@zama-fhe/relayer-sdkimport { 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();
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
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,
});
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.MockEncryptModule and MockDecryptModule — implementation TBD.