// 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"; 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; 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; private config: RelayClientConfig; private subscription: SubCloser | null = null; private myPubkey: Uint8Array; private myPubkeyHex: string; private lastProcessedTimestamp: number = 0; private messageQueue: MessageQueue; private readyTimeoutId: ReturnType | null = null; 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) { super(); this.config = { logNetworkActivity: true, ...config, }; this.pool = pool ?? new SimplePool(); this.sharedPool = pool !== undefined; 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); } else { this.pairedPubkeyHex = ""; } } setPairedPublicKey(pairedPublicKey: Uint8Array): void { this.config.pairedPublicKey = pairedPublicKey; const pairedNostrPubkey = pairedPublicKey.length === 33 ? pairedPublicKey.slice(1) : pairedPublicKey; this.pairedPubkeyHex = binToHex(pairedNostrPubkey); 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 { 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] }, { onevent: (event: NostrEvent) => this.handleWrappedEvent(event), oneose: () => { if (this.readyTimeoutId) { clearTimeout(this.readyTimeoutId); this.readyTimeoutId = null; } if (this.config.logNetworkActivity) { debug(Scope.Relay, `EOSE received, relay connected`); } this.messageQueue.setReady((msg) => this.publishMessage(msg)); this.emit("connection"); }, onclose: (reasons: string[]) => { if (this.config.logNetworkActivity) { debug(Scope.Relay, `Subscription closed: ${reasons.join(", ")}`); } 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()) { if (this.config.logNetworkActivity) { debug(Scope.Relay, `EOSE timeout, assuming ready`); } this.messageQueue.setReady((msg) => this.publishMessage(msg)); this.emit("connection"); } this.readyTimeoutId = null; }, 5000); if (this.config.logNetworkActivity) { debug( Scope.Relay, `Subscription created, waiting for relay connection`, ); } } catch (error) { if (this.config.logNetworkActivity) { logError(Scope.Relay, `Connection failed:`, error); } throw error; } } async disconnect(): Promise { 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); } } getLastProcessedTimestamp(): number { return this.lastProcessedTimestamp; } setLastProcessedTimestamp(timestamp: number): void { this.lastProcessedTimestamp = timestamp; } async relay(message: ProtocolMessage): Promise { 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 { 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, ); try { await Promise.any( this.pool.publish(this.config.explicitRelayUrls, wrapped), ); 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; } if ( !payload.time || (this.lastProcessedTimestamp > 0 && payload.time < this.lastProcessedTimestamp) ) { if (this.config.logNetworkActivity) { debug( Scope.Relay, `Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`, ); } 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); } else { if (this.config.logNetworkActivity) { debug( Scope.Relay, `Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`, ); } } } catch (error) { if (this.config.logNetworkActivity) { logError(Scope.Relay, "Error handling incoming message:", error); } this.emitError(error as Error); } } 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 { 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); } }