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.0The 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.