2.0 RC docsView 1.x docs
Concept · Operators

Approve the contract before it pulls your tokens

Every flow that funds from your ERC-7984 balance needs an operator grant on the token first.

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.

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.

ProductSpender to approveFlows that pull
/fhe-vestingThe manager clonecreateVesting, batchCreateVesting
/fhe-airdropThe airdrop factorycreateAndFundEcdsaAirdrop, createAndFundMerkleAirdrop, fundAirdrop
/fhe-disperseThe disperse singletonEvery 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 });
FunctionNotes
setOperatordeadline defaults to ERC7984_OPERATOR_MAX_DEADLINE. Waits for the receipt unless waitForReceipt: false. Resolves the transaction hash.
revokeOperatorsetOperator(spender, 0): until = 0 is the ERC-7984 revoke convention. No deadline argument.
isOperatorRead-only, no wallet. The on-chain check is until >= block.timestamp, so an expired grant reads false without any revoke.
ensureOperatorResolves { 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.

ApproveThenCreate.tsx
tsx
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, spender and a holder (the holder option, or the connected account) are known. It takes enabled and chainId; it has no query option. Its key starts with ["tokenops-sdk", "fhe", "isOperator"]; invalidate it after a grant.
  • useEnsureOperator takes chainId, telemetry and gasHeadroomPercent at the hook, and token, spender, deadline, account, waitForReceipt and gas per mutation.

Typed failures#

ErrorWhen
MissingAccountErrorNo account argument and no walletClient.account.
InvalidArgumentErrordeadline outside the uint48 range (or not positive, for ensureOperator); amount outside uint64 for mintMockERC7984.
WalletRejectedErrorThe user rejected the transaction in the wallet.
WalletChainMismatchError, NetworkError, InsufficientGasFundsError, ContractRevertErrorOther send failures, classified like every product write. A revert is decoded.
TokenOpsContractErrorThe 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 });

See also