// Encrypted cross-device state sync via Nostr (opt-out, configurable relays). // // The wallet's Nostr identity is derived from the same seed (NIP-06, // m/44'/1237'/0'/0/0). Wallet state is encrypted to ourselves (NIP-44) and // published as a single parameterized-replaceable event (kind 30078, NIP-78) // to the configured relays — so any device with the seed can pull the latest // state without re-scanning, and a relay only ever keeps the newest copy. // // Sync is on by default (relay.coinos.io) but can be turned off or pointed at // other relays in Settings; the preference lives in localStorage. import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; import { getPublicKey, finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure'; import { decode as nip19decode, npubEncode, nsecEncode, neventEncode } from 'nostr-tools/nip19'; import { normalizeURL } from 'nostr-tools/utils'; import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; import { dlog } from './debug.js'; import { randomBytes, bytesToHex } 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. // Ping + reconnect matter: without them a quietly dropped socket (proxy idle // timeout, network blip) takes every live subscription with it and nothing // re-subscribes — sync stops merging and NWC stops answering until a reload. // On reconnect the pool re-fires open subs with since = last event seen. // verifyEvent: () => true switches OFF the pool's own synchronous signature // check — a schnorr verify per event, which on a relay backlog froze the main // thread (the boot/chat carousel stutter). We re-add verification off-thread: // every event is gated through a Web Worker (verifyEventsAsync) before it // reaches a caller, so nothing unverified is ever trusted, but the crypto // runs on another thread. matchFilters still runs in the pool. const pool = new SimplePool({ enablePing: true, enableReconnect: true, verifyEvent: () => true }); // ---- off-main-thread verification ----------------------------------------- let _vw = null, _vwDead = false, _vwSeq = 0; const _vwPending = new Map(); // reqId -> resolve function verifyWorker() { if (_vw || _vwDead) return _vw; try { _vw = new Worker('/verify-worker.js', { type: 'module' }); _vw.onmessage = (e) => { const { reqId, results } = e.data || {}; const done = _vwPending.get(reqId); if (done) { _vwPending.delete(reqId); done(results); } }; _vw.onerror = () => { _vwDead = true; _vw = null; }; // fall back to main-thread verify } catch { _vwDead = true; } return _vw; } const _verifySync = (events) => events.map((e) => { try { return verifyEvent(e); } catch { return false; } }); // Verify a batch; resolves to a boolean[] aligned to `events`. Off-thread when // the worker is available, else synchronous. Never resolves an event as valid // without an actual check. export function verifyEventsAsync(events) { if (!events.length) return Promise.resolve([]); const w = verifyWorker(); if (!w) return Promise.resolve(_verifySync(events)); return new Promise((resolve) => { const reqId = ++_vwSeq; let settled = false; const finish = (r) => { if (!settled) { settled = true; resolve(r); } }; // if the worker ever stalls, don't hang the event forever — verify inline const t = setTimeout(() => { _vwPending.delete(reqId); finish(_verifySync(events)); }, 4000); _vwPending.set(reqId, (r) => { clearTimeout(t); finish(r); }); try { w.postMessage({ reqId, events }); } catch { clearTimeout(t); _vwPending.delete(reqId); finish(_verifySync(events)); } }); } // A single event → Promise. Used to gate live subscription events. function verifyOneAsync(event) { return verifyEventsAsync([event]).then((r) => !!r[0]); } // Heavier nip44 jobs ride the same worker: opening community wraps (decrypt // under the symmetric stream key + seal verify per wrap) and unwrapping // NIP-17 DMs when the raw identity key is in hand (two ECDH ops + a seal // verify each) — the remaining boot secp256k1 after verification moved off. // Resolves to the worker's results array, or null when the worker can't take // the job (never created, died, stalled) — callers keep their time-sliced // main-thread fallback, so a dead worker only changes WHERE the crypto runs. function workerCrypto(msg, timeoutMs = 10_000) { const w = verifyWorker(); if (!w) return null; return new Promise((resolve) => { const reqId = ++_vwSeq; const t = setTimeout(() => { _vwPending.delete(reqId); resolve(null); }, timeoutMs); _vwPending.set(reqId, (r) => { clearTimeout(t); resolve(Array.isArray(r) ? r : null); }); try { w.postMessage({ reqId, ...msg }); } catch { clearTimeout(t); _vwPending.delete(reqId); resolve(null); } }); } // convKey / sk are Uint8Array; hex crosses the thread boundary. export function openWrapsOffthread(wraps, convKey) { return workerCrypto({ op: 'openWraps', wraps, convKey: bytesToHex(convKey) }); } export function unwrapDMsOffthread(wraps, sk) { return workerCrypto({ op: 'unwrapDMs', wraps, sk: bytesToHex(sk) }); } // Subscribe / publish on an explicit relay set, independent of the sync // feature's configuration. NWC needs this: the relays it advertises in a // connection URI must be exactly the ones it listens on, or a client's // request lands somewhere the wallet isn't and it waits forever. // ---- relay health --------------------------------------------------------- // A relay that refuses connections (damus has been down for days at a time) // otherwise gets retried on every profile fetch, every sync, every chat // subscribe — each attempt logging a browser-level WebSocket error we cannot // catch from JS. So we remember failures and stop dialing for a while. // A relay found dead is skipped for ten minutes the first time, and twice as // long each time it fails again (a day at most), and the verdict survives a // reload — a relay that has been gone for weeks (relay.mostr.pub, say, from // somebody's relay list) is otherwise redialled at every boot, each attempt // a red line in the console. const SICK_MS = 10 * 60_000; const SICK_MAX_MS = 24 * 60 * 60_000; const SICK_KEY = 'coinos-sick-relays'; const relayKey = (u) => { try { return normalizeURL(u); } catch { return u; } }; const HOME_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.damus.io', 'wss://relay.primal.net']; const sick = new Map(); // normalized url -> { until, n } try { const saved = JSON.parse(localStorage.getItem(SICK_KEY) || '{}'); for (const [u, v] of Object.entries(saved)) if (v && v.until > Date.now()) sick.set(u, v); } catch {} const saveSick = () => { try { localStorage.setItem(SICK_KEY, JSON.stringify(Object.fromEntries([...sick].slice(-200)))); } catch {} }; // The relays the app stands on are never written off for long: a phone // asleep or between networks fails every socket at once, and an escalating // verdict on relay.coinos.io then kept a profile at the 2 posts primal still // held for hours after the network was back (2026-10-01). const HOME_SICK_MS = 60_000; const isHomeRelay = (n) => HOME_RELAYS.some((u) => relayKey(u) === n); // Failures while the tab is hidden or offline say nothing about the relay. const offline = () => typeof navigator !== 'undefined' && navigator.onLine === false; const hiddenTab = () => typeof document !== 'undefined' && document.visibilityState === 'hidden'; export function markRelaySick(url, why) { if (!url) return; const n = relayKey(url); if (offline() || hiddenTab()) { resetRelay(url); return; } const prev = sick.get(n); const strikes = prev ? (prev.n || 1) + 1 : 1; const ms = isHomeRelay(n) ? HOME_SICK_MS : Math.min(SICK_MAX_MS, SICK_MS * 2 ** (strikes - 1)); if (!prev) dlog(`nostr: ${url} unreachable (${why || 'connect failed'}) — skipping for ${Math.round(ms / 60_000)} min`); sick.set(n, { until: Date.now() + ms, n: strikes }); saveSick(); // and out of the pool: left in, its own reconnect loop keeps dialling // (ten seconds, then twenty, then every minute) for as long as the tab lives resetRelay(url); } const isSick = (u) => sick.has(relayKey(u)); // ---- rate limits ---------------------------------------------------------- // A relay that answers "rate-limited:" (a CLOSED on a REQ, an OK false on a // publish, or a NOTICE asking us to slow down) is up, just tired of us — // asking again at the same pace is how an IP ends up banned (nos.lol and // damus both did, 2026-10). So one-shot reads skip it for a while: 30 s the // first time, doubling while it keeps refusing (ten minutes at most), and a // quiet stretch forgives the count. Live subscriptions and publishes still go // to it — they're few, and dropping a DM inbox relay costs more than a REQ. const COOL_MS = 30_000; const COOL_MAX_MS = 10 * 60_000; const cooling = new Map(); // normalized url -> { until, n } const RATE_LIMITED = /rate.?limit|slow down|too (many|fast)|throttl/i; export function markRelayRateLimited(url, why) { if (!url) return; const n = relayKey(url); const prev = cooling.get(n); const now = Date.now(); // a strike long after the last cool-off starts over const strikes = prev && now - prev.until < COOL_MAX_MS ? prev.n + 1 : 1; if (prev && prev.until > now) return; // already cooling; one burst is one strike const ms = Math.min(COOL_MAX_MS, COOL_MS * 2 ** (strikes - 1)); console.warn(`nostr: ${url} rate-limited us (${why}) — no queries for ${Math.round(ms / 1000)} s`); cooling.set(n, { until: now + ms, n: strikes }); } const isCooling = (u) => { const c = cooling.get(relayKey(u)); return !!c && c.until > Date.now(); }; // Every relay the pool opens reports its refusals here: wrap its subscribe / // publish / NOTICE once, so every caller (ours and the pool's) is covered. const _ensureRelay = pool.ensureRelay.bind(pool); pool.ensureRelay = async (url, params) => { const relay = await _ensureRelay(url, params); if (relay && !relay._coinosRL) { relay._coinosRL = true; const notice = relay.onnotice; relay.onnotice = (msg) => { if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); try { notice?.call(relay, msg); } catch {} }; const subscribe = relay.subscribe.bind(relay); relay.subscribe = (filters, p = {}) => subscribe(filters, { ...p, onclose: (reason) => { if (RATE_LIMITED.test(String(reason || ''))) markRelayRateLimited(relay.url, String(reason)); p.onclose?.(reason); }, }); const publish = relay.publish.bind(relay); relay.publish = (evt) => publish(evt).catch((e) => { const msg = e?.message || String(e || ''); if (RATE_LIMITED.test(msg)) markRelayRateLimited(relay.url, msg); throw e; }); } return relay; }; // Back online or back on screen: the home relays get a fresh chance at once // (verdicts saved by older builds could still be hours long). function forgiveHomeRelays() { let changed = false; for (const u of [...sick.keys()]) if (isHomeRelay(u)) { sick.delete(u); changed = true; } if (changed) saveSick(); } forgiveHomeRelays(); if (typeof window !== 'undefined') { window.addEventListener('online', forgiveHomeRelays); document.addEventListener('visibilitychange', () => { if (!hiddenTab()) forgiveHomeRelays(); }); } // Drop known-bad relays, but never hand back an empty set: if everything is // sick the network probably is, and one doomed attempt beats doing nothing. function liveRelays(list) { const now = Date.now(); let changed = false; for (const [u, v] of sick) if (v.until < now) { sick.delete(u); changed = true; } if (changed) saveSick(); // a relay the pool has been failing to reconnect to is dead by now for (const u of list || []) { const r = pool.relays.get(relayKey(u)); if (r && !r.connected && (r.reconnectAttempts || 0) >= 3) markRelaySick(u, 'reconnect loop'); } const ok = (list || []).filter((u) => !isSick(u)); return ok.length ? ok : (list || []); } // For one-shot reads: also leave out relays cooling off a rate limit. An // empty answer is fine here — a query nobody can take now just finds nothing. function readRelays(list) { const live = liveRelays(list); return live.filter((u) => !isCooling(u)); } // Dial a relay once in the background to learn whether it's reachable; a // rejection here is the only reliable signal nostr-tools gives us. const probed = new Set(); function probeRelays(list) { for (const url of list || []) { const n = relayKey(url); if (probed.has(n) || sick.has(n)) continue; probed.add(n); Promise.resolve(pool.ensureRelay(url, { connectionTimeout: 6000 })) .then(() => {}) .catch((e) => markRelaySick(url, e && e.message)) .finally(() => setTimeout(() => probed.delete(n), SICK_MS)); } } // Whether the pool still holds a live socket to a relay: connected, or // dialing / waiting out a reconnect backoff for less than DIAL_MAX. A socket // that ERRORED is dropped from the pool outright, its subscriptions with it, // and nothing dials it again on its own; a dial that never answers (the // network went away mid-handshake) hangs the pool's reconnect forever with // no error at all. Both are the silence a watchdog has to catch. const DIAL_MAX = 20_000; const dialingSince = new Map(); // normalized url -> when we first saw it not-yet-connected export function relayAlive(url) { const n = normalizeURL(url); // the pool keys by the normalized form (trailing slash) const r = pool.relays.get(n); if (!r) { dialingSince.delete(n); return false; } if (r.connected) { dialingSince.delete(n); return true; } if (!r.connectionPromise && !r.reconnectTimeoutHandle) { dialingSince.delete(n); return false; } const since = dialingSince.get(n) || (dialingSince.set(n, Date.now()), Date.now()); return Date.now() - since < DIAL_MAX; } // Forget a relay the pool is stuck on, so the next subscribe dials afresh: // its (hung or half-dead) socket is closed and the entry dropped. Its // subscriptions close too — the caller is about to rebuild them. export function resetRelay(url) { const n = normalizeURL(url); dialingSince.delete(n); const r = pool.relays.get(n); if (!r) return; // no zombie: a dropped relay must not go on reconnecting from outside the pool try { r.skipReconnection = true; if (r.reconnectTimeoutHandle) clearTimeout(r.reconnectTimeoutHandle); } catch {} try { r.close(); } catch {} pool.relays.delete(n); } // the relays a subscription on this list actually dials (sick ones skipped) export const liveRelayList = (list) => liveRelays(list); // ...and the ones a one-shot read goes to (rate-limited ones skipped too) export const readRelayList = (list) => readRelays(list); export function subscribeOn(relays, filter, onEvent, extra = {}) { try { // nostr-tools ≥2.23 takes a single filter object here; wrapping it in an // array serializes as ["REQ",id,[{...}]] which every relay rejects with // "provided filter is not an object" — and the subscription dies silently. const live = liveRelays(relays); probeRelays(relays); // gate each event through off-thread verification before the caller sees it const gated = (event) => { verifyOneAsync(event).then((ok) => { if (ok) onEvent(event); }); }; const sub = pool.subscribeMany(live, filter, { onevent: gated, ...extra }); return () => { try { sub.close(); } catch {} }; } catch { return () => {}; } } // NIP-42: a subscription on relays that may answer CLOSED "auth-required" // — a NIP-17 inbox such as auth.nostr1.com shows a user's gift wraps only // to that user. `onauth(template)` signs the AUTH event; the relay is then // asked again. Per relay rather than the pool's own retry, whose re-asked // subscription escapes the closer (a resubscribe would stack copies). // `onclose` hears only real closes, not the auth handshake. export function subscribeAuthed(relays, filter, onEvent, { onauth = null, onclose = null } = {}) { let closed = false; const subs = []; const gated = (event) => { verifyOneAsync(event).then((ok) => { if (ok) onEvent(event); }); }; probeRelays(relays); for (const url of liveRelays(relays)) { (async () => { let relay; try { relay = await pool.ensureRelay(url, { connectionTimeout: 8000 }); } catch (e) { if (!closed) onclose?.([String(e?.message || e)]); return; } let tried = false; const open = () => { if (closed) return; subs.push(relay.subscribe([filter], { onevent: gated, onclose: (reason) => { if (closed) return; if (!tried && onauth && /^auth-required:/.test(String(reason))) { tried = true; // a refused or unsignable auth is not a dropped socket: // rebuilding would only ask again authRelay(relay, onauth).then(open, (e) => dlog('nostr: auth to ' + url + ' failed: ' + (e?.message || e))); return; } onclose?.([reason]); }, })); }; open(); })(); } return () => { closed = true; for (const s of subs) { try { s.close(); } catch {} } }; } // The relay's challenge can trail its CLOSED: wait a beat for it. // Signed BEFORE handing it to the relay: nostr-tools caches its auth promise // per connection, and a signer that throws inside it leaves that promise // pending forever — every later auth on the socket would hang. async function authRelay(relay, onauth) { for (let i = 0; i < 10 && !relay.challenge; i++) await new Promise((r) => setTimeout(r, 300)); if (!relay.challenge) throw new Error('no auth challenge'); if (!relay.authPromise) { const signed = await onauth({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [['relay', relay.url], ['challenge', relay.challenge]], content: '' }); return relay.auth(async () => signed); } return relay.auth(onauth); } export async function queryOn(relays, filter, maxWait = 1500) { probeRelays(relays); try { const live = readRelays(relays); if (!live.length) return []; const evs = await pool.querySync(live, filter, { maxWait }); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } } // Several filters in ONE REQ per relay (NIP-01 allows a list) — one // subscription against the relay's REQ budget instead of one per filter. export async function queryManyOn(relays, filters, maxWait = 1500) { probeRelays(relays); try { const live = readRelays(relays); if (!live.length || !filters.length) return []; const evs = await new Promise((resolve) => { const got = []; let sub = null, done = false; const finish = () => { if (!done) { done = true; resolve(got); } }; sub = pool.subscribeMap(live.flatMap((url) => filters.map((filter) => ({ url, filter }))), { maxWait, onevent: (e) => got.push(e), oneose: () => { try { sub?.close('closed automatically on eose'); } catch {} finish(); }, onclose: finish, }); }); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } } // A bounded query that delivers verified events before the slowest relay's // EOSE. Completion also waits for verification and the consumer's work. export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) { probeRelays(relays); const pending = []; try { const live = readRelays(relays); if (!live.length) return; await new Promise((resolve) => { pool.subscribeManyEose(live, filter, { maxWait, onevent(event) { pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {})); }, onclose: resolve, }); }); } catch {} finally { await Promise.all(pending); } } export async function publishOn(relays, evt, { onauth = null } = {}) { try { const live = liveRelays(relays); // A rejected publish is a relay refusing the event (policy, rate limit, // ban). Swallowing that silently once hid a relay-side rejection behind // what looked like a client bug — always say which relay refused and why. // with onauth, a relay that answers "auth-required" (NIP-42) is // authenticated and asked again const pubs = pool.publish(live, evt).map((p, i) => Promise.resolve(p).then(() => true, async (reason) => { if (onauth && /^auth-required:/.test(reason?.message || String(reason || ''))) { try { const r = await pool.ensureRelay(live[i]); await authRelay(r, onauth); await r.publish(evt); return true; } catch (e) { reason = e; } } const msg = reason?.message || String(reason || ''); // "relay connection errored/closed" means the socket never came up — // that's a sick relay, not a rejected event. if (/connect|closed|errored|timeout|network/i.test(msg)) markRelaySick(live[i], msg); else console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg); return false; })); // The first acceptance IS delivery — one durable copy is enough, and // waiting on allSettled meant every send moved at the pace of the // slowest relay in the set. The stragglers keep settling in the // background so the sick-list still learns from them. return await new Promise((resolve) => { let left = pubs.length; if (!left) return resolve(false); for (const p of pubs) p.then((ok) => { if (ok) resolve(true); else if (!--left) resolve(false); }); }); } catch { return false; } } // NIP-47 wallet services sign with their own per-connection key and must // speak whichever encryption the client asked for, so export both schemes. export { nip04, nip44 }; export { getPublicKey, finalizeEvent, generateSecretKey }; // The nostr identity a seed carries (NostrSync#load's key, without the // wallet) — so the account switcher can put a face on every account on the // device, not just the one that's open. Memoized: nip06 stretches the seed. const _seedPks = new Map(); export function seedPubkey(mnemonic, passphrase = '', accountIndex = 0) { const k = `${mnemonic}\n${passphrase}\n${accountIndex}`; if (_seedPks.has(k)) return _seedPks.get(k); let pk = null; try { pk = getPublicKey(nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0)); } catch {} _seedPks.set(k, pk); return pk; } 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; } // An npub or 64-hex nostr pubkey → hex, or null if it isn't one. // Memoized: the feed asks for the reader's npub thousands of times while it // filters rows, and bech32 encoding was the top self time of a scroll. const npubMemo = new Map(); export function npubOf(pkHex) { if (npubMemo.has(pkHex)) return npubMemo.get(pkHex); let v = null; try { v = npubEncode(pkHex); } catch {} if (npubMemo.size > 4000) npubMemo.clear(); npubMemo.set(pkHex, v); return v; } // An event reference other clients can resolve: the id plus who wrote it, so // a quote can be fetched even by someone who doesn't have it yet. export function neventOf(id, author) { try { return neventEncode({ id, author }); } catch { return null; } } // Any nostr entity reference → something renderable: npub/nprofile land as a // pubkey (with relay hints when carried), note/nevent as an event id. export function parseNostrRef(input) { try { const d = nip19decode(String(input || '').trim()); if (d.type === 'npub') return { type: 'pubkey', pk: d.data }; if (d.type === 'nprofile') return { type: 'pubkey', pk: d.data.pubkey, relays: d.data.relays || [] }; if (d.type === 'note') return { type: 'event', id: d.data, relays: [] }; if (d.type === 'nevent') return { type: 'event', id: d.data.id, relays: d.data.relays || [], author: d.data.author || null }; if (d.type === 'naddr') return { type: 'addr', kind: d.data.kind, pk: d.data.pubkey, d: d.data.identifier || '', relays: d.data.relays || [] }; } catch {} return null; } // A raw secret key → nsec, for the one place that deliberately exports it. export function nsecOf(sk) { try { return nsecEncode(sk); } catch { return null; } } // Profiles live across the network, so look them up on popular relays (broader // than the sync relays). // Where we look for people who haven't said where they write. Measured // against a real 862-follow list: nos.lol carries 267 of them, damus.io 249, // primal 170 — and damus wasn't here, which left 232 follows with no relay // list of their own leaning on two relays instead of three. export const PROFILE_RELAYS = HOME_RELAYS; // Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { 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, about: typeof m.about === 'string' ? m.about : null, nip05: m.nip05 || null, // NIP-57/LUD-16/06: where to send a Lightning zap. lud16 is a // lightning address (name@domain); lud06 is a bech32 lnurl. lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null, lud06: typeof m.lud06 === 'string' ? m.lud06.trim() : null, }; } catch { return null; } } // 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) { 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 []; } // 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. // (NIP-44 takes raw 32-byte key material, so the one-time code works as a key.) 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())); } const DTAG = 'bitcoin-wallet'; // Each network syncs under its own d-tag base — a shared one would let a signet // snapshot overwrite mainnet state (and vice versa). Mainnet keeps the bare tag // existing wallets already publish under. export const syncDtag = (netName) => (!netName || netName === 'mainnet' ? DTAG : `${DTAG}:${netName}`); // Per-DEVICE d-tag: every device publishes to its OWN replaceable-event slot // (`:d:`), so no device can ever clobber another's snapshot. // Readers pull ALL of the identity's kind-30078 events and merge across them, // so a device sees every other device's coins. The legacy shared-tag events // (from before this) are still read and merged — they just never get written // to again. const DEVICE_KEY = 'btc-wallet-device-id'; function deviceId() { try { let id = localStorage.getItem(DEVICE_KEY); if (!id) { id = bytesToHexLocal(randomBytes(8)); localStorage.setItem(DEVICE_KEY, id); } return id; } catch { return 'ephemeral'; } } const bytesToHexLocal = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join(''); export const deviceDtag = (netName) => `${syncDtag(netName)}:d:${deviceId()}`; // Per-DOMAIN slot (`:x::`): the snapshot is split // into independent pieces (core wallet, ark, nwc, …) so each event stays far // below common relay size limits and only changed domains republish. The // `:x:` marker is deliberately NOT `:d:` — clients from before the split // don't match it, so they never mistake a domain fragment for a full // snapshot and wipe their core state with it. export const domainDtag = (netName, domain) => `${syncDtag(netName)}:x:${deviceId()}:${domain}`; // True if `evtDtag` is one of OUR slots for `netName` (our device, another // device, a domain slot, or the legacy shared tag) — selects events to merge. export const isOurDtag = (evtDtag, netName) => { const base = syncDtag(netName); return evtDtag === base || evtDtag.startsWith(base + ':d:') || evtDtag.startsWith(base + ':x:'); }; // True if the event carries CORE wallet fields (legacy full snapshots and the // split 'core' domain) — the only events safe to apply as a full snapshot. export const isCoreDtag = (evtDtag, netName) => { const base = syncDtag(netName); if (evtDtag === base || evtDtag.startsWith(base + ':d:')) return true; return evtDtag.startsWith(base + ':x:') && evtDtag.endsWith(':core'); }; // True if the slot belongs to THIS device (legacy or domain form). export const isOwnDeviceDtag = (evtDtag, netName) => evtDtag === deviceDtag(netName) || evtDtag.startsWith(`${syncDtag(netName)}:x:${deviceId()}:`); const SYNC_KEY = 'btc-wallet-sync'; export const DEFAULT_SYNC_RELAYS = ['wss://relay.coinos.io']; // --- sync preference (enabled + relays), global, persisted in localStorage --- export function getSyncConfig() { try { const raw = localStorage.getItem(SYNC_KEY); if (raw) { const c = JSON.parse(raw); const relays = Array.isArray(c.relays) ? c.relays : []; // Always on — the toggle is gone; relays follow the user's NIP-65 // (adoptSyncRelays in the sync feature), coinos relay as the default. return { enabled: true, relays: relays.length ? relays : DEFAULT_SYNC_RELAYS }; } } catch {} return { enabled: true, relays: DEFAULT_SYNC_RELAYS }; // default: on, coinos relay } export function setSyncConfig({ enabled, relays }) { try { localStorage.setItem(SYNC_KEY, JSON.stringify({ enabled: !!enabled, relays: relays || DEFAULT_SYNC_RELAYS })); } catch {} } export class NostrSync { constructor() { this.sk = null; this.pk = null; this.ck = null; // self conversation key for NIP-44 this.relays = DEFAULT_SYNC_RELAYS; this.dtag = DTAG; } setDtag(d) { this.dtag = d || DTAG; } load(mnemonic, passphrase = '', accountIndex = 0) { // a derived sibling wallet (same seed, higher BIP84 account) gets its own // nostr identity and sync slots — nip06 shifts by account index this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0); this.pk = getPublicKey(this.sk); this.ck = nip44.getConversationKey(this.sk, this.pk); } unload() { this.sk = this.pk = this.ck = null; } setRelays(relays) { this.relays = Array.isArray(relays) && relays.length ? relays : DEFAULT_SYNC_RELAYS; } // Encrypt + publish the latest state to every relay (best-effort). The d-tag // is passed in so a debounced publish keeps the tag of the network the // snapshot was taken on, even if the wallet switched networks meanwhile. async publish(stateObj, dtag = this.dtag) { if (!this.sk) return; 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 false; } const results = await Promise.allSettled(pool.publish(this.relays, evt)); // A refused snapshot means devices silently stop syncing — the failure // that once hid behind "my other device shows no history". Say so. results.forEach((r, i) => { if (r.status === 'rejected') { console.warn(`nostr: state snapshot (${evt.content.length}b) refused by ${this.relays[i]}:`, r.reason?.message || r.reason); } }); return results.some((r) => r.status === 'fulfilled'); } // Prepare synchronously while this wallet's identity is still loaded. // The outbox can deliver this event after logout without retaining a key. stateEvent(stateObj, dtag, created_at) { if (!this.sk || !this.ck) return null; return finalizeEvent({ kind: 30078, created_at, tags: [['d', dtag]], content: nip44.encrypt(JSON.stringify(stateObj), this.ck) }, this.sk); } decodeStateEvents(events) { if (!this.ck || !this.pk) return []; const out = []; for (const e of events.sort((a, b) => b.created_at - a.created_at)) { if (e.pubkey !== this.pk || e.kind !== 30078 || !verifyEvent(e)) continue; try { const state = JSON.parse(nip44.decrypt(e.content, this.ck)); if (state) out.push({ state, dtag: e.tags.find(t => t[0] === 'd')?.[1] || '', created_at: e.created_at }); } catch {} } return out; } // Publish an arbitrary event (finalized here with our key) to the relays. // Used by features that speak their own event kinds (e.g. ark zaps). async publishEvent(partial) { if (!this.sk) return null; const evt = this.signEvent(partial); if (!evt) return null; await Promise.allSettled(pool.publish(this.relays, evt)); return evt; } // Sign an event with our key WITHOUT publishing it. A NIP-57 zap request // (kind 9734) is signed by the sender but handed to the recipient's LNURL // server — never broadcast to relays by us — so it needs signing alone. signEvent(partial) { if (!this.sk) return null; try { return finalizeEvent({ created_at: Math.floor(Date.now() / 1000), tags: [], content: '', ...partial }, this.sk); } catch { return null; } } // 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. async sendDM(recipientPkHex, text, relays = null) { if (!this.sk) 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); } const res = await Promise.allSettled(pool.publish(targets, evt)); return res.some((x) => x.status === 'fulfilled'); } // Publish an event signed by some other key (the NWC service key). The // wallet's own key must not sign wallet-service traffic — connections are // revocable and shouldn't be tied to the user's nostr identity. async publishSigned(evt, relays = this.relays) { try { await Promise.allSettled(pool.publish(relays, evt)); return true; } catch { return false; } } // Fetch events matching a filter from the relays (best-effort). async fetchEvents(filter, maxWait = 5000) { try { const evs = await pool.querySync(this.relays, filter, { maxWait }); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } } // Live subscription; returns an unsubscribe function. subscribeEvents(filter, onEvent) { try { // single filter object — see subscribeOn for the array-wrapping trap const gated = (event) => { verifyOneAsync(event).then((ok) => { if (ok) onEvent(event); }); }; const sub = pool.subscribeMany(this.relays, filter, { onevent: gated }); return () => { try { sub.close(); } catch {} }; } catch { return () => {}; } } // Live subscription to ALL our devices' state events (no #d filter), each // decrypted and delivered. A save on any device — under any per-device tag — // arrives here within seconds. The caller filters by network. subscribeStates(onState) { if (!this.pk) return () => {}; return this.subscribeEvents( { kinds: [30078], authors: [this.pk] }, (ev) => { try { const v = JSON.parse(nip44.decrypt(ev.content, this.ck)); const dtag = (ev.tags.find((t) => t[0] === 'd') || [])[1] || ''; if (v) onState(v, dtag); } catch {} }); } // Fetch the newest decrypted state (kept for callers that want a single // best snapshot). Prefers our own device's slot, else the newest of ours. async fetch() { const all = await this.fetchAllStates(); // a domain fragment (ark/nwc slice) is not a usable "best snapshot" const fulls = all.filter((s) => !s.dtag.includes(':x:') || s.dtag.endsWith(':core')); return fulls.length ? fulls[0].state : null; } // All of this identity's decrypted state snapshots, newest first, each with // its d-tag. The reader merges ark state across every device and applies the // newest full snapshot for the (rescannable) on-chain state. async fetchAllStates() { if (!this.sk) return []; let events; try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk] }, { maxWait: 6000 }); } catch { return []; } return this.decodeStateEvents(events); } }