aboutsummaryrefslogtreecommitdiffstats
path: root/src/nostr.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 07:34:36 -0700
committerAdam Soltys <[email protected]>2026-06-24 07:34:36 -0700
commitdc1e7a400d1158d0bb8a6a7914f7ac1b3073e034 (patch)
treea15b8e4932c85a3b95ab612c4dfd6bffe139e4c1 /src/nostr.js
parent6190f25ca44ed12a0e136004096aef042c472b77 (diff)
downloadcoinosv3-dc1e7a400d1158d0bb8a6a7914f7ac1b3073e034.tar.gz
coinosv3-dc1e7a400d1158d0bb8a6a7914f7ac1b3073e034.zip
Configurable Nostr sync; fully reconcile wallet state on load
Device sync: - Add a "Device sync" settings card: toggle (on by default) + editable relay list (defaults to wss://relay.coinos.io). When off, the wallet never contacts a relay. nostr.js is now multi-relay and reads an enabled/relays preference from localStorage. Reconciliation (fixes a stray "payment received" + stale history on import, especially via the Nostr/cache load path): - Always full-scan on load instead of a frontier-only refreshLive, so balance, history confirmations and spends are reconciled and the celebration baselines against everything that already exists (cache/ Nostr still shows instantly first). - Actually start the periodic full-scan safety net (_deepTimer, 2 min); it was declared but never wired up. - WS pushes now refresh an existing tx's pending→confirmed status, not only add new transactions. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/nostr.js')
-rw-r--r--src/nostr.js138
1 files changed, 98 insertions, 40 deletions
diff --git a/src/nostr.js b/src/nostr.js
index 1616c1e..74d8d25 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -1,25 +1,52 @@
-// Encrypted cross-device state sync via Nostr.
+// 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 relay.coinos.io — so any device with the seed can pull the latest state
-// without re-scanning, and the relay only ever keeps the newest copy.
+// 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 { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { Relay } from 'nostr-tools/relay';
-const RELAY = 'wss://relay.coinos.io';
const DTAG = 'bitcoin-wallet';
+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 : [];
+ return {
+ enabled: c.enabled !== false,
+ 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._relay = null;
+ this.relays = DEFAULT_SYNC_RELAYS;
+ this._conns = new Map(); // url -> Relay
}
load(mnemonic, passphrase = '') {
@@ -30,65 +57,96 @@ export class NostrSync {
unload() {
this.sk = this.pk = this.ck = null;
- if (this._relay) {
+ this._closeAll();
+ }
+
+ _closeAll() {
+ for (const r of this._conns.values()) {
try {
- this._relay.close();
+ r.close();
} catch {}
- this._relay = null;
}
+ this._conns.clear();
}
- async _conn() {
- if (this._relay && this._relay.connected) return this._relay;
- this._relay = await Relay.connect(RELAY);
- return this._relay;
+ // 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;
}
- // Encrypt + publish the latest state (best-effort; never throws).
+ 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;
+ }
+
+ // Encrypt + publish the latest state to every relay (best-effort).
async publish(stateObj) {
if (!this.sk) return;
+ let evt;
try {
const content = nip44.encrypt(JSON.stringify(stateObj), this.ck);
- const evt = finalizeEvent(
+ evt = finalizeEvent(
{ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', DTAG]], content },
this.sk
);
- const relay = await this._conn();
- await relay.publish(evt);
} catch {
- /* offline / relay down — localStorage still has the state */
+ return;
}
+ await Promise.allSettled(
+ this.relays.map(async (url) => {
+ const relay = await this._connect(url);
+ await relay.publish(evt);
+ })
+ );
}
- // Fetch + decrypt the latest state, or null.
+ // Fetch from every relay; return the newest decrypted state, or null.
async fetch() {
if (!this.sk) return null;
- try {
- const relay = await this._conn();
- return await 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(() => {
+ 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;
+ }
+ 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);
- }, 6000);
+ },
});
- } catch {
- return null;
- }
+ setTimeout(() => {
+ try {
+ sub.close();
+ } catch {}
+ resolve(result);
+ }, 6000);
+ });
}
}