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: 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/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/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" }, 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, 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 { 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