@tokenops/sdk
v1.0.0
Published
Typed viem-first SDK for TokenOps FHEVM contracts (confidential vesting, confidential airdrops, confidential disperse) — consumed by tokenops-app (Next.js) and tokenops-api (Express).
Readme
@tokenops/sdk
Typed viem-first SDK for TokenOps FHEVM contracts — confidential vesting, confidential airdrops, and confidential disperse. Works with viem publicClient / walletClient directly; no framework required.
Deployed factories only. The SDK calls into pre-deployed factories / singletons; there are no deployFactory helpers. Currently deployed: @tokenops/sdk/fhe-vesting (factory live on Sepolia), @tokenops/sdk/fhe-airdrop (factory live on Sepolia), @tokenops/sdk/fhe-disperse (singleton live on mainnet + Sepolia).
Install
pnpm add @tokenops/sdk viem @zama-fhe/sdk@^3
# React hook subpaths additionally need:
pnpm add wagmi react react-dom @tanstack/react-query @zama-fhe/react-sdk@^3Node >= 22 is required (constraint from @zama-fhe/sdk).
Subpath layout
All subpaths are independently tree-shakeable. The /react paths keep React deps out of the server bundle.
| Subpath | Status | Description |
| ------------------------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------- |
| @tokenops/sdk | stable | Root re-exports (version, core error types) |
| @tokenops/sdk/telemetry | stable | Telemetry sink adapters (NoopTelemetry, ConsoleTelemetry, TokenOpsTelemetry) |
| @tokenops/sdk/fhe | stable | FHE utility helpers (ratio scaling, encrypted-input types, operator helpers) |
| @tokenops/sdk/fhe/react | stable | React hooks for shared FHE utilities |
| @tokenops/sdk/fhe-vesting | factory live on Sepolia | Confidential vesting (LibClone + packed immutable args) |
| @tokenops/sdk/fhe-vesting/react | stable | React/wagmi hooks for confidential vesting |
| @tokenops/sdk/fhe-vesting/advanced | stable | Pre-mine address prediction (predictManagerAddress) |
| @tokenops/sdk/fhe-vesting/advanced/react | stable | React hooks for advanced vesting flows |
| @tokenops/sdk/fhe-airdrop | factory live on Sepolia | ConfidentialAirdrop (EIP-712 gated confidential claims) |
| @tokenops/sdk/fhe-airdrop/react | stable | React/wagmi hooks for confidential airdrops |
| @tokenops/sdk/fhe-airdrop/advanced | stable | Pre-mine address prediction (predictAirdropAddress) |
| @tokenops/sdk/fhe-airdrop/advanced/react | stable | React hooks for advanced airdrop flows |
| @tokenops/sdk/fhe-disperse | singleton live on mainnet + Sepolia | DisperseConfidential (singleton + per-user wallet-pair clones) |
| @tokenops/sdk/fhe-disperse/react | stable | React/wagmi hooks for confidential disperse |
The canonical exports map is in package.json under "exports".
Quickstart — confidential vesting
import { createPublicClient, createWalletClient, http, parseEventLogs } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk/node";
import {
createConfidentialVestingFactoryClient,
createConfidentialVestingManagerClient,
confidentialVestingManagerAbi,
erc7984OperatorAbi,
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),
});
// Build the Zama encryptor for Sepolia.
// SepoliaConfig provides all required contract addresses and the public relayer URL.
// Replace process.env.RPC_URL with a private endpoint for production traffic.
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: process.env.RPC_URL! } },
getChainId: async () => sepolia.id,
});
const factory = createConfidentialVestingFactoryClient({ publicClient, walletClient });
// Deploy the clone and read its address from the ManagerCreated event.
// The factory packs the deployment block number into the clone's immutable
// args, so predict-then-deploy is unreliable on a live chain — use the
// receipt-parsing helper instead.
const token = process.env.ERC7984_TOKEN_ADDRESS as `0x${string}`;
const userSalt = "0x0000000000000000000000000000000000000000000000000000000000000001" as const;
const { manager: managerAddress } = await factory.createManagerAndGetAddress({
token,
userSalt,
});
// Authorise the manager clone to spend the funder's confidential tokens.
// ERC-7984's setOperator uses a uint48 unix deadline (not uint256) — passing the
// wrong type encodes silently. Use the exported ABI to avoid the footgun.
const FAR_FUTURE: number = 2_000_000_000; // ~year 2033, uint48 deadline
await walletClient.writeContract({
address: token,
abi: erc7984OperatorAbi,
functionName: "setOperator",
args: [managerAddress, FAR_FUTURE],
});
// Create a vesting schedule. Pass a plaintext bigint — the SDK encrypts it.
// amount is raw token units: 1_000_000n = 1 USDC at 6 decimals, 1e18 for 18-decimal tokens.
const manager = createConfidentialVestingManagerClient({
publicClient,
walletClient,
address: managerAddress,
encryptor,
});
const vestingHash = await manager.createVesting({
params: {
recipient: "0xRecipientAddress" as `0x${string}`,
startTimestamp: Math.floor(Date.now() / 1000),
endTimestamp: Math.floor(Date.now() / 1000) + 365 * 86400,
cliffSeconds: 90 * 86400,
releaseIntervalSecs: 86400,
timelockSeconds: 0,
initialUnlockBps: 0,
cliffAmountBps: 0,
isRevocable: true,
},
amount: 1_000_000n,
});
// Extract the vestingId from the VestingCreated event in the receipt.
// vestingId is a bytes32 (Hex) used in all subsequent calls: claim, split, disclose, etc.
const receipt = await publicClient.waitForTransactionReceipt({ hash: vestingHash });
const events = parseEventLogs({
abi: confidentialVestingManagerAbi,
eventName: "VestingCreated",
logs: receipt.logs,
});
const vestingId = events[0]!.args.vestingId; // `0x${string}` — save this for later calls
// Claim vested tokens (recipient calls this).
// `ClaimArgs` is discriminated by the clone's `feeType` (read once via
// `manager.feeType()`):
// - FeeType.Gas → include `value: <wei>` (per-claim gas fee).
// - FeeType.DistributionToken → omit `value` (token fee deducted on-chain).
await manager.claim({ vestingId, feeType: FeeType.DistributionToken });See Confidential Vesting on docs.tokenops.xyz for the full API reference.
Quickstart — confidential vesting (React hooks)
Uses @tokenops/sdk/fhe-vesting/react. Requires wagmi, @tanstack/react-query, and @zama-fhe/react-sdk as additional peer deps.
import { useQueryClient } from "@tanstack/react-query";
import { useZamaSDK } from "@zama-fhe/react-sdk";
import {
useCreateManagerAndGetAddress,
useCreateVesting,
useClaim,
useManagerFeeInfo,
FeeType,
type VestingParams,
} from "@tokenops/sdk/fhe-vesting/react";
// Wrap your app in wagmi's <WagmiProvider> + <QueryClientProvider> +
// @zama-fhe/react-sdk's <ZamaProvider> before rendering this component.
function VestingManager({ managerAddress }: { managerAddress: `0x${string}` }) {
const queryClient = useQueryClient();
const zamaSDK = useZamaSDK();
// Read the fee configuration once — needed to pass `value` on Gas-fee claims.
const { data: feeInfo } = useManagerFeeInfo({ address: managerAddress });
// Create a vesting schedule. Pass a plaintext bigint — the SDK encrypts it.
const create = useCreateVesting({
address: managerAddress,
// Lazy factory: the SDK calls this per-encryption, picking up the live
// ZamaSDK context at submit time rather than a stale mount-time capture.
// `useZamaSDK()` returns `ZamaSDK`; the encrypt-capable interface lives
// on `.relayer` (a `RelayerSDK`).
encryptor: () => zamaSDK.relayer,
});
// Claim vested tokens. For Gas-fee managers, attach `value = fee`.
const claim = useClaim({ address: managerAddress });
const params: VestingParams = {
recipient: "0xRecipient" as `0x${string}`,
startTimestamp: Math.floor(Date.now() / 1000),
endTimestamp: Math.floor(Date.now() / 1000) + 365 * 86400,
cliffSeconds: 0,
releaseIntervalSecs: 86400,
timelockSeconds: 0,
initialUnlockBps: 0,
cliffAmountBps: 0,
isRevocable: false,
};
return (
<div>
<button
onClick={() =>
create.mutate(
{ params, amount: 1_000_000n }, // 1 USDC at 6 decimals
{
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-vesting"] }),
},
)
}
disabled={create.isPending}
>
Create vesting
</button>
<button
onClick={() =>
claim.mutate({
vestingId: "0xYourVestingId" as `0x${string}`,
value: feeInfo?.feeType === FeeType.Gas ? feeInfo.fee : undefined,
})
}
disabled={claim.isPending}
>
Claim
</button>
</div>
);
}
// --- Deploy a manager clone first ---
function DeployManager({
token,
onDeployed,
}: {
token: `0x${string}`;
onDeployed: (manager: `0x${string}`) => void;
}) {
const queryClient = useQueryClient();
// Factory address resolves automatically from DEPLOYED_ADDRESSES on Sepolia/Mainnet.
const create = useCreateManagerAndGetAddress();
return (
<button
onClick={() =>
create.mutate(
{
token,
userSalt:
"0x0000000000000000000000000000000000000000000000000000000000000001" as `0x${string}`,
},
{
onSuccess: ({ manager }) => {
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-vesting"] });
onDeployed(manager);
},
},
)
}
disabled={create.isPending}
>
{create.isPending ? "Deploying…" : "Deploy manager"}
</button>
);
}See Confidential Vesting — React hooks on docs.tokenops.xyz for the full hook catalogue, encryptor wiring, address overrides, and query-key reference.
Quickstart — confidential airdrop
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk/node";
import {
createConfidentialAirdropFactoryClient,
createConfidentialAirdropClient,
encryptUint64,
signClaimAuthorization,
} from "@tokenops/sdk/fhe-airdrop";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const rpcUrl = process.env.RPC_URL!;
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});
// Factory address resolves automatically from DEPLOYED_ADDRESSES on Sepolia.
const factory = createConfidentialAirdropFactoryClient({
publicClient,
walletClient,
encryptor,
});
const now = Math.floor(Date.now() / 1000);
const userSalt = "0x0000000000000000000000000000000000000000000000000000000000000001" as const;
const token = process.env.TOKEN as `0x${string}`;
const params = {
token,
startTimestamp: now + 60,
endTimestamp: now + 30 * 86400,
canExtendClaimWindow: false,
admin: account.address,
};
// Deploy and read the clone address from the ConfidentialAirdropCreated event.
// (predictAirdropAddress also works — airdrop immutable args don't pack
// block.number, so the predicted address is stable across blocks.)
const { airdrop: airdropAddress } = await factory.createConfidentialAirdropAndGetAddress({
params,
userSalt,
});
// Admin: issue a signed claim authorization for a recipient. Bind the input
// proof to the RECIPIENT — Zama proofs commit to (contractAddress, userAddress)
// at encrypt time, and `FHE.fromExternal` on-chain rejects any other binding.
// amount is raw token units; ERC-7984 uses 6 decimals, so 1_000_000n = 1 token.
const recipient = "0xRecipientAddress" as `0x${string}`;
const encrypted = await encryptUint64({
encryptor,
contractAddress: airdropAddress,
userAddress: recipient,
value: 1_000_000n,
});
const signature = await signClaimAuthorization({
walletClient,
airdropAddress,
recipient,
encryptedAmountHandle: encrypted.handle,
});
// Deliver { encryptedInput: encrypted, signature } to the recipient via API / email / etc.
// User: claim tokens. The SDK submits the admin-issued pair verbatim — no
// re-encryption. (GAS_FEE() is fetched and attached as msg.value automatically.)
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: airdropAddress,
});
await airdrop.claim({ signature, encryptedInput: encrypted });See Confidential Airdrop on docs.tokenops.xyz for the full API reference and claim flow diagram.
Quickstart — confidential airdrop (React hooks)
Uses @tokenops/sdk/fhe-airdrop/react. Requires wagmi, @tanstack/react-query, and @zama-fhe/react-sdk as additional peer deps.
import { useQueryClient } from "@tanstack/react-query";
import { useZamaSDK } from "@zama-fhe/react-sdk";
import {
useCreateConfidentialAirdropAndGetAddress,
useSignClaimAuthorization,
useClaim,
encryptUint64,
} from "@tokenops/sdk/fhe-airdrop/react";
// Wrap your app in wagmi's <WagmiProvider> + <QueryClientProvider> +
// @zama-fhe/react-sdk's <ZamaProvider> before rendering these components.
// --- Admin: deploy a campaign and issue a claim authorization ---
function AdminPanel({
token,
recipient,
amount,
onCampaignReady,
}: {
token: `0x${string}`;
recipient: `0x${string}`;
amount: bigint;
onCampaignReady: (
airdrop: `0x${string}`,
payload: {
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
signature: `0x${string}`;
},
) => void;
}) {
const queryClient = useQueryClient();
const zamaSDK = useZamaSDK();
// Factory address resolves automatically from DEPLOYED_ADDRESSES on Sepolia.
const create = useCreateConfidentialAirdropAndGetAddress();
// walletClient is pulled from wagmi's useWalletClient() internally.
const sign = useSignClaimAuthorization();
const now = Math.floor(Date.now() / 1000);
async function handleLaunch() {
const { airdrop } = await create.mutateAsync({
params: {
token,
startTimestamp: now + 60,
endTimestamp: now + 30 * 86400,
canExtendClaimWindow: false,
admin: "0xYourAdminAddress" as `0x${string}`,
},
userSalt:
"0x0000000000000000000000000000000000000000000000000000000000000001" as `0x${string}`,
});
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-airdrop"] });
// Encrypt the allocation for the recipient.
const encrypted = await encryptUint64({
encryptor: zamaSDK.relayer,
contractAddress: airdrop,
userAddress: recipient,
value: amount,
});
// Sign the EIP-712 claim authorization.
const signature = await sign.mutateAsync({
airdropAddress: airdrop,
recipient,
encryptedAmountHandle: encrypted.handle,
});
// Deliver { encryptedInput: encrypted, signature } to the recipient via your API / email.
onCampaignReady(airdrop, { encryptedInput: encrypted, signature });
}
return (
<button onClick={handleLaunch} disabled={create.isPending || sign.isPending}>
{create.isPending ? "Deploying…" : sign.isPending ? "Signing…" : "Launch airdrop"}
</button>
);
}
// --- Recipient: claim tokens ---
function RecipientClaim({
airdropAddress,
claimPayload,
}: {
airdropAddress: `0x${string}`;
// The admin-issued payload — encryption bound to this recipient, handle
// committed to by the signature. The recipient submits it verbatim.
claimPayload: {
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
signature: `0x${string}`;
};
}) {
const queryClient = useQueryClient();
const claim = useClaim({ address: airdropAddress });
return (
<button
onClick={() =>
claim.mutate(claimPayload, {
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-airdrop"] }),
})
}
disabled={claim.isPending}
>
{claim.isPending ? "Claiming…" : "Claim tokens"}
</button>
);
}See Confidential Airdrop — React hooks on docs.tokenops.xyz for the full hook catalogue, encryptor wiring, address overrides, and query-key reference.
Quickstart — confidential disperse
Confidential bulk payouts: see Confidential Disperse on docs.tokenops.xyz for the full API reference.
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk/node";
import { createConfidentialDisperseClient } from "@tokenops/sdk/fhe-disperse";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const rpcUrl = process.env.RPC_URL!;
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});
// Singleton address resolves automatically from DEPLOYED_ADDRESSES on Sepolia and mainnet.
const client = createConfidentialDisperseClient({
publicClient,
walletClient,
encryptor,
});
const token = process.env.TOKEN as `0x${string}`;
// Register once to deploy your dedicated wallet pair.
const isRegistered = await client.isRegistered(account.address);
if (!isRegistered) {
await client.register({ token });
}
const recipients = ["0xRecipient1" as `0x${string}`, "0xRecipient2" as `0x${string}`];
const amounts = [1_000_000n, 500_000n]; // raw token units; ERC-7984 uses 6 decimals
// Preflight checks all five failure modes in one call.
const report = await client.preflightDisperse({
user: account.address,
token,
recipients,
amounts,
mode: "wallet",
});
if (!report.ready) {
// `report.blockerErrors` is `TokenOpsSdkError[]` — branch on `error.code`
// (e.g. `TOKENOPS_USER_NOT_REGISTERED`) for typed UI, or use `.message` for
// a quick string render. (The older `report.blockers: string[]` is still
// present for back-compat but will be removed in the next major.)
throw new Error(report.blockerErrors.map((e) => e.message).join("; "));
}
// Encrypt + disperse. The SDK encrypts amounts and subtotals, attaches the ETH gas fee.
const { hash } = await client.disperse({ token, mode: "wallet", recipients, amounts });
console.log("Disperse tx:", hash);Quickstart — confidential disperse (React hooks)
Uses @tokenops/sdk/fhe-disperse/react. Requires wagmi, @tanstack/react-query, and @zama-fhe/react-sdk as additional peer deps.
import { useQueryClient } from "@tanstack/react-query";
import { useZamaSDK } from "@zama-fhe/react-sdk";
import {
useIsRegistered,
useRegister,
usePreflightDisperse,
useDisperse,
} from "@tokenops/sdk/fhe-disperse/react";
// Wrap your app in wagmi's <WagmiProvider> + <QueryClientProvider> +
// @zama-fhe/react-sdk's <ZamaProvider> before rendering these components.
// Singleton address resolves automatically from DEPLOYED_ADDRESSES on Sepolia + mainnet.
// --- Step 1: register (one-time per user) ---
function RegisterButton({ token, user }: { token: `0x${string}`; user: `0x${string}` }) {
const queryClient = useQueryClient();
const { data: isRegistered } = useIsRegistered({ user });
const register = useRegister();
return (
<button
onClick={() =>
register.mutate(
{ token },
{
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-disperse"] }),
},
)
}
disabled={isRegistered === true || register.isPending}
>
{isRegistered ? "Registered" : register.isPending ? "Registering…" : "Register"}
</button>
);
}
// --- Step 2: preflight then disperse ---
function DispersePanel({
token,
recipients,
amounts,
user,
}: {
token: `0x${string}`;
recipients: `0x${string}`[];
amounts: bigint[];
user: `0x${string}`;
}) {
const queryClient = useQueryClient();
// Preflight: enabled when all args are present.
const { data: report } = usePreflightDisperse({
user,
token,
recipients,
amounts,
mode: "wallet",
});
// Lazy encryptor: SDK calls () => zamaSDK.relayer per-encryption so
// the live Zama React SDK context is always used (CLAUDE.md Pitfall #3).
const zamaSDK = useZamaSDK();
const disperse = useDisperse({
encryptor: () => zamaSDK.relayer,
});
return (
<div>
{report && !report.ready && (
<ul>
{report.blockerErrors.map((err) => (
// `err.code` is the stable machine-readable key (e.g.
// `TOKENOPS_USER_NOT_REGISTERED`); `err.message` is the prose.
<li key={err.code + err.message}>{err.message}</li>
))}
</ul>
)}
<button
onClick={() =>
disperse.mutate(
{ token, mode: "wallet", recipients, amounts },
{
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe-disperse"] }),
},
)
}
disabled={!report?.ready || disperse.isPending}
>
{disperse.isPending ? "Dispersing…" : "Disperse tokens"}
</button>
</div>
);
}See Confidential Disperse — React hooks on docs.tokenops.xyz for the full hook catalogue, encryptor wiring, address overrides, usePreflightDisperse gating pattern, useGetEncryptedFeeReserve encrypted-view flow, and query-key reference.
Peer dependencies
| Package | Range | Required for |
| ----------------------- | -------- | ------------------------------------------------------------------------------ |
| viem | ^2.47 | all subpaths (hard required) |
| @zama-fhe/sdk | ^3.0.0 | encryption + decryption + FHE write flows on every FHE subpath (optional peer) |
| @zama-fhe/react-sdk | ^3.0.0 | /react hook subpaths that submit encrypted inputs (optional peer) |
| react | >=18 | /react hook subpaths (optional peer) |
| wagmi | ^2 | /react hook subpaths (optional peer) |
| @tanstack/react-query | ^5 | /react hook subpaths (optional peer) |
All peers except viem are marked optional via peerDependenciesMeta so read-only / ABI-only consumers can install the package without pulling them in. Install @zama-fhe/sdk explicitly the first time you encrypt, decrypt, or submit an FHE write; install the React peers if you use any /react hook subpath.
Design
- Single package, subpath exports. No companion
@tokenops/react-sdk— hooks live at@tokenops/sdk/<product>/react. - Deployed factories only. No
deployFactoryhelpers. Addresses live insrc/core/addresses.ts. - Zama SDK v3 native. FHE modules import from
@zama-fhe/sdk/viemor/nodedirectly. The SDK does not re-wrap the Zama surface. - Encapsulated FHE complexity. Where a contract demands encrypted inputs, KMS proofs, scaled integers, and ACL grants, the SDK accepts natural plaintext inputs and handles the rest.
Docs
- Confidential Vesting (
@tokenops/sdk/fhe-vesting) - Confidential Airdrop (
@tokenops/sdk/fhe-airdrop) - Confidential Disperse (
@tokenops/sdk/fhe-disperse) - Local dev with
link:
Development
pnpm install
pnpm typecheck # tsc --noEmit
pnpm build # tsup → dist/ (esm + cjs + dts per subpath)
pnpm test # vitest run
pnpm lint # eslint + prettier --checkLicense
BSD-3-Clause-Clear. See LICENSE.
