238 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|