Replace nostr-dev-kit -> nostr-tools

The package nostr-dev-kit was using a dependency (tseep) which
downstream would flag as not CSP-safe.

Additionally nostr-tools footprint is much smaller.
This commit is contained in:
Dagur Valberg Johannsson 2026-03-26 08:46:26 +01:00
parent 95bbf96065
commit fcfcd64d14
No known key found for this signature in database
GPG key ID: FD701804AEE88107
4 changed files with 140 additions and 940 deletions

782
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@bch-wc2/interfaces": "^0.0.8", "@bch-wc2/interfaces": "^0.0.8",
"@nostr-dev-kit/ndk": "^2.18.1", "nostr-tools": "^2.23.0",
"@bitauth/libauth": "^3.1.0-next.2", "@bitauth/libauth": "^3.1.0-next.2",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^5.0.0",

View file

@ -4,6 +4,7 @@
export { RelayClient } from "./relay-client.js"; export { RelayClient } from "./relay-client.js";
export type { RelayClientConfig } from "./relay-client.js"; export type { RelayClientConfig } from "./relay-client.js";
export { SimplePool } from "nostr-tools/pool";
export { RelayStatus, initiateRelay } from "./relay-handler.js"; export { RelayStatus, initiateRelay } from "./relay-handler.js";
export type { export type {
RelayUpdatePayload, RelayUpdatePayload,

View file

@ -3,15 +3,14 @@
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html // 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 { throwUnless, unwrap } from "./primitives.js";
import NDK, { import {
NDKPrivateKeySigner, SimplePool,
NDKUser, useWebSocketImplementation,
NDKEvent, type SubCloser,
NDKKind, } from "nostr-tools/pool";
giftWrap, import { wrapEvent, unwrapEvent } from "nostr-tools/nip59";
giftUnwrap, import type { NostrEvent } from "nostr-tools/core";
NDKSubscription, import WebSocket from "isomorphic-ws";
} from "@nostr-dev-kit/ndk";
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth"; import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
import { EventEmitter } from "eventemitter3"; import { EventEmitter } from "eventemitter3";
import { import {
@ -23,6 +22,11 @@ import { deriveNostrPublicKey } from "./utilnostr.js";
import { MessageQueue } from "./message-queue.js"; import { MessageQueue } from "./message-queue.js";
import { debug, error as logError, Scope } from "./log.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 { export interface RelayClientConfig {
explicitRelayUrls: string[]; explicitRelayUrls: string[];
signerPrivateKey: Uint8Array; signerPrivateKey: Uint8Array;
@ -31,14 +35,16 @@ export interface RelayClientConfig {
} }
export class RelayClient extends EventEmitter { export class RelayClient extends EventEmitter {
private ndk: NDK; private pool: SimplePool;
private paired: NDKUser; private sharedPool: boolean;
private pairedPubkeyHex: string;
private config: RelayClientConfig; private config: RelayClientConfig;
private messageSubscription: NDKSubscription | null = null; private subscription: SubCloser | null = null;
private myPubkey: Uint8Array; private myPubkey: Uint8Array;
private myPubkeyHex: string; private myPubkeyHex: string;
private lastProcessedTimestamp: number = 0; private lastProcessedTimestamp: number = 0;
private messageQueue: MessageQueue; private messageQueue: MessageQueue;
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
private sequence: number = Math.floor( private sequence: number = Math.floor(
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000), Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
@ -60,18 +66,14 @@ export class RelayClient extends EventEmitter {
} }
>(); >();
constructor(config: RelayClientConfig) { constructor(config: RelayClientConfig, pool?: SimplePool) {
super(); super();
this.config = { this.config = {
logNetworkActivity: true, logNetworkActivity: true,
...config, ...config,
}; };
this.ndk = new NDK({ this.pool = pool ?? new SimplePool();
explicitRelayUrls: this.config.explicitRelayUrls, this.sharedPool = pool !== undefined;
signer: new NDKPrivateKeySigner(this.config.signerPrivateKey),
enableOutboxModel: false,
autoConnectUserRelays: false,
});
this.messageQueue = new MessageQueue({ this.messageQueue = new MessageQueue({
logActivity: this.config.logNetworkActivity, logActivity: this.config.logNetworkActivity,
@ -87,9 +89,9 @@ export class RelayClient extends EventEmitter {
this.config.pairedPublicKey.length === 33 this.config.pairedPublicKey.length === 33
? this.config.pairedPublicKey.slice(1) ? this.config.pairedPublicKey.slice(1)
: this.config.pairedPublicKey; : this.config.pairedPublicKey;
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) }); this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
} else { } else {
this.paired = new NDKUser({ pubkey: "" }); this.pairedPubkeyHex = "";
} }
} }
@ -99,7 +101,7 @@ export class RelayClient extends EventEmitter {
pairedPublicKey.length === 33 pairedPublicKey.length === 33
? pairedPublicKey.slice(1) ? pairedPublicKey.slice(1)
: pairedPublicKey; : pairedPublicKey;
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) }); this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
this.emit("paired"); this.emit("paired");
} }
@ -128,119 +130,49 @@ export class RelayClient extends EventEmitter {
} }
try { try {
await this.ndk.connect(); this.subscription = this.pool.subscribeMany(
this.config.explicitRelayUrls,
if (this.config.logNetworkActivity) { { kinds: [KIND_GIFT_WRAP], "#p": [this.myPubkeyHex] },
debug(Scope.Relay, `NDK connect() resolved`);
}
this.messageSubscription = this.ndk.subscribe(
{ {
kinds: [NDKKind.GiftWrap], onevent: (event: NostrEvent) => this.handleWrappedEvent(event),
"#p": [this.myPubkeyHex], 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"));
},
}, },
{ closeOnEose: false },
); );
this.messageSubscription.on("event", async (wrappedEvent: NDKEvent) => { // Fallback: if EOSE doesn't arrive within 5 seconds, assume ready
try { this.readyTimeoutId = setTimeout(() => {
const signer = this.ndk.signer; if (!this.messageQueue.getReady()) {
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) { if (this.config.logNetworkActivity) {
logError(Scope.Relay, "Error handling incoming message:", error); debug(Scope.Relay, `EOSE timeout, assuming ready`);
} }
this.emitError(error as Error); this.messageQueue.setReady((msg) => this.publishMessage(msg));
this.emit("connection");
} }
}); this.readyTimeoutId = null;
}, 5000);
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) { if (this.config.logNetworkActivity) {
debug( debug(
Scope.Relay, Scope.Relay,
`Subscription created and handler set up, emitting connection event`, `Subscription created, waiting for relay connection`,
); );
} }
this.emit("connection");
} catch (error) { } catch (error) {
if (this.config.logNetworkActivity) { if (this.config.logNetworkActivity) {
logError(Scope.Relay, `Connection failed:`, error); logError(Scope.Relay, `Connection failed:`, error);
@ -253,9 +185,18 @@ export class RelayClient extends EventEmitter {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000); this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
this.messageQueue.setNotReady(); this.messageQueue.setNotReady();
if (this.messageSubscription) { if (this.readyTimeoutId) {
this.messageSubscription.stop(); clearTimeout(this.readyTimeoutId);
this.messageSubscription = null; this.readyTimeoutId = null;
}
if (this.subscription) {
this.subscription.close();
this.subscription = null;
}
if (!this.sharedPool) {
this.pool.close(this.config.explicitRelayUrls);
} }
} }
@ -284,21 +225,21 @@ export class RelayClient extends EventEmitter {
private async publishMessage(message: ProtocolMessage): Promise<void> { private async publishMessage(message: ProtocolMessage): Promise<void> {
this.netlog("send", message.action); this.netlog("send", message.action);
const signer = this.ndk.signer; const wrapped = wrapEvent(
if (!signer) { {
throw new Error("No signer available"); kind: KIND_PRIVATE_DIRECT_MESSAGE,
} content: JSON.stringify(message),
created_at: Math.floor(Date.now() / 1000),
const rumor = new NDKEvent(this.ndk); tags: [["p", this.pairedPubkeyHex]],
rumor.kind = NDKKind.PrivateDirectMessage; },
rumor.content = JSON.stringify(message); this.config.signerPrivateKey,
rumor.created_at = Math.floor(Date.now() / 1000); this.pairedPubkeyHex,
rumor.tags = [["p", this.paired.pubkey]]; );
const wrappedEvent = await giftWrap(rumor, this.paired, signer);
try { try {
await wrappedEvent.publish(); await Promise.any(
this.pool.publish(this.config.explicitRelayUrls, wrapped),
);
if (this.config.logNetworkActivity) { if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Published message ${message.action} to relay`); debug(Scope.Relay, `Published message ${message.action} to relay`);
} }
@ -314,46 +255,78 @@ export class RelayClient extends EventEmitter {
} }
} }
private async waitForRelaysReady(): Promise<void> { private handleWrappedEvent(wrappedEvent: NostrEvent): void {
const maxWaitTime = 5000; try {
const checkInterval = 100; const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey);
const startTime = Date.now();
const checkRelays = async (): Promise<void> => { if (rumor.kind === KIND_PRIVATE_DIRECT_MESSAGE) {
const pool = (this.ndk as any).pool; let payload: ProtocolMessage;
if (pool) { try {
const relays = pool.relays || []; payload = JSON.parse(rumor.content);
// NDKRelayStatus: DISCONNECTED=1, CONNECTED=5, AUTHENTICATED=8 } catch (e) {
const connectedRelays = Array.from(relays.values()).filter( if (this.config.logNetworkActivity) {
(relay: any) => relay.status >= 5, logError(
); Scope.Relay,
"Failed to parse message content as JSON:",
e,
);
}
return;
}
if (connectedRelays.length > 0) { if (
!payload.time ||
(this.lastProcessedTimestamp > 0 &&
payload.time < this.lastProcessedTimestamp)
) {
if (this.config.logNetworkActivity) { if (this.config.logNetworkActivity) {
debug( debug(
Scope.Relay, Scope.Relay,
`Relays ready (${connectedRelays.length} connected), processing queued messages`, `Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
); );
} }
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
return; return;
} }
}
if (Date.now() - startTime < maxWaitTime) { // wallet_ready carries the key exchange data (public_key + secret) so it
setTimeout(checkRelays, checkInterval); // 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 { } else {
if (this.config.logNetworkActivity) { if (this.config.logNetworkActivity) {
debug( debug(
Scope.Relay, Scope.Relay,
"Relay ready check timeout, assuming ready and processing queued messages", `Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
); );
} }
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
} }
}; } catch (error) {
if (this.config.logNetworkActivity) {
setTimeout(checkRelays, 200); logError(Scope.Relay, "Error handling incoming message:", error);
}
this.emitError(error as Error);
}
} }
isConnected(): boolean { isConnected(): boolean {