diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 14 | ||||
| -rw-r--r-- | src/features/sync.js | 11 | ||||
| -rw-r--r-- | src/nostr.js | 17 | ||||
| -rw-r--r-- | src/wallet.js | 1 |
4 files changed, 33 insertions, 10 deletions
@@ -1877,8 +1877,12 @@ function offlineBanner() { } function balanceCard() { - // Only dim on the very first load; background updates happen silently. - const firstLoad = wallet.scanning && !wallet.loaded; + // Dim + spin until this network has data (cache, Nostr state, or a scan has + // populated balances once). `scanning` alone misses the window between + // wallet.load() and the first scan — the cache check and Nostr sync on a + // network switch — where a bare 0 reads as a final balance. Background + // updates after that happen silently. + const firstLoad = !wallet.loaded && !wallet.offline; const pending = wallet.pendingIncoming; const featLines = featureAll('balanceLines'); return h( @@ -1888,7 +1892,9 @@ function balanceCard() { // Headline is the spendable balance: confirmed coins plus our own pending // change, minus gift locks — so a pending spend debits immediately, while a // pending incoming receive stays out of it until it confirms. - h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, fmtAmount(wallet.spendable), ' ', unitTag('unit')), + h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, + firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null, + fmtAmount(wallet.spendable), ' ', unitTag('unit')), pending > 0 || featLines.length ? h( 'div', @@ -2746,7 +2752,7 @@ function historyTab() { } if (wallet.offline) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('historyOffline'))); - if ((wallet.scanning && !wallet.loaded) || (wallet.historyLoading && !txs.length)) + if (!wallet.loaded || (wallet.historyLoading && !txs.length)) return h( 'div', { class: 'card center col', style: 'align-items:center;gap:10px' }, diff --git a/src/features/sync.js b/src/features/sync.js index 8cbd9f3..41d772e 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -3,7 +3,7 @@ // (used e.g. to DM locked-gift claim codes). Installed onto the core wallet; // a build without sync ships none of it and never talks to a relay. -import { NostrSync, getSyncConfig, setSyncConfig, npubOf } from '../nostr.js'; +import { NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag } from '../nostr.js'; import { t } from '../i18n.js'; export function installSyncWallet(wallet) { @@ -28,6 +28,7 @@ export function installSyncWallet(wallet) { const sync = getSyncConfig(); if (this.offline || !sync.enabled) return false; this.nostr.setRelays(sync.relays); + this.nostr.setDtag(syncDtag(this.netName)); let remote; try { remote = await this.nostr.fetch(); @@ -35,6 +36,9 @@ export function installSyncWallet(wallet) { return false; } if (!remote) return false; + // A snapshot for another network must never apply here (events published + // before per-network d-tags all live under the mainnet tag). + if (remote.netName && remote.netName !== this.netName) return false; if ((remote.savedAt || 0) > (this._savedAt || 0)) { this._applySnapshot(remote); this.saveCache(); // mirror into localStorage @@ -57,8 +61,11 @@ export function installSyncWallet(wallet) { const sync = getSyncConfig(); if (wallet.offline || !sync.enabled) return; wallet.nostr.setRelays(sync.relays); + // bind the d-tag now: the debounced publish must keep this snapshot's + // network even if the wallet switches networks before the timer fires + const dtag = syncDtag(wallet.netName); clearTimeout(wallet._nostrPubTimer); - wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap), 2500); + wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap, dtag), 2500); }); wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) }); } diff --git a/src/nostr.js b/src/nostr.js index 31c9858..ccb797d 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -79,6 +79,10 @@ export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) { } const DTAG = 'bitcoin-wallet'; +// Each network syncs to its own replaceable event — one shared d-tag would let +// a signet snapshot overwrite the mainnet state on the relays (and vice versa). +// Mainnet keeps the bare tag existing wallets already publish under. +export const syncDtag = (netName) => (!netName || netName === 'mainnet' ? DTAG : `${DTAG}:${netName}`); const SYNC_KEY = 'btc-wallet-sync'; export const DEFAULT_SYNC_RELAYS = ['wss://relay.coinos.io']; @@ -110,8 +114,11 @@ export class NostrSync { 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 = '') { this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined); this.pk = getPublicKey(this.sk); @@ -124,13 +131,15 @@ export class NostrSync { this.relays = Array.isArray(relays) && relays.length ? relays : DEFAULT_SYNC_RELAYS; } - // Encrypt + publish the latest state to every relay (best-effort). - async publish(stateObj) { + // 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); + evt = finalizeEvent({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dtag]], content }, this.sk); } catch { return; } await Promise.allSettled(pool.publish(this.relays, evt)); } @@ -156,7 +165,7 @@ export class NostrSync { async fetch() { if (!this.sk) return null; let events; - try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk], '#d': [DTAG] }, { maxWait: 6000 }); } catch { return null; } + try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk], '#d': [this.dtag] }, { maxWait: 6000 }); } catch { return null; } for (const e of events.sort((a, b) => b.created_at - a.created_at)) { try { const v = JSON.parse(nip44.decrypt(e.content, this.ck)); if (v) return v; } catch {} } diff --git a/src/wallet.js b/src/wallet.js index 93ebe63..a5d7b6e 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1754,6 +1754,7 @@ export class Wallet { return { v: 1, savedAt: Date.now(), + netName: this.netName, receive: this.receive, change: this.change, utxos: this.utxos, |
