whisk
Concepts

Resolvers

Turning "vitalik.eth" into an on-chain address and plugging in custom lookups.

A resolver takes whatever the user typed and returns an address on the destination chain. Whisk's defaultResolver chains two of them:

  1. addressResolver — accepts a raw hex address or Solana pubkey, checks the checksum, returns it.
  2. ensResolver — accepts an ENS name and looks up the right address for the destination chain using ENSIP-11 coin types.

<WhiskProvider> wires this default in for you. If all you need is addresses + ENS, do nothing.

Chaining your own resolvers

composeResolvers(...resolvers) tries each one in order and takes the first match. To add a custom resolver while keeping the defaults, stack them:

resolvers/with-lens.ts
import {
  composeResolvers,
  addressResolver,
  ensResolver,
} from "@usewhisk/react";
import { lensResolver } from "./lens";

export const customResolver = composeResolvers([
  addressResolver,
  ensResolver, // `vitalik.eth` — pre-instantiated singleton (mainnet ENS)
  lensResolver, // `vitalik.lens`
]);

Pass it to the config:

const config = createWhiskConfig({
  wallets: [evm({ projectId })],
  chains: ["Base"],
  resolver: customResolver,
});

Writing a resolver

A resolver is an object with name, a cheap synchronous matches guard, and the actual resolve function:

type Resolver = {
  name: string;
  matches: (input: string) => boolean;
  resolve: (
    input: string,
    ctx: ResolverContext,
  ) => Promise<ResolvedRecipient | null>;
};

The widget calls matches synchronously on every keystroke; only the first resolver whose matches returns true gets to run resolve. Return null from resolve to mean "I tried and gave up" — the engine surfaces InvalidAddressError if no resolver finds anything. Throw a ResolverError if something unexpected broke (network down, etc.).

resolvers/email.ts
import type { Resolver } from "@usewhisk/react";
import { lookupAddressForEmail } from "./api";

export const emailResolver: Resolver = {
  name: "email",
  matches: (input) => input.includes("@"),
  resolve: async (input, ctx) => {
    const address = await lookupAddressForEmail(input, ctx.chain);
    if (!address) return null;

    return {
      chain: ctx.chain,
      address,
      displayName: input,
    };
  },
};

A few rules that pay off:

  • Be fast. Resolvers run sequentially. A 2-second resolver delays the entire chain.
  • Bail early. Check the input shape (includes("@") here) before hitting the network. The user types every character and the resolver chain re-runs each time.
  • Don't normalize blindly. Lowercasing an ENS name is fine. Lowercasing a base58-encoded Solana pubkey breaks it. If you normalize, do it per format.

ENSIP-11, briefly

ENS names can point to a different address on each chain via "coin types" ('60 for Ethereum, '2147483658 for Base, and so on). The built-in ensResolver handles this. It pulls the coin type for the destination chain and returns the address recorded there.

If the ENS record doesn't have a coin type set for the destination chain, the resolver falls back to the primary Ethereum address. This matches the behaviour most ENS users expect.

For testnet apps, use createDefaultResolver({ mode: "testnet" }) instead — that composes a Sepolia-first → mainnet-fallback ENS chain so dev-registered Sepolia names resolve before falling back to mainnet names like vitalik.eth.

Debugging a chain

When a resolver chain isn't claiming the input you expect, add a trace resolver near the top of the chain:

const trace: Resolver = {
  name: "trace",
  matches: () => true, // see every keystroke
  resolve: async (input, ctx) => {
    console.log("[resolver] saw:", input, "for", ctx.chain);
    return null; // always fall through
  },
};

export const debugResolver = composeResolvers([
  trace,
  addressResolver,
  ensResolver,
]);

You'll see every keystroke fly past in the console, which makes it obvious whether the upstream chain is being hit at all.

Next

How fees and quotes are built.

On this page