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.
This commit is contained in:
Dagur Valberg Johannsson 2026-03-22 20:59:37 +01:00
parent 33c45596ef
commit add06b6476
No known key found for this signature in database
GPG key ID: FD701804AEE88107
3 changed files with 173 additions and 1 deletions

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

@ -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") {