A TokenOps contract that moves your confidential tokens calls confidentialTransferFrom on the ERC-7984 token, which reverts ERC7984UnauthorizedSpender unless you, the holder, made that contract an operator with setOperator(operator, until). The SDK maps that revert to OperatorNotApprovedError, whose message names the holder, the spender and the call to make.
| Product | Spender to approve | Flows that pull |
|---|---|---|
| /fhe-vesting | The manager clone | createVesting, batchCreateVesting |
| /fhe-airdrop | The airdrop factory | createAndFundEcdsaAirdrop, createAndFundMerkleAirdrop, fundAirdrop |
| /fhe-disperse | The disperse singleton | Every disperse mode |
ensureOperator: check, then set only if missing#
ensureOperator reads isOperator first and sends setOperator only when the grant is missing, so the no-op path costs one eth_call. Its deadline is required.
import { ensureOperator } from "@tokenops/sdk/fhe";
const oneHour = BigInt(Math.floor(Date.now() / 1000) + 3600);
const { alreadyOperator, hash } = await ensureOperator({
publicClient,
walletClient,
token,
spender: manager, // the vesting manager, the airdrop factory, or the disperse singleton
deadline: oneHour, // required: the SDK will not default an approval window
});
// hash is null when the grant was already in place and nothing was sent.setOperator, revokeOperator and isOperator#
import { isOperator, revokeOperator, setOperator } from "@tokenops/sdk/fhe";
await setOperator({ publicClient, walletClient, token, spender: manager }); // deadline: max uint48
const authorized = await isOperator({ publicClient, token, holder: account.address, spender: manager });
// Replace a stale manager clone? Revoke the old one: setOperator(spender, 0) on-chain.
await revokeOperator({ publicClient, walletClient, token, spender: staleManager });| Function | Notes |
|---|---|
setOperator | deadline defaults to ERC7984_OPERATOR_MAX_DEADLINE. Waits for the receipt unless waitForReceipt: false. Resolves the transaction hash. |
revokeOperator | setOperator(spender, 0): until = 0 is the ERC-7984 revoke convention. No deadline argument. |
isOperator | Read-only, no wallet. The on-chain check is until >= block.timestamp, so an expired grant reads false without any revoke. |
ensureOperator | Resolves { alreadyOperator, hash }; hash is null on the no-op path. deadline must be in (0, 2^48 - 1]. |
account on every write accepts a viem Account or an Address and falls back to walletClient.account. Pass the full Account from privateKeyToAccount to sign locally: an address string sends through eth_sendTransaction, which public RPCs reject. The writes take gasHeadroomPercent and a per-call gas, like every SDK write, and an optional telemetry sink (spans fhe.setOperator, fhe.revokeOperator, fhe.isOperator, fhe.ensureOperator).
ERC7984_OPERATOR_MAX_DEADLINE#
281474976710655, the largest uint48 (2^48 - 1 unix seconds, far past the year 9999). It is the right default for local dev loops and test fixtures and the wrong one for production: a manager clone you replace would keep operator rights forever. Scope production grants to the expected operation window, and revoke stale spenders.
React: useIsOperator and useEnsureOperator#
Both live in @tokenops/sdk/fhe/react because the prerequisite is identical across products. /fhe-vesting/react and /fhe-disperse/react re-export them; /fhe-airdrop/react does not, so import them from /fhe/react there.
import { useIsOperator, useEnsureOperator } from "@tokenops/sdk/fhe/react";
import { useQueryClient } from "@tanstack/react-query";
export function ApproveThenCreate({ token, manager }: { token: `0x${string}`; manager: `0x${string}` }) {
const queryClient = useQueryClient();
const { data: authorized } = useIsOperator({ token, spender: manager });
const ensure = useEnsureOperator();
if (authorized) return <CreateVestingButton />;
return (
<button
disabled={ensure.isPending}
onClick={() =>
ensure.mutate(
{ token, spender: manager, deadline: BigInt(Math.floor(Date.now() / 1000) + 3600) },
{
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["tokenops-sdk", "fhe", "isOperator"] }),
},
)
}
>
Approve operator
</button>
);
}- useIsOperator stays disabled until
token,spenderand a holder (theholderoption, or the connected account) are known. It takesenabledandchainId; it has noqueryoption. Its key starts with["tokenops-sdk", "fhe", "isOperator"]; invalidate it after a grant. - useEnsureOperator takes
chainId,telemetryandgasHeadroomPercentat the hook, andtoken,spender,deadline,account,waitForReceiptandgasper mutation.
Typed failures#
| Error | When |
|---|---|
MissingAccountError | No account argument and no walletClient.account. |
InvalidArgumentError | deadline outside the uint48 range (or not positive, for ensureOperator); amount outside uint64 for mintMockERC7984. |
WalletRejectedError | The user rejected the transaction in the wallet. |
WalletChainMismatchError, NetworkError, InsufficientGasFundsError, ContractRevertError | Other send failures, classified like every product write. A revert is decoded. |
TokenOpsContractError | The receipt wait failed, the mined transaction reverted, or the isOperator read failed (RPC error, non-ERC-7984 token). The viem error is the cause. |
TokenOpsContractError shares TOKENOPS_CONTRACT_REVERT with ContractRevertError even when the cause is an RPC failure, so tell them apart with instanceof or name. /fhe/react re-exports every class useEnsureOperator throws.
mintMockERC7984 for local and test tokens#
For a mock ERC-7984 with an open mint(address,uint64), mintMockERC7984 gives the funding wallet a starting balance before setOperator and the create call. It resolves { hash, blockNumber }, with blockNumber 0n under waitForReceipt: false. For the Sepolia test-token pair, use the testnet faucet instead.
import { createMockErc7984Client, mintMockERC7984 } from "@tokenops/sdk/fhe";
// Local or test token with an open mint(address,uint64).
const { hash, blockNumber } = await mintMockERC7984({
publicClient,
walletClient,
token: mockToken,
to: walletClient.account!.address,
amount: 1_000_000n,
});
// Several mints against one token.
const tokenClient = createMockErc7984Client({ publicClient, walletClient, address: mockToken });
await tokenClient.mint({ to: alice, amount: 1_000_000n });