aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/nostr.js154
1 files changed, 33 insertions, 121 deletions
diff --git a/src/nostr.js b/src/nostr.js
index 0ebfe92..f1c1802 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -12,12 +12,17 @@
import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
import * as nip04 from 'nostr-tools/nip04';
+import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17';
import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19';
-import { Relay } from 'nostr-tools/relay';
+import { SimplePool } from 'nostr-tools/pool';
import { randomBytes } from '@noble/hashes/utils';
import { base64urlnopad } from '@scure/base';
+// One shared pool for all relay I/O — it manages connection lifecycles (and the
+// CLOSE-after-EOSE handshake) cleanly, so we never send on a half-closed socket.
+const pool = new SimplePool();
+
// --- locking a gift to a recipient's Nostr key ---------------------------
// An npub or 64-hex nostr pubkey → hex, or null if it isn't one.
export function parseNostrPubkey(input) {
@@ -51,53 +56,28 @@ export function decryptWithCode(code, ct) {
export const PROFILE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.coinos.io'];
// Fetch a recipient's profile (name + picture) for a pubkey, newest across relays.
export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) {
- const results = await Promise.allSettled(relays.map((url) => _fetchProfileFrom(url, pubkeyHex)));
- let best = null;
- for (const r of results) {
- const v = r.status === 'fulfilled' ? r.value : null;
- if (v && (!best || (v.created_at || 0) > (best.created_at || 0))) best = v;
- }
+ let events;
+ try { events = await pool.querySync(relays, { kinds: [0], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return null; }
+ const best = events.sort((a, b) => b.created_at - a.created_at)[0];
if (!best) return null;
try {
const m = JSON.parse(best.content);
return { name: m.display_name || m.name || null, picture: m.picture || null, nip05: m.nip05 || null };
} catch { return null; }
}
-async function _fetchProfileFrom(url, pk) {
- let relay;
- try { relay = await Relay.connect(url); } catch { return null; }
- return new Promise((resolve) => {
- let ev = null;
- const done = () => { try { sub.close(); } catch {} try { relay.close(); } catch {} resolve(ev); };
- const sub = relay.subscribe([{ kinds: [0], authors: [pk], limit: 1 }], {
- onevent: (e) => { if (!ev || e.created_at > ev.created_at) ev = e; },
- oneose: done,
- });
- setTimeout(done, 5000);
- });
-}
// Where to deliver a DM to a pubkey: prefer their NIP-17 DM relay list (kind
// 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back).
export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) {
- const lists = (await Promise.allSettled(relays.map((url) => _fetchRelayListFrom(url, pubkeyHex)))).flatMap((r) => (r.status === 'fulfilled' && r.value) || []);
- const newest = (kind) => lists.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0];
+ let events;
+ try { events = await pool.querySync(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return []; }
+ const newest = (kind) => events.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0];
const dm = newest(10050);
if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; }
const nip65 = newest(10002);
if (nip65) { const r = nip65.tags.filter((t) => t[0] === 'r' && t[1] && (!t[2] || t[2] === 'read')).map((t) => t[1]); if (r.length) return r; }
return [];
}
-async function _fetchRelayListFrom(url, pk) {
- let relay;
- try { relay = await Relay.connect(url); } catch { return []; }
- return new Promise((resolve) => {
- const evs = [];
- const done = () => { try { sub.close(); } catch {} try { relay.close(); } catch {} resolve(evs); };
- const sub = relay.subscribe([{ kinds: [10002, 10050], authors: [pk] }], { onevent: (e) => evs.push(e), oneose: done });
- setTimeout(done, 5000);
- });
-}
const DTAG = 'bitcoin-wallet';
const SYNC_KEY = 'btc-wallet-sync';
@@ -131,7 +111,6 @@ export class NostrSync {
this.pk = null;
this.ck = null; // self conversation key for NIP-44
this.relays = DEFAULT_SYNC_RELAYS;
- this._conns = new Map(); // url -> Relay
}
load(mnemonic, passphrase = '') {
@@ -140,40 +119,10 @@ export class NostrSync {
this.ck = nip44.getConversationKey(this.sk, this.pk);
}
- unload() {
- this.sk = this.pk = this.ck = null;
- this._closeAll();
- }
-
- _closeAll() {
- for (const r of this._conns.values()) {
- try {
- r.close();
- } catch {}
- }
- this._conns.clear();
- }
+ unload() { this.sk = this.pk = this.ck = null; }
- // Point at a new relay list, closing connections that are no longer used.
setRelays(relays) {
- const next = Array.isArray(relays) && relays.length ? relays : DEFAULT_SYNC_RELAYS;
- for (const url of [...this._conns.keys()]) {
- if (!next.includes(url)) {
- try {
- this._conns.get(url).close();
- } catch {}
- this._conns.delete(url);
- }
- }
- this.relays = next;
- }
-
- async _connect(url) {
- const existing = this._conns.get(url);
- if (existing && existing.connected) return existing;
- const r = await Relay.connect(url);
- this._conns.set(url, r);
- return r;
+ this.relays = Array.isArray(relays) && relays.length ? relays : DEFAULT_SYNC_RELAYS;
}
// Encrypt + publish the latest state to every relay (best-effort).
@@ -182,76 +131,39 @@ export class NostrSync {
let evt;
try {
const content = nip44.encrypt(JSON.stringify(stateObj), this.ck);
- evt = finalizeEvent(
- { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', DTAG]], content },
- this.sk
- );
- } catch {
- return;
- }
- await Promise.allSettled(
- this.relays.map(async (url) => {
- const relay = await this._connect(url);
- await relay.publish(evt);
- })
- );
+ evt = finalizeEvent({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', DTAG]], content }, this.sk);
+ } catch { return; }
+ await Promise.allSettled(pool.publish(this.relays, evt));
}
- // Send an encrypted DM (NIP-04 kind 4) — used to deliver a locked gift's claim
- // code. Delivers to the recipient's published inbox relays (NIP-17 / NIP-65) so
- // their client actually sees it, plus a broad fallback. Returns true if ≥1 relay
- // accepted it.
+ // Deliver an encrypted DM (a locked gift's claim code) to a recipient. Sends it
+ // BOTH ways so any client sees it: NIP-04 (kind 4 — older clients like Amethyst)
+ // and NIP-17 (kind 1059 gift wrap — newer clients like Jumble, which ignore
+ // NIP-04). Goes to the recipient's published inbox relays (NIP-17/65) plus a
+ // broad fallback. Returns true if ≥1 relay accepted ≥1 of them.
async sendDM(recipientPkHex, text, relays = null) {
if (!this.sk) return false;
- let evt;
- try {
- const content = await nip04.encrypt(this.sk, recipientPkHex, text);
- evt = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content }, this.sk);
- } catch { return false; }
+ const events = [];
+ try { events.push(finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content: await nip04.encrypt(this.sk, recipientPkHex, text) }, this.sk)); } catch {}
+ try { events.push(nip17WrapEvent(this.sk, { publicKey: recipientPkHex }, text)); } catch {}
+ if (!events.length) return false;
let targets = relays;
if (!targets) {
const inbox = await fetchInboxRelays(recipientPkHex);
targets = [...new Set([...inbox, ...PROFILE_RELAYS])].slice(0, 8); // recipient's inbox + safety net
}
- const res = await Promise.allSettled(targets.map(async (url) => { const r = await this._connect(url); await r.publish(evt); }));
+ const res = await Promise.allSettled(events.flatMap((evt) => pool.publish(targets, evt)));
return res.some((x) => x.status === 'fulfilled');
}
- // Fetch from every relay; return the newest decrypted state, or null.
+ // Fetch the newest decrypted state across relays, or null.
async fetch() {
if (!this.sk) return null;
- const results = await Promise.allSettled(this.relays.map((url) => this._fetchOne(url)));
- let best = null;
- for (const res of results) {
- const v = res.status === 'fulfilled' ? res.value : null;
- if (v && (!best || (v.savedAt || 0) > (best.savedAt || 0))) best = v;
+ let events;
+ try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk], '#d': [DTAG] }, { maxWait: 6000 }); } catch { return null; }
+ for (const e of events.sort((a, b) => b.created_at - a.created_at)) {
+ try { const v = JSON.parse(nip44.decrypt(e.content, this.ck)); if (v) return v; } catch {}
}
- return best;
- }
-
- async _fetchOne(url) {
- const relay = await this._connect(url);
- return new Promise((resolve) => {
- let result = null;
- const sub = relay.subscribe([{ kinds: [30078], authors: [this.pk], '#d': [DTAG], limit: 1 }], {
- onevent: (e) => {
- try {
- result = JSON.parse(nip44.decrypt(e.content, this.ck));
- } catch {}
- },
- oneose: () => {
- try {
- sub.close();
- } catch {}
- resolve(result);
- },
- });
- setTimeout(() => {
- try {
- sub.close();
- } catch {}
- resolve(result);
- }, 6000);
- });
+ return null;
}
}