From d459e4aa196b356cfc24f2118cc1ef54eda7600e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:22:45 -0700 Subject: Lock a gift to a nostr key: public link, claim code DM'd to the recipient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gift can now be locked to a recipient's nostr npub. The shareable link (/lg/) carries the amount + recipient in the clear (anyone can see what it is and who it's for), but the claim payload is encrypted under a one-time code. That code is delivered as an encrypted nostr DM (NIP-04) to the recipient — so only they can read it. They paste the code to decrypt, then claim into a fresh wallet exactly like a normal bearer gift. - nostr.js: parseNostrPubkey/npubOf, encryptWithCode/decryptWithCode (random 32-byte code), fetchNostrProfile, NostrSync.sendDM (broad relays). - wallet.js: lockGift → { blob, claimCode }, previewLockedGift, nostr identity + sendNostrDM. - app.js: gift form gains an optional 'Lock to npub' field that shows the recipient's avatar + name (nostr kind-0 profile); create encrypts + DMs the code (with sent/failed status + a copyable fallback); /lg/ links open a claim view showing the gift + recipient where you paste the code; account settings shows your own npub to share. Verified: code lock/unlock round-trip, a real nostr DM published and read back from relay.damus.io, and the UI loads + renders with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nostr.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) (limited to 'src/nostr.js') diff --git a/src/nostr.js b/src/nostr.js index 74d8d25..02e37a4 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -11,8 +11,64 @@ import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; +import * as nip04 from 'nostr-tools/nip04'; import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; import { Relay } from 'nostr-tools/relay'; +import { randomBytes } from '@noble/hashes/utils'; +import { base64urlnopad } from '@scure/base'; + +// --- 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) { + const s = (input || '').trim(); + if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase(); + try { const d = nip19decode(s); if (d.type === 'npub') return d.data; } catch {} + return null; +} +export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } } + +// Encrypt a payload under a fresh one-time code (32 random bytes → base64url). +// The code travels to the recipient out-of-band (a nostr DM), so the public link +// holding the ciphertext can't be claimed without it. Returns { code, ct }. +export function encryptWithCode(plaintext) { + const key = randomBytes(32); + return { code: base64urlnopad.encode(key), ct: nip44.encrypt(plaintext, key) }; +} +export function decryptWithCode(code, ct) { + return nip44.decrypt(ct, base64urlnopad.decode((code || '').trim())); +} + +// Profiles live across the network, so look them up on popular relays (broader +// than the sync relays). +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; + } + 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); + }); +} const DTAG = 'bitcoin-wallet'; const SYNC_KEY = 'btc-wallet-sync'; @@ -112,6 +168,20 @@ export class NostrSync { ); } + // Send an encrypted DM (NIP-04 kind 4) — used to deliver a locked gift's claim + // code. Best-effort across the broad relay set so the recipient's client sees + // it. Returns true if at least one relay accepted it. + async sendDM(recipientPkHex, text, relays = PROFILE_RELAYS) { + 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 res = await Promise.allSettled((relays || PROFILE_RELAYS).map(async (url) => { const r = await this._connect(url); await r.publish(evt); })); + return res.some((x) => x.status === 'fulfilled'); + } + // Fetch from every relay; return the newest decrypted state, or null. async fetch() { if (!this.sk) return null; -- cgit v1.2.3 From 96d9b7ee6914b157a17e078cfe597cb40f35c9e2 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:28:18 -0700 Subject: Route the gift DM to the recipient's published inbox relays Discover where a recipient reads DMs before sending the claim code: prefer their NIP-17 DM relay list (kind 10050), else their NIP-65 read relays (kind 10002), unioned with the broad fallback set (so users without a list still get it). This makes the locked-gift DM far more likely to land in the recipient's client. Verified: resolves fiatjaf's real inbox relays; a key with no list returns none and falls back to defaults. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nostr.js | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) (limited to 'src/nostr.js') diff --git a/src/nostr.js b/src/nostr.js index 02e37a4..378cec9 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -70,6 +70,28 @@ async function _fetchProfileFrom(url, pk) { }); } +// 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]; + 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'; export const DEFAULT_SYNC_RELAYS = ['wss://relay.coinos.io']; @@ -169,16 +191,22 @@ export class NostrSync { } // Send an encrypted DM (NIP-04 kind 4) — used to deliver a locked gift's claim - // code. Best-effort across the broad relay set so the recipient's client sees - // it. Returns true if at least one relay accepted it. - async sendDM(recipientPkHex, text, relays = PROFILE_RELAYS) { + // 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. + 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 res = await Promise.allSettled((relays || PROFILE_RELAYS).map(async (url) => { const r = await this._connect(url); await r.publish(evt); })); + 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); })); return res.some((x) => x.status === 'fulfilled'); } -- cgit v1.2.3 From 0fbeb3c55413fb221d890b3bf3464ebd2aed9fa1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:31:51 -0700 Subject: Locked gifts: let NIP-07 extension users decrypt in-place, skip the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gift payload is now encrypted two ways in the link: under the one-time code (the DM / manual path) AND to the recipient's nostr pubkey via an ephemeral key. When a NIP-07 browser extension (window.nostr) is present, the claim view offers 'Claim with nostr extension' — it checks the extension's pubkey matches the gift, decrypts ctKey via window.nostr.nip44.decrypt, and claims into a fresh wallet with no code. The pasted-code path remains as the fallback for everyone else. Verified: both decrypt paths recover the payload from the blob; a different extension account is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 26 +++++++++++++++++++++++--- src/i18n.js | 5 +++++ src/nostr.js | 21 ++++++++++++++------- src/wallet.js | 6 +++--- 4 files changed, 45 insertions(+), 13 deletions(-) (limited to 'src/nostr.js') diff --git a/src/app.js b/src/app.js index 84cec51..803a82b 100644 --- a/src/app.js +++ b/src/app.js @@ -1848,6 +1848,9 @@ function readLockedGiftHash() { // then it's claimed into a fresh wallet exactly like a normal gift. function lockedGiftClaimView() { const lk = ui.claimLocked; + // NIP-07: if a nostr browser extension is present, the recipient can decrypt + // in-place — no code needed. + const hasExt = !!(typeof globalThis !== 'undefined' && globalThis.nostr && lk.eph && lk.ctKey); return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, h('h3', { style: 'margin:0' }, t('giftForYou')), @@ -1855,16 +1858,33 @@ function lockedGiftClaimView() { h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to, { size: 36 })), - h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + hasExt ? h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: claimViaExtension }, ui.busy ? h('span', { class: 'spinner' }) : t('claimWithExtension')) : null, + hasExt ? h('div', { class: 'small faint', style: 'text-align:center' }, t('orEnterCode')) : h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), h('input', { type: 'text', class: 'mono-input', style: 'width:100%', placeholder: t('claimCodePlaceholder'), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.claimCodeInput, onInput: (e) => { ui.claimCodeInput = e.target.value; } }), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - h('button', { class: 'btn-primary btn-block', onClick: submitLockedCode }, t('claimGift')), + h('button', { class: (hasExt ? '' : 'btn-primary ') + 'btn-block', onClick: submitLockedCode }, t('claimGift')), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); } }, t('dismiss')) ) ); } +async function claimViaExtension() { + const ext = typeof globalThis !== 'undefined' && globalThis.nostr; + if (!ext) return; + ui.busy = true; ui.claimError = ''; render(); + try { + const pk = await ext.getPublicKey(); + if (pk !== ui.claimLocked.to) { ui.claimError = t('extWrongAccount'); ui.busy = false; render(); return; } + if (!ext.nip44 || !ext.nip44.decrypt) { ui.claimError = t('extNoNip44'); ui.busy = false; render(); return; } + const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey); + if (!payload || !previewGift(payload)) { ui.claimError = t('extDecryptFailed'); ui.busy = false; render(); return; } + ui.busy = false; ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim + } catch { + ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); + } +} function submitLockedCode() { const code = (ui.claimCodeInput || '').trim(); if (!code) return; diff --git a/src/i18n.js b/src/i18n.js index 48dcef0..31cc3c9 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -240,6 +240,11 @@ const STR = { giftDmSending: 'Sending the claim code…', giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:', giftCopyCode: 'Copy claim code', + claimWithExtension: 'Claim with nostr extension', + orEnterCode: 'or paste the claim code', + extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.', + extNoNip44: 'Your nostr extension doesn’t support NIP-44 — paste the code instead.', + extDecryptFailed: 'Couldn’t decrypt with the extension — try the code.', nostrKeyTitle: 'Nostr address', nostrKeyDesc: 'Share this so others can lock a gift to you — the claim code is sent here as a nostr DM.', giftSplitExplain: 'This gift would lock {lock} until it’s claimed ({change} of it comes back to you then). You can split a right-sized coin first for about {fee} so only the gift amount stays locked.', diff --git a/src/nostr.js b/src/nostr.js index 378cec9..0ebfe92 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -12,7 +12,7 @@ import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; -import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; import { Relay } from 'nostr-tools/relay'; import { randomBytes } from '@noble/hashes/utils'; @@ -28,12 +28,19 @@ export function parseNostrPubkey(input) { } export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } } -// Encrypt a payload under a fresh one-time code (32 random bytes → base64url). -// The code travels to the recipient out-of-band (a nostr DM), so the public link -// holding the ciphertext can't be claimed without it. Returns { code, ct }. -export function encryptWithCode(plaintext) { - const key = randomBytes(32); - return { code: base64urlnopad.encode(key), ct: nip44.encrypt(plaintext, key) }; +// Encrypt a gift payload two ways: (1) under a fresh one-time code, delivered to +// the recipient out-of-band via a nostr DM — the manual path; and (2) to the +// recipient's nostr pubkey via an ephemeral key, so a NIP-07 browser extension +// can decrypt it in-place with no code. Both decrypt to the same payload. +export function encryptGiftPayload(plaintext, recipientPkHex) { + const codeKey = randomBytes(32); + const ephSk = generateSecretKey(); + return { + code: base64urlnopad.encode(codeKey), + ctCode: nip44.encrypt(plaintext, codeKey), + eph: getPublicKey(ephSk), + ctKey: nip44.encrypt(plaintext, nip44.getConversationKey(ephSk, recipientPkHex)), + }; } export function decryptWithCode(code, ct) { return nip44.decrypt(ct, base64urlnopad.decode((code || '').trim())); diff --git a/src/wallet.js b/src/wallet.js index b2ff3e3..e2b5b35 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -23,7 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; -import { NostrSync, getSyncConfig, encryptWithCode, npubOf } from './nostr.js'; +import { NostrSync, getSyncConfig, encryptGiftPayload, npubOf } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; @@ -2579,8 +2579,8 @@ const LOCKED_GIFT_VERSION = 1; // to the recipient via a nostr DM; the public link carries only the ciphertext. // Returns { blob (for the /lg/ link), claimCode (to DM) }. export function lockGift(code, amount, recipientPkHex) { - const { code: claimCode, ct } = encryptWithCode(code); - const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct }))); + const { code: claimCode, ctCode, eph, ctKey } = encryptGiftPayload(code, recipientPkHex); + const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct: ctCode, eph, ctKey }))); return { blob, claimCode }; } // The public fields of a locked-gift blob (or null). Decryption needs the code. -- cgit v1.2.3 From 210eeb8073cd3cc46dae3411ce84ec99c928d2b1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 14:05:19 -0700 Subject: Nostr: use SimplePool (fixes WS console errors) + send DMs as NIP-04 AND NIP-17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrated all relay I/O (profile/inbox fetch, DM publish, state sync) to a shared SimplePool. The old code opened a relay per request and closed it manually, which raced nostr-tools' CLOSE-after-EOSE and spammed 'WebSocket is already in CLOSING or CLOSED state'. SimplePool owns the connection lifecycle, so that's gone. Also reuses connections instead of churning one per fetch. - Gift DMs now go out as BOTH NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap). Older clients (Amethyst) read NIP-04; newer ones (Jumble) only support NIP-17 and ignore NIP-04 — sending both means the claim code shows up either way. Verified: profile/inbox fetch + DM still work via the pool; the NIP-17 wrap is published and unwraps to the original message; the NIP-04 kind-4 still lands too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nostr.js | 154 +++++++++++++---------------------------------------------- 1 file changed, 33 insertions(+), 121 deletions(-) (limited to 'src/nostr.js') 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; } } -- cgit v1.2.3 From ca2dec6ebca48daef99427c1fcd39439b7d75d4b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 14:07:11 -0700 Subject: Send gift DMs as NIP-17 only (drop NIP-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop also sending the legacy NIP-04 kind-4 DM — clients that support both (e.g. Amethyst) were showing the claim code twice. NIP-17 alone covers current clients. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nostr.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) (limited to 'src/nostr.js') diff --git a/src/nostr.js b/src/nostr.js index f1c1802..31c9858 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -11,7 +11,6 @@ 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'; @@ -136,23 +135,20 @@ export class NostrSync { await Promise.allSettled(pool.publish(this.relays, evt)); } - // 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. + // Deliver an encrypted DM (a locked gift's claim code) as a NIP-17 gift wrap + // (kind 1059) — the modern standard every current client supports. Goes to the + // recipient's published inbox relays (NIP-17/65) plus a broad fallback. Returns + // true if ≥1 relay accepted it. async sendDM(recipientPkHex, text, relays = null) { if (!this.sk) 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 evt; + try { evt = nip17WrapEvent(this.sk, { publicKey: recipientPkHex }, text); } catch { 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(events.flatMap((evt) => pool.publish(targets, evt))); + const res = await Promise.allSettled(pool.publish(targets, evt)); return res.some((x) => x.status === 'fulfilled'); } -- cgit v1.2.3