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:
commit
930f09a167
9 changed files with 277 additions and 9 deletions
|
|
@ -21,6 +21,7 @@ test:
|
|||
extends: .node-common
|
||||
script:
|
||||
- npm install
|
||||
- npm run build
|
||||
- npm run test
|
||||
|
||||
build:
|
||||
|
|
|
|||
34
docs/dapp.md
34
docs/dapp.md
|
|
@ -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"`,
|
||||
|
|
|
|||
|
|
@ -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
6
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function initiateDappRelay(
|
|||
const cleanup = initiateRelay(
|
||||
wrappedCallback,
|
||||
dappPrivateKey,
|
||||
new Uint8Array(33),
|
||||
walletPublicKeyNostr ?? new Uint8Array(33),
|
||||
{
|
||||
explicitRelayUrls: relayUrls,
|
||||
reconnectInterval: options.reconnectInterval,
|
||||
|
|
|
|||
108
packages/dapp/src/dapp-connection-manager.test.ts
Normal file
108
packages/dapp/src/dapp-connection-manager.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue