aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js54
-rw-r--r--src/i18n.js7
-rw-r--r--src/nostr.js138
-rw-r--r--src/wallet.js33
4 files changed, 179 insertions, 53 deletions
diff --git a/src/app.js b/src/app.js
index ba44544..4636749 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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();