2.0 RC docsView 1.x docs
Concept · Encryptors

One encryptor contract, three ways to build it

Every write that takes a plaintext amount encrypts it through an Encryptor: a ZamaSDK, a Node helper, or a local mock.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.

The Encryptor contract#

Encryptor mirrors ZamaSDK.encrypt from @zama-fhe/sdk3.6 exactly, and it is restated in the SDK rather than imported, so the optional Zama peer stays out of the SDK's types. One definition is re-exported by /fhe, /fhe-vesting, /fhe-airdrop and /fhe-disperse.

import type { Address, Hex } from "viem";
import type { FheValueInput } from "@tokenops/sdk/fhe";

interface Encryptor {
  encrypt(params: {
    readonly values: readonly FheValueInput[];
    contractAddress: Address;
    userAddress: Address;
  }): Promise<{
    readonly encryptedValues: readonly Hex[]; // one bytes32 handle per value, in order
    inputProof: Hex;
  }>;
}

// Eager instance, or a lazy factory called on every encryption.
type EncryptorSource = Encryptor | (() => Encryptor | undefined);

Handles and the proof are hex. The input proof is bound to the (contractAddress, userAddress) pair, and the on-chain verifier rejects it for any other contract or sender.

FheValueInput#

The value union narrows to upstream's EncryptInput. All fields are readonly.

typevalue
euint8 | euint16 | euint32 | euint64 | euint128 | euint256bigint
eboolboolean | 1n | 0n
eaddressAddress

Lazy sources and wallet switches#

Every client config and hook option takes an EncryptorSource. The per-call encryptor override on createVesting and the encryptUint64 helpers take an eager Encryptor; resolve a lazy source with resolveEncryptor and handle its undefined first. The lazy form is invoked per encryption, so a React context, Vue ref or signal can swap the instance without rebuilding the client. resolveEncryptor (on /fhe) normalizes either form.

import { resolveEncryptor } from "@tokenops/sdk/fhe";

resolveEncryptor(sdk);        // the instance itself
resolveEncryptor(() => sdk);  // calls the factory
resolveEncryptor(undefined);  // undefined

Pass the ZamaSDK as the encryptor#

A ZamaSDK instance satisfies Encryptor structurally, so pass it eagerly (encryptor: sdk) or lazily (encryptor: () => sdk). Its relayer property no longer has encrypt in 3.6, so () => sdk.relayer fails to compile.

CreateVestingButton.tsx
tsx
import { useZamaSDK } from "@zama-fhe/react-sdk";
import { useCreateVesting } from "@tokenops/sdk/fhe-vesting/react";

export function CreateVestingButton({ manager }: { manager: `0x${string}` }) {
  const sdk = useZamaSDK();
  // A ZamaSDK satisfies Encryptor with no cast. The lazy form always sees the live instance.
  const create = useCreateVesting({ address: manager, encryptor: () => sdk });
  // ...
}

A custom encryptor returns one hex bytes32 handle per input value, in input order, plus the hex proof. The SDK checks the handle count before using the result.

import { bytesToHex } from "viem";
import type { Encryptor } from "@tokenops/sdk/fhe";

// A backend that produces bytes converts them to hex before returning.
export const remoteEncryptor: Encryptor = {
  async encrypt({ values, contractAddress, userAddress }) {
    const { handles, proof } = await myProofService.encrypt({ values, contractAddress, userAddress });
    return { encryptedValues: handles.map((h) => bytesToHex(h)), inputProof: bytesToHex(proof) };
  },
};

createSepoliaEncryptor in Node#

createSepoliaEncryptor (@tokenops/sdk/fhe) builds a ZamaSDK over the node() relayer transport. Despite the name it serves Sepolia and mainnet. Wrap create and use in try / finally so the SDK is released on errors.

scripts/create-vesting.ts
ts
import { createSepoliaEncryptor } from "@tokenops/sdk/fhe";
import { createConfidentialVestingManagerClient } from "@tokenops/sdk/fhe-vesting";

const encryptor = await createSepoliaEncryptor({ rpcUrl: process.env.SEPOLIA_RPC_URL });
try {
  const manager = createConfidentialVestingManagerClient({
    publicClient,
    walletClient,
    address: managerAddress,
    encryptor,
  });
  await manager.createVesting({ params, amount: 1_000_000n });
} finally {
  encryptor.terminate();
}
OptionDefaultNotes
rpcUrlThe chain preset's public RPCHost-chain reads (the preset's network and the read provider). The default is shared and rate-limited; pass your own.
chainIdSEPOLIA_CHAIN_IDSEPOLIA_CHAIN_ID or MAINNET_CHAIN_ID. Anything else throws InvalidArgumentError.
relayerUrlhttps://relayer.testnet.zama.org (Sepolia), https://relayer.mainnet.zama.org (mainnet)Point it at your own proxy or a self-hosted relayer.
authunsetRelayerAuth, set as the chain's auth. Required against the Zama-hosted mainnet relayer; leave unset on Sepolia. See Relayer API key.
loggernoneAny subset of debug / info / warn / error; missing methods become no-ops.
onPhasenone"initializing", then "downloading-params" before the first encrypt, then "ready". No percentages: upstream exposes no progress API.
server/encryptor.ts
ts
import { createSepoliaEncryptor, MAINNET_CHAIN_ID } from "@tokenops/sdk/fhe";

// Server code only: the key is a billing credential.
const encryptor = await createSepoliaEncryptor({
  chainId: MAINNET_CHAIN_ID,
  rpcUrl: process.env.MAINNET_RPC_URL!,
  auth: { __type: "ApiKeyHeader", value: process.env.RELAYER_API_KEY! },
});

The result is an Encryptor plus instance (the ZamaSDK, typed unknown), chainId and terminate(). The Node helper builds its ZamaSDK without a signer, so user decryption through instance throws; build your own ZamaSDK with a signer to decrypt.

When the first encrypt fails#

  • The first encrypt does the real initialization: WASM load plus the FHE public key and params download. Build one encryptor at startup and reuse it.
  • If that initialization fails, the ZamaSDK keeps the failure and every retry fails the same way. Call terminate() and build a new encryptor. No timeout bounds this init.
  • onPhase does not fire "ready" after a failure, and the next attempt re-fires "downloading-params". Exceptions thrown by the callback are swallowed.

createMockEncryptor for a local chain#

createMockEncryptor wraps MockFhevmInstance against a local FHEVM-ready Anvil node. Its host addresses default to the hardhat chain preset of @zama-fhe/sdk/chains; override hostAddresses or coprocessorSignerPrivateKey only for a node deployed some other way. createLocalFhevmEncryptor is an alias.

pnpm add -D @fhevm/mock-utils @zama-fhe/relayer-sdk@0.4.1 @zama-fhe/sdk@~3.6.0 ethers
import { createMockEncryptor } from "@tokenops/sdk/fhe";

// Against a local FHEVM-ready Anvil node. Defaults: http://127.0.0.1:8545, chain 31337.
const encryptor = await createMockEncryptor({ rpcUrl: "http://127.0.0.1:8545" });

MockFhevmInstance is process-scoped: only the instance that produced a handle can decrypt it, so keep encrypt, submit and decrypt in one process. Mock and real-relayer ciphertexts are not interchangeable.

encryptUint64 and encryptUint64Batch#

The product subpaths (/fhe-vesting, /fhe-airdrop, /fhe-disperse) export two helpers for callers that pre-encrypt an amount and pass the ciphertext instead of a plaintext. Their outputs are the EncryptedInput and EncryptedInputs types from /fhe. Out-of-range values throw InvalidArgumentError without copying the amount into context.value. Both take an eager Encryptor, not an EncryptorSource. When you hold the lazy form, call resolveEncryptor(source) and handle undefined before passing the result.

import { encryptUint64, encryptUint64Batch } from "@tokenops/sdk/fhe-vesting";

// One value: { handle, inputProof }, bound to (contractAddress, userAddress).
const one = await encryptUint64({
  encryptor,
  contractAddress: managerAddress,
  userAddress: account.address,
  value: 1_000_000n,
});

// Many values under one proof: { handles, inputProof }, handles[i] matches values[i].
const many = await encryptUint64Batch({
  encryptor,
  contractAddress: managerAddress,
  userAddress: account.address,
  values: [100_000n, 200_000n],
});

One proof carries 32 euint64 values#

MAX_EUINT64_PER_INPUT_PROOF is 32: Zama packs at most 2048 bits into one input ciphertext. Every contract entry point that takes an array of encrypted amounts verifies them against a single inputProof, so the SDK cannot split them and refuses an oversized batch with InvalidArgumentError before encrypting.

  • batchCreateVesting takes at most 32 items.
  • disperse takes at most 32 recipients in "direct" mode and 30 in the wallet modes, whose two subtotals share the proof. preflightDisperse reports it as a blocker on recipients.
  • Every SDK encrypt, encryptUint64Batch included, checks the 2048-bit budget before calling the encryptor.

MissingPeerDependencyError#

createSepoliaEncryptor, createSepoliaEncryptorWeb and createMockEncryptor load their Zama peers at call time. When one is missing they reject with MissingPeerDependencyError (TOKENOPS_MISSING_PEER_DEPENDENCY), whose context carries method, packageName and installHint; the import failure is the cause. It is exported from the root and from /fhe.

import { createSepoliaEncryptor, MissingPeerDependencyError } from "@tokenops/sdk/fhe";

try {
  await createSepoliaEncryptor();
} catch (err) {
  if (err instanceof MissingPeerDependencyError) {
    console.error(`${err.context.packageName} is missing: ${err.context.installHint}`);
  }
  throw err;
}

See also