From add06b6476648e08cb7ea089d34052d8c110cb75 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Sun, 22 Mar 2026 20:59:37 +0100 Subject: [PATCH 1/5] Add getSessionPaths() and restoreSessionPaths() to DappConnectionManager Allows dapps to cache the raw PathXpub[] from wallet_ready and restore them on subsequent page loads. restoreSessionPaths decodes the xpub strings and populates pubkeyState so getPubkey() works without waiting for the wallet to reconnect. Throws on invalid xpub data. --- docs/dapp.md | 34 ++++++ .../dapp/src/dapp-connection-manager.test.ts | 108 ++++++++++++++++++ packages/dapp/src/dapp-connection-manager.ts | 32 +++++- 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 packages/dapp/src/dapp-connection-manager.test.ts diff --git a/docs/dapp.md b/docs/dapp.md index acf9046..10753a9 100644 --- a/docs/dapp.md +++ b/docs/dapp.md @@ -37,6 +37,12 @@ class DappConnectionManager extends EventEmitter { * Caller is responsible for calling dappRelay.cleanup() afterwards. */ sendDisconnect(message?: string): Promise + /** 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"`, diff --git a/packages/dapp/src/dapp-connection-manager.test.ts b/packages/dapp/src/dapp-connection-manager.test.ts new file mode 100644 index 0000000..a09d147 --- /dev/null +++ b/packages/dapp/src/dapp-connection-manager.test.ts @@ -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 + }); + }); +}); diff --git a/packages/dapp/src/dapp-connection-manager.ts b/packages/dapp/src/dapp-connection-manager.ts index 0fc2218..733ebbc 100644 --- a/packages/dapp/src/dapp-connection-manager.ts +++ b/packages/dapp/src/dapp-connection-manager.ts @@ -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 Date: Sun, 22 Mar 2026 21:04:14 +0100 Subject: [PATCH 2/5] Persist xpub paths in session storage for offline pubkey derivation The useWizardConnect hook now saves PathXpub[] from wallet_ready into the localStorage session. On auto-reconnect, restoreSessionPaths() is called so getPubkey() works immediately without waiting for the wallet. Corrupt cached paths are cleared with a warning. --- docs/react.md | 9 ++-- .../react/src/hooks/useWizardConnect.test.ts | 50 +++++++++++++++++++ packages/react/src/hooks/useWizardConnect.ts | 44 +++++++++++++++- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/docs/react.md b/docs/react.md index 0cfecb5..19deb33 100644 --- a/docs/react.md +++ b/docs/react.md @@ -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: diff --git a/packages/react/src/hooks/useWizardConnect.test.ts b/packages/react/src/hooks/useWizardConnect.test.ts index 70b805c..27e10c2 100644 --- a/packages/react/src/hooks/useWizardConnect.test.ts +++ b/packages/react/src/hooks/useWizardConnect.test.ts @@ -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((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((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"], diff --git a/packages/react/src/hooks/useWizardConnect.ts b/packages/react/src/hooks/useWizardConnect.ts index 1e88cb4..f2e62b5 100644 --- a/packages/react/src/hooks/useWizardConnect.ts +++ b/packages/react/src/hooks/useWizardConnect.ts @@ -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 From cb4f1ee5989e98ebff308708d36d6b6905a4f943 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Sun, 22 Mar 2026 23:09:21 +0100 Subject: [PATCH 3/5] bug: Pass pubkey to initiateRelay on reconnect Pass the pubkey if we already know it. --- packages/core/src/dapp-relay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/dapp-relay.ts b/packages/core/src/dapp-relay.ts index aa3b836..3408ff8 100644 --- a/packages/core/src/dapp-relay.ts +++ b/packages/core/src/dapp-relay.ts @@ -178,7 +178,7 @@ export function initiateDappRelay( const cleanup = initiateRelay( wrappedCallback, dappPrivateKey, - new Uint8Array(33), + walletPublicKeyNostr ?? new Uint8Array(33), { explicitRelayUrls: relayUrls, reconnectInterval: options.reconnectInterval, From acab94dc68e15083247288a0b592fea1640c7531 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Mon, 23 Mar 2026 08:43:00 +0100 Subject: [PATCH 4/5] ci: Build before running tests --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9b7efb6..9eff01a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,6 +21,7 @@ test: extends: .node-common script: - npm install + - npm run build - npm run test build: From 68581a4929f63be243f940f1ee83101c742ce701 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Mon, 23 Mar 2026 08:43:44 +0100 Subject: [PATCH 5/5] up to date, audited 269 packages in 1s 106 packages are looking for funding run `npm fund` for details found 0 vulnerabilities --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f9949ed..e41095b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" },