2.0 RC docsView 1.x docs
Vesting 2.0 · Quickstart

Deploy, fund and claim a vesting

One Sepolia path: deploy a manager clone, approve it, open an encrypted schedule, claim it.

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.

Install

The 2.0 line ships on the next dist-tag and needs the Zama 3.6 peers. Node 22 or later. Amounts are bigint, so set compilerOptions.target to ES2020 or later for 1_000_000n literals.

terminal
bash
pnpm add @tokenops/sdk@next viem @zama-fhe/sdk@~3.6.0
# React hooks additionally need:
pnpm add wagmi react react-dom @tanstack/react-query @zama-fhe/react-sdk@~3.6.0

The whole path

The vesting factory is deployed on Sepolia only, so both variants run against Sepolia. The distribution token must be an ERC-7984 confidential token; the CTTT test token from the testnet faucet works.

@tokenops/sdk/fhe-vesting
ts
import { createPublicClient, createWalletClient, http, parseEventLogs, toHex } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { createSepoliaEncryptor } from "@tokenops/sdk/fhe";
import {
  createConfidentialVestingFactoryClient,
  createConfidentialVestingManagerClient,
  confidentialVestingManagerAbi,
  setOperator,
  FeeType,
} from "@tokenops/sdk/fhe-vesting";

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: sepolia, transport: http(process.env.RPC_URL) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(process.env.RPC_URL) });

// A ZamaSDK over the Sepolia preset. Call terminate() before the process exits.
const encryptor = await createSepoliaEncryptor({ rpcUrl: process.env.RPC_URL! });

// 1. Deploy a manager clone. The factory address resolves from DEPLOYED_ADDRESSES.
const token = process.env.ERC7984_TOKEN_ADDRESS as `0x${string}`;
const factory = createConfidentialVestingFactoryClient({ publicClient, walletClient });
const { manager: managerAddress } = await factory.createManagerAndGetAddress({
  token,
  userSalt: toHex(crypto.getRandomValues(new Uint8Array(32))), // fresh per deploy
});

// 2. Let the clone pull from your confidential balance. Waits for the receipt.
await setOperator({ publicClient, walletClient, token, spender: managerAddress });

// 3. Open a schedule. Pass plaintext; the SDK encrypts it.
const manager = createConfidentialVestingManagerClient({
  publicClient,
  walletClient,
  address: managerAddress,
  encryptor,
});
const recipientWalletClient = createWalletClient({
  account: privateKeyToAccount(process.env.RECIPIENT_PRIVATE_KEY as `0x${string}`),
  chain: sepolia,
  transport: http(process.env.RPC_URL),
});
const recipient = recipientWalletClient.account.address;
const now = Math.floor(Date.now() / 1000);
const params = {
  recipient,
  startTimestamp: now,
  endTimestamp: now + 365 * 86400,
  cliffSeconds: 90 * 86400,
  releaseIntervalSecs: 86400,
  timelockSeconds: 0,
  initialUnlockBps: 0,
  cliffAmountBps: 0,
  isRevocable: true,
};

// Optional: the same checks the write would fail on, collected as typed errors.
const report = await manager.preflightCreateVesting({ params, creator: account.address });
if (!report.ready) throw report.blockerErrors[0];

const hash = await manager.createVesting({ params, amount: 1_000_000n }); // 1 token at 6 decimals
const receipt = await publicClient.waitForTransactionReceipt({ hash });
const [created] = parseEventLogs({
  abi: confidentialVestingManagerAbi,
  eventName: "VestingCreated",
  logs: receipt.logs,
});
const vestingId = created!.args.vestingId;

// 4. The recipient claims from a client over their own wallet. Before the 90-day
// cliff this claims an encrypted zero; run it after the cliff, or set cliffSeconds: 0 for a demo.
const recipientManager = createConfidentialVestingManagerClient({
  publicClient,
  walletClient: recipientWalletClient,
  address: managerAddress,
});
// ClaimArgs is discriminated by the clone's fee model; read it, do not assume it.
const { feeType, fee } = await recipientManager.getFeeInfo();
await recipientManager.claim(
  feeType === FeeType.Gas ? { vestingId, feeType, value: fee } : { vestingId, feeType },
);

encryptor.terminate();

What to know before you ship

The operator approval comes first
createVesting pulls the amount from your confidential balance through the manager clone, so the clone must be an ERC-7984 operator on the token. Without it the write fails with OperatorNotApprovedError, and preflightCreateVesting reports the same error in blockerErrors.
An underfunded create does not revert
ERC-7984 transfers move an encrypted zero instead of reverting when the balance is short, so the schedule is created with a zero amount. The preflight cannot read your encrypted balance; confirm it covers the total first.
Safe and multisig signers
Pass waitForReceipt: false to createManager, createManagerAndGetAddress or splitVesting and the call returns on submission with pending: true and no manager / newVestingId. setOperator and useEnsureOperator take the same option.
Gas
Every write sends its estimate plus 25% (DEFAULT_GAS_HEADROOM_PERCENT). Set gasHeadroomPercent on the client or hook, or pass gas on an argument-object write to send a fixed limit.