// 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, vi } from "vitest"; import { encodeHdPublicKey } from "@bitauth/libauth"; import type { PathXpub } from "@wizardconnect/core"; import { RelayMsgAction, PROTOCOL_NAME } from "@wizardconnect/core"; import type { WalletReadyMessage, SignTransactionRequest, ProtocolMessage, } 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 }); }); describe("re-sends pending sign requests on wallet_ready", () => { function makeTestXpubForSession(): string { const node = { chainCode: new Uint8Array(32).fill(0xbb), 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(result); return result.hdPublicKey; } /** Minimal mock of RelayClient — just enough to drive DappConnectionManager. */ function makeMockClient() { const listeners = new Map void)[]>(); const relayed: ProtocolMessage[] = []; return { relayed, on(event: string, fn: (...args: any[]) => void) { if (!listeners.has(event)) listeners.set(event, []); listeners.get(event)!.push(fn); }, emit(event: string, ...args: any[]) { for (const fn of listeners.get(event) ?? []) fn(...args); }, relay: vi.fn(async (msg: ProtocolMessage) => { relayed.push(msg); }), isKeyExchangeComplete: () => true, nextSequence: (() => { let seq = 0; return () => (seq += 2); })(), }; } function makeWalletReady(xpub: string): WalletReadyMessage { return { action: RelayMsgAction.WalletReady, wallet_name: "Test Wallet", wallet_icon: "", public_key: "aa".repeat(32), secret: "bb".repeat(16), supported_protocols: [PROTOCOL_NAME], dapp_discovered: true, session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] }, }, time: Math.floor(Date.now() / 1000), }; } it("re-relays pending sign requests when wallet_ready arrives", async () => { const mgr = new DappConnectionManager(); const client = makeMockClient(); const xpub = makeTestXpubForSession(); // Attach manager to mock client mgr.updateConnection(client as any, { status: "connected" }); // Send a sign request (wallet is "connected" but hasn't sent wallet_ready yet) const request: SignTransactionRequest = { action: RelayMsgAction.SignTransactionRequest, sequence: client.nextSequence(), time: Math.floor(Date.now() / 1000), inputPaths: [], transaction: "deadbeef", }; const signPromise = mgr.sendSignRequest(request); // The initial relay call expect(client.relayed).toHaveLength(2); // dapp_ready + sign request // Now simulate wallet_ready arriving (wallet just opened) client.relayed.length = 0; client.emit("message", makeWalletReady(xpub)); // Wait a tick for the async re-send await new Promise((r) => setTimeout(r, 10)); // The sign request should have been re-sent const resent = client.relayed.filter( (m) => m.action === RelayMsgAction.SignTransactionRequest, ); expect(resent).toHaveLength(1); expect(resent[0].sequence).toBe(request.sequence); // Clean up: resolve the pending promise so it doesn't leak client.emit("message", { action: RelayMsgAction.SignTransactionResponse, sequence: request.sequence, time: Math.floor(Date.now() / 1000), transaction: "signed", }); await signPromise; }); it("does not re-send if there are no pending requests", async () => { const mgr = new DappConnectionManager(); const client = makeMockClient(); const xpub = makeTestXpubForSession(); mgr.updateConnection(client as any, { status: "connected" }); client.relayed.length = 0; client.emit("message", makeWalletReady(xpub)); await new Promise((r) => setTimeout(r, 10)); // Only dapp_ready should have been sent (reactive), no sign requests const signMsgs = client.relayed.filter( (m) => m.action === RelayMsgAction.SignTransactionRequest, ); expect(signMsgs).toHaveLength(0); }); }); });