whisk
Concepts

Error handling & recovery

How Whisk keeps user funds safe when something fails — the recovery primitives that defend against burned-but-not-minted, dropped tabs, and stuck attestations.

USDC bridges have one truly bad failure mode: the burn lands on the source chain, the mint doesn't land on the destination. The user's funds are gone from where they were and not yet where they're going. Every other failure is a UX problem; that one is a trust problem.

Whisk's recovery primitives exist to make sure this never becomes a permanent state for your users, no matter what fails — App Kit's internal poll, Circle's Iris relayer, the user's RPC, the user's tab. This page is the map.

Three classes of failure

ClassWhat it looks likeRiskDefault UX
Pre-burnAuthorize / approve step fails — wallet rejects, network drops, gas lowNo funds at risk"Try again" — resets cleanly
Mid-flightBurn succeeded on source, mint didn't land on destinationFunds in transit"Mint pending on destination · Complete the mint"
TerminalMint landed (success), or unrecoverable upstream errorNoneTx hash + explorer link

The widget routes every failure into one of these three lanes. The classification drives which CTA is shown, what state gets persisted, and whether engine.retry() can resume the flow.

What the widget does automatically

Mount <WhiskSend> and you get all of this without writing a line:

  1. Mid-flight detection. When the bridge fails with a successful burn step but no successful mint step, the failure UI swaps from "Try again" to "Complete the mint" — calling engine.retry() which wraps App Kit's kit.retryBridge with the saved attestation. The user signs once on the destination chain; the burn isn't repeated.
  2. 48-hour persistence. The in-flight failure snapshot (raw + steps + quote + serialized error) is written to localStorage keyed on (walletKind, address, sourceChain). A refresh, a tab close, even a browser restart within 48 hours lands the user back on the recovery CTA when they reconnect the same wallet + source chain. Replay-safe via CCTP nonces.
  3. Manual recovery escape hatch. Beneath "Complete the mint" sits a secondary "Submit manually (advanced)" button. It polls Iris independently of App Kit, gets the attestation, and submits MessageTransmitter.receiveMessage directly on the destination chain. Same gas the retry would have cost, just bypassing App Kit's sometimes-flaky polling.
  4. Pre-flight checks. Before the user can sign anything, the Review screen runs read-only checks: USDC balance vs amount, native gas sufficiency, wallet chain alignment. Blocking checks gate the Send button. Warnings render inline.
  5. Cross-tab single-flight. If the same wallet has Whisk open in two tabs and tab A is mid-burn, tab B's Send button disables and shows "Active send in another tab." Prevents accidental double-burns via BroadcastChannel.
  6. Telemetry opt-out. App Kit 1.5+ ships unhandled-error reporting to Circle's collection endpoint by default. Whisk passes disableErrorReporting: true on construction so host apps don't leak error events without consent.

The principles behind these choices

  • Funds > UX > polish. Every design tradeoff weighs fund-loss risk first.
  • Pessimistic UI. Assume every step can fail. Bake recovery into the default render, not into a separate error branch.
  • Provenance. Every step that has a txHash gets an explorer link. Every error names its source (typed WhiskError.category).
  • Idempotency. CCTP nonces at the protocol level, plus BroadcastChannel at the UI level, mean a user cannot accidentally double-burn — no matter how many tabs, how many clicks, how many refreshes.
  • Escape hatches. When in-widget recovery fails, the user can still recover manually using the same attestation Iris returns. Never a dead end.

The recovery toolkit

For most apps, the defaults are enough. When you want to build custom UI, headless flows, or back-office tooling, the primitives are public. All live in @usewhisk/core/recovery (server-friendly, no React dependency) and are re-exported from @usewhisk/core for convenience.

Persistence

import {
  saveInflight,
  loadInflight,
  clearInflight,
  listInflightSnapshots,
  type InflightSnapshot,
} from "@usewhisk/core";

saveInflight(snapshot) writes a recovery snapshot to localStorage, keyed on (walletKind, address, sourceChain) and TTL'd to 48 hours. loadInflight(walletKind, address, sourceChain) returns the snapshot if one exists and is not expired; clearInflight(...) removes it. listInflightSnapshots() returns every non-expired snapshot — useful for a "you have N pending recoveries" surface.

The default widget calls these for you on SEND_FAILURE / SEND_SUCCESS / RESET. Reach for them directly when you're driving the engine without <WhiskSend>.

Iris polling

import {
  pollAttestation,
  fetchAttestationOnce,
  type IrisMessage,
} from "@usewhisk/core";

pollAttestation(sourceChain, burnTxHash, options) polls Circle's Iris attestation API until the status is terminal (complete or failed), or your timeout elapses. Defaults to a 10-minute window with a 3-second polling interval — substantially longer than App Kit's internal poll, which is what lets us recover the "attestation is ready, App Kit gave up" case.

const result = await pollAttestation("Avalanche_Fuji", burnTxHash, {
  timeout: 5 * 60_000,
  onUpdate: (msg) => console.log("Iris status:", msg.status),
});
if (result.status === "complete") {
  // result.message + result.attestation ready to submit
}

Use fetchAttestationOnce(...) for a single-shot check when you don't want to block.

Direct CCTP recovery

import {
  buildReceiveMessageCall,
  messageTransmitterAddress,
  RECEIVE_MESSAGE_ABI,
  type ReceiveMessageCall,
} from "@usewhisk/core";

buildReceiveMessageCall(destinationChain, message, attestation) returns a ready-to-submit call descriptor for the destination chain's MessageTransmitter.receiveMessage(message, attestation). The returned object has { address, abi, functionName, args } — feed straight into viem's writeContract or wagmi's useWriteContract.

import { useWriteContract } from "wagmi";

const call = buildReceiveMessageCall(
  "Polygon_Amoy_Testnet",
  message,
  attestation,
);
if (!call) {
  // No MessageTransmitter registered for this chain. Fall back to
  // App Kit retry or surface the attestation copy-paste escape hatch.
  return;
}
const txHash = await writeContractAsync({
  ...call,
  args: [call.args[0] as `0x${string}`, call.args[1] as `0x${string}`],
  chainId: chainInfo("Polygon_Amoy_Testnet").evmChainId,
});

For React apps, the useManualMint hook wraps this whole flow (Iris poll → wallet sign → tx hash) in one call.

Domain helpers

import { cctpDomainFor, chainForCctpDomain } from "@usewhisk/core";

CCTP identifies chains by domain numbers (Ethereum is 0, Base is 6, Solana is 5, Arc is 26, etc.), not EVM chain IDs. Iris and MessageTransmitter both key on domain. These helpers convert between Whisk's Chain literals and CCTP domain integers.

The hooks

Three React hooks compose the recovery primitives into widget-ready behaviour. Each is independent — use one, use all three, use none.

HookWhat it doesWhen to reach for it
useManualMintDrives the direct-CCTP recovery flow (Iris poll → wallet sign → tx hash)Building a custom failure UI that surfaces the manual-recovery escape hatch
usePreflightRead-only balance / gas / chain alignment checksCustom Review screens; gating Send on insufficient funds before signature
useTabLockCross-tab single-flight via BroadcastChannelMulti-tab apps that need their own coordination layer

A complete recovery integration (headless)

If you're driving the engine without <WhiskSend> — embedded in a non-React surface, building a back-office tool, custom-UI integration — this is the shape:

import {
  createWhisk,
  pollAttestation,
  buildReceiveMessageCall,
  saveInflight,
  clearInflight,
  serializeError,
} from "@usewhisk/core";

const engine = createWhisk({
  chains: ["Arc_Testnet", "Base_Sepolia", "Polygon_Amoy_Testnet"],
  defaultSourceChain: "Arc_Testnet",
});

async function bridgeWithRecovery({
  adapter,
  recipient,
  amount,
  source,
  dest,
}) {
  const resolved = await engine.resolve(recipient, dest);
  const quote = await engine.quote({
    sourceChain: source,
    destinationChain: dest,
    recipient: resolved,
    amount,
    adapter,
  });

  const result = await engine.send({ quote, adapter });
  if (result.kind === "success") return result;

  // Mid-flight detection.
  const burned = result.steps?.some(
    (s) => s.name === "burn" && s.state === "success",
  );
  const minted = result.steps?.some(
    (s) => s.name === "mint" && s.state === "success",
  );
  if (!burned || minted || !result.raw) {
    // Pre-burn failure or already fully landed. Propagate.
    return result;
  }

  // Persist the snapshot so a process restart can resume.
  saveInflight({
    walletKind: adapter.kind,
    walletAddress: adapter.address,
    sourceChain: source,
    destinationChain: dest,
    quote,
    steps: result.steps ?? [],
    error: serializeError(result.error),
    raw: result.raw,
  });

  // Attempt 1: retry via App Kit.
  const retried = await engine.retry({
    failed: {
      kind: "failure",
      error: result.error,
      steps: result.steps ?? [],
      raw: result.raw,
    },
    adapter,
  });
  if (retried.kind === "success") {
    clearInflight(adapter.kind, adapter.address, source);
    return retried;
  }

  // Attempt 2: direct CCTP via Iris + MessageTransmitter.
  const burnTxHash = result.steps?.find((s) => s.name === "burn")?.txHash;
  if (!burnTxHash) return retried;

  const iris = await pollAttestation(source, burnTxHash, {
    timeout: 5 * 60_000,
  });
  if (iris.status !== "complete" || !iris.message || !iris.attestation) {
    // Iris failed. Surface the snapshot so the user can resume later.
    // clearInflight is NOT called here. The widget restores on next mount.
    return retried;
  }

  const call = buildReceiveMessageCall(dest, iris.message, iris.attestation);
  if (!call) return retried;

  // Submit `receiveMessage` via your own wallet plumbing here.
  // CCTP's nonce tracking on `MessageTransmitter` makes this safe to
  // retry. Duplicate submissions revert at the protocol level.
}

The widget version of this flow lives across useWhisk (retry action), useManualMint (Iris + MessageTransmitter), and <ResultStep> (the two CTAs). The headless version above is the same logic, unwrapped.

What about Solana?

Today's recovery primitives cover EVM destinations end-to-end. Solana destinations have a different signing model (program instructions, not EVM contract calls) and buildReceiveMessageCall returns null when the destination is Solana — caller falls back to App Kit retry or surfaces the attestation copy-paste escape hatch. Native Solana manual-mint support is on the roadmap.

Solana source bridges work normally; the recovery story above applies as long as the destination is EVM.

  • Errors reference — every WhiskError subclass and the category field that powers machine-readable failure classification.
  • State machine — the failed state's shape and the RETRY_START / HYDRATE_FAILED transitions.
  • useWhisk — the retry action and how it composes with the recovery snapshot.
  • useManualMint — the high-level React hook for the manual-recovery escape hatch.
  • usePreflight — pre-signature checks that prevent failures from happening in the first place.
  • useTabLock — cross-tab coordination.

On this page