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:13:35 +00:00
commit ec9db48453
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. */ /** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
protocol: string | null; 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. /** 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. */
updateConnection(client: RelayClient | null, status: RelayStatus): void updateConnection(client: RelayClient | null, status: RelayStatus): void
isWalletDiscovered(): boolean isWalletDiscovered(): boolean
/** Get the next sequence number for a SignTransactionRequest. */ /** Convenience: build a full SignTransactionRequest, send it, and optionally
nextSequence(): number * 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. /** Low-level: send a fully constructed sign request. */
* Rejects if the wallet returns an error or if the connection drops. */
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse> sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
/** Send a UserDisconnect courtesy message to the wallet. /** Cancel an in-flight sign request by sequence number. */
* Caller is responsible for calling dappRelay.cleanup() afterwards. */ 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> sendDisconnect(message?: string): Promise<void>
/** Get raw PathXpub[] received in wallet_ready (for caching). */ /** 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. */ /** Restore cached xpub paths — enables getPubkey() without wallet_ready. */
restoreSessionPaths(paths: PathXpub[]): void restoreSessionPaths(paths: PathXpub[]): void
// Session persistence (see "Session persistence" section below)
attachRelay(relay: DappRelayResult): void
loadStoredSession(): StoredSession | null
clearStoredSession(): void
// Events // Events
on("walletready", (msg: WalletReadyMessage) => void) on("walletready", (msg: WalletReadyMessage) => void)
on("messagesent", (msg: ProtocolMessage) => void) on("messagesent", (msg: ProtocolMessage) => void)
@ -107,11 +122,12 @@ const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.p
const relay = initiateDappRelay( const relay = initiateDappRelay(
(payload) => { (payload) => {
dappMgr.updateConnection(payload.client, payload.status); 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. // Show relay.uri as a QR code for the wallet to scan.
console.log("Scan this URI:", relay.uri); 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 ### 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 ```typescript
const seq = dappMgr.nextSequence(); const seq = dappMgr.nextSequence();
const request: SignTransactionRequest = { const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest, action: RelayMsgAction.SignTransactionRequest,
sequence: seq, sequence: seq,
time: Math.floor(Date.now() / 1000), time: Math.floor(Date.now() / 1000),
transaction: { transaction: { ... },
transaction: { inputs, outputs, version: 2, locktime: 0 }, inputPaths: [[0, "receive", 0]],
sourceOutputs,
userPrompt: "Confirm swap",
broadcast: true,
},
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
}; };
try {
const response = await dappMgr.sendSignRequest(request); const response = await dappMgr.sendSignRequest(request);
console.log("Signed tx:", response.signedTransaction); // Cancel with: await dappMgr.sendSignCancel(seq, "reason");
} catch (err) {
console.error("Signing failed:", err.message);
}
``` ```
`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 ### Disconnecting
```typescript ```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"); await dappMgr.sendDisconnect("user closed the tab");
relay.cleanup(); relay.cleanup();
@ -189,27 +234,85 @@ The `disconnect` event fires in two cases:
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the `supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
wallet before emitting the event. wallet before emitting the event.
### Session path persistence ### Session persistence
After `wallet_ready`, the manager stores the raw `PathXpub[]` from the wallet. Use Session persistence is **enabled by default**. The manager automatically:
`getSessionPaths()` to retrieve them (e.g. for caching in localStorage). On a subsequent
page load, restore them with `restoreSessionPaths()` so `getPubkey()` works immediately - **On construction**: restores xpub paths from storage so `getPubkey()` works immediately.
without waiting for the wallet to reconnect: - **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 ```typescript
// After wallet_ready — save for later const relay = initiateDappRelay(callback);
const paths = dappMgr.getSessionPaths(); dappMgr.attachRelay(relay); // saves credentials + auto-saves walletPublicKey
localStorage.setItem("myapp-paths", JSON.stringify(paths));
// On page load — restore before wallet reconnects // On disconnect:
const cached = JSON.parse(localStorage.getItem("myapp-paths") ?? "null"); dappMgr.clearStoredSession();
if (cached) { ```
dappMgr.restoreSessionPaths(cached);
// getPubkey() now works without wallet_ready #### 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 ### 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`. For React dapps, prefer the `useWizardConnect` hook from `@wizardconnect/react` over
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface. managing the relay lifecycle manually. The hook handles session persistence, auto-reconnect,
and relay cleanup automatically. See [react.md](react.md).
Key design points: Dapps that need a custom wallet adapter (e.g. Cauldron, Moria) can use the hook and wrap
- `DappConnectionManager` is created per connection session (not a singleton). the returned `manager` in their adapter:
- `updateConnection()` is called from the relay status callback.
- `getXpubNode(childIndex)` and `getPubkey(childIndex, index)` are the primary access patterns. ```typescript
- Child indices are used internally (0/1/7); `childIndexOfPathName()` converts from PathName const wc = useWizardConnect({ dappName: "My Dapp" });
when processing `wallet_ready` data.
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: The `useWizardConnect` hook manages the full relay lifecycle:
1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, and returns 1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, sets
the connection URI for QR display. `uri`/`qrUri` for QR display, and transitions to `"connecting"`.
2. **Key exchange** — listens for `keyexchangecomplete` and persists the wallet's public key to 2. **Key exchange** — persists the wallet's public key to session storage.
localStorage for auto-reconnect. 3. **`walletready`** — transitions to `"connected"` and populates `walletName`/`walletIcon`.
3. **`walletready`** — updates `state` to `"connected"` and populates `walletName`/`walletIcon`. 4. **Auto-reconnect** — on mount, checks session storage for a stored session with a
4. **Auto-reconnect** — on mount, checks localStorage for a stored session with a `walletPublicKey` `walletPublicKey` and automatically reconnects. During auto-reconnect, `uri`/`qrUri` remain
and automatically reconnects. `null` (no QR code to display — credentials are already known).
5. **`disconnect()`** — sends a courtesy disconnect, cleans up the relay, and clears session storage. 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 ### Return value
@ -72,11 +74,16 @@ Once `state === "connected"`, the `manager` is a live `DappConnectionManager` fr
// Derive a receive address pubkey // Derive a receive address pubkey
const pubkey = wc.manager.getPubkey(0, 0n); const pubkey = wc.manager.getPubkey(0, 0n);
// Send a sign request // Send a sign request (auto-fills action, sequence, time)
const seq = wc.manager.nextSequence(); const response = await wc.manager.signTransaction({
const response = await wc.manager.sendSignRequest({ ... }); 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 For most dapps, you'll wrap the manager in an app-specific wallet adapter (like
`RelayWalletDapp` in the Cauldron and Moria codebases). `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 ## Session persistence
By default, `useWizardConnect` persists session credentials (private key, shared secret, By default, `useWizardConnect` persists session data to localStorage under the
wallet public key, and xpub paths) to localStorage under the `wizardconnect-session` key. `wizardconnect-session` key. The stored session includes:
This enables auto-reconnect when the user refreshes the page.
When xpub paths are cached, they are restored via `restoreSessionPaths()` on auto-reconnect - **Relay credentials** (`privateKey`, `secret`) — saved on `connect()`
so that `getPubkey()` works immediately — before the wallet sends a new `wallet_ready`. - **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 ```typescript
useWizardConnect({ useWizardConnect({
sessionKey: "my-app-wc-session", // custom localStorage key sessionKey: "my-app-session", // custom storage key (default: "wizardconnect-session")
persistSession: false, // disable persistence entirely 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 The `storage` option accepts any object with `getItem`, `setItem`, and `removeItem` methods
is cleared. (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" "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": { "node_modules/@types/whatwg-mimetype": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
@ -2435,26 +2407,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lossless-json": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
@ -2820,33 +2772,6 @@
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
"license": "MIT" "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": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@ -2918,16 +2843,6 @@
"fsevents": "~2.3.2" "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": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -3522,11 +3437,11 @@
"qrcode-generator": "^1.4.4" "qrcode-generator": "^1.4.4"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^18.0.0", "@types/react-dom": "^19.0.0",
"happy-dom": "^20.8.4", "happy-dom": "^20.8.4",
"react": "^18.0.0", "react": "^19.0.0",
"react-dom": "^18.0.0", "react-dom": "^19.0.0",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vitest": "^3.2.3" "vitest": "^3.2.3"
}, },
@ -3535,6 +3450,56 @@
"react-dom": ">=18.0.0" "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": { "packages/test-cli": {
"name": "@wizardconnect/test-cli", "name": "@wizardconnect/test-cli",
"version": "0.1.0", "version": "0.1.0",

View file

@ -20,9 +20,25 @@ import {
PROTOCOL_NAME, PROTOCOL_NAME,
childIndexOfPathName, childIndexOfPathName,
isHdwalletv1Session, isHdwalletv1Session,
binToHex,
} from "@wizardconnect/core"; } from "@wizardconnect/core";
import type { PathXpub } from "@wizardconnect/core"; import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js"; 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 { export interface DappConnectionManagerEvents {
/** Fired after wallet_ready is received and state is updated. */ /** Fired after wallet_ready is received and state is updated. */
@ -70,18 +86,97 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
reject: (e: Error) => void; 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 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 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( constructor(
private dappName?: string, private dappName?: string,
private dappIcon?: string, private dappIcon?: string,
options?: { session?: DappSessionOptions | false },
) { ) {
super(); super();
this.pubkeyState = new DappPubkeyStateManager(); 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`. * Call this from the RelayStatusCallback passed to `initiateDappRelay`.
@ -185,6 +280,57 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagesent", msg); 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 ------------------------------------------------ // --- Pubkey state delegation ------------------------------------------------
// Convenience methods that forward to pubkeyState. // Convenience methods that forward to pubkeyState.
@ -352,6 +498,20 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("walletready", msg); 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 // Re-send any pending sign requests the wallet may have missed
// (e.g. wallet app wasn't open when the request was first sent). // (e.g. wallet app wasn't open when the request was first sent).
if (this.pendingSignatureRequests.size > 0 && this.conn) { 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 // 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 { 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 { 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" "qrcode-generator": "^1.4.4"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^18.0.0", "@types/react-dom": "^19.0.0",
"happy-dom": "^20.8.4", "happy-dom": "^20.8.4",
"react": "^18.0.0", "react": "^19.0.0",
"react-dom": "^18.0.0", "react-dom": "^19.0.0",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vitest": "^3.2.3" "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 { return {
// Use real session utilities (they work with our mock localStorage)
loadSession: actual.loadSession,
saveSession: actual.saveSession,
clearSession: actual.clearSession,
DappConnectionManager: vi.fn(() => ({ DappConnectionManager: vi.fn(() => ({
on: vi.fn(), on: vi.fn(),
off: vi.fn(), off: vi.fn(),
@ -53,6 +60,9 @@ vi.mock("@wizardconnect/dapp", () => {
sendDisconnect: vi.fn(() => Promise.resolve()), sendDisconnect: vi.fn(() => Promise.resolve()),
getSessionPaths: vi.fn(() => []), getSessionPaths: vi.fn(() => []),
restoreSessionPaths: 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); 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 }); const { result } = await renderHook({ persistSession: true });
result.current.connect(); result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0)); await new Promise<void>((r) => setTimeout(r, 0));
const stored = localStorage.getItem(SESSION_KEY); const mgrInstance = DappConnectionManager.mock.results[0]?.value;
expect(stored).not.toBeNull(); expect(mgrInstance.attachRelay).toHaveBeenCalledOnce();
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 () => { it("does not save credentials when persistSession is false", async () => {
@ -188,12 +199,17 @@ describe("useWizardConnect", () => {
expect(localStorage.getItem(SESSION_KEY)).toBeNull(); 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 }); const { result } = await renderHook({ persistSession: true });
result.current.connect(); result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0)); 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 result.current.disconnect();
await new Promise<void>((r) => setTimeout(r, 0)); await new Promise<void>((r) => setTimeout(r, 0));
@ -202,7 +218,7 @@ describe("useWizardConnect", () => {
expect(result.current.manager).toBeNull(); expect(result.current.manager).toBeNull();
expect(result.current.uri).toBeNull(); expect(result.current.uri).toBeNull();
expect(result.current.qrUri).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 () => { it("attempts auto-reconnect when stored session has walletPublicKey", async () => {
@ -222,13 +238,15 @@ describe("useWizardConnect", () => {
// Should pass existing credentials // Should pass existing credentials
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0]; const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
expect(callArgs[1]).toEqual({ expect(callArgs[1]).toEqual(
expect.objectContaining({
existingCredentials: { existingCredentials: {
privateKey: "d".repeat(64), privateKey: "d".repeat(64),
secret: "e".repeat(16), secret: "e".repeat(16),
walletPublicKey: "f".repeat(64), walletPublicKey: "f".repeat(64),
}, },
}); }),
);
}); });
it("does not auto-reconnect when stored session lacks walletPublicKey", async () => { it("does not auto-reconnect when stored session lacks walletPublicKey", async () => {
@ -279,10 +297,11 @@ describe("useWizardConnect", () => {
expect(DappConnectionManager).toHaveBeenCalledWith( expect(DappConnectionManager).toHaveBeenCalledWith(
"My Dapp", "My Dapp",
"https://example.com/icon.png", "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 = [ const testPaths = [
{ name: "receive" as const, xpub: "xpub6test1" }, { name: "receive" as const, xpub: "xpub6test1" },
{ name: "change" as const, xpub: "xpub6test2" }, { name: "change" as const, xpub: "xpub6test2" },
@ -304,30 +323,30 @@ describe("useWizardConnect", () => {
await renderHook({ persistSession: true }); await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0)); await new Promise<void>((r) => setTimeout(r, 0));
// Manager should have been created and restoreSessionPaths called // Manager is constructed with session option so it can auto-restore paths
const mgrInstance = DappConnectionManager.mock.results[0]?.value; expect(DappConnectionManager).toHaveBeenCalledWith(
expect(mgrInstance.restoreSessionPaths).toHaveBeenCalledWith(testPaths); undefined,
}); undefined,
expect.objectContaining({
it("does not call restoreSessionPaths when no cached paths", async () => { session: expect.objectContaining({ key: SESSION_KEY }),
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
}), }),
); );
});
it("disables session when persistSession is false", async () => {
const { DappConnectionManager } = vi.mocked( const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"), 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)); await new Promise<void>((r) => setTimeout(r, 0));
const mgrInstance = DappConnectionManager.mock.results[0]?.value; expect(DappConnectionManager).toHaveBeenCalledWith(
expect(mgrInstance.restoreSessionPaths).not.toHaveBeenCalled(); undefined,
undefined,
expect.objectContaining({ session: false }),
);
}); });
it("passes relayUrls to initiateDappRelay", async () => { it("passes relayUrls to initiateDappRelay", async () => {

View file

@ -7,10 +7,8 @@ import {
initiateDappRelay, initiateDappRelay,
type DappRelayResult, type DappRelayResult,
type RelayUpdatePayload, type RelayUpdatePayload,
type PathXpub,
binToHex,
} from "@wizardconnect/core"; } from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp"; import { DappConnectionManager, loadSession } from "@wizardconnect/dapp";
import type { import type {
UseWizardConnectOptions, UseWizardConnectOptions,
UseWizardConnectResult, UseWizardConnectResult,
@ -19,37 +17,6 @@ import type {
const DEFAULT_SESSION_KEY = "wizardconnect-session"; 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. * React hook that encapsulates the WizardConnect relay lifecycle.
* *
@ -67,6 +34,7 @@ export function useWizardConnect(
relayUrls, relayUrls,
sessionKey = DEFAULT_SESSION_KEY, sessionKey = DEFAULT_SESSION_KEY,
persistSession = true, persistSession = true,
storage,
} = options; } = options;
const [state, setState] = useState<WizardConnectState>("idle"); const [state, setState] = useState<WizardConnectState>("idle");
@ -92,7 +60,9 @@ export function useWizardConnect(
setError(null); setError(null);
setState("connecting"); setState("connecting");
const mgr = new DappConnectionManager(dappName, dappIcon); const mgr = new DappConnectionManager(dappName, dappIcon, {
session: persistSession ? { key: sessionKey, storage } : false,
});
managerRef.current = mgr; managerRef.current = mgr;
setManager(mgr); setManager(mgr);
@ -100,24 +70,15 @@ export function useWizardConnect(
setWalletName(mgr.walletName); setWalletName(mgr.walletName);
setWalletIcon(mgr.walletIcon); setWalletIcon(mgr.walletIcon);
setState("connected"); 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", () => { mgr.on("disconnect", () => {
setState("disconnected"); setState("disconnected");
setWalletName(null); setWalletName(null);
setWalletIcon(null); setWalletIcon(null);
mgr.clearStoredSession();
relayRef.current?.cleanup();
relayRef.current = null;
}); });
try { try {
@ -132,31 +93,12 @@ export function useWizardConnect(
); );
relayRef.current = relay; relayRef.current = relay;
// Only expose URI for new connections (QR pairing), not reconnects
if (!existingCredentials) {
setUri(relay.uri); setUri(relay.uri);
setQrUri(relay.qrUri); 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,
});
} }
mgr.attachRelay(relay);
relay.events.on(
"keyexchangecomplete",
(walletPublicKey: Uint8Array) => {
if (persistSession) {
const stored = loadSession(sessionKey);
if (stored) {
stored.walletPublicKey = binToHex(walletPublicKey);
saveSession(sessionKey, stored);
}
}
},
);
return true; return true;
} catch (e) { } catch (e) {
@ -167,7 +109,7 @@ export function useWizardConnect(
return false; return false;
} }
}, },
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession], [state, dappName, dappIcon, relayUrls, sessionKey, persistSession, storage],
); );
const connect = useCallback((): boolean => { const connect = useCallback((): boolean => {
@ -182,6 +124,7 @@ export function useWizardConnect(
} finally { } finally {
relayRef.current?.cleanup(); relayRef.current?.cleanup();
relayRef.current = null; relayRef.current = null;
managerRef.current?.clearStoredSession();
managerRef.current = null; managerRef.current = null;
setManager(null); setManager(null);
setUri(null); setUri(null);
@ -189,11 +132,8 @@ export function useWizardConnect(
setWalletName(null); setWalletName(null);
setWalletIcon(null); setWalletIcon(null);
setState("idle"); setState("idle");
if (persistSession) {
clearSession(sessionKey);
} }
} }, []);
}, [persistSession, sessionKey]);
// Auto-reconnect on mount if a stored session exists // Auto-reconnect on mount if a stored session exists
useEffect(() => { useEffect(() => {
@ -201,39 +141,20 @@ export function useWizardConnect(
if (!persistSession) return; if (!persistSession) return;
autoReconnectAttempted.current = true; autoReconnectAttempted.current = true;
const stored = loadSession(sessionKey); // Read session before creating the manager (need credentials for startRelay)
console.log( const stored = loadSession(sessionKey, storage);
"[wizardconnect/react] auto-reconnect: stored session:",
stored
? `walletPublicKey=${!!stored.walletPublicKey}, paths=${stored.paths?.length ?? 0}`
: "null",
);
if (!stored || !stored.walletPublicKey) return; if (!stored || !stored.walletPublicKey) return;
const started = startRelay({ if (stored.walletName) {
setWalletName(stored.walletName);
}
startRelay({
privateKey: stored.privateKey, privateKey: stored.privateKey,
secret: stored.secret, secret: stored.secret,
walletPublicKey: stored.walletPublicKey, walletPublicKey: stored.walletPublicKey,
}); });
}, [persistSession, sessionKey, storage, startRelay]);
// 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]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {

View file

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