Pattern 1 · Fee payment mismatch → resubmit with the right value#
InsufficientFeeErrormeans the fee payment didn't match what the contract requires. The per-clone fee is immutable — both FEE_TYPE and FEE are packed into the clone bytecode and never change — so this is not a stale cache and re-reading getFeeInfo won't fix it. Its err.context.feeKind is "gas" (the gas-fee manager requires msg.value === fee exactly, so underpaying, overpaying, or a stale value all revert — resubmit with the exact fee, and top up only if the wallet balance is genuinely below it) or "token" (the fee is a bps residual carved out ofthe claimed amount, so this signals the encrypted claim total didn't match the expected fee, not a separate cost the claim must cover). Resubmit with the correct value.
import { InsufficientFeeError } from "@tokenops/sdk/fhe-vesting";
const claim = useClaim({ address: managerAddress });
claim.mutate(args, {
onError(err) {
if (err instanceof InsufficientFeeError) {
// err.context.feeKind is "gas" or "token". The per-clone fee is immutable
// (baked into the clone bytecode), so this is a fee *payment* mismatch,
// not a stale cache.
if (err.context.feeKind === "gas") {
// Gas managers require msg.value === fee EXACTLY — overpaying or a stale
// value reverts too, not just a low balance. Resubmit with the exact fee.
toast.info("The native fee must match exactly. Resubmit with the exact fee amount.");
} else {
// Token fee is a bps residual carved OUT of the claimed amount, not an
// extra cost. This means the encrypted total didn't match the expected fee.
toast.info("The encrypted claim total didn't match the expected token fee. Retry.");
}
}
},
});Pattern 2 · Lock not lifted → render countdown#
ClaimLockedError can carry err.context.unlocksAt, the Unix timestamp the timelock expires (startTimestamp + timelockSeconds). The cliff and the timelock are separate schedule parameters. That field is optional— it's populated only when the SDK has already read vesting info (e.g. via preflight); the on-chain revert mapper builds the error without it. Guard for undefinedbefore rendering a countdown; don't spam retry.
import { ClaimLockedError } from "@tokenops/sdk/fhe-vesting";
claim.mutate(args, {
onError(err) {
if (err instanceof ClaimLockedError) {
// err.context.unlocksAt is optional — present only when the SDK read
// vesting info (e.g. via preflight). It is absent on an on-chain revert.
if (err.context.unlocksAt !== undefined) {
const secondsLeft = err.context.unlocksAt - Math.floor(Date.now() / 1000);
setCountdown(secondsLeft);
} else {
// Fall back to refetching vesting info to learn the unlock time.
toast.info("Claim is still locked. Try again later.");
}
}
},
});Pattern 3 · Signature replay → request fresh sig#
AlreadyClaimedError means the admin signature was already redeemed. Single-use by design — the clone stores the struct hash of (recipient, encryptedAmount). The operator must sign a NEW Claim over a re-encrypted amount handle; surface that requirement.
import { AlreadyClaimedError } from "@tokenops/sdk/fhe-airdrop";
claim.mutate(args, {
onError(err) {
if (err instanceof AlreadyClaimedError) {
// Single-use signature already redeemed. Admin must sign a fresh
// Claim over a re-encrypted amount handle.
toast.error("This claim was already redeemed.");
promptOperator(`request_new_signature`, args.recipient);
}
},
});Pattern 4 · Preflight surface → render blockers inline#
Preflight never throws — usePreflightDisperse returns a PreflightReport. When data.ready is false, iterate data.blockerErrors (typed TokenOpsSdkError[]) and branch on error.code. Render each as its own inline alert; don't collapse to a generic toast.
const preflight = usePreflightDisperse({ recipients, amounts });
if (preflight.data && !preflight.data.ready) {
// blockerErrors: TokenOpsSdkError[] — branch on error.code to render
setBlockers(preflight.data.blockerErrors);
}