WizardConnect/packages/dapp/src/dapp-connection-manager.test.ts
2026-04-21 12:49:17 +02:00

238 lines
8.1 KiB
TypeScript

// 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<string, ((...args: any[]) => 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,
setPeerSupportsChunking: vi.fn(),
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);
});
});
});