summaryrefslogtreecommitdiffstats
path: root/src/nostr.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-28 09:22:45 -0700
committerAdam Soltys <[email protected]>2026-06-28 09:22:45 -0700
commitd459e4aa196b356cfc24f2118cc1ef54eda7600e (patch)
tree4cd0bd4bed365121417e7a57b2371c6295c86908 /src/nostr.js
parent5e19371642b0d2edc7540f96f53a17c47cf51a15 (diff)
downloadcoinosv3-d459e4aa196b356cfc24f2118cc1ef54eda7600e.tar.gz
coinosv3-d459e4aa196b356cfc24f2118cc1ef54eda7600e.zip
Lock a gift to a nostr key: public link, claim code DM'd to the recipient
A gift can now be locked to a recipient's nostr npub. The shareable link (/lg/<blob>) 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) <[email protected]>
Diffstat (limited to 'src/nostr.js')
-rw-r--r--src/nostr.js70
1 files changed, 70 insertions, 0 deletions
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;