@arch-network/wallet-connect-kit
v0.1.1
Published
Wallet connect SDK for Arch Network apps: Bitcoin wallets (via LaserEyes), the Arch Wallet extension, and Turnkey Wallet Hub passkey/email wallets — headless core, React hooks, and a themeable connect modal.
Readme
@arch-network/wallet-connect-kit
Wallet connect SDK for Arch Network dApps. Drop in a connect button and modal, get back a connected wallet identity and a signer — for every wallet type Arch users have:
- Bitcoin extension wallets via LaserEyes — Xverse, Phantom, UniSat, Leather (incl. silent session restore, network-switch settling, and the per-wallet signing quirks).
- Arch Wallet browser extension (
window.arch) with its Arch-native digest signer. - Turnkey Wallet Hub passkey + email (OTP) wallets, signing client-side.
Ships in three layers — use as much as you need:
| Layer | What you get |
|---|---|
| Headless core | Connect flows, identity derivation (pubkeyXCoord, base58 arch address), network guard, storage, signers, a vanilla zustand store |
| React | ArchWalletKitProvider, useWallet, useWalletSigner, useWalletHubAccount |
| UI | WalletConnectModal (picker / email + OTP / connecting / connected / install views) and ConnectWalletButton, themeable via CSS variables |
A runnable consumer lives in examples/vite-react.
Requirements
- React 18+ and a bundler (Next.js, Vite, …).
dist/uses extensionless relative imports and is not consumable by plain Node ESM. - On Vite: add
vite-plugin-node-polyfills— transitive deps of the bip322 stack import node builtins (crypto,stream,events). Next.js ships fallbacks for these out of the box. Seeexamples/vite-react/vite.config.ts.
Setup
1. Install the package and its peers:
pnpm add @arch-network/wallet-connect-kit @omnisat/lasereyes react react-dom
# or: npm install / yarn add — same packagesInstalls from the public npm registry. Releases are also mirrored to GitHub Packages (see the sidebar), but npmjs is canonical — GitHub Packages requires a GitHub token even for public installs.
@omnisat/lasereyesmust be a single instance shared with your app, which is why it's a peer. The range pins0.0.162: the kit's Xverse/UniSat signing workarounds are coded against that release's adapter bugs — bump it deliberately, not automatically.
2. Copy the wallet icons from this repo's assets/ into your
app's public/ directory (/arch-icon.jpeg, /xverse-logo.png,
/phantom-icon.svg, /unisat-logo.png, /leather-icon.svg). (Or skip this
and pass your own archWallet / otherWallets descriptors to the modal.)
3. Mount the provider, button, and modal — this is the whole integration:
"use client"; // Next.js app router only
import {
ArchWalletKitProvider,
ConnectWalletButton,
WalletConnectModal,
type WalletKitConfig,
} from "@arch-network/wallet-connect-kit";
import "@arch-network/wallet-connect-kit/styles.css";
const config: WalletKitConfig = {
network: "testnet4", // "mainnet" | "testnet" | "testnet4" | "regtest"
appName: "My Arch App",
};
export function App({ children }: { children: React.ReactNode }) {
return (
<ArchWalletKitProvider config={config}>
<ConnectWalletButton />
{children}
<WalletConnectModal />
</ArchWalletKitProvider>
);
}The provider owns session restore (silent reconnect on page load) and modal state; the button and modal wire themselves from context. There is nothing else to wire.
4. Read the connection anywhere under the provider:
import { useWallet } from "@arch-network/wallet-connect-kit";
function Balance() {
const { wallet } = useWallet();
if (!wallet) return <p>Not connected</p>;
return <p>{wallet.archAddress}</p>;
}useWallet() returns:
| Field | What it is |
|---|---|
| wallet | WalletState \| null — address, pubkey, pubkeyXCoord, archAddress, balances |
| connectionPhase / connectionError | Connect-flow progress and failure copy |
| openConnectModal() / handleDisconnect() | Open the picker / tear down the session |
| handleConnect(walletId, options?) | Start a connect flow imperatively |
| repairOnboarding() | Re-run your onboard hook without disconnecting — surface it when a transaction fails with an account-not-ready error |
| laserEyes, detectedWallets, showConnect, … | Lower-level state for custom UIs |
5. Sign with the active wallet:
import { useWalletSigner } from "@arch-network/wallet-connect-kit";
const signer = useWalletSigner(); // ChallengeSigner | DigestSigner
const signature =
typeof signer === "function"
? await signer(challenge) // BIP-322 challenge signer
: await signer.signDigest(digestHex); // digest signer (Arch ext, Turnkey)All wallet types produce signatures your transaction runner can treat
identically (BIP-322 witness / raw 64-byte Schnorr shapes). Outside React,
use resolveActiveTransactionSigner(...).
Configuration reference
const config: WalletKitConfig = {
// Required. A network name, or the full triple
// { bitcoinNetwork, xverseNetworkType, networkId } when the derived
// Xverse network.type / Hub x-network values don't fit your deployment.
network: "testnet4",
// Optional — names the app in passkey prompts and Hub wallet labels.
appName: "My Arch App", // default "Arch"
// Optional — prefix for every localStorage/sessionStorage key.
storagePrefix: "myapp", // default "arch"
// Optional — Turnkey Wallet Hub endpoint (see "Wallet Hub" below).
hub: { baseUrl: "/api/wallet-hub", apiKey: undefined },
// Optional — console diagnostics, filterable with "[WalletConnect]".
debug: false,
// Optional host hooks — see "Host hooks" below.
onboard: async ({ identity, signer, onPhase }) => {},
fetchBalances: async (identity) => ({ balances: {}, totalUsdValue: null }),
};The config is captured once on mount. To switch networks, remount the
provider (key={network}) or reload the page — per-network derived state is
not valid across networks.
Host hooks
The kit owns connecting; your app owns what happens on-chain afterwards:
onboard({ identity, signer, onPhase })— run post-connect provisioning (e.g. Archcreate_account+ associated token accounts). Report progress viaonPhase("checking-account" | "creating-account" | …)and the modal renders stage copy for each step. Throwing aborts the connect and tears down partial state. Omit it and connect finishes after identity validation.fetchBalances(identity)— return the initial{ balances, totalUsdValue }snapshot committed with the wallet. Omit it and the wallet connects with empty balances; keep your own polling layer updatingwalletKitStoreafterwards.
Wallet Hub (passkey / email wallets)
hub.baseUrlmay behttps://hub.arch.networkor (recommended in browsers) a same-origin proxy that attachesx-api-keyserver-side and avoids CORS.hub.apiKeyis a public platform gate, not a user credential.- The Hub derives wallets on the network named by
network.networkId(x-networkheader).
Same-origin proxy (Next.js)
Browser requests to the hosted Hub with x-api-key/x-network trigger a CORS
preflight the Hub doesn't serve on every route. Keep the browser on your
origin and attach the key server-side:
// app/api/wallet-hub/[...path]/route.ts
import { NextRequest } from "next/server";
const UPSTREAM = process.env.WALLET_HUB_BASE_URL ?? "https://hub.arch.network";
async function proxy(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
const { path } = await params;
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
headers.delete("content-length");
if (process.env.WALLET_HUB_API_KEY) headers.set("x-api-key", process.env.WALLET_HUB_API_KEY);
// Trust the client's x-network (the kit always sends it); default otherwise.
if (!headers.has("x-network")) headers.set("x-network", "mainnet");
const method = request.method.toUpperCase();
const upstream = await fetch(
`${UPSTREAM.replace(/\/+$/, "")}/${(path ?? []).join("/")}${request.nextUrl.search}`,
{
method,
headers,
body: ["GET", "HEAD"].includes(method) ? undefined : await request.text(),
cache: "no-store",
},
);
return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
}
export const GET = proxy;
export const POST = proxy;
export async function OPTIONS() {
return new Response(null, { status: 204 });
}Then set hub: { baseUrl: "/api/wallet-hub" } — the kit appends /v1
itself. On Vite, the dev server can do the same with a server.proxy entry
(see the example app).
Customization
Theming
- Import
@arch-network/wallet-connect-kit/styles.cssonce. - Override
--awck-*CSS variables (--awck-surface,--awck-fg,--awck-primary,--awck-accent, …). Dark mode followsprefers-color-schemeand can be forced withdata-awck-theme="dark"on<html>.
Component props
Every modal/button prop overrides its context default, so partial customization is a prop away:
<WalletConnectModal
network={{ label: "Testnet4", tone: "test", onSwitch: flipNetwork }}
explorerAccountUrl={(arch) => `https://explorer.arch.network/accounts/${arch}`}
archWallet={myArchDescriptor}
otherWallets={myWalletList}
/>Outside the provider, both components run fully controlled — pass the complete prop set (they throw a named error if something's missing).
Picker tags and analytics
The modal's recentWalletId / connectedWalletId props and
connect/disconnect analytics are host concerns:
import { getStoredWalletProvider, useWallet, type WalletId } from "@arch-network/wallet-connect-kit";
const LASEREYES_IDS = new Set(["xverse", "phantom", "unisat", "leather"]);
function asWalletId(value: unknown): WalletId | null {
return typeof value === "string" && LASEREYES_IDS.has(value)
? (value as WalletId)
: null;
}
function AppShell() {
const { wallet, laserEyes } = useWallet();
// "Recent" tag in the picker: the last provider we stored.
const recentWalletId = asWalletId(getStoredWalletProvider());
// Source of truth for the active connection is the live LaserEyes provider;
// fall back to the persisted id.
const liveProvider = asWalletId(laserEyes.provider);
const connectedWalletId = wallet ? (liveProvider ?? recentWalletId) : null;
// Analytics: watch the wallet transition edges with an effect on `wallet`
// and emit your own events.
return (
<WalletConnectModal
recentWalletId={recentWalletId}
connectedWalletId={connectedWalletId}
/>
);
}Headless usage
Everything the UI does is exported: runConnectFlow,
runArchExtensionConnectFlow, runTurnkeyHubConnectFlow, the vanilla
walletKitStore (+ selectors), identity/encoding helpers, and the signers.
Build your own UI on useWallet() — or skip React entirely and drive the
core. If you skip <ArchWalletKitProvider>, mount useSyncWalletSession
yourself to get silent reconnect.
Storage
Everything the kit persists is prefixed with storagePrefix (default
"arch"): "<prefix>:last-connected-wallet" (localStorage) plus the Turnkey
account/session keys (<prefix>_turnkey_*). Wallet state itself is
deliberately session-only — every page load starts disconnected and
silent-reconnects.
Not included (by design)
- On-chain onboarding and balance/indexer reads — inject via
onboard/fetchBalances. - Analytics — observe
walletKitStore(oruseWallet()) and emit your own events.
Development
pnpm install
pnpm typecheck
pnpm build # tsc → dist/ (preserves "use client" directives)
pnpm test # vitestCI runs typecheck + build + test on PRs and main pushes. Releases are
automated from Conventional Commits — see RELEASING.md.
Engineering invariants (BIP-322 port, LaserEyes pin, layering rules) live in
AGENTS.md.
