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.
370 lines
12 KiB
TypeScript
370 lines
12 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 { EventEmitter } from "eventemitter3";
|
|
import { decodeHdPublicKey } from "@bitauth/libauth";
|
|
import type { HdPublicNodeValid } from "@bitauth/libauth";
|
|
import {
|
|
RelayClient,
|
|
RelayStatus,
|
|
RelayMsgAction,
|
|
DappReadyMessage,
|
|
WalletReadyMessage,
|
|
DisconnectMessage,
|
|
DisconnectReason,
|
|
SignTransactionRequest,
|
|
SignTransactionResponse,
|
|
SignCancelMessage,
|
|
ProtocolMessage,
|
|
PROTOCOL_NAME,
|
|
PathName,
|
|
childIndexOfPathName,
|
|
isHdwalletv1Session,
|
|
} from "@wizardconnect/core";
|
|
import type { PathXpub } from "@wizardconnect/core";
|
|
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
|
|
|
export interface DappConnectionManagerEvents {
|
|
/** Fired after wallet_ready is received and state is updated. */
|
|
walletready: [msg: WalletReadyMessage];
|
|
/** Fired for every protocol message sent by the dapp. */
|
|
messagesent: [msg: ProtocolMessage];
|
|
/** Fired for every protocol message received from the wallet. */
|
|
messagereceived: [msg: ProtocolMessage];
|
|
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
|
|
disconnect: [reason: DisconnectReason, message: string | undefined];
|
|
}
|
|
|
|
/**
|
|
* Manages the dapp side of a single WizardConnect session.
|
|
*
|
|
* Protocol responsibilities:
|
|
* - Sends dapp_ready after key exchange completes (and on reconnect)
|
|
* - Receives wallet_ready, sign_transaction_response
|
|
* - Tracks pubkeys and address indices (via xpub on-demand derivation)
|
|
*
|
|
* No chain-specific logic — child indices are plain numbers:
|
|
* 0 = Receive, 1 = Change, 7 = Cauldron (BCH convention)
|
|
*/
|
|
export class DappConnectionManager extends EventEmitter<DappConnectionManagerEvents> {
|
|
private conn: RelayClient | null = null;
|
|
private listenerAttached = false;
|
|
|
|
/** Pubkey state — exposed for callers that need to query by index. */
|
|
readonly pubkeyState: DappPubkeyStateManager;
|
|
|
|
walletName: string | null = null;
|
|
walletIcon: string | null = null;
|
|
protocol: string | null = null;
|
|
|
|
/** Protocols this dapp supports, in preference order. */
|
|
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
|
|
|
|
private walletDiscovered = false;
|
|
private sessionPaths: PathXpub[] = [];
|
|
private pendingSignatureRequests = new Map<
|
|
number,
|
|
{
|
|
resolve: (r: SignTransactionResponse) => void;
|
|
reject: (e: Error) => void;
|
|
}
|
|
>();
|
|
|
|
/**
|
|
* @param dappName Optional display name of the dapp (sent in dapp_ready).
|
|
* @param dappIcon Optional icon URL/data-URI of the dapp (sent in dapp_ready).
|
|
*/
|
|
constructor(
|
|
private dappName?: string,
|
|
private dappIcon?: string,
|
|
) {
|
|
super();
|
|
this.pubkeyState = new DappPubkeyStateManager();
|
|
}
|
|
|
|
/**
|
|
* Call this from the RelayStatusCallback passed to `initiateDappRelay`.
|
|
* Attaches the message listener exactly once and re-sends dapp_ready
|
|
* each time the connection is established (handles reconnects).
|
|
*/
|
|
updateConnection(
|
|
client: RelayClient | null | undefined,
|
|
status: RelayStatus,
|
|
): void {
|
|
if (client) {
|
|
// Attach message listener once (same RelayClient object is reused across reconnects)
|
|
if (!this.listenerAttached) {
|
|
this.listenerAttached = true;
|
|
client.on("message", (msg: ProtocolMessage) => this.handleMessage(msg));
|
|
}
|
|
this.conn = client;
|
|
}
|
|
|
|
if (status.status === "connected" && this.conn) {
|
|
this.onConnected();
|
|
}
|
|
}
|
|
|
|
isWalletDiscovered(): boolean {
|
|
return this.walletDiscovered;
|
|
}
|
|
|
|
/**
|
|
* Get a sequence number from the relay client.
|
|
* Use this to populate the `sequence` field of a SignTransactionRequest.
|
|
*/
|
|
nextSequence(): number {
|
|
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
|
|
return this.conn.nextSequence();
|
|
}
|
|
|
|
/**
|
|
* Send a sign transaction request and wait for the wallet's response.
|
|
* The caller is responsible for creating the full SignTransactionRequest
|
|
* (including sequence from `nextSequence()`).
|
|
*/
|
|
async sendSignRequest(
|
|
request: SignTransactionRequest,
|
|
): Promise<SignTransactionResponse> {
|
|
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
|
|
|
|
return new Promise<SignTransactionResponse>((resolve, reject) => {
|
|
this.pendingSignatureRequests.set(request.sequence, { resolve, reject });
|
|
|
|
this.conn!.relay(request)
|
|
.then(() => {
|
|
this.emit("messagesent", request);
|
|
})
|
|
.catch((err) => {
|
|
this.pendingSignatureRequests.delete(request.sequence);
|
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cancel an in-flight sign request.
|
|
* Immediately rejects the pending Promise and sends sign_cancel to the wallet.
|
|
*/
|
|
async sendSignCancel(sequence: number, reason?: string): Promise<void> {
|
|
// Reject pending promise immediately — no response will come
|
|
const handlers = this.pendingSignatureRequests.get(sequence);
|
|
if (handlers) {
|
|
this.pendingSignatureRequests.delete(sequence);
|
|
handlers.reject(new Error(reason ?? "Sign request cancelled"));
|
|
}
|
|
if (!this.conn) return;
|
|
const msg: SignCancelMessage = {
|
|
action: RelayMsgAction.SignCancel,
|
|
sequence,
|
|
...(reason !== undefined && { reason }),
|
|
time: Math.floor(Date.now() / 1000),
|
|
};
|
|
await this.conn.relay(msg);
|
|
this.emit("messagesent", msg);
|
|
}
|
|
|
|
/**
|
|
* Send a disconnect message to the wallet (courtesy notification).
|
|
* The caller is responsible for calling dappRelay.cleanup() afterwards.
|
|
*/
|
|
async sendDisconnect(message?: string): Promise<void> {
|
|
if (!this.conn) return;
|
|
const msg: DisconnectMessage = {
|
|
action: RelayMsgAction.Disconnect,
|
|
reason: DisconnectReason.UserDisconnect,
|
|
time: Math.floor(Date.now() / 1000),
|
|
...(message !== undefined && { message }),
|
|
};
|
|
await this.conn.relay(msg);
|
|
this.emit("messagesent", msg);
|
|
}
|
|
|
|
// --- Pubkey state delegation ------------------------------------------------
|
|
// Convenience methods that forward to pubkeyState.
|
|
|
|
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined {
|
|
return this.pubkeyState.getPubkey(childIndex, index);
|
|
}
|
|
|
|
/** Returns true if an xpub node is available for this child index. */
|
|
hasPath(childIndex: number): boolean {
|
|
return this.pubkeyState.hasPath(childIndex);
|
|
}
|
|
|
|
/**
|
|
* Returns the stored xpub node for the given child index.
|
|
* Available after wallet_ready is received.
|
|
*/
|
|
getXpubNode(childIndex: number): HdPublicNodeValid | undefined {
|
|
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 {
|
|
(async () => {
|
|
// Wait until key exchange is complete before sending dapp_ready
|
|
while (this.conn && !this.conn.isKeyExchangeComplete()) {
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
if (this.conn) {
|
|
await this.pushDappReady();
|
|
}
|
|
})().catch((e) =>
|
|
console.error("[wizardconnect/dapp] Error in onConnected:", e),
|
|
);
|
|
}
|
|
|
|
private async pushDappReady(): Promise<void> {
|
|
if (!this.conn) return;
|
|
|
|
const msg: DappReadyMessage = {
|
|
action: RelayMsgAction.DappReady,
|
|
supported_protocols: this.supportedProtocols,
|
|
wallet_discovered: this.walletDiscovered,
|
|
time: Math.floor(Date.now() / 1000),
|
|
// Include selected_protocol on the reactive send (after the dapp has seen the wallet)
|
|
...(this.walletDiscovered &&
|
|
this.protocol && { selected_protocol: this.protocol }),
|
|
...(this.dappName !== undefined && { dapp_name: this.dappName }),
|
|
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }),
|
|
};
|
|
|
|
await this.conn.relay(msg);
|
|
this.emit("messagesent", msg);
|
|
}
|
|
|
|
private handleMessage(msg: ProtocolMessage): void {
|
|
this.emit("messagereceived", msg);
|
|
switch (msg.action) {
|
|
case RelayMsgAction.WalletReady:
|
|
this.handleWalletReady(msg as WalletReadyMessage);
|
|
break;
|
|
case RelayMsgAction.SignTransactionResponse:
|
|
this.handleSignTransactionResponse(msg as SignTransactionResponse);
|
|
break;
|
|
case RelayMsgAction.Disconnect:
|
|
this.handleRemoteDisconnect(msg as DisconnectMessage);
|
|
break;
|
|
case RelayMsgAction.DappReady:
|
|
// Not expected on dapp side — silently ignore
|
|
break;
|
|
default:
|
|
console.warn(
|
|
"[wizardconnect/dapp] Unknown message action:",
|
|
msg.action,
|
|
);
|
|
}
|
|
}
|
|
|
|
private handleRemoteDisconnect(msg: DisconnectMessage): void {
|
|
this.emit("disconnect", msg.reason, msg.message);
|
|
}
|
|
|
|
private handleWalletReady(msg: WalletReadyMessage): void {
|
|
this.walletDiscovered = true;
|
|
this.walletName = msg.wallet_name;
|
|
this.walletIcon = msg.wallet_icon;
|
|
|
|
// Protocol selection: first match in dapp's preference order
|
|
const agreed = this.supportedProtocols.find((p) =>
|
|
msg.supported_protocols.includes(p),
|
|
);
|
|
if (!agreed) {
|
|
const detail = `No protocol overlap. Wallet: [${msg.supported_protocols}], Dapp: [${this.supportedProtocols}]`;
|
|
const disconnectMsg: DisconnectMessage = {
|
|
action: RelayMsgAction.Disconnect,
|
|
reason: DisconnectReason.ProtocolMismatch,
|
|
message: detail,
|
|
time: Math.floor(Date.now() / 1000),
|
|
};
|
|
this.conn?.relay(disconnectMsg).catch(() => {});
|
|
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
|
|
return;
|
|
}
|
|
this.protocol = agreed;
|
|
|
|
// Extract and validate protocol-specific session data
|
|
const sessionData = msg.session[agreed];
|
|
if (!isHdwalletv1Session(sessionData)) {
|
|
console.error(
|
|
"[wizardconnect/dapp] Invalid hdwalletv1 session data:",
|
|
sessionData,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 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") {
|
|
console.warn(
|
|
"[wizardconnect/dapp] Bad xpub for path",
|
|
pathInfo.name,
|
|
decoded,
|
|
);
|
|
continue;
|
|
}
|
|
const ci = childIndexOfPathName(pathInfo.name as PathName);
|
|
this.pubkeyState.setXpubNode(ci, decoded.node);
|
|
}
|
|
|
|
if (!msg.dapp_discovered) {
|
|
this.pushDappReady().catch((e) =>
|
|
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
|
|
);
|
|
}
|
|
|
|
this.emit("walletready", msg);
|
|
}
|
|
|
|
private handleSignTransactionResponse(
|
|
response: SignTransactionResponse,
|
|
): void {
|
|
const handlers = this.pendingSignatureRequests.get(response.sequence);
|
|
if (!handlers) {
|
|
console.warn(
|
|
"[wizardconnect/dapp] No pending request for sequence:",
|
|
response.sequence,
|
|
);
|
|
return;
|
|
}
|
|
this.pendingSignatureRequests.delete(response.sequence);
|
|
|
|
if (response.error) {
|
|
handlers.reject(new Error(response.error));
|
|
} else {
|
|
handlers.resolve(response);
|
|
}
|
|
}
|
|
}
|