Recipe · Setup · 5 min

Your first encrypted tx

Three steps: get a wallet client, get an encryptor, open a vesting. Every value is real Sepolia — paste a token, run.

1. Viem clients#

Anything you build with viem already has these, bring your own. Otherwise: stand them up like this. Sepolia for free; mainnet only for /fhe-disperse today.

lib/viem.ts
ts
import { createPublicClient, createWalletClient, custom, http } from "viem";
import { sepolia } from "viem/chains";

export const publicClient = createPublicClient({
  chain: sepolia,
  transport: http(),
});

export const walletClient = createWalletClient({
  chain: sepolia,
  transport: custom(window.ethereum),
});

2. Encryptor#

The browser encryptor wraps Zama's RelayerWeb. The user signs an authorization once; subsequent encryptions reuse that signature for the relayer.

lib/encryptor.ts
ts
import { createSepoliaEncryptorWeb } from "@tokenops/sdk/fhe";
import { publicClient, walletClient } from "./viem";

// Browser-flavour encryptor wraps Zama RelayerWeb. Resolves the user's
// wallet to sign the per-encrypt authorization.
export const encryptor = await createSepoliaEncryptorWeb({
  publicClient,
  walletClient,
});

3. Deploy + vest#

Two SDK calls, one tx each. The amount is encrypted client-side; the contract stores a 32-byte handle and grants ACL to the operator + the recipient atomically.

components/firstVesting.ts
ts
import { parseEventLogs } from "viem";
import {
  createConfidentialVestingFactoryClient,
  createConfidentialVestingManagerClient,
  confidentialVestingManagerAbi,
} from "@tokenops/sdk/fhe-vesting";

// Pre-deployed Sepolia factory, DEPLOYED_ADDRESSES resolves it by chainId.
const factory = createConfidentialVestingFactoryClient({
  publicClient,
  walletClient,
});

// 1. Deploy a manager clone (one tx).
const { hash: deployHash, manager: managerAddress } =
  await factory.createManagerAndGetAddress({
    token: "0xYOUR_ERC7984_TOKEN_ON_SEPOLIA",
    userSalt: "0x".padEnd(66, "0").slice(0, 65) + "a", // any unique 32-byte salt
  });

// 2. Mount a per-clone manager client.
const manager = createConfidentialVestingManagerClient({
  publicClient,
  walletClient,
  address: managerAddress,
  encryptor,
});

// 3. Create a vesting, the amount is encrypted client-side before submit.
const vestingHash = await manager.createVesting({
  params: {
    recipient: "0xRECIPIENT_ADDRESS",
    startTimestamp: Math.floor(Date.now() / 1000),
    endTimestamp: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
    cliffSeconds: 0,
    releaseIntervalSecs: 86_400,  // unlock per-day
    timelockSeconds: 0,
    initialUnlockBps: 0,
    cliffAmountBps: 0,
    isRevocable: false,
  },
  amount: 100_000n,                // bigint, encrypted to euint64 before submit
});

// createVesting returns the tx hash only; 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;

console.log({ deployHash, managerAddress, vestingHash, vestingId });

Run it live#

Connect your wallet (top-right pill) on Sepolia, edit the token field if you have your own ERC-7984 deployment, and click Run on Sepolia. The same Monaco editor + runner that powers the stories, the tx broadcasts for real.

Interactive · live Sepolia tx
Loading editor…
Edit any input above, then press to run.
Console0 lines
No output yet. Run the snippet to see logs stream in.

What you have now#

deployHash + vestingHash on Etherscan, a vestingId you can pass to useGetVestedAmount / useClaim, and a manager clone you can keep opening vestings inside without re-deploying. The recipient can now call useDecryptedHandle against the vesting's encrypted view handles to see their balance.

Optional: 2–3× faster encryption#

Want 2–3× faster encryption? Set threads: 4–8 on Zama's RelayerWeb — this requires cross-origin isolation, because multi-threaded WASM runs on SharedArrayBuffer, which browsers only enable on cross-origin-isolated pages.

createSepoliaEncryptorWeb keeps its config minimal and stays single-threaded — it exposes no threads option. To opt in, wire Zama's RelayerWeb (which takes threads) as the encryptor instead — the provider setup shown in the Next.js App Router and Vite SPA recipes — and serve your app with the headers below.

next.config.mjs
js
// next.config.mjs — cross-origin isolation for threads > 1
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
          { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
        ],
      },
    ];
  },
};

export default nextConfig;

Two things to know before flipping the switch. Cross-origin isolation is page-wide: Cross-Origin-Embedder-Policy: require-corp blocks cross-origin iframes, scripts, and images that do not send CORP or CORS headers, so audit your embeds first. And the setting fails soft: with threads > 1but no isolation, Zama's SDK logs a warning and encryption keeps working single-threaded — nothing breaks, it is just not faster. React Native is a separate story (no browser worker at all): see the React Native recipe.

See also