Merge branch 'nicer-react' into 'master'

Add session persistence and refactor dapp manager

See merge request riftenlabs/lib/wizardconnect!19
This commit is contained in:
Dagur Valberg Johannsson 2026-04-03 15:15:26 +00:00
commit 4bcbef2aae
12 changed files with 732 additions and 305 deletions

View file

@ -17,24 +17,34 @@ class DappConnectionManager extends EventEmitter {
/** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
protocol: string | null;
constructor(dappName?: string, dappIcon?: string)
constructor(dappName?: string, dappIcon?: string, options?: {
/** Session persistence config. Enabled by default (key: "wizardconnect-session",
* storage: localStorage). Pass `false` to disable. */
session?: DappSessionOptions | false;
})
/** Call from the RelayStatusCallback each time the relay status changes.
* Attaches the message listener exactly once (on first client seen).
* Triggers onConnected() on each "connected" event. */
/** Call from the RelayStatusCallback each time the relay status changes. */
updateConnection(client: RelayClient | null, status: RelayStatus): void
isWalletDiscovered(): boolean
/** Get the next sequence number for a SignTransactionRequest. */
nextSequence(): number
/** Convenience: build a full SignTransactionRequest, send it, and optionally
* cancel via AbortSignal. See "Sending a sign request" below. */
signTransaction(
request: Pick<SignTransactionRequest, "transaction" | "inputPaths">,
options?: { signal?: AbortSignal },
): Promise<SignTransactionResponse>
/** Send a sign request and wait for the wallet's response.
* Rejects if the wallet returns an error or if the connection drops. */
/** Low-level: send a fully constructed sign request. */
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
/** Send a UserDisconnect courtesy message to the wallet.
* Caller is responsible for calling dappRelay.cleanup() afterwards. */
/** Cancel an in-flight sign request by sequence number. */
sendSignCancel(sequence: number, reason?: string): Promise<void>
/** Get the next sequence number (for manual request construction). */
nextSequence(): number
/** Send a UserDisconnect courtesy message to the wallet. */
sendDisconnect(message?: string): Promise<void>
/** Get raw PathXpub[] received in wallet_ready (for caching). */
@ -43,6 +53,11 @@ class DappConnectionManager extends EventEmitter {
/** Restore cached xpub paths — enables getPubkey() without wallet_ready. */
restoreSessionPaths(paths: PathXpub[]): void
// Session persistence (see "Session persistence" section below)
attachRelay(relay: DappRelayResult): void
loadStoredSession(): StoredSession | null
clearStoredSession(): void
// Events
on("walletready", (msg: WalletReadyMessage) => void)
on("messagesent", (msg: ProtocolMessage) => void)
@ -107,11 +122,12 @@ const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.p
const relay = initiateDappRelay(
(payload) => {
dappMgr.updateConnection(payload.client, payload.status);
// also update your own UI state here (connected/disconnected indicator)
},
{ explicitRelayUrls: ["wss://relay.cauldron.quest:443"] },
);
// Persist relay credentials and auto-save walletPublicKey on key exchange
dappMgr.attachRelay(relay);
// Show relay.uri as a QR code for the wallet to scan.
console.log("Scan this URI:", relay.uri);
```
@ -138,37 +154,66 @@ See [pubkey-derivation.md](pubkey-derivation.md) for full details.
### Sending a sign request
The `signTransaction` convenience method auto-fills `action`, `sequence`, and `time`:
```typescript
const response = await dappMgr.signTransaction({
transaction: {
transaction: txHex,
sourceOutputs,
userPrompt: "Confirm swap",
broadcast: true,
},
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
});
console.log("Signed tx:", response.signedTransaction);
```
#### Cancellation via AbortSignal
Pass an `AbortSignal` to automatically cancel the request when aborted. This sends
`sign_cancel` to the wallet and rejects the promise with an `AbortError`:
```typescript
const controller = new AbortController();
cancelButton.onclick = () => controller.abort("User cancelled");
try {
const response = await dappMgr.signTransaction(
{ transaction: { ... }, inputPaths: [...] },
{ signal: controller.signal },
);
} catch (err) {
if (err.name === "AbortError") {
console.log("User cancelled the signature request");
}
}
```
#### Low-level: sendSignRequest
For full control over the request, use `sendSignRequest` directly:
```typescript
const seq = dappMgr.nextSequence();
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: seq,
time: Math.floor(Date.now() / 1000),
transaction: {
transaction: { inputs, outputs, version: 2, locktime: 0 },
sourceOutputs,
userPrompt: "Confirm swap",
broadcast: true,
},
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
transaction: { ... },
inputPaths: [[0, "receive", 0]],
};
try {
const response = await dappMgr.sendSignRequest(request);
console.log("Signed tx:", response.signedTransaction);
} catch (err) {
console.error("Signing failed:", err.message);
}
const response = await dappMgr.sendSignRequest(request);
// Cancel with: await dappMgr.sendSignCancel(seq, "reason");
```
`sendSignRequest` returns a Promise that resolves when the wallet sends back a
`sign_transaction_response` with the matching `sequence`. It rejects if the wallet sends an
error response.
### Disconnecting
```typescript
// Dapp-initiated: send courtesy message, then tear down the relay
// Dapp-initiated: send courtesy message, clear session, then tear down the relay
dappMgr.clearStoredSession();
await dappMgr.sendDisconnect("user closed the tab");
relay.cleanup();
@ -189,27 +234,85 @@ The `disconnect` event fires in two cases:
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
wallet before emitting the event.
### Session path persistence
### Session persistence
After `wallet_ready`, the manager stores the raw `PathXpub[]` from the wallet. Use
`getSessionPaths()` to retrieve them (e.g. for caching in localStorage). On a subsequent
page load, restore them with `restoreSessionPaths()` so `getPubkey()` works immediately
without waiting for the wallet to reconnect:
Session persistence is **enabled by default**. The manager automatically:
- **On construction**: restores xpub paths from storage so `getPubkey()` works immediately.
- **On `walletready`**: saves `walletName`, `walletIcon`, and xpub `paths` to storage.
The default storage key is `"wizardconnect-session"` and the default backend is `localStorage`.
#### Saving session data
Call `attachRelay()` after `initiateDappRelay()` to persist relay credentials and
automatically save the wallet public key when key exchange completes:
```typescript
// After wallet_ready — save for later
const paths = dappMgr.getSessionPaths();
localStorage.setItem("myapp-paths", JSON.stringify(paths));
const relay = initiateDappRelay(callback);
dappMgr.attachRelay(relay); // saves credentials + auto-saves walletPublicKey
// On page load — restore before wallet reconnects
const cached = JSON.parse(localStorage.getItem("myapp-paths") ?? "null");
if (cached) {
dappMgr.restoreSessionPaths(cached);
// getPubkey() now works without wallet_ready
// On disconnect:
dappMgr.clearStoredSession();
```
#### Loading for reconnection
Use `loadStoredSession()` on an existing manager, or the standalone `loadSession()` when
you need to read the session before creating the manager (e.g. to get relay credentials
for `initiateDappRelay`):
```typescript
import { loadSession } from "@wizardconnect/dapp";
const session = loadSession(); // uses default key and localStorage
if (session?.walletPublicKey) {
const relay = initiateDappRelay(callback, { existingCredentials: session });
}
```
Throws if any xpub string is invalid (corrupt cached data should be cleared).
#### Configuration
```typescript
// Default: session enabled, key "wizardconnect-session", localStorage
const mgr = new DappConnectionManager("My Dapp");
// Custom key:
const mgr = new DappConnectionManager("My Dapp", undefined, {
session: { key: "my-app-session" },
});
// Custom storage backend (e.g. for React Native or SSR):
const mgr = new DappConnectionManager("My Dapp", undefined, {
session: { storage: myCustomStorage },
});
// Disable session persistence:
const mgr = new DappConnectionManager("My Dapp", undefined, {
session: false,
});
```
The `SessionStorage` interface matches the Web Storage API:
```typescript
interface SessionStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
```
#### Manual path management
For advanced use cases, `getSessionPaths()` and `restoreSessionPaths()` are still available:
```typescript
const paths = dappMgr.getSessionPaths(); // raw PathXpub[] from wallet_ready
dappMgr.restoreSessionPaths(paths); // re-populate pubkeyState from cached paths
```
`restoreSessionPaths` throws if any xpub string is invalid.
### Reconnection
@ -247,14 +350,24 @@ relay.events.on("keyexchangecomplete", async (walletPubkey) => {
});
```
## Cauldron (cauldron-beta) implementation notes
## Using with React
Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`.
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface.
For React dapps, prefer the `useWizardConnect` hook from `@wizardconnect/react` over
managing the relay lifecycle manually. The hook handles session persistence, auto-reconnect,
and relay cleanup automatically. See [react.md](react.md).
Key design points:
- `DappConnectionManager` is created per connection session (not a singleton).
- `updateConnection()` is called from the relay status callback.
- `getXpubNode(childIndex)` and `getPubkey(childIndex, index)` are the primary access patterns.
- Child indices are used internally (0/1/7); `childIndexOfPathName()` converts from PathName
when processing `wallet_ready` data.
Dapps that need a custom wallet adapter (e.g. Cauldron, Moria) can use the hook and wrap
the returned `manager` in their adapter:
```typescript
const wc = useWizardConnect({ dappName: "My Dapp" });
useEffect(() => {
if (!wc.manager) return;
const wallet = new MyWalletAdapter(wc.manager);
// dispatch wallet to your store
}, [wc.manager]);
```
The `DappConnectionManager` is created by the hook; the adapter receives it rather than
creating its own.

View file

@ -38,14 +38,16 @@ function App() {
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.
1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, sets
`uri`/`qrUri` for QR display, and transitions to `"connecting"`.
2. **Key exchange** — persists the wallet's public key to session storage.
3. **`walletready`** — transitions to `"connected"` and populates `walletName`/`walletIcon`.
4. **Auto-reconnect** — on mount, checks session storage for a stored session with a
`walletPublicKey` and automatically reconnects. During auto-reconnect, `uri`/`qrUri` remain
`null` (no QR code to display — credentials are already known).
5. **`disconnect()`** — sends a courtesy disconnect, cleans up the relay, and clears session storage.
6. **Remote disconnect** — when the wallet sends a disconnect, the hook transitions to
`"disconnected"`, clears the session, and tears down the relay.
### Return value
@ -72,11 +74,16 @@ Once `state === "connected"`, the `manager` is a live `DappConnectionManager` fr
// 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({ ... });
// Send a sign request (auto-fills action, sequence, time)
const response = await wc.manager.signTransaction({
transaction: { transaction: txHex, sourceOutputs, broadcast: true },
inputPaths: [[0, "receive", 0]],
});
```
See the [dapp integration docs](dapp.md#sending-a-sign-request) for cancellation via
`AbortSignal` and the low-level `sendSignRequest` API.
For most dapps, you'll wrap the manager in an app-specific wallet adapter (like
`RelayWalletDapp` in the Cauldron and Moria codebases).
@ -130,21 +137,48 @@ level H (30% recovery), which allows a center logo overlay without breaking the
## Session persistence
By default, `useWizardConnect` persists session credentials (private key, shared secret,
wallet public key, and xpub paths) to localStorage under the `wizardconnect-session` key.
This enables auto-reconnect when the user refreshes the page.
By default, `useWizardConnect` persists session data to localStorage under the
`wizardconnect-session` key. The stored session includes:
When xpub paths are cached, they are restored via `restoreSessionPaths()` on auto-reconnect
so that `getPubkey()` works immediately — before the wallet sends a new `wallet_ready`.
- **Relay credentials** (`privateKey`, `secret`) — saved on `connect()`
- **Wallet public key** (`walletPublicKey`) — saved after key exchange
- **Wallet identity** (`walletName`, `walletIcon`) — saved on `walletready`
- **Xpub paths** (`paths`) — saved on `walletready`
The persistence key and behavior can be customized:
On page refresh, the hook auto-reconnects if a stored session with `walletPublicKey` exists.
Xpub paths and wallet name are restored immediately so `getPubkey()` works and the UI can
display the wallet name before the wallet app responds. During auto-reconnect, `uri` and
`qrUri` remain `null` — the QR dialog should not be shown. The typical pattern is:
```tsx
{wc.uri && wc.qrUri && (
<WizardConnectQRDialog
show={wc.state === "connecting"}
onClose={() => wc.disconnect()}
uri={wc.uri}
qrUri={wc.qrUri}
/>
)}
```
This naturally hides the dialog during auto-reconnect since `wc.uri` is `null`.
If the wallet sends a disconnect, the hook clears the session automatically and transitions
to `"disconnected"`. Dapps should listen for this state change to update their UI (e.g.
clear the wallet from their store).
### Customization
```typescript
useWizardConnect({
sessionKey: "my-app-wc-session", // custom localStorage key
persistSession: false, // disable persistence entirely
sessionKey: "my-app-session", // custom storage key (default: "wizardconnect-session")
persistSession: false, // disable persistence entirely
storage: myCustomStorage, // custom SessionStorage backend (default: localStorage)
});
```
Credentials are only stored after the key exchange completes. On disconnect, the stored session
is cleared.
The `storage` option accepts any object with `getItem`, `setItem`, and `removeItem` methods
(the standard Web Storage API). This is useful for React Native or server-side rendering
where `localStorage` is not available.
On disconnect, the stored session is cleared.

143
package-lock.json generated
View file

@ -1147,34 +1147,6 @@
"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/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
@ -2435,26 +2407,6 @@
"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",
@ -2820,33 +2772,6 @@
"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/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@ -2918,16 +2843,6 @@
"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",
@ -3522,11 +3437,11 @@
"qrcode-generator": "^1.4.4"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"happy-dom": "^20.8.4",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.3"
},
@ -3535,6 +3450,56 @@
"react-dom": ">=18.0.0"
}
},
"packages/react/node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"packages/react/node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"packages/react/node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"packages/react/node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"packages/react/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"dev": true,
"license": "MIT"
},
"packages/test-cli": {
"name": "@wizardconnect/test-cli",
"version": "0.1.0",

View file

@ -20,9 +20,25 @@ import {
PROTOCOL_NAME,
childIndexOfPathName,
isHdwalletv1Session,
binToHex,
} from "@wizardconnect/core";
import type { PathXpub } from "@wizardconnect/core";
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
import {
type SessionStorage,
type StoredSession,
DEFAULT_SESSION_KEY,
loadSession,
saveSession,
clearSession,
} from "./session.js";
export interface DappSessionOptions {
/** Storage key for session persistence. Default: "wizardconnect-session" */
key?: string;
/** Storage backend. Defaults to localStorage if available. */
storage?: SessionStorage;
}
export interface DappConnectionManagerEvents {
/** Fired after wallet_ready is received and state is updated. */
@ -70,19 +86,98 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
reject: (e: Error) => void;
}
>();
private sessionOptions: { key: string; storage?: SessionStorage } | null =
null;
/**
* @param dappName Optional display name of the dapp (sent in dapp_ready).
* @param dappIcon Optional icon URL/data-URI of the dapp (sent in dapp_ready).
* @param options Optional configuration. Session persistence is enabled by
* default (key: "wizardconnect-session", storage: localStorage).
* Pass `session: false` to disable.
*/
constructor(
private dappName?: string,
private dappIcon?: string,
options?: { session?: DappSessionOptions | false },
) {
super();
this.pubkeyState = new DappPubkeyStateManager();
if (options?.session !== false) {
const sessionConf = options?.session ?? {};
this.sessionOptions = {
key: sessionConf.key ?? DEFAULT_SESSION_KEY,
storage: sessionConf.storage,
};
// Auto-restore from stored session
const stored = loadSession(
this.sessionOptions.key,
this.sessionOptions.storage,
);
if (stored) {
if (stored.walletName) this.walletName = stored.walletName;
if (stored.walletIcon) this.walletIcon = stored.walletIcon;
if (stored.paths?.length) {
try {
this.restoreSessionPaths(stored.paths);
} catch {
// Corrupt cached paths — ignore, wallet_ready will repopulate
}
}
}
}
}
// --- Session persistence (public API) ----------------------------------------
/**
* Attach a relay result from `initiateDappRelay()`. Automatically:
* - Saves relay credentials (privateKey, secret) to the session
* - Listens for `keyexchangecomplete` and saves the wallet public key
*
* No-op if session persistence is disabled.
*/
attachRelay(relay: DappRelayResult): void {
if (!this.sessionOptions) return;
saveSession(
this.sessionOptions.key,
{
privateKey: relay.credentials.privateKey,
secret: relay.credentials.secret,
},
this.sessionOptions.storage,
);
relay.events.on("keyexchangecomplete", (walletPublicKey: Uint8Array) => {
if (!this.sessionOptions) return;
saveSession(
this.sessionOptions.key,
{ walletPublicKey: binToHex(walletPublicKey) },
this.sessionOptions.storage,
);
});
}
/**
* Load the stored session (e.g. for reconnection).
* Returns null if session persistence is disabled or no session exists.
*/
loadStoredSession(): StoredSession | null {
if (!this.sessionOptions) return null;
return loadSession(this.sessionOptions.key, this.sessionOptions.storage);
}
/**
* Clear the stored session. Call on disconnect.
* No-op if session persistence is disabled.
*/
clearStoredSession(): void {
if (!this.sessionOptions) return;
clearSession(this.sessionOptions.key, this.sessionOptions.storage);
}
// --- Relay connection -------------------------------------------------------
/**
* Call this from the RelayStatusCallback passed to `initiateDappRelay`.
* Attaches the message listener exactly once and re-sends dapp_ready
@ -185,6 +280,57 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagesent", msg);
}
/**
* Convenience method: build and send a sign transaction request.
* Auto-fills `action`, `sequence`, and `time`. Supports cancellation via
* AbortSignal when aborted, sendSignCancel is called automatically.
*/
async signTransaction(
request: Pick<SignTransactionRequest, "transaction" | "inputPaths">,
options?: { signal?: AbortSignal },
): Promise<SignTransactionResponse> {
const sequence = this.nextSequence();
const fullRequest: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
time: Math.floor(Date.now() / 1000),
sequence,
...request,
};
const signPromise = this.sendSignRequest(fullRequest);
if (!options?.signal) return signPromise;
// Suppress unhandled rejection — abort path rejects separately
signPromise.catch(() => {});
return new Promise<SignTransactionResponse>((resolve, reject) => {
const onAbort = () => {
const reason =
options.signal!.reason instanceof Error
? options.signal!.reason.message
: typeof options.signal!.reason === "string"
? options.signal!.reason
: "Sign request cancelled";
this.sendSignCancel(sequence, reason).catch(() => {});
reject(new DOMException(reason, "AbortError"));
};
if (options.signal!.aborted) {
onAbort();
return;
}
options.signal!.addEventListener("abort", onAbort, { once: true });
signPromise
.then(resolve)
.catch(reject)
.finally(() => {
options.signal!.removeEventListener("abort", onAbort);
});
});
}
// --- Pubkey state delegation ------------------------------------------------
// Convenience methods that forward to pubkeyState.
@ -352,6 +498,20 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("walletready", msg);
// Auto-persist wallet identity and xpub paths to session storage
if (this.sessionOptions) {
const sessionUpdate: Partial<StoredSession> = {
walletName: this.walletName ?? undefined,
walletIcon: this.walletIcon ?? undefined,
paths: this.getSessionPaths(),
};
saveSession(
this.sessionOptions.key,
sessionUpdate,
this.sessionOptions.storage,
);
}
// Re-send any pending sign requests the wallet may have missed
// (e.g. wallet app wasn't open when the request was first sent).
if (this.pendingSignatureRequests.size > 0 && this.conn) {

View file

@ -3,5 +3,15 @@
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
export { DappConnectionManager } from "./dapp-connection-manager.js";
export type { DappConnectionManagerEvents } from "./dapp-connection-manager.js";
export type {
DappConnectionManagerEvents,
DappSessionOptions,
} from "./dapp-connection-manager.js";
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
export {
DEFAULT_SESSION_KEY,
loadSession,
saveSession,
clearSession,
} from "./session.js";
export type { SessionStorage, StoredSession } from "./session.js";

View file

@ -0,0 +1,112 @@
// 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, beforeEach } from "vitest";
import {
loadSession,
saveSession,
clearSession,
type SessionStorage,
type StoredSession,
} from "./session.js";
/** In-memory storage for testing (avoids depending on a DOM environment). */
function createMemoryStorage(): SessionStorage {
const store = new Map<string, string>();
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
};
}
describe("session utilities", () => {
let storage: SessionStorage;
const KEY = "test-session";
beforeEach(() => {
storage = createMemoryStorage();
});
describe("loadSession", () => {
it("returns null for missing key", () => {
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns null for malformed JSON", () => {
storage.setItem(KEY, "not-json{");
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns null when privateKey is missing", () => {
storage.setItem(KEY, JSON.stringify({ secret: "s" }));
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns null when secret is missing", () => {
storage.setItem(KEY, JSON.stringify({ privateKey: "pk" }));
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns session with required fields", () => {
const session: StoredSession = { privateKey: "pk", secret: "s" };
storage.setItem(KEY, JSON.stringify(session));
expect(loadSession(KEY, storage)).toEqual(session);
});
it("returns session with all optional fields", () => {
const session: StoredSession = {
privateKey: "pk",
secret: "s",
walletPublicKey: "wpk",
walletName: "TestWallet",
walletIcon: "icon.png",
paths: [{ name: "receive", xpub: "xpub123" }],
};
storage.setItem(KEY, JSON.stringify(session));
expect(loadSession(KEY, storage)).toEqual(session);
});
});
describe("saveSession", () => {
it("saves a new session", () => {
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
const loaded = loadSession(KEY, storage);
expect(loaded).toEqual({ privateKey: "pk", secret: "s" });
});
it("merges with existing session", () => {
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
saveSession(KEY, { walletPublicKey: "wpk" }, storage);
const loaded = loadSession(KEY, storage);
expect(loaded).toEqual({
privateKey: "pk",
secret: "s",
walletPublicKey: "wpk",
});
});
it("overwrites fields on merge", () => {
saveSession(
KEY,
{ privateKey: "pk", secret: "s", walletName: "Old" },
storage,
);
saveSession(KEY, { walletName: "New" }, storage);
expect(loadSession(KEY, storage)?.walletName).toBe("New");
});
});
describe("clearSession", () => {
it("removes the stored session", () => {
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
clearSession(KEY, storage);
expect(loadSession(KEY, storage)).toBeNull();
});
it("is a no-op for missing key", () => {
expect(() => clearSession(KEY, storage)).not.toThrow();
});
});
});

View file

@ -0,0 +1,87 @@
// 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 { PathXpub } from "@wizardconnect/core";
/**
* Abstraction over localStorage for session persistence.
* Matches the Web Storage API subset, so `localStorage` can be passed directly.
*/
export interface SessionStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** Persisted session data for a WizardConnect dapp connection. */
export interface StoredSession {
privateKey: string;
secret: string;
walletPublicKey?: string;
walletName?: string;
walletIcon?: string;
paths?: PathXpub[];
}
function defaultStorage(): SessionStorage | null {
if (
typeof localStorage !== "undefined" &&
typeof localStorage.getItem === "function"
)
return localStorage;
return null;
}
function resolveStorage(storage?: SessionStorage): SessionStorage | null {
return storage ?? defaultStorage();
}
export const DEFAULT_SESSION_KEY = "wizardconnect-session";
/**
* Load a stored session. Returns null if the key is missing, the data is
* malformed, or the required fields (privateKey, secret) are absent.
*/
export function loadSession(
key: string = DEFAULT_SESSION_KEY,
storage?: SessionStorage,
): StoredSession | null {
const s = resolveStorage(storage);
if (!s) return null;
const raw = s.getItem(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as StoredSession;
if (!parsed.privateKey || !parsed.secret) return null;
return parsed;
} catch {
return null;
}
}
/**
* Save session data. Merges with any existing stored session so callers
* can save incrementally (e.g. credentials first, then walletPublicKey later).
*/
export function saveSession(
key: string = DEFAULT_SESSION_KEY,
data: Partial<StoredSession>,
storage?: SessionStorage,
): void {
const s = resolveStorage(storage);
if (!s) return;
const existing = loadSession(key, s);
const merged = { ...existing, ...data };
s.setItem(key, JSON.stringify(merged));
}
/** Remove a stored session. */
export function clearSession(
key: string = DEFAULT_SESSION_KEY,
storage?: SessionStorage,
): void {
const s = resolveStorage(storage);
if (!s) return;
s.removeItem(key);
}

View file

@ -33,11 +33,11 @@
"qrcode-generator": "^1.4.4"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"happy-dom": "^20.8.4",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.3"
}

View file

@ -42,8 +42,15 @@ vi.mock("@wizardconnect/core", () => {
};
});
vi.mock("@wizardconnect/dapp", () => {
vi.mock("@wizardconnect/dapp", async () => {
const actual = await vi.importActual<typeof import("@wizardconnect/dapp")>(
"@wizardconnect/dapp",
);
return {
// Use real session utilities (they work with our mock localStorage)
loadSession: actual.loadSession,
saveSession: actual.saveSession,
clearSession: actual.clearSession,
DappConnectionManager: vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
@ -53,6 +60,9 @@ vi.mock("@wizardconnect/dapp", () => {
sendDisconnect: vi.fn(() => Promise.resolve()),
getSessionPaths: vi.fn(() => []),
restoreSessionPaths: vi.fn(),
attachRelay: vi.fn(),
loadStoredSession: vi.fn(() => null),
clearStoredSession: vi.fn(),
})),
};
});
@ -166,17 +176,18 @@ describe("useWizardConnect", () => {
expect(secondResult).toBe(false);
});
it("saves credentials to localStorage on connect when persistSession is true", async () => {
it("calls attachRelay on connect when persistSession is true", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
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));
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
expect(mgrInstance.attachRelay).toHaveBeenCalledOnce();
});
it("does not save credentials when persistSession is false", async () => {
@ -188,12 +199,17 @@ describe("useWizardConnect", () => {
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
});
it("disconnect() clears state and localStorage", async () => {
it("disconnect() clears state and calls clearStoredSession", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
const { result } = await renderHook({ persistSession: true });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(localStorage.getItem(SESSION_KEY)).not.toBeNull();
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
await result.current.disconnect();
await new Promise<void>((r) => setTimeout(r, 0));
@ -202,7 +218,7 @@ describe("useWizardConnect", () => {
expect(result.current.manager).toBeNull();
expect(result.current.uri).toBeNull();
expect(result.current.qrUri).toBeNull();
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
expect(mgrInstance.clearStoredSession).toHaveBeenCalled();
});
it("attempts auto-reconnect when stored session has walletPublicKey", async () => {
@ -222,13 +238,15 @@ describe("useWizardConnect", () => {
// 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),
},
});
expect(callArgs[1]).toEqual(
expect.objectContaining({
existingCredentials: {
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
},
}),
);
});
it("does not auto-reconnect when stored session lacks walletPublicKey", async () => {
@ -279,10 +297,11 @@ describe("useWizardConnect", () => {
expect(DappConnectionManager).toHaveBeenCalledWith(
"My Dapp",
"https://example.com/icon.png",
expect.objectContaining({}),
);
});
it("restores cached xpub paths on auto-reconnect", async () => {
it("passes session option with stored paths to DappConnectionManager", async () => {
const testPaths = [
{ name: "receive" as const, xpub: "xpub6test1" },
{ name: "change" as const, xpub: "xpub6test2" },
@ -304,30 +323,30 @@ describe("useWizardConnect", () => {
await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0));
// Manager should have been created and restoreSessionPaths called
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
expect(mgrInstance.restoreSessionPaths).toHaveBeenCalledWith(testPaths);
});
it("does not call restoreSessionPaths when no cached paths", async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
// Manager is constructed with session option so it can auto-restore paths
expect(DappConnectionManager).toHaveBeenCalledWith(
undefined,
undefined,
expect.objectContaining({
session: expect.objectContaining({ key: SESSION_KEY }),
}),
);
});
it("disables session when persistSession is false", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
await renderHook({ persistSession: true });
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
expect(mgrInstance.restoreSessionPaths).not.toHaveBeenCalled();
expect(DappConnectionManager).toHaveBeenCalledWith(
undefined,
undefined,
expect.objectContaining({ session: false }),
);
});
it("passes relayUrls to initiateDappRelay", async () => {

View file

@ -7,10 +7,8 @@ import {
initiateDappRelay,
type DappRelayResult,
type RelayUpdatePayload,
type PathXpub,
binToHex,
} from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";
import { DappConnectionManager, loadSession } from "@wizardconnect/dapp";
import type {
UseWizardConnectOptions,
UseWizardConnectResult,
@ -19,37 +17,6 @@ import type {
const DEFAULT_SESSION_KEY = "wizardconnect-session";
interface StoredSession {
privateKey: string;
secret: string;
walletPublicKey?: string;
/** Raw xpub paths from wallet_ready, persisted for offline pubkey derivation. */
paths?: PathXpub[];
}
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.
*
@ -67,6 +34,7 @@ export function useWizardConnect(
relayUrls,
sessionKey = DEFAULT_SESSION_KEY,
persistSession = true,
storage,
} = options;
const [state, setState] = useState<WizardConnectState>("idle");
@ -92,7 +60,9 @@ export function useWizardConnect(
setError(null);
setState("connecting");
const mgr = new DappConnectionManager(dappName, dappIcon);
const mgr = new DappConnectionManager(dappName, dappIcon, {
session: persistSession ? { key: sessionKey, storage } : false,
});
managerRef.current = mgr;
setManager(mgr);
@ -100,24 +70,15 @@ export function useWizardConnect(
setWalletName(mgr.walletName);
setWalletIcon(mgr.walletIcon);
setState("connected");
// Persist xpub paths so getPubkey works on next page load
if (persistSession) {
const paths = mgr.getSessionPaths();
if (paths.length > 0) {
const stored = loadSession(sessionKey);
if (stored) {
stored.paths = paths;
saveSession(sessionKey, stored);
}
}
}
});
mgr.on("disconnect", () => {
setState("disconnected");
setWalletName(null);
setWalletIcon(null);
mgr.clearStoredSession();
relayRef.current?.cleanup();
relayRef.current = null;
});
try {
@ -132,31 +93,12 @@ export function useWizardConnect(
);
relayRef.current = relay;
setUri(relay.uri);
setQrUri(relay.qrUri);
if (persistSession) {
// Merge with existing session to preserve walletPublicKey and paths
const existing = loadSession(sessionKey);
saveSession(sessionKey, {
...existing,
privateKey: relay.credentials.privateKey,
secret: relay.credentials.secret,
});
// Only expose URI for new connections (QR pairing), not reconnects
if (!existingCredentials) {
setUri(relay.uri);
setQrUri(relay.qrUri);
}
relay.events.on(
"keyexchangecomplete",
(walletPublicKey: Uint8Array) => {
if (persistSession) {
const stored = loadSession(sessionKey);
if (stored) {
stored.walletPublicKey = binToHex(walletPublicKey);
saveSession(sessionKey, stored);
}
}
},
);
mgr.attachRelay(relay);
return true;
} catch (e) {
@ -167,7 +109,7 @@ export function useWizardConnect(
return false;
}
},
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession],
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession, storage],
);
const connect = useCallback((): boolean => {
@ -182,6 +124,7 @@ export function useWizardConnect(
} finally {
relayRef.current?.cleanup();
relayRef.current = null;
managerRef.current?.clearStoredSession();
managerRef.current = null;
setManager(null);
setUri(null);
@ -189,11 +132,8 @@ export function useWizardConnect(
setWalletName(null);
setWalletIcon(null);
setState("idle");
if (persistSession) {
clearSession(sessionKey);
}
}
}, [persistSession, sessionKey]);
}, []);
// Auto-reconnect on mount if a stored session exists
useEffect(() => {
@ -201,39 +141,20 @@ export function useWizardConnect(
if (!persistSession) return;
autoReconnectAttempted.current = true;
const stored = loadSession(sessionKey);
console.log(
"[wizardconnect/react] auto-reconnect: stored session:",
stored
? `walletPublicKey=${!!stored.walletPublicKey}, paths=${stored.paths?.length ?? 0}`
: "null",
);
// Read session before creating the manager (need credentials for startRelay)
const stored = loadSession(sessionKey, storage);
if (!stored || !stored.walletPublicKey) return;
const started = startRelay({
if (stored.walletName) {
setWalletName(stored.walletName);
}
startRelay({
privateKey: stored.privateKey,
secret: stored.secret,
walletPublicKey: stored.walletPublicKey,
});
// Restore cached xpub paths so getPubkey works before wallet_ready
if (started && stored.paths?.length && managerRef.current) {
try {
managerRef.current.restoreSessionPaths(stored.paths);
} catch (e) {
// Corrupt cached paths — clear them but don't block reconnect
console.warn(
"[wizardconnect/react] Failed to restore cached xpub paths:",
e,
);
const refreshed = loadSession(sessionKey);
if (refreshed) {
delete refreshed.paths;
saveSession(sessionKey, refreshed);
}
}
}
}, [persistSession, sessionKey, startRelay]);
}, [persistSession, sessionKey, storage, startRelay]);
// Cleanup on unmount
useEffect(() => {

View file

@ -13,3 +13,4 @@ export type {
UseWizardConnectResult,
WizardConnectState,
} from "./types.js";
export type { SessionStorage } from "@wizardconnect/dapp";

View file

@ -2,7 +2,10 @@
// 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";
import type {
DappConnectionManager,
SessionStorage,
} from "@wizardconnect/dapp";
// ---- QR Code ----
@ -91,6 +94,8 @@ export interface UseWizardConnectOptions {
sessionKey?: string;
/** Whether to persist session for auto-reconnect. Default: true */
persistSession?: boolean;
/** Custom storage backend. Defaults to localStorage. */
storage?: SessionStorage;
}
export interface UseWizardConnectResult {