Merge branch 'pkg-react' into 'master'

Add 'react' package with QR code and modal dialog

See merge request riftenlabs/lib/wizardconnect!11
This commit is contained in:
Dagur Valberg Johannsson 2026-03-18 15:41:30 +00:00
commit 33c45596ef
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/**"],
},
});