diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 07:34:36 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 07:34:36 -0700 |
| commit | dc1e7a400d1158d0bb8a6a7914f7ac1b3073e034 (patch) | |
| tree | a15b8e4932c85a3b95ab612c4dfd6bffe139e4c1 /src | |
| parent | 6190f25ca44ed12a0e136004096aef042c472b77 (diff) | |
| download | coinosv3-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')
| -rw-r--r-- | src/app.js | 54 | ||||
| -rw-r--r-- | src/i18n.js | 7 | ||||
| -rw-r--r-- | src/nostr.js | 138 | ||||
| -rw-r--r-- | src/wallet.js | 33 |
4 files changed, 179 insertions, 53 deletions
@@ -7,6 +7,7 @@ import { Wallet, newMnemonic, isValidMnemonic, utxoId } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; +import { getSyncConfig, setSyncConfig } from './nostr.js'; import { t, LANGS, getLang, setLang, isRTL } from './i18n.js'; import { fmtBtc, @@ -521,10 +522,11 @@ async function enterWallet(mnemonic, passphrase) { // Cross-device state: pull the latest from Nostr (may supply state on a // device with no local cache, or a newer copy from another device). const hadNostr = await wallet.syncFromNostr(); - // With existing state (local or remote), only check the live frontier; - // a brand-new wallet does one full scan. - if (hadCache || hadNostr) await wallet.refreshLive(); - else await wallet.scan(); + // Always do a full scan to fully reconcile (balance, history confirmations, + // spends, stale cache/relay state). Cache/Nostr just gave us something to + // show instantly; a partial frontier-only refresh leaves the state stale and + // would let the celebration baseline before a known payment is even counted. + await wallet.scan({ silent: hadCache || hadNostr }); // Persisted acknowledgement: first time, baseline to the current index so we // don't celebrate historical payments. After that, any advance beyond the // acknowledged index shows the celebration (and survives refreshes). @@ -675,6 +677,7 @@ function settingsTab() { : h('button', { disabled: wallet.scanning, onClick: () => wallet.scan() }, wallet.scanning ? t('scanning') : t('rescanWallet')) ), + syncCard(), h( 'div', { class: 'card col' }, @@ -684,6 +687,49 @@ function settingsTab() { ); } +// Cross-device sync settings: toggle + editable relay list (default coinos). +function syncCard() { + const cfg = getSyncConfig(); + const setEnabled = (enabled) => { + if (enabled === cfg.enabled) return; + setSyncConfig({ enabled, relays: cfg.relays }); + render(); + // On enable, pull anything newer from the relays, then push our copy up. + if (enabled && !wallet.offline) { + wallet.syncFromNostr().catch(() => {}).finally(() => wallet.saveCache()); + } + }; + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('deviceSync')), + h('p', { class: 'small muted', style: 'margin:0' }, t('deviceSyncDesc')), + h('div', { class: 'row between' }, + h('span', { class: 'lab', style: 'margin:0' }, t('syncAcross')), + h('div', { class: 'seg' }, + h('button', { type: 'button', class: cfg.enabled ? 'active' : '', onClick: () => setEnabled(true) }, t('syncOn')), + h('button', { type: 'button', class: !cfg.enabled ? 'active' : '', onClick: () => setEnabled(false) }, t('syncOff')) + ) + ), + cfg.enabled + ? h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('relaysLabel')), + h('textarea', { + placeholder: 'wss://relay.example.com', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + style: 'min-height:64px', + value: cfg.relays.join('\n'), + onInput: (e) => { + const relays = e.target.value.split('\n').map((s) => s.trim()).filter(Boolean); + setSyncConfig({ enabled: true, relays }); + }, + }), + h('div', { class: 'small faint' }, t('relaysHint')) + ) + : null + ); +} + // Language selector. Changing it persists the choice, flips text direction for // RTL languages, and re-renders the whole app in the new language. function languagePicker() { diff --git a/src/i18n.js b/src/i18n.js index 556a4d0..5c153fd 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -153,6 +153,13 @@ const STR = { rescanDesc: 'Your current address updates automatically. Rescan only if a payment was sent to an old (reused) address and isn’t showing.', rescanWallet: 'Rescan wallet', scanning: 'Scanning…', + deviceSync: 'Device sync', + deviceSyncDesc: 'Keep this wallet in sync across your devices by storing an encrypted copy on a Nostr relay — only a device with your seed can read it. Turn it off to keep everything on this device.', + syncAcross: 'Sync across devices', + syncOn: 'On', + syncOff: 'Off', + relaysLabel: 'Relays', + relaysHint: 'One wss:// URL per line.', language: 'Language', offlineBanner: "Can't reach the network — working offline. Import a snapshot on the Settings tab to load coins.", retry: 'Retry', 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); + }); } } diff --git a/src/wallet.js b/src/wallet.js index 79c6353..111fb92 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -17,7 +17,7 @@ import * as btc from '@scure/btc-signer'; import { p2wpkh } from '@scure/btc-signer/payment'; import { Api, pool, wsUrl } from './api.js'; -import { NostrSync } from './nostr.js'; +import { NostrSync, getSyncConfig } from './nostr.js'; // No look-ahead: stop scanning a chain at the first unused address. This wallet // only ever exposes ONE unused address at a time (freshReceive = first unused; @@ -678,8 +678,11 @@ export class Wallet { this.stopRealtime(); // Safety-net poll: only the fresh frontier (next receive/change address), // in case the WebSocket misses a deposit. Already-scanned addresses are - // never re-polled. No periodic full scan — use the manual Rescan for that. + // never re-polled — the deep scan below covers spends/confirmations. this._pollTimer = setInterval(() => this.refreshLive(), 20000); + // Periodic full scan: reconciles everything refreshLive can't see on its own + // — pending→confirmed history, spends, and stale cache/relay state. + this._deepTimer = setInterval(() => this.scan({ silent: true }).catch(() => {}), 120000); if (typeof WebSocket === 'undefined') return; this._wsWant = true; @@ -832,10 +835,17 @@ export class Wallet { const a = vin.prevout && vin.prevout.scriptpubkey_address; if (a && mine.has(a)) relevant = true; } - // Record it in history (so a pending deposit shows up there too). - if (relevant && !this.txs.some((t) => t.txid === tx.txid)) { - this.txs.push(this._txSummary(tx)); - changed = true; + // Record it in history — or refresh it, so a pending tx flips to confirmed. + if (relevant) { + const idx = this.txs.findIndex((t) => t.txid === tx.txid); + const summary = this._txSummary(tx); + if (idx < 0) { + this.txs.push(summary); + changed = true; + } else if (this.txs[idx].confirmed !== summary.confirmed) { + this.txs[idx] = summary; + changed = true; + } } } if (changed) { @@ -952,8 +962,11 @@ export class Wallet { try { localStorage.setItem(this._cacheKey(), JSON.stringify(snap)); } catch {} - // Push to Nostr too (debounced), so other devices get the update. - if (!this.offline) { + // Push to the configured relays too (debounced), so other devices get the + // update — unless cross-device sync is turned off. + const sync = getSyncConfig(); + if (!this.offline && sync.enabled) { + this.nostr.setRelays(sync.relays); clearTimeout(this._nostrPubTimer); this._nostrPubTimer = setTimeout(() => this.nostr.publish(snap), 2500); } @@ -974,7 +987,9 @@ export class Wallet { // Pull the latest state from Nostr; apply it if it's newer than what we have. // Returns true if state was applied (so the caller can skip a full scan). async syncFromNostr() { - if (this.offline) return false; + const sync = getSyncConfig(); + if (this.offline || !sync.enabled) return false; + this.nostr.setRelays(sync.relays); let remote; try { remote = await this.nostr.fetch(); |
