407 lines
12 KiB
TypeScript
407 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 { 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<void> {
|
||
|
|
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<void> {
|
||
|
|
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<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 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<void> {
|
||
|
|
const maxWaitTime = 5000;
|
||
|
|
const checkInterval = 100;
|
||
|
|
const startTime = Date.now();
|
||
|
|
|
||
|
|
const checkRelays = async (): Promise<void> => {
|
||
|
|
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<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);
|
||
|
|
}
|
||
|
|
}
|