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:
addressResolver— accepts a raw hex address or Solana pubkey, checks the checksum, returns it.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:
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.).
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.
