1. Spin up the local chain#
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.
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 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 = createMockEncryptor({ publicClient, walletClient });
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 { createConfidentialVestingManagerClient } from "@tokenops/sdk/fhe-vesting";
describe("createVesting (mock encryptor)", () => {
it("opens a vesting and grants ACL to the recipient", async () => {
const { publicClient, walletClient, encryptor } = makeLocalClients(process.env.LOCAL_PK as `0x${string}`);
const manager = createConfidentialVestingManagerClient({
publicClient,
walletClient,
address: process.env.LOCAL_MANAGER_ADDRESS as `0x${string}`,
encryptor,
});
const { vestingId } = 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,
});
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.