1. Spin up the local chain#
The mock encryptor doesn't care who runs the chain — it only needs a JSON-RPC URL with the FHEVM host contracts deployed. Two ways to get there, depending on where your tests live.
Contributing to the SDK monorepo#
The SDK repo carries the bring-up: pnpm fhevm:up clones zama-ai/forge-fhevm into .cache/, spawns Anvil, runs the FHEVM host-contract deployment script. After that, your tests hit http://127.0.0.1:8545 the same way any vitest suite hits a local node.
Testing your own project#
Outside the monorepo there is no pnpm fhevm:up — you bring the chain up yourself with whichever toolchain your contracts workspace already uses. Both routes land the host contracts at the deterministic Zama addresses on chain id 31337, which is exactly what createMockEncryptor() assumes by default.
# 1. Vendor Zama's forge-fhevm harness (the same one `pnpm fhevm:up` wraps)
git clone --depth 1 https://github.com/zama-ai/forge-fhevm.git .cache/forge-fhevm
# 2. Terminal A — plain Anvil on the forge-fhevm default chain id
anvil --chain-id 31337
# 3. Terminal B — deploy the FHEVM host contracts
# (ACL, KMSVerifier, InputVerifier, FHEVMExecutor, ...)
cd .cache/forge-fhevm
forge soldeer install # first run only
./deploy-local.sh --rpc-url http://127.0.0.1:8545
# 4. Deploy the contracts under test with your usual forge scripts,
# then run your suite against http://127.0.0.1:8545Writing Solidity-level tests insideHardhat instead of an app-level vitest suite? The FHEVM Hardhat plugin's hre.fhevm handles encrypt/decrypt in-process — you can skip the SDK fixture below entirely and come back to it for the client-facing suite.
If your bring-up differs — non-default port, another chain id, or host contracts deployed at custom addresses — every default is overridable:
import { createMockEncryptor } from "@tokenops/sdk/fhe";
// Defaults assume the deterministic Zama deployment on chain id 31337 —
// what forge-fhevm's deploy-local.sh and the FHEVM Hardhat plugin both
// produce. Brought the host contracts up some other way? Point the mock
// at your addresses instead:
const encryptor = await createMockEncryptor({
rpcUrl: "http://127.0.0.1:8546",
chainId: 31338,
hostAddresses: {
aclContractAddress: "0x...",
inputVerifierContractAddress: "0x...",
kmsContractAddress: "0x...",
verifyingContractAddressDecryption: "0x...",
verifyingContractAddressInputVerification: "0x...",
},
});2. Build a clients fixture#
The mock encryptor wraps the same Encryptor interface as the production flavours, drop-in compatible with every SDK client method.
// test/helpers/clients.ts
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { hardhat } from "viem/chains";
import { createMockEncryptor } from "@tokenops/sdk/fhe";
export const ANVIL_RPC = "http://127.0.0.1:8545";
export async function makeLocalClients(privateKey: `0x${string}`) {
const account = privateKeyToAccount(privateKey);
const publicClient = createPublicClient({ chain: hardhat, transport: http(ANVIL_RPC) });
const walletClient = createWalletClient({ account, chain: hardhat, transport: http(ANVIL_RPC) });
// Mock encryptor binds to the local fhevm-deployed coprocessor.
// No Zama relayer round-trip; deterministic per-test.
const encryptor = await createMockEncryptor({ rpcUrl: ANVIL_RPC });
return { publicClient, walletClient, encryptor };
}3. Write the test#
// test/fhe-vesting/createVesting.test.ts
import { describe, it, expect } from "vitest";
import { makeLocalClients } from "../helpers/clients";
import { parseEventLogs } from "viem";
import {
createConfidentialVestingManagerClient,
confidentialVestingManagerAbi,
} from "@tokenops/sdk/fhe-vesting";
describe("createVesting (mock encryptor)", () => {
it("opens a vesting and grants ACL to the recipient", async () => {
const { publicClient, walletClient, encryptor } = await makeLocalClients(process.env.LOCAL_PK as `0x${string}`);
const manager = createConfidentialVestingManagerClient({
publicClient,
walletClient,
address: process.env.LOCAL_MANAGER_ADDRESS as `0x${string}`,
encryptor,
});
const vestingHash = await manager.createVesting({
params: {
recipient: "0xRecipient...",
startTimestamp: Math.floor(Date.now() / 1000),
endTimestamp: Math.floor(Date.now() / 1000) + 3600,
cliffSeconds: 0,
releaseIntervalSecs: 60,
timelockSeconds: 0,
initialUnlockBps: 0,
cliffAmountBps: 0,
isRevocable: false,
},
amount: 1_000n,
});
// createVesting returns the tx hash; parse VestingCreated for the vestingId.
const receipt = await publicClient.waitForTransactionReceipt({ hash: vestingHash });
const [vestingEvent] = parseEventLogs({
abi: confidentialVestingManagerAbi,
eventName: "VestingCreated",
logs: receipt.logs,
});
const vestingId = vestingEvent.args.vestingId;
expect(vestingId).toMatch(/^0x[0-9a-f]{64}$/i);
});
});Read smokes vs write smokes#
Don't gate read-only chain tests on PRIVATE_KEY , they only need RPC_URL. Conflating the two silently skips the read smokes when a developer doesn't have a key set up. SDK's test helpers split this, see describeSepoliaFheVestingRead vs describeSepoliaFheVestingFull.