// 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 } from 'nostr-tools/pure';
import { decode as nip19decode, npubEncode, nsecEncode } from 'nostr-tools/nip19';
import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17';
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.
// 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.
const pool = new SimplePool({ enablePing: true, enableReconnect: true });
// 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.
const SICK_MS = 10 * 60_000;
const sick = new Map(); // url -> until
export function markRelaySick(url, why) {
if (!url) return;
if (!sick.has(url)) console.warn(`nostr: ${url} unreachable (${why || 'connect failed'}) — skipping for 10 min`);
sick.set(url, Date.now() + SICK_MS);
}
// 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();
for (const [u, until] of sick) if (until < now) sick.delete(u);
const ok = (list || []).filter((u) => !sick.has(u));
return ok.length ? ok : (list || []);
}
// 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 || []) {
if (probed.has(url) || sick.has(url)) continue;
probed.add(url);
Promise.resolve(pool.ensureRelay(url, { connectionTimeout: 6000 }))
.then(() => {})
.catch((e) => markRelaySick(url, e && e.message))
.finally(() => setTimeout(() => probed.delete(url), SICK_MS));
}
}
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);
const sub = pool.subscribeMany(live, filter, { onevent: onEvent, ...extra });
return () => { try { sub.close(); } catch {} };
} catch { return () => {}; }
}
export async function queryOn(relays, filter, maxWait = 1500) {
probeRelays(relays);
try { return await pool.querySync(liveRelays(relays), filter, { maxWait }); } catch { return []; }
}
export async function publishOn(relays, evt) {
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.
const pubs = pool.publish(live, evt).map((p, i) =>
Promise.resolve(p).then(() => true, (reason) => {
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 };
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.
export function npubOf(pkHex) { try { return npubEncode(pkHex); } 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).
export const PROFILE_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net'];
// 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');
}
// 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 { return await pool.querySync(this.relays, filter, { maxWait }); } catch { return []; }
}
// Live subscription; returns an unsubscribe function.
subscribeEvents(filter, onEvent) {
try {
// single filter object — see subscribeOn for the array-wrapping trap
const sub = pool.subscribeMany(this.relays, filter, { onevent: onEvent });
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 []; }
const out = [];
for (const e of events.sort((a, b) => b.created_at - a.created_at)) {
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;
}
}