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