WizardConnect/packages/core/src/relay-client.ts

380 lines
11 KiB
TypeScript
Raw Normal View History

2026-02-26 11:19:47 +01:00
// 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 { throwUnless, unwrap } from "./primitives.js";
import {
SimplePool,
useWebSocketImplementation,
type SubCloser,
} from "nostr-tools/pool";
import { wrapEvent, unwrapEvent } from "nostr-tools/nip59";
import type { NostrEvent } from "nostr-tools/core";
import WebSocket from "isomorphic-ws";
2026-02-26 11:19:47 +01:00
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
import { EventEmitter } from "eventemitter3";
import {
isProtocolMessage,
ProtocolMessage,
RelayMsgAction,
} from "./protocols/hdwalletv1.js";
import { deriveNostrPublicKey } from "./utilnostr.js";
import { MessageQueue } from "./message-queue.js";
import { debug, error as logError, Scope } from "./log.js";
useWebSocketImplementation(WebSocket);
const KIND_GIFT_WRAP = 1059;
const KIND_PRIVATE_DIRECT_MESSAGE = 14;
2026-02-26 11:19:47 +01:00
export interface RelayClientConfig {
explicitRelayUrls: string[];
signerPrivateKey: Uint8Array;
pairedPublicKey?: Uint8Array;
logNetworkActivity?: boolean;
}
export class RelayClient extends EventEmitter {
private pool: SimplePool;
private sharedPool: boolean;
private pairedPubkeyHex: string;
2026-02-26 11:19:47 +01:00
private config: RelayClientConfig;
private subscription: SubCloser | null = null;
2026-02-26 11:19:47 +01:00
private myPubkey: Uint8Array;
private myPubkeyHex: string;
private lastProcessedTimestamp: number = 0;
private messageQueue: MessageQueue;
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
2026-02-26 11:19:47 +01:00
private sequence: number = Math.floor(
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
);
private pendingCalls = new Map<
number,
{
resolve: (_value: any) => void;
reject: (_error: Error) => void;
}
>();
private pendingDeliveries = new Map<
number,
{
resolve: (_value: any) => void;
reject: (_error: Error) => void;
}
>();
constructor(config: RelayClientConfig, pool?: SimplePool) {
2026-02-26 11:19:47 +01:00
super();
this.config = {
logNetworkActivity: true,
...config,
};
this.pool = pool ?? new SimplePool();
this.sharedPool = pool !== undefined;
2026-02-26 11:19:47 +01:00
this.messageQueue = new MessageQueue({
logActivity: this.config.logNetworkActivity,
});
this.myPubkey = unwrap(
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
);
this.myPubkeyHex = deriveNostrPublicKey(this.config.signerPrivateKey);
if (this.config.pairedPublicKey) {
const pairedNostrPubkey =
this.config.pairedPublicKey.length === 33
? this.config.pairedPublicKey.slice(1)
: this.config.pairedPublicKey;
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
2026-02-26 11:19:47 +01:00
} else {
this.pairedPubkeyHex = "";
2026-02-26 11:19:47 +01:00
}
}
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
this.config.pairedPublicKey = pairedPublicKey;
const pairedNostrPubkey =
pairedPublicKey.length === 33
? pairedPublicKey.slice(1)
: pairedPublicKey;
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
2026-02-26 11:19:47 +01:00
this.emit("paired");
}
getPublicKey(): Uint8Array {
return this.myPubkey;
}
getPublicKeyHex(): string {
return this.myPubkeyHex;
}
isKeyExchangeComplete(): boolean {
if (!this.config.pairedPublicKey) {
return false;
}
return !this.config.pairedPublicKey.every((byte) => byte === 0);
}
async connect(): Promise<void> {
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Connecting to relay...`);
}
if (this.lastProcessedTimestamp === 0) {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
}
try {
this.subscription = this.pool.subscribeMany(
this.config.explicitRelayUrls,
{ kinds: [KIND_GIFT_WRAP], "#p": [this.myPubkeyHex] },
2026-02-26 11:19:47 +01:00
{
onevent: (event: NostrEvent) => this.handleWrappedEvent(event),
oneose: () => {
if (this.readyTimeoutId) {
clearTimeout(this.readyTimeoutId);
this.readyTimeoutId = null;
2026-02-26 11:19:47 +01:00
}
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `EOSE received, relay connected`);
2026-02-26 11:19:47 +01:00
}
this.messageQueue.setReady((msg) => this.publishMessage(msg));
this.emit("connection");
},
onclose: (reasons: string[]) => {
2026-02-26 11:19:47 +01:00
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Subscription closed: ${reasons.join(", ")}`);
2026-02-26 11:19:47 +01:00
}
this.emit("disconnect", new Error("Subscription closed"));
},
},
);
// Fallback: if EOSE doesn't arrive within 5 seconds, assume ready
this.readyTimeoutId = setTimeout(() => {
if (!this.messageQueue.getReady()) {
2026-02-26 11:19:47 +01:00
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `EOSE timeout, assuming ready`);
2026-02-26 11:19:47 +01:00
}
this.messageQueue.setReady((msg) => this.publishMessage(msg));
this.emit("connection");
2026-02-26 11:19:47 +01:00
}
this.readyTimeoutId = null;
}, 5000);
2026-02-26 11:19:47 +01:00
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Subscription created, waiting for relay connection`,
2026-02-26 11:19:47 +01:00
);
}
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, `Connection failed:`, error);
}
throw error;
}
}
async disconnect(): Promise<void> {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
this.messageQueue.setNotReady();
if (this.readyTimeoutId) {
clearTimeout(this.readyTimeoutId);
this.readyTimeoutId = null;
}
if (this.subscription) {
this.subscription.close();
this.subscription = null;
}
if (!this.sharedPool) {
this.pool.close(this.config.explicitRelayUrls);
2026-02-26 11:19:47 +01:00
}
}
getLastProcessedTimestamp(): number {
return this.lastProcessedTimestamp;
}
setLastProcessedTimestamp(timestamp: number): void {
this.lastProcessedTimestamp = timestamp;
}
async relay(message: ProtocolMessage): Promise<void> {
if (!this.config.pairedPublicKey) {
throw new Error(
"Cannot relay message: paired public key not set. Call setPairedPublicKey() first.",
);
}
if (!this.messageQueue.getReady()) {
return this.messageQueue.enqueue(message);
}
return this.publishMessage(message);
}
private async publishMessage(message: ProtocolMessage): Promise<void> {
this.netlog("send", message.action);
const wrapped = wrapEvent(
{
kind: KIND_PRIVATE_DIRECT_MESSAGE,
content: JSON.stringify(message),
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.pairedPubkeyHex]],
},
this.config.signerPrivateKey,
this.pairedPubkeyHex,
);
2026-02-26 11:19:47 +01:00
try {
await Promise.any(
this.pool.publish(this.config.explicitRelayUrls, wrapped),
);
2026-02-26 11:19:47 +01:00
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Published message ${message.action} to relay`);
}
} catch (error) {
if (this.config.logNetworkActivity) {
logError(
Scope.Relay,
`Failed to publish message ${message.action}:`,
error,
);
}
throw error;
}
}
private handleWrappedEvent(wrappedEvent: NostrEvent): void {
try {
const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey);
if (rumor.kind === KIND_PRIVATE_DIRECT_MESSAGE) {
let payload: ProtocolMessage;
try {
payload = JSON.parse(rumor.content);
} catch (e) {
if (this.config.logNetworkActivity) {
logError(
Scope.Relay,
"Failed to parse message content as JSON:",
e,
);
}
return;
}
2026-02-26 11:19:47 +01:00
if (
!payload.time ||
(this.lastProcessedTimestamp > 0 &&
payload.time < this.lastProcessedTimestamp)
) {
2026-02-26 11:19:47 +01:00
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
2026-02-26 11:19:47 +01:00
);
}
return;
}
// wallet_ready carries the key exchange data (public_key + secret) so it
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
const isKeyExchangeMessage =
payload.action === RelayMsgAction.WalletReady;
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
const pairedNostrPubkey =
this.config.pairedPublicKey.length === 33
? binToHex(this.config.pairedPublicKey.slice(1))
: binToHex(this.config.pairedPublicKey);
if (rumor.pubkey !== pairedNostrPubkey) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring '${payload.action}' message from unknown peer: ${rumor.pubkey} (expected: ${pairedNostrPubkey})`,
);
}
return;
}
}
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Received message ${payload.action} from relay`);
}
this.handleRelayMessage(payload);
2026-02-26 11:19:47 +01:00
} else {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
2026-02-26 11:19:47 +01:00
);
}
}
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, "Error handling incoming message:", error);
}
this.emitError(error as Error);
}
2026-02-26 11:19:47 +01:00
}
isConnected(): boolean {
return true;
}
private netlog(
direction: "recv" | "send",
what: string,
sequence?: number,
): void {
if (!this.config.logNetworkActivity) {
return;
}
const us = binToHex(hash256(this.config.signerPrivateKey)).slice(-6);
const them = this.config.pairedPublicKey
? binToHex(this.config.pairedPublicKey).slice(-6)
: "??????";
const pending = `c${this.pendingCalls.size} d${this.pendingDeliveries.size}`;
if (direction === "send") {
debug(
Scope.Relay,
`net [${sequence ?? "?"} ${pending}] ${us} -> ${them}: ${what}`,
);
} else {
debug(
Scope.Relay,
`net [${sequence ?? "?"} ${pending}] ${us} <- ${them}: ${what}`,
);
}
}
private async handleRelayMessage(message: ProtocolMessage): Promise<void> {
throwUnless(
isProtocolMessage(message),
`Invalid protocol message: ${message}`,
);
this.emit("message", message);
}
public nextSequence(): number {
const current = this.sequence;
this.sequence += 2;
return current;
}
private emitError(error: Error): void {
this.emit("error", error);
}
}