Recipe · Operations

Error recovery patterns by class

Each typed error class carries enough information to recover specifically. These four patterns cover ~80% of production recovery flows.

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.

patterns/fee-recovery.ts
ts
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.

patterns/locked.ts
ts
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.

patterns/sig-replay.ts
ts
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.

patterns/preflight.ts
ts
const preflight = usePreflightDisperse({ recipients, amounts });

if (preflight.data && !preflight.data.ready) {
  // blockerErrors: TokenOpsSdkError[] — branch on error.code to render
  setBlockers(preflight.data.blockerErrors);
}

See also