Merge branch 'session-path' into 'master'

Add proper auto-reconnect with @wizardconnect/react

See merge request riftenlabs/lib/wizardconnect!12
This commit is contained in:
Dagur Valberg Johannsson 2026-03-23 07:44:43 +00:00
commit 930f09a167
9 changed files with 277 additions and 9 deletions

View file

@ -21,6 +21,7 @@ test:
extends: .node-common
script:
- npm install
- npm run build
- npm run test
build:

View file

@ -37,6 +37,12 @@ class DappConnectionManager extends EventEmitter {
* Caller is responsible for calling dappRelay.cleanup() afterwards. */
sendDisconnect(message?: string): Promise<void>
/** Get raw PathXpub[] received in wallet_ready (for caching). */
getSessionPaths(): PathXpub[]
/** Restore cached xpub paths — enables getPubkey() without wallet_ready. */
restoreSessionPaths(paths: PathXpub[]): void
// Events
on("walletready", (msg: WalletReadyMessage) => void)
on("messagesent", (msg: ProtocolMessage) => void)
@ -72,6 +78,12 @@ removeFromChangeQueue(index: bigint): void
// Get the stored xpub node (after wallet_ready)
getXpubNode(childIndex: number): HdPublicNodeValid | undefined
// Get raw PathXpub[] from wallet_ready (for caching)
getSessionPaths(): PathXpub[]
// Restore cached PathXpub[] — populates pubkeyState so getPubkey works without wallet_ready
restoreSessionPaths(paths: PathXpub[]): void
```
Child index values: `0` = receive, `1` = change, `7` = defi (Cauldron). These are
@ -172,6 +184,28 @@ 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
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:
```typescript
// After wallet_ready — save for later
const paths = dappMgr.getSessionPaths();
localStorage.setItem("myapp-paths", JSON.stringify(paths));
// 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
}
```
Throws if any xpub string is invalid (corrupt cached data should be cleared).
### Reconnection
`updateConnection()` is called on every relay status change. When `status.status === "connected"`,

View file

@ -130,9 +130,12 @@ 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, and
wallet public key) to localStorage under the `wizardconnect-session` key. This enables
auto-reconnect when the user refreshes the page.
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.
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`.
The persistence key and behavior can be customized:

6
package-lock.json generated
View file

@ -2487,9 +2487,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},

View file

@ -178,7 +178,7 @@ export function initiateDappRelay(
const cleanup = initiateRelay(
wrappedCallback,
dappPrivateKey,
new Uint8Array(33),
walletPublicKeyNostr ?? new Uint8Array(33),
{
explicitRelayUrls: relayUrls,
reconnectInterval: options.reconnectInterval,

View file

@ -0,0 +1,108 @@
// 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 } from "vitest";
import { encodeHdPublicKey } from "@bitauth/libauth";
import type { PathXpub } from "@wizardconnect/core";
import { DappConnectionManager } from "./dapp-connection-manager.js";
// Build a valid xpub string from a deterministic test node.
// Uses the secp256k1 generator point (a known valid public key).
function makeTestXpub(): string {
const node = {
chainCode: new Uint8Array(32).fill(0xbb),
// secp256k1 generator point (compressed) — always valid
publicKey: Uint8Array.from(
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.match(/.{2}/g)!
.map((h) => parseInt(h, 16)),
),
childIndex: 0,
depth: 3,
parentFingerprint: new Uint8Array(4),
};
const result = encodeHdPublicKey({ node, network: "mainnet" });
if (typeof result === "string")
throw new Error("Failed to encode test xpub: " + result);
return result.hdPublicKey;
}
describe("DappConnectionManager", () => {
describe("getSessionPaths", () => {
it("returns empty array before wallet_ready", () => {
const mgr = new DappConnectionManager();
expect(mgr.getSessionPaths()).toEqual([]);
});
});
describe("restoreSessionPaths", () => {
it("restores xpubs so getPubkey works", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
const paths: PathXpub[] = [{ name: "receive", xpub }];
mgr.restoreSessionPaths(paths);
expect(mgr.hasPath(0)).toBe(true);
const pk = mgr.getPubkey(0, 0n);
expect(pk).toBeInstanceOf(Uint8Array);
expect(pk!.length).toBe(33);
});
it("makes paths available via getSessionPaths", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
const paths: PathXpub[] = [
{ name: "receive", xpub },
{ name: "change", xpub },
];
mgr.restoreSessionPaths(paths);
const stored = mgr.getSessionPaths();
expect(stored).toHaveLength(2);
expect(stored[0].name).toBe("receive");
expect(stored[1].name).toBe("change");
});
it("returns a copy, not a reference", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
const paths = mgr.getSessionPaths();
paths.push({ name: "change", xpub });
expect(mgr.getSessionPaths()).toHaveLength(1);
});
it("throws on invalid xpub strings", () => {
const mgr = new DappConnectionManager();
const paths: PathXpub[] = [{ name: "receive", xpub: "not-a-valid-xpub" }];
expect(() => mgr.restoreSessionPaths(paths)).toThrow("Invalid xpub");
});
it("can derive multiple child indices after restore", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
for (const idx of [0n, 1n, 5n, 10n]) {
const pk = mgr.getPubkey(0, idx);
expect(pk, `index ${idx}`).toBeInstanceOf(Uint8Array);
expect(pk!.length, `index ${idx}`).toBe(33);
}
});
it("does not affect paths not in the restore set", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
expect(mgr.hasPath(0)).toBe(true); // receive = 0
expect(mgr.hasPath(1)).toBe(false); // change = 1
});
});
});

View file

@ -22,6 +22,7 @@ import {
childIndexOfPathName,
isHdwalletv1Session,
} from "@wizardconnect/core";
import type { PathXpub } from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
export interface DappConnectionManagerEvents {
@ -61,6 +62,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
private walletDiscovered = false;
private sessionPaths: PathXpub[] = [];
private pendingSignatureRequests = new Map<
number,
{
@ -199,6 +201,33 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
return this.pubkeyState.getXpubNode(childIndex);
}
/**
* Returns the raw PathXpub entries received from the wallet.
* Available after wallet_ready is received.
*/
getSessionPaths(): PathXpub[] {
return [...this.sessionPaths];
}
/**
* Restore session paths from a previous session (e.g. from localStorage).
* Decodes xpub strings and populates pubkeyState so getPubkey() works
* without waiting for wallet_ready.
*/
restoreSessionPaths(paths: PathXpub[]): void {
this.sessionPaths = [...paths];
for (const pathInfo of paths) {
const decoded = decodeHdPublicKey(pathInfo.xpub);
if (typeof decoded === "string") {
throw new Error(
`[wizardconnect/dapp] Invalid xpub for path "${pathInfo.name}": ${decoded}`,
);
}
const ci = childIndexOfPathName(pathInfo.name as PathName);
this.pubkeyState.setXpubNode(ci, decoded.node);
}
}
// --- Private protocol handling -------------------------------------------
private onConnected(): void {
@ -294,7 +323,8 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
return;
}
// Store xpub nodes — no eager derivation; consumer drives it
// Store raw paths for getSessionPaths() and xpub nodes for derivation
this.sessionPaths = [...sessionData.paths];
for (const pathInfo of sessionData.paths) {
const decoded = decodeHdPublicKey(pathInfo.xpub);
if (typeof decoded === "string") {

View file

@ -51,6 +51,8 @@ vi.mock("@wizardconnect/dapp", () => {
walletIcon: null,
updateConnection: vi.fn(),
sendDisconnect: vi.fn(() => Promise.resolve()),
getSessionPaths: vi.fn(() => []),
restoreSessionPaths: vi.fn(),
})),
};
});
@ -280,6 +282,54 @@ describe("useWizardConnect", () => {
);
});
it("restores cached xpub paths on auto-reconnect", async () => {
const testPaths = [
{ name: "receive" as const, xpub: "xpub6test1" },
{ name: "change" as const, xpub: "xpub6test2" },
];
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
paths: testPaths,
}),
);
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
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),
}),
);
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0));
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
expect(mgrInstance.restoreSessionPaths).not.toHaveBeenCalled();
});
it("passes relayUrls to initiateDappRelay", async () => {
const { result } = await renderHook({
relayUrls: ["wss://custom-relay:443"],

View file

@ -7,6 +7,7 @@ import {
initiateDappRelay,
type DappRelayResult,
type RelayUpdatePayload,
type PathXpub,
binToHex,
} from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";
@ -22,6 +23,8 @@ 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 {
@ -97,6 +100,18 @@ 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", () => {
@ -121,7 +136,10 @@ export function useWizardConnect(
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,
});
@ -184,13 +202,37 @@ export function useWizardConnect(
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",
);
if (!stored || !stored.walletPublicKey) return;
startRelay({
const started = 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]);
// Cleanup on unmount