- TypeScript 95.5%
- JavaScript 2.4%
- CSS 2.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| dist | ||
| scripts | ||
| src | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| RELEASING.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
| tsup.preact.config.ts | ||
| vitest.config.ts | ||
icu-wallet-kit
Wallet connection for EVM apps.
It handles the whole connect lifecycle — discovering the wallets a browser actually has, connecting and disconnecting, restoring a session on reload, switching chains, and turning provider errors into something a person can read. It speaks EIP-1193 directly, so the core needs no framework and no wallet library underneath it.
The optional UI carries no design system. Components render through slots you fill with your own, so the picker inherits your app's look rather than imposing one.
- EIP-6963 discovery, deduplicated — the wallets actually installed, each listed once, with the name and icon the wallet itself announces.
- WalletConnect is optional and switched on by configuration. Without a project ID it is never registered, so the picker cannot offer an option that would not work.
- No dead ends. Every row corresponds to something that can actually complete; when nothing can, it says so instead.
- Peer dependencies only, and
viemis the only one always required.
Install
// package.json
"dependencies": {
"icu-wallet-kit": "git+https://git.gui.icu/dev/icu-wallet-kit.git#v0.1.0"
}
The pin is a tag, and tags here are immutable — a published one is never moved. Upgrading means changing the ref and re-resolving the lockfile; see RELEASING.md.
Three entries — take only what you need
| entry | peers | use when |
|---|---|---|
icu-wallet-kit/core |
viem | the kit on its own — discovery, connect, session, chain switching, no UI and no framework |
icu-wallet-kit/preact |
preact, viem | you want the ready-made UI in a Preact app |
icu-wallet-kit |
react, wagmi, viem | you want the ready-made UI in a React app that already uses wagmi |
Everything except viem is optional, so an app installs only what its entry
point touches.
Pick one entry per app and import the session API from it exclusively —
never /core and /preact (or .) together. Each entry is a separately
built, self-contained bundle (splitting: false on purpose, so a /core-only
consumer never pays for the UI layer it didn't import), which means each has
its own independent copy of the connect/session state. Importing
getWalletProvider/switchChain/etc. from /core while connecting through
/preact's useWallet silently reads a session that was never populated —
getWalletProvider() returns undefined forever, switchChain() throws "No
wallet connected." on a wallet the UI shows as plainly connected. /preact
(and ., where applicable) re-export the full /core surface for exactly
this reason — "a host needs one import specifier, not two."
The kit on its own
core is the whole thing minus the UI, and it depends on nothing but viem:
import {
initWalletKit, connectWallet, disconnectWallet, restoreWallet,
switchChain, getWalletProvider, getWalletState, subscribeWallet,
} from "icu-wallet-kit/core";
import { pulsechain } from "viem/chains";
initWalletKit({ chains: [pulsechain.id] });
await restoreWallet(); // silently re-attach a prior session
const wallets = getWalletState().wallets; // EIP-6963 discoveries, deduplicated
await connectWallet(wallets[0].id);
subscribeWallet((state) => render(state.address, state.chainId));
getWalletProvider() hands back the raw EIP-1193 provider, so it drops into
viem's custom() transport — or into anything else that speaks the standard.
With the bundled UI
Both UI entries expose the same names and the same props —
WalletKitProvider + slots + classNames, ConnectButton, WalletPicker —
so a theme file and its call sites port between them unchanged.
// Preact
import { WalletKitProvider, ConnectButton, useWallet } from "icu-wallet-kit/preact";
import { pulsechain, pulsechainV4 } from "viem/chains";
useWallet({
chains: [pulsechain.id, pulsechainV4.id],
// Omit, or leave projectId blank, to ship injected-only — the row is then
// never built, so the picker cannot offer an option that cannot work.
walletConnect: { projectId: runtimeConfig.wcProjectId },
});
For a React app already built on wagmi, the React entry builds the wagmi config for you and the components read from it:
import { buildWagmiConfig, WalletKitProvider, ConnectButton } from "icu-wallet-kit";
import { pulsechain, pulsechainV4 } from "viem/chains";
export const wagmiConfig = buildWagmiConfig({
chains: [pulsechain, pulsechainV4],
rpcUrls: { [pulsechain.id]: ["https://rpc.pulsechain.com", "https://rpc-pulsechain.g4mm4.io"] },
walletConnect: {
// RUNTIME config, not a build-time env var — see below.
projectId: window.__APP_CONFIG__?.wcProjectId,
metadata: { name: "My App", description: "…", url: location.origin, icons: [] },
},
});
Then wrap once, inside WagmiProvider, injecting your own components:
<WalletKitProvider
slots={{ Button: MyButton, Modal: MyModal, Alert: MyAlert }}
classNames={{ walletItem: "w-full justify-start gap-3" }}
>
<App />
</WalletKitProvider>
<ConnectButton /> then drops in anywhere.
Connected-account menus accept host actions and a post-disconnect callback:
<ConnectButton
accountMenuItems={[{ id: "sign-out", label: "Sign out", onSelect: signOut }]}
onDisconnect={signOut}
/>
Theming
Two levers, and between them the kit can be made to look like anything:
slots— supply your ownButton,Modal, andAlert. They receive plain props, so components from any UI library or hand-written equivalents drop straight in. The picker owns the error live region;Alertowns only its visual treatment.classNames— per-element class hooks (connectButton,walletList,walletItem,walletName,walletIcon,walletBadge,walletConnectPanel,error,emptyState, …) for when you only need to restyle, not replace.
Each wallet row also carries wallet.installed (true for anything actually
discovered live over EIP-6963, false for the WalletConnect entry) — the
picker renders an "Installed" badge for it automatically, styled via
classNames.walletBadge. The badge only appears when the list actually
mixes installed and not-installed wallets, i.e. WalletConnect is configured
and at least one wallet was also discovered — a label that's true on every
row (no WalletConnect configured, or nothing discovered yet) conveys
nothing, so the picker suppresses it rather than showing it unconditionally.
Omit both and you get unstyled defaults that work but assume nothing.
Connection errors are separated from the wallet choices in both layouts by
var(--wallet-kit-notice-gap, 0.75rem). Override the custom property on the
modal or [data-icu-wallet-notice] when a host needs a different stack gap.
For a fully custom UI, skip the components and use the headless hook:
const { wallets, connect, isPending, error } = useWalletPicker();
Layout: list or grid
<WalletPicker layout="grid" /> (or <ConnectButton layout="grid" />, which
forwards it) switches the picker from its default single-column row list to
a square-tile grid — a data-icu-wallet-layout="grid" attribute on the
<ul> is the only thing this adds; layout="list" (the default) renders
exactly as it always has, byte-for-byte.
The kit still ships no CSS of its own for either layout. For a ready-made grid look with zero CSS to write, import the optional companion stylesheet once:
import "icu-wallet-kit/styles/wallet-picker.css";
It styles layout="grid" only (square tiles, full wallet names wrapping
onto a 2nd line rather than truncating, an auto-fill grid so a single
wallet stays a fixed-size tile instead of stretching to fill the row) via a
handful of --wallet-kit-* custom properties with sane fallbacks — list
mode is untouched by this file too, so importing it can never change an
existing list-mode consumer's look. Skip the import and style
[data-icu-wallet-layout="grid"] yourself if you want full control instead.
WalletConnect
Supply projectId and the WalletConnect row appears; omit it and the connector
is never registered.
Mobile / coarse-pointer contexts still fully delegate to WalletConnect's own
modal (showQrModal: true) for QR and deeplinks — reimplementing its per-wallet
deeplink registry is out of scope for this kit, so that part is unchanged.
Desktop contexts render the pairing QR inline instead, in the picker's own
modal, themed via the same Button slot as everything else — no second,
WalletConnect-branded popup on top of your app's UI. classNames.walletConnectPanel
styles the wrapper; WalletPicker/ConnectButton need no extra prop for this,
it activates automatically whenever a connect is waiting on a scan. The choice
between the two is prefersDelegatedQrModal() (exported from every entry),
based on matchMedia("(pointer: coarse)") — override it by building your own
UI against the headless hook if you need different behavior.
Either way, WalletConnect (and its QR-rendering dependency) is loaded lazily on first use, so it costs nothing until a user actually picks it.
Get the ID at runtime, not at build time. A build-time VITE_* variable is
compiled into the bundle, so it cannot differ per deployment and every operator
self-hosting your app would be stuck with yours. It also fails quietly: an unset
variable becomes an empty string, and a blank project ID produces a connector
that dead-ends at the relay rather than an error you would notice.
Mobile coverage, for the record:
| situation | handled by |
|---|---|
| wallet's in-app browser | the injected provider — already works, no WalletConnect needed |
| mobile Safari/Chrome | WalletConnect modal's deeplinks |
| desktop + phone wallet (QR) | WalletConnect — the pairing protocol is not something to reimplement |
What this deliberately does not do
- Chain switching UI. Consuming apps generally already have their own. The
payload builder is exported: use
buildAddEthereumChainParameterwithswitchChain({ addEthereumChainParameter }). It matters — a wallet asked to switch to a chain it has never seen can reject immediately and show the user nothing at all, so the add-chain fallback is effectively the whole flow, and what you hand it decides which RPCs the user ends up with. - ENS names and avatars. Opt in via
resolveName. It is off by default because on forked chains the ENS registry is inherited at the canonical address, so lookups resolve to pre-fork names that may no longer belong to that address on the original chain — a wrong name is worse than none. - Recent-wallet memory, transaction history, balances. Out of scope.
Development
npm install
npm test # vitest, jsdom
npm run typecheck
npm run build # tsup → dist/ (ESM + d.ts) for both entries
dist/ is committed. The package is consumed as a git dependency, and a
consumer installing with --ignore-scripts would never run prepare — so
shipping the build output keeps the package usable without asking anyone to
relax their install flags or put a build toolchain in their image. Rebuild and
commit it alongside any source change.
License
MIT — see LICENSE.