Recipe · Testing

Testing with the mock encryptor

The mock encryptor is the test flavour, deterministic, no relayer, no signature pop-ups. Pair with Anvil + forge-fhevm to run real on-chain FHE in CI.

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
ts
// 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
ts
// 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.

See also