Add 'react' package with QR code and modal dialog

Makes it easier for dapp developers to integrate WizardConnect and have
consistent user interface for it across dapps.
This commit is contained in:
Dagur Valberg Johannsson 2026-03-18 16:13:38 +01:00
parent e2476626c2
commit 45fd9f4cb5
No known key found for this signature in database
GPG key ID: FD701804AEE88107
19 changed files with 1744 additions and 3 deletions

View file

@ -64,6 +64,7 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes |
| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes |
| `docs/dapp.md` | `DappConnectionManager` API or session lifecycle changes |
| `docs/react.md` | React components, hooks, or QR dialog API changes |
| `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes |
| `docs/xpub-sharing.md` | xpub sharing rationale, security model, or comparison with other protocols changes |
| `docs/index.md` | New top-level docs files are added |
@ -71,7 +72,9 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
## Packages
- `@wizardconnect/core` — transport + protocol primitives (relay client, key exchange, hdwalletv1 message types)
- `@wizardconnect/dapp` — dapp-side helpers (`DappConnectionManager`, `DappPubkeyStateManager`)
- `@wizardconnect/wallet` — wallet-side helpers (`WalletConnectionManager`, `WalletAdapter`, `PubkeyStateManager`)
- `@wizardconnect/react` — React components and hooks (`WizardConnectQRDialog`, `AlphanumericQRCode`, `useWizardConnect`)
- `@wizardconnect/test-cli` — manual test CLI (`dapp` and `wallet` modes)
## Build

View file

@ -32,6 +32,10 @@ libwizardconnect/
│ DappConnectionManager, DappPubkeyStateManager.
│ Single-session dapp helper, on-demand xpub derivation.
├── packages/react — @wizardconnect/react
│ React components and hooks for dapp integration.
│ QR dialog, useWizardConnect hook.
└── packages/test-cli — @wizardconnect/test-cli (private)
CLI for manual and exploratory testing.
```
@ -76,4 +80,5 @@ future if needed. You will be prompted to sign the CLA when you open your first
| Relay transport and encryption | [transport.md](transport.md) |
| Wallet integration guide | [wallet.md](wallet.md) |
| Dapp integration guide | [dapp.md](dapp.md) |
| React components and hooks | [react.md](react.md) |
| xpub delivery and pubkey derivation | [pubkey-derivation.md](pubkey-derivation.md) |

147
docs/react.md Normal file
View file

@ -0,0 +1,147 @@
# React integration
`@wizardconnect/react` provides React components and hooks for dapp developers who want to add
WizardConnect wallet connectivity without writing relay boilerplate.
## Quick start
```tsx
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
function App() {
const wc = useWizardConnect({ dappName: "My Dapp" });
return (
<>
<button onClick={wc.connect} disabled={wc.state !== "idle"}>
Connect Wallet
</button>
{wc.uri && wc.qrUri && (
<WizardConnectQRDialog
show={wc.state === "connecting"}
onClose={() => wc.disconnect()}
uri={wc.uri}
qrUri={wc.qrUri}
/>
)}
{wc.state === "connected" && (
<p>Connected to {wc.walletName}</p>
)}
</>
);
}
```
## useWizardConnect
The `useWizardConnect` hook manages the full relay lifecycle:
1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, and returns
the connection URI for QR display.
2. **Key exchange** — listens for `keyexchangecomplete` and persists the wallet's public key to
localStorage for auto-reconnect.
3. **`walletready`** — updates `state` to `"connected"` and populates `walletName`/`walletIcon`.
4. **Auto-reconnect** — on mount, checks localStorage for a stored session with a `walletPublicKey`
and automatically reconnects.
5. **`disconnect()`** — sends a courtesy disconnect, cleans up the relay, and clears session storage.
### Return value
```typescript
interface UseWizardConnectResult {
state: "idle" | "connecting" | "connected" | "disconnected";
manager: DappConnectionManager | null;
uri: string | null;
qrUri: string | null;
walletName: string | null;
walletIcon: string | null;
connect: () => boolean;
disconnect: () => Promise<void>;
error: string | null;
}
```
### Using the manager
Once `state === "connected"`, the `manager` is a live `DappConnectionManager` from
`@wizardconnect/dapp`. Use it to derive addresses and request signatures:
```typescript
// Derive a receive address pubkey
const pubkey = wc.manager.getPubkey(0, 0n);
// Send a sign request
const seq = wc.manager.nextSequence();
const response = await wc.manager.sendSignRequest({ ... });
```
For most dapps, you'll wrap the manager in an app-specific wallet adapter (like
`RelayWalletDapp` in the Cauldron and Moria codebases).
## WizardConnectQRDialog
A framework-independent modal dialog for displaying the connection QR code. Uses inline styles
(no Tailwind or CSS framework dependency) with a dark theme by default.
### Customization
Colors, text, and logos are all customizable via props:
```tsx
<WizardConnectQRDialog
show={true}
onClose={handleClose}
uri={connection.uri}
qrUri={connection.qrUri}
title="My Protocol"
subtitle="Scan to pair your wallet"
logoUrl="/my-logo.png"
theme={{
dialogBackground: "#0f172a",
headerBackground: "#0f172a",
borderColor: "#334155",
}}
onCopy={(uri) => {
navigator.clipboard.writeText(uri);
showToast("Copied!");
}}
/>
```
The `onCopy` callback replaces the default `navigator.clipboard.writeText` behavior, which is
useful when the dapp has its own toast/notification system.
## AlphanumericQRCode
A standalone canvas-based QR code component. Uses QR Alphanumeric mode with error correction
level H (30% recovery), which allows a center logo overlay without breaking the code.
```tsx
<AlphanumericQRCode
value="WIZ://..."
size={280}
foreground="#1e2a4a"
background="#ffffff"
logoUrl="/logo.png"
/>
```
## Session persistence
By default, `useWizardConnect` persists session credentials (private key, shared secret, and
wallet public key) to localStorage under the `wizardconnect-session` key. This enables
auto-reconnect when the user refreshes the page.
The persistence key and behavior can be customized:
```typescript
useWizardConnect({
sessionKey: "my-app-wc-session", // custom localStorage key
persistSession: false, // disable persistence entirely
});
```
Credentials are only stored after the key exchange completes. On disconnect, the stored session
is cleared.

View file

@ -8,7 +8,7 @@ module.exports = [
ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"],
},
{
files: ["**/*.ts"],
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser,
parserOptions: {

View file

@ -18,7 +18,7 @@ const REQUIRED_LINES = [
// Matches: "Copyright (C) 2026 Whiterun LLC" or "Copyright (C) 2024-2026 Whiterun LLC"
const COPYRIGHT_PATTERN = /Copyright \(C\) (\d{4}-)?\d{4} Whiterun LLC/;
const EXTENSIONS = [".ts", ".mjs"];
const EXTENSIONS = [".ts", ".tsx", ".mjs"];
const DIRECTORIES = ["packages", "linters"];
function walk(dir) {

182
package-lock.json generated
View file

@ -1339,12 +1339,57 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@ -1707,6 +1752,10 @@
"resolved": "packages/dapp",
"link": true
},
"node_modules/@wizardconnect/react": {
"resolved": "packages/react",
"link": true
},
"node_modules/@wizardconnect/test-cli": {
"resolved": "packages/test-cli",
"link": true
@ -1984,6 +2033,13 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2058,6 +2114,19 @@
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@ -2490,6 +2559,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/happy-dom": {
"version": "20.8.4",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz",
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@ -2756,6 +2843,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loose-envify/node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lossless-json": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
@ -3271,6 +3378,39 @@
"node": ">=6"
}
},
"node_modules/qrcode-generator": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz",
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@ -3366,6 +3506,16 @@
"fsevents": "~2.3.2"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -4011,6 +4161,16 @@
}
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -4127,6 +4287,28 @@
"vitest": "^3.2.3"
}
},
"packages/react": {
"name": "@wizardconnect/react",
"version": "0.1.0",
"dependencies": {
"@wizardconnect/core": "*",
"@wizardconnect/dapp": "*",
"qrcode-generator": "^1.4.4"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"happy-dom": "^20.8.4",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.3"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"packages/test-cli": {
"name": "@wizardconnect/test-cli",
"version": "0.1.0",

View file

@ -5,7 +5,7 @@
"packages/*"
],
"scripts": {
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/test-cli",
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/react -w packages/test-cli",
"test": "npm run test --workspaces --if-present",
"test:integration": "npm run test:integration --workspaces --if-present",
"dapp": "npm run dapp --workspace @wizardconnect/test-cli",

155
packages/react/README.md Normal file
View file

@ -0,0 +1,155 @@
# @wizardconnect/react
React components and hooks for integrating WizardConnect into dapps.
## Installation
```bash
npm install @wizardconnect/react @wizardconnect/core @wizardconnect/dapp
```
React 18+ is required as a peer dependency.
## Components
### WizardConnectQRDialog
A portal-based modal dialog that displays a WizardConnect QR code for wallet pairing. Uses inline styles for framework independence (no Tailwind or CSS framework required).
```tsx
import { WizardConnectQRDialog } from "@wizardconnect/react";
<WizardConnectQRDialog
show={showDialog}
onClose={() => setShowDialog(false)}
uri={connection.uri}
qrUri={connection.qrUri}
logoUrl="/my-logo.png"
theme={{
dialogBackground: "#1e293b",
headerBackground: "#1e293b",
}}
/>;
```
**Props:**
| Prop | Type | Default | Description |
| ----------- | ----------------------- | ------------------------------------ | --------------------------------------------------- |
| `show` | `boolean` | _required_ | Whether the dialog is visible |
| `onClose` | `() => void` | _required_ | Called when the user clicks close or the backdrop |
| `uri` | `string` | _required_ | Human-readable URI to display (`wiz://...`) |
| `qrUri` | `string` | _required_ | Alphanumeric-safe URI for QR encoding (`WIZ://...`) |
| `onCopy` | `(uri: string) => void` | `navigator.clipboard.writeText` | Called when copy button is clicked |
| `theme` | `WizardConnectQRTheme` | dark theme defaults | Color overrides |
| `title` | `string` | `"WizardConnect"` | Dialog title |
| `subtitle` | `string` | `"Scan with your wallet to connect"` | Subtitle text |
| `logoUrl` | `string` | none | Logo for the header |
| `className` | `string` | none | Additional CSS class on the outermost container |
### AlphanumericQRCode
A standalone canvas-based QR code renderer. Uses Alphanumeric mode with error correction level H (30% recovery) to tolerate a center logo overlay.
```tsx
import { AlphanumericQRCode } from "@wizardconnect/react";
<AlphanumericQRCode
value="WIZ://..."
size={280}
foreground="#1e2a4a"
background="#ffffff"
logoUrl="/logo.png"
/>;
```
## Hooks
### useWizardConnect
Encapsulates the full WizardConnect relay lifecycle: relay initiation, `DappConnectionManager` management, key exchange events, session persistence, and auto-reconnect.
```tsx
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
function ConnectButton() {
const {
state, // "idle" | "connecting" | "connected" | "disconnected"
manager, // DappConnectionManager (null until connect())
uri, // connection URI (null until connect())
qrUri, // QR-safe URI (null until connect())
walletName, // wallet name (null until walletready)
walletIcon, // wallet icon (null until walletready)
connect, // () => boolean — initiate a new connection
disconnect, // () => Promise<void> — disconnect and clean up
error, // string | null — error message
} = useWizardConnect({
dappName: "My Dapp",
dappIcon: "https://example.com/icon.png",
});
return (
<>
{state === "idle" && <button onClick={connect}>Connect</button>}
{state === "connected" && <span>Connected to {walletName}</span>}
{uri && qrUri && (
<WizardConnectQRDialog
show={state === "connecting"}
onClose={disconnect}
uri={uri}
qrUri={qrUri}
/>
)}
</>
);
}
```
**Options:**
| Option | Type | Default | Description |
| ---------------- | ---------- | ------------------------- | ------------------------------------------ |
| `dappName` | `string` | none | Display name sent in `dapp_ready` |
| `dappIcon` | `string` | none | Icon URL sent in `dapp_ready` |
| `relayUrls` | `string[]` | default relay | Explicit relay WebSocket URLs |
| `sessionKey` | `string` | `"wizardconnect-session"` | localStorage key for session persistence |
| `persistSession` | `boolean` | `true` | Whether to save session for auto-reconnect |
**Using the `manager`:**
After `state` becomes `"connected"`, use `manager` to build your app-specific wallet adapter. The manager provides:
- `getPubkey(childIndex, addressIndex)` — derive pubkeys from xpubs
- `sendSignRequest(request)` — request transaction signatures
- `sendSignCancel(sequence)` — cancel an in-flight sign request
- `on("walletready", callback)` — listen for wallet handshake completion
See the [`@wizardconnect/dapp` documentation](../../docs/dapp.md) for the full `DappConnectionManager` API.
## Theme customization
All colors in `WizardConnectQRDialog` can be overridden via the `theme` prop:
```tsx
const myTheme: WizardConnectQRTheme = {
backdropColor: "rgba(0,0,0,0.5)",
dialogBackground: "#1a1f2e",
headerBackground: "#1a1f2e",
titleColor: "#ffffff",
subtitleColor: "#9ca3af",
qrForeground: "#1e2a4a",
qrBackground: "#ffffff",
uriRowBackground: "rgba(31,41,55,0.6)",
uriTextColor: "#9ca3af",
borderColor: "#374151",
closeButtonColor: "#9ca3af",
copyButtonColor: "#9ca3af",
logoUrl: "/my-qr-logo.png",
qrSize: 280,
};
```
## License
LGPL-3.0-or-later. See [LICENSE](../../LICENSE).

View file

@ -0,0 +1,44 @@
{
"name": "@wizardconnect/react",
"version": "0.1.0",
"type": "module",
"description": "React components and hooks for WizardConnect dapp integration",
"repository": {
"type": "git",
"url": "git+https://gitlab.com/riftenlabs/lib/wizardconnect.git",
"directory": "packages/react"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/"
],
"scripts": {
"build": "tsc",
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
"lint:prettier": "prettier --ignore-path ../../.gitignore . --list-different",
"lint:eslint": "eslint .",
"lint": "npm run lint:eslint && npm run lint:prettier",
"fix": "npm run fix:eslint && npm run fix:prettier",
"fix:prettier": "prettier --ignore-path ../../.gitignore . --write",
"fix:eslint": "npm run lint:eslint -- --fix"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"dependencies": {
"@wizardconnect/core": "*",
"@wizardconnect/dapp": "*",
"qrcode-generator": "^1.4.4"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"happy-dom": "^20.8.4",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.3"
}
}

View file

@ -0,0 +1,100 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { useRef, useEffect } from "react";
import qrGenerator from "qrcode-generator";
import { WIZARDCONNECT_LOGO } from "./logo.js";
import type { AlphanumericQRCodeProps } from "../types.js";
const DEFAULT_SIZE = 280;
const DEFAULT_QUIET_ZONE = 4;
const DEFAULT_FG = "#1e2a4a";
const DEFAULT_BG = "#ffffff";
/**
* Renders a QR code on a canvas using explicit Alphanumeric mode.
* Optionally overlays a logo in the center (uses error correction H).
*/
export function AlphanumericQRCode({
value,
size = DEFAULT_SIZE,
foreground = DEFAULT_FG,
background = DEFAULT_BG,
quietZone = DEFAULT_QUIET_ZONE,
}: AlphanumericQRCodeProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Use error correction H (30% recovery) to tolerate the center logo
const qr = qrGenerator(0, "H");
qr.addData(value, "Alphanumeric");
qr.make();
const moduleCount = qr.getModuleCount();
const cellSize = size / moduleCount;
const canvasSize = size + 2 * quietZone;
const scale =
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
canvas.width = canvasSize * scale;
canvas.height = canvasSize * scale;
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
const ctx = canvas.getContext("2d")!;
ctx.scale(scale, scale);
// Background
ctx.fillStyle = background;
ctx.fillRect(0, 0, canvasSize, canvasSize);
// Draw QR modules
ctx.fillStyle = foreground;
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qr.isDark(row, col)) {
ctx.fillRect(
Math.round(col * cellSize) + quietZone,
Math.round(row * cellSize) + quietZone,
Math.ceil(cellSize),
Math.ceil(cellSize),
);
}
}
}
// Overlay WizardConnect logo in the center
{
const logo = new Image();
logo.onload = () => {
const logoSize = size * 0.22;
const logoPadding = 4;
const dx = (canvasSize - logoSize) / 2;
const dy = (canvasSize - logoSize) / 2;
// Clear rectangular area behind logo
ctx.fillStyle = background;
ctx.fillRect(
dx - logoPadding,
dy - logoPadding,
logoSize + logoPadding * 2,
logoSize + logoPadding * 2,
);
ctx.drawImage(logo, dx, dy, logoSize, logoSize);
};
logo.src = WIZARDCONNECT_LOGO;
}
}, [value, size, foreground, background, quietZone]);
return (
<canvas
ref={canvasRef}
style={{ height: "auto", maxWidth: `${size}px`, width: "100%" }}
/>
);
}

View file

@ -0,0 +1,157 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { WizardConnectQRDialog } from "./WizardConnectQRDialog.js";
// Mock the QR component to avoid canvas issues in happy-dom
vi.mock("./AlphanumericQRCode.js", () => ({
AlphanumericQRCode: (props: { value: string }) =>
createElement("div", {
"data-testid": "qr-code",
"data-value": props.value,
}),
}));
describe("WizardConnectQRDialog", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
root.unmount();
container.remove();
// Clean up portals
const portals = document.querySelectorAll("[style*='position: fixed']");
portals.forEach((el) => el.remove());
});
function render(
props: Partial<Parameters<typeof WizardConnectQRDialog>[0]> = {},
) {
const defaultProps = {
show: true,
onClose: () => {},
uri: "wiz://test",
qrUri: "WIZ://TEST",
};
root.render(
createElement(WizardConnectQRDialog, { ...defaultProps, ...props }),
);
// Flush synchronous React work
return new Promise<void>((resolve) => setTimeout(resolve, 0));
}
it("renders nothing when show is false", async () => {
await render({ show: false });
const backdrops = document.querySelectorAll("[style*='position: fixed']");
expect(backdrops.length).toBe(0);
});
it("renders a portal modal when show is true", async () => {
await render({ show: true });
const backdrops = document.querySelectorAll("[style*='position: fixed']");
expect(backdrops.length).toBe(1);
});
it("displays the default title", async () => {
await render();
expect(document.body.textContent).toContain("WizardConnect");
});
it("displays a custom title", async () => {
await render({ title: "My Custom Title" });
expect(document.body.textContent).toContain("My Custom Title");
});
it("displays the URI text", async () => {
await render({ uri: "wiz://my-unique-uri" });
expect(document.body.textContent).toContain("wiz://my-unique-uri");
});
it("displays the default subtitle", async () => {
await render();
expect(document.body.textContent).toContain(
"Scan with your wallet to connect",
);
});
it("displays a custom subtitle", async () => {
await render({ subtitle: "Custom scan instructions" });
expect(document.body.textContent).toContain("Custom scan instructions");
});
it("calls onClose when close button is clicked", async () => {
const onClose = vi.fn();
await render({ onClose });
const closeButton = document.querySelector(
"button[aria-label='Close']",
) as HTMLButtonElement;
expect(closeButton).not.toBeNull();
closeButton.click();
expect(onClose).toHaveBeenCalledOnce();
});
it("calls onCopy with the URI when copy button is clicked", async () => {
const onCopy = vi.fn();
await render({ uri: "wiz://copy-test", onCopy });
const copyButton = document.querySelector(
"button[aria-label='Copy URI']",
) as HTMLButtonElement;
expect(copyButton).not.toBeNull();
copyButton.click();
expect(onCopy).toHaveBeenCalledOnce();
expect(onCopy).toHaveBeenCalledWith("wiz://copy-test");
});
it("applies custom theme colors", async () => {
await render({
theme: {
dialogBackground: "#ff0000",
headerBackground: "#00ff00",
},
});
const elements = document.querySelectorAll("[style*='background-color']");
const styles = Array.from(elements).map(
(el) => (el as HTMLElement).style.backgroundColor,
);
expect(styles).toContain("#00ff00");
expect(styles).toContain("#ff0000");
});
it("always renders the WizardConnect logo in the header", async () => {
await render();
const logo = document.querySelector("img") as HTMLImageElement;
expect(logo).not.toBeNull();
expect(logo.src).toContain("data:image/png;base64,");
});
it("passes qrUri to the QR code component", async () => {
await render({ qrUri: "WIZ://MY-QR-VALUE" });
const qrCode = document.querySelector("[data-testid='qr-code']");
expect(qrCode).not.toBeNull();
expect(qrCode?.getAttribute("data-value")).toBe("WIZ://MY-QR-VALUE");
});
});

View file

@ -0,0 +1,262 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import React, { useCallback } from "react";
import { createPortal } from "react-dom";
import { AlphanumericQRCode } from "./AlphanumericQRCode.js";
import { WIZARDCONNECT_LOGO } from "./logo.js";
import type {
WizardConnectQRDialogProps,
WizardConnectQRTheme,
} from "../types.js";
const defaults: Required<
Pick<
WizardConnectQRTheme,
| "backdropColor"
| "dialogBackground"
| "headerBackground"
| "titleColor"
| "subtitleColor"
| "qrForeground"
| "qrBackground"
| "uriRowBackground"
| "uriTextColor"
| "borderColor"
| "closeButtonColor"
| "copyButtonColor"
| "qrSize"
>
> = {
backdropColor: "rgba(0,0,0,0.5)",
dialogBackground: "#1a1f2e",
headerBackground: "#1a1f2e",
titleColor: "#ffffff",
subtitleColor: "#9ca3af",
qrForeground: "#1e2a4a",
qrBackground: "#ffffff",
uriRowBackground: "rgba(31,41,55,0.6)",
uriTextColor: "#9ca3af",
borderColor: "#374151",
closeButtonColor: "#9ca3af",
copyButtonColor: "#9ca3af",
qrSize: 280,
};
function CloseIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M5 5L15 15M15 5L5 15"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function CopyIcon({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
stroke={color}
strokeWidth="2"
/>
<path
d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"
stroke={color}
strokeWidth="2"
/>
</svg>
);
}
/**
* A portal-based modal dialog that displays a WizardConnect QR code
* for wallet pairing. Framework-independent (inline styles, no Tailwind).
*/
export function WizardConnectQRDialog({
show,
onClose,
uri,
qrUri,
onCopy,
theme,
className,
subtitle = "Scan with your wallet to connect",
title = "WizardConnect",
}: WizardConnectQRDialogProps) {
const t = { ...defaults, ...theme };
const handleCopy = useCallback(() => {
if (onCopy) {
onCopy(uri);
} else if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(uri).catch(() => {});
}
}, [uri, onCopy]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!show) return null;
return createPortal(
<div
className={className}
onClick={handleBackdropClick}
style={{
position: "fixed",
inset: 0,
zIndex: 40,
display: "grid",
placeItems: "center",
padding: "16px",
backgroundColor: t.backdropColor,
backdropFilter: "blur(4px)",
}}
>
<div
style={{
width: "100%",
maxWidth: "384px",
borderRadius: "16px",
overflow: "hidden",
boxShadow: "0 25px 50px -12px rgba(0,0,0,0.5)",
border: `1px solid ${t.borderColor}`,
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
backgroundColor: t.headerBackground,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<img
src={WIZARDCONNECT_LOGO}
alt=""
style={{ width: "28px", height: "28px" }}
/>
<span
style={{
color: t.titleColor,
fontWeight: 600,
fontSize: "18px",
}}
>
{title}
</span>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
}}
aria-label="Close"
>
<CloseIcon color={t.closeButtonColor} />
</button>
</div>
{/* Body */}
<div
style={{
backgroundColor: t.dialogBackground,
padding: "0 20px 20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<p style={{ color: t.subtitleColor, fontSize: "14px", margin: 0 }}>
{subtitle}
</p>
{/* QR container */}
<div
style={{
backgroundColor: t.qrBackground,
padding: "12px",
borderRadius: "12px",
}}
>
<AlphanumericQRCode
value={qrUri}
size={t.qrSize}
foreground={t.qrForeground}
background={t.qrBackground}
/>
</div>
{/* Copy URI row */}
<div
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 12px",
backgroundColor: t.uriRowBackground,
borderRadius: "8px",
border: `1px solid ${t.borderColor}`,
}}
>
<p
style={{
color: t.uriTextColor,
fontSize: "12px",
margin: 0,
flex: 1,
wordBreak: "break-all",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{uri}
</p>
<button
onClick={handleCopy}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px",
flexShrink: 0,
display: "flex",
alignItems: "center",
}}
aria-label="Copy URI"
>
<CopyIcon color={t.copyButtonColor} />
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,299 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { UseWizardConnectResult } from "../types.js";
// Mock @wizardconnect/core before importing the hook
vi.mock("@wizardconnect/core", () => {
const EventEmitter = vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
}));
return {
initiateDappRelay: vi.fn(() => ({
client: {},
uri: "wiz://test-uri",
qrUri: "WIZ://TEST-URI",
credentials: {
privateKey: "a".repeat(64),
publicKey: "b".repeat(64),
secret: "c".repeat(16),
},
events: new (EventEmitter as unknown as {
new (): {
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
emit: ReturnType<typeof vi.fn>;
};
})(),
cleanup: vi.fn(),
})),
binToHex: vi.fn((bytes: Uint8Array) =>
Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
),
};
});
vi.mock("@wizardconnect/dapp", () => {
return {
DappConnectionManager: vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
walletName: null,
walletIcon: null,
updateConnection: vi.fn(),
sendDisconnect: vi.fn(() => Promise.resolve()),
})),
};
});
// Import AFTER mocks are set up
const { useWizardConnect } = await import("./useWizardConnect.js");
const { initiateDappRelay } = await import("@wizardconnect/core");
// Mock localStorage since happy-dom's implementation is incomplete
const storage = new Map<string, string>();
const mockLocalStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
get length() {
return storage.size;
},
key: (_index: number) => null,
};
Object.defineProperty(globalThis, "localStorage", {
value: mockLocalStorage,
writable: true,
});
describe("useWizardConnect", () => {
let container: HTMLDivElement;
let root: Root;
const SESSION_KEY = "wc-test-session";
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
storage.clear();
vi.clearAllMocks();
});
afterEach(() => {
root.unmount();
container.remove();
storage.clear();
});
/** Renders the hook inside a test component, returning a ref to the latest result. */
async function renderHook(
options?: Parameters<typeof useWizardConnect>[0],
): Promise<{ result: { current: UseWizardConnectResult } }> {
const result: { current: UseWizardConnectResult } = {
current: null as unknown as UseWizardConnectResult,
};
function TestComponent() {
const hookResult = useWizardConnect({
sessionKey: SESSION_KEY,
...options,
});
result.current = hookResult;
return null;
}
root.render(createElement(TestComponent));
// Wait for React to process the render
await new Promise<void>((r) => setTimeout(r, 0));
return { result };
}
it("starts in idle state", async () => {
const { result } = await renderHook({ persistSession: false });
expect(result.current.state).toBe("idle");
expect(result.current.manager).toBeNull();
expect(result.current.uri).toBeNull();
expect(result.current.qrUri).toBeNull();
expect(result.current.walletName).toBeNull();
expect(result.current.walletIcon).toBeNull();
expect(result.current.error).toBeNull();
});
it("transitions to connecting on connect()", async () => {
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(result.current.state).toBe("connecting");
expect(result.current.manager).not.toBeNull();
expect(result.current.uri).toBe("wiz://test-uri");
expect(result.current.qrUri).toBe("WIZ://TEST-URI");
});
it("connect() calls initiateDappRelay", async () => {
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).toHaveBeenCalledOnce();
});
it("connect() returns false when already connecting", async () => {
const { result } = await renderHook({ persistSession: false });
const firstResult = result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const secondResult = result.current.connect();
expect(firstResult).toBe(true);
expect(secondResult).toBe(false);
});
it("saves credentials to localStorage on connect when persistSession is true", async () => {
const { result } = await renderHook({ persistSession: true });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const stored = localStorage.getItem(SESSION_KEY);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!);
expect(parsed.privateKey).toBe("a".repeat(64));
expect(parsed.secret).toBe("c".repeat(16));
});
it("does not save credentials when persistSession is false", async () => {
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
});
it("disconnect() clears state and localStorage", async () => {
const { result } = await renderHook({ persistSession: true });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(localStorage.getItem(SESSION_KEY)).not.toBeNull();
await result.current.disconnect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(result.current.state).toBe("idle");
expect(result.current.manager).toBeNull();
expect(result.current.uri).toBeNull();
expect(result.current.qrUri).toBeNull();
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
});
it("attempts auto-reconnect when stored session has walletPublicKey", async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
}),
);
await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).toHaveBeenCalledOnce();
// Should pass existing credentials
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
expect(callArgs[1]).toEqual({
existingCredentials: {
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
},
});
});
it("does not auto-reconnect when stored session lacks walletPublicKey", async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
}),
);
await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).not.toHaveBeenCalled();
});
it("does not auto-reconnect when persistSession is false", async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
}),
);
await renderHook({ persistSession: false });
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).not.toHaveBeenCalled();
});
it("passes dappName and dappIcon to DappConnectionManager", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
const { result } = await renderHook({
dappName: "My Dapp",
dappIcon: "https://example.com/icon.png",
persistSession: false,
});
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(DappConnectionManager).toHaveBeenCalledWith(
"My Dapp",
"https://example.com/icon.png",
);
});
it("passes relayUrls to initiateDappRelay", async () => {
const { result } = await renderHook({
relayUrls: ["wss://custom-relay:443"],
persistSession: false,
});
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
expect(callArgs[1]).toEqual(
expect.objectContaining({
explicitRelayUrls: ["wss://custom-relay:443"],
}),
);
});
});

View file

@ -0,0 +1,214 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { useState, useRef, useEffect, useCallback } from "react";
import {
initiateDappRelay,
type DappRelayResult,
type RelayUpdatePayload,
binToHex,
} from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";
import type {
UseWizardConnectOptions,
UseWizardConnectResult,
WizardConnectState,
} from "../types.js";
const DEFAULT_SESSION_KEY = "wizardconnect-session";
interface StoredSession {
privateKey: string;
secret: string;
walletPublicKey?: string;
}
function loadSession(key: string): StoredSession | null {
if (typeof localStorage === "undefined") return null;
const stored = localStorage.getItem(key);
if (!stored) return null;
try {
const parsed = JSON.parse(stored) as StoredSession;
if (!parsed.privateKey || !parsed.secret) return null;
return parsed;
} catch {
return null;
}
}
function saveSession(key: string, session: StoredSession): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(key, JSON.stringify(session));
}
function clearSession(key: string): void {
if (typeof localStorage === "undefined") return;
localStorage.removeItem(key);
}
/**
* React hook that encapsulates the WizardConnect relay lifecycle.
*
* Manages: relay initiation, DappConnectionManager, key exchange events,
* session persistence, and auto-reconnect on page refresh.
*
* The returned `manager` can be used to build an app-specific wallet adapter.
*/
export function useWizardConnect(
options: UseWizardConnectOptions = {},
): UseWizardConnectResult {
const {
dappName,
dappIcon,
relayUrls,
sessionKey = DEFAULT_SESSION_KEY,
persistSession = true,
} = options;
const [state, setState] = useState<WizardConnectState>("idle");
const [manager, setManager] = useState<DappConnectionManager | null>(null);
const [uri, setUri] = useState<string | null>(null);
const [qrUri, setQrUri] = useState<string | null>(null);
const [walletName, setWalletName] = useState<string | null>(null);
const [walletIcon, setWalletIcon] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const relayRef = useRef<DappRelayResult | null>(null);
const managerRef = useRef<DappConnectionManager | null>(null);
const autoReconnectAttempted = useRef(false);
const startRelay = useCallback(
(existingCredentials?: {
privateKey: string;
secret: string;
walletPublicKey: string;
}): boolean => {
if (state === "connecting" || state === "connected") return false;
setError(null);
setState("connecting");
const mgr = new DappConnectionManager(dappName, dappIcon);
managerRef.current = mgr;
setManager(mgr);
mgr.on("walletready", () => {
setWalletName(mgr.walletName);
setWalletIcon(mgr.walletIcon);
setState("connected");
});
mgr.on("disconnect", () => {
setState("disconnected");
setWalletName(null);
setWalletIcon(null);
});
try {
const relay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
mgr.updateConnection(payload.client, payload.status);
},
{
existingCredentials,
explicitRelayUrls: relayUrls,
},
);
relayRef.current = relay;
setUri(relay.uri);
setQrUri(relay.qrUri);
if (persistSession) {
saveSession(sessionKey, {
privateKey: relay.credentials.privateKey,
secret: relay.credentials.secret,
});
}
relay.events.on(
"keyexchangecomplete",
(walletPublicKey: Uint8Array) => {
if (persistSession) {
const stored = loadSession(sessionKey);
if (stored) {
stored.walletPublicKey = binToHex(walletPublicKey);
saveSession(sessionKey, stored);
}
}
},
);
return true;
} catch (e) {
const message =
e instanceof Error ? e.message : "Failed to start relay";
setError(message);
setState("idle");
return false;
}
},
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession],
);
const connect = useCallback((): boolean => {
return startRelay();
}, [startRelay]);
const disconnect = useCallback(async (): Promise<void> => {
try {
if (managerRef.current) {
await managerRef.current.sendDisconnect().catch(() => {});
}
} finally {
relayRef.current?.cleanup();
relayRef.current = null;
managerRef.current = null;
setManager(null);
setUri(null);
setQrUri(null);
setWalletName(null);
setWalletIcon(null);
setState("idle");
if (persistSession) {
clearSession(sessionKey);
}
}
}, [persistSession, sessionKey]);
// Auto-reconnect on mount if a stored session exists
useEffect(() => {
if (autoReconnectAttempted.current) return;
if (!persistSession) return;
autoReconnectAttempted.current = true;
const stored = loadSession(sessionKey);
if (!stored || !stored.walletPublicKey) return;
startRelay({
privateKey: stored.privateKey,
secret: stored.secret,
walletPublicKey: stored.walletPublicKey,
});
}, [persistSession, sessionKey, startRelay]);
// Cleanup on unmount
useEffect(() => {
return () => {
relayRef.current?.cleanup();
};
}, []);
return {
state,
manager,
uri,
qrUri,
walletName,
walletIcon,
connect,
disconnect,
error,
};
}

View file

@ -0,0 +1,15 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
export { WizardConnectQRDialog } from "./components/WizardConnectQRDialog.js";
export { AlphanumericQRCode } from "./components/AlphanumericQRCode.js";
export { useWizardConnect } from "./hooks/useWizardConnect.js";
export type {
WizardConnectQRDialogProps,
WizardConnectQRTheme,
AlphanumericQRCodeProps,
UseWizardConnectOptions,
UseWizardConnectResult,
WizardConnectState,
} from "./types.js";

115
packages/react/src/types.ts Normal file
View file

@ -0,0 +1,115 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import type { DappConnectionManager } from "@wizardconnect/dapp";
// ---- QR Code ----
export interface AlphanumericQRCodeProps {
/** The alphanumeric string to encode. */
value: string;
/** QR code pixel size. Default: 280 */
size?: number;
/** Module (dark cell) color. Default: "#1e2a4a" */
foreground?: string;
/** Background color. Default: "#ffffff" */
background?: string;
/** Quiet zone size in pixels. Default: 4 */
quietZone?: number;
}
// ---- QR Dialog ----
export interface WizardConnectQRTheme {
/** Backdrop overlay color. Default: "rgba(0,0,0,0.5)" */
backdropColor?: string;
/** Dialog body background. Default: "#1a1f2e" */
dialogBackground?: string;
/** Header bar background. Default: "#1a1f2e" */
headerBackground?: string;
/** Title text color. Default: "#ffffff" */
titleColor?: string;
/** Subtitle text color. Default: "#9ca3af" */
subtitleColor?: string;
/** QR foreground (module) color. Default: "#1e2a4a" */
qrForeground?: string;
/** QR background color. Default: "#ffffff" */
qrBackground?: string;
/** URI display row background. Default: "rgba(31,41,55,0.6)" */
uriRowBackground?: string;
/** URI text color. Default: "#9ca3af" */
uriTextColor?: string;
/** Border color. Default: "#374151" */
borderColor?: string;
/** Close button color. Default: "#9ca3af" */
closeButtonColor?: string;
/** Copy button color. Default: "#9ca3af" */
copyButtonColor?: string;
/** QR code size in pixels. Default: 280 */
qrSize?: number;
}
export interface WizardConnectQRDialogProps {
/** Whether the dialog is visible. */
show: boolean;
/** Called when the user clicks close or the backdrop. */
onClose: () => void;
/** The human-readable URI to display (lowercase wiz://...). */
uri: string;
/** The QR-alphanumeric-safe URI for encoding (uppercase WIZ://...). */
qrUri: string;
/** Called when the user clicks the copy button. Receives the uri string.
* If not provided, uses navigator.clipboard.writeText(). */
onCopy?: (uri: string) => void;
/** Theme overrides. */
theme?: WizardConnectQRTheme;
/** Additional CSS class on the outermost container. */
className?: string;
/** Subtitle text. Default: "Scan with your wallet to connect" */
subtitle?: string;
/** Title text. Default: "WizardConnect" */
title?: string;
}
// ---- Hook ----
export type WizardConnectState =
| "idle"
| "connecting"
| "connected"
| "disconnected";
export interface UseWizardConnectOptions {
/** Display name of the dapp. */
dappName?: string;
/** Icon URL/data-URI of the dapp. */
dappIcon?: string;
/** Explicit relay URLs. */
relayUrls?: string[];
/** LocalStorage key for session persistence. Default: "wizardconnect-session" */
sessionKey?: string;
/** Whether to persist session for auto-reconnect. Default: true */
persistSession?: boolean;
}
export interface UseWizardConnectResult {
/** Current connection state. */
state: WizardConnectState;
/** The DappConnectionManager (null before connect()). */
manager: DappConnectionManager | null;
/** The connection URI (null before connect()). */
uri: string | null;
/** The QR-alphanumeric URI (null before connect()). */
qrUri: string | null;
/** Wallet name (null until walletready). */
walletName: string | null;
/** Wallet icon (null until walletready). */
walletIcon: string | null;
/** Initiate a new connection. Returns false if already connecting/connected. */
connect: () => boolean;
/** Disconnect and clean up. */
disconnect: () => Promise<void>;
/** Error message if connection failed. */
error: string | null;
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx"
]
}

View file

@ -0,0 +1,19 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@wizardconnect/core": path.resolve(__dirname, "../core/src/index.ts"),
"@wizardconnect/dapp": path.resolve(__dirname, "../dapp/src/index.ts"),
},
},
test: {
environment: "happy-dom",
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
},
});