// 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 NDK, { NDKPrivateKeySigner, NDKUser, NDKEvent, NDKKind, giftWrap, giftUnwrap, NDKSubscription, } from "@nostr-dev-kit/ndk"; 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"; export interface RelayClientConfig { explicitRelayUrls: string[]; signerPrivateKey: Uint8Array; pairedPublicKey?: Uint8Array; logNetworkActivity?: boolean; } export class RelayClient extends EventEmitter { private ndk: NDK; private paired: NDKUser; private config: RelayClientConfig; private messageSubscription: NDKSubscription | null = null; private myPubkey: Uint8Array; private myPubkeyHex: string; private lastProcessedTimestamp: number = 0; private messageQueue: MessageQueue; 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) { super(); this.config = { logNetworkActivity: true, ...config, }; this.ndk = new NDK({ explicitRelayUrls: this.config.explicitRelayUrls, signer: new NDKPrivateKeySigner(this.config.signerPrivateKey), enableOutboxModel: false, autoConnectUserRelays: false, }); 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.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) }); } else { this.paired = new NDKUser({ pubkey: "" }); } } setPairedPublicKey(pairedPublicKey: Uint8Array): void { this.config.pairedPublicKey = pairedPublicKey; const pairedNostrPubkey = pairedPublicKey.length === 33 ? pairedPublicKey.slice(1) : pairedPublicKey; this.paired = new NDKUser({ pubkey: 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 { await this.ndk.connect(); if (this.config.logNetworkActivity) { debug(Scope.Relay, `NDK connect() resolved`); } this.messageSubscription = this.ndk.subscribe( { kinds: [NDKKind.GiftWrap], "#p": [this.myPubkeyHex], }, { closeOnEose: false }, ); this.messageSubscription.on("event", async (wrappedEvent: NDKEvent) => { try { const signer = this.ndk.signer; if (!signer) { throw new Error("No signer available"); } const rumor = await giftUnwrap(wrappedEvent, undefined, signer); if (rumor.kind === NDKKind.PrivateDirectMessage) { 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); } }); this.messageSubscription.on("close", () => { if (this.config.logNetworkActivity) { debug(Scope.Relay, "Subscription closed, connection may be stale"); } this.emit("disconnect", new Error("Subscription closed")); }); this.waitForRelaysReady(); if (this.config.logNetworkActivity) { debug( Scope.Relay, `Subscription created and handler set up, emitting connection event`, ); } this.emit("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.messageSubscription) { this.messageSubscription.stop(); this.messageSubscription = null; } } 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 signer = this.ndk.signer; if (!signer) { throw new Error("No signer available"); } const rumor = new NDKEvent(this.ndk); rumor.kind = NDKKind.PrivateDirectMessage; rumor.content = JSON.stringify(message); rumor.created_at = Math.floor(Date.now() / 1000); rumor.tags = [["p", this.paired.pubkey]]; const wrappedEvent = await giftWrap(rumor, this.paired, signer); try { await wrappedEvent.publish(); 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 async waitForRelaysReady(): Promise { const maxWaitTime = 5000; const checkInterval = 100; const startTime = Date.now(); const checkRelays = async (): Promise => { const pool = (this.ndk as any).pool; if (pool) { const relays = pool.relays || []; // NDKRelayStatus: DISCONNECTED=1, CONNECTED=5, AUTHENTICATED=8 const connectedRelays = Array.from(relays.values()).filter( (relay: any) => relay.status >= 5, ); if (connectedRelays.length > 0) { if (this.config.logNetworkActivity) { debug( Scope.Relay, `Relays ready (${connectedRelays.length} connected), processing queued messages`, ); } await this.messageQueue.setReady((msg) => this.publishMessage(msg)); return; } } if (Date.now() - startTime < maxWaitTime) { setTimeout(checkRelays, checkInterval); } else { if (this.config.logNetworkActivity) { debug( Scope.Relay, "Relay ready check timeout, assuming ready and processing queued messages", ); } await this.messageQueue.setReady((msg) => this.publishMessage(msg)); } }; setTimeout(checkRelays, 200); } 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); } }