diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 22:21:12 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 22:21:12 -0700 |
| commit | 086bcf33d532f61505a0bc19e8f2d1f08392acce (patch) | |
| tree | e30d3adeb9adb70029abb7d932ab7bd28a96d010 | |
| parent | 0f96ea2500a311ab77a9fed3b10ab928370a6b0d (diff) | |
| download | coinosv3-086bcf33d532f61505a0bc19e8f2d1f08392acce.tar.gz coinosv3-086bcf33d532f61505a0bc19e8f2d1f08392acce.zip | |
Per-network nostr sync + honest balance loading state
One shared d-tag let a network switch apply the mainnet snapshot to a
signet wallet (the flash of wrong balance) and clobber mainnet state on
the relays. Each network now syncs its own replaceable event (mainnet
keeps the legacy bare tag), snapshots carry netName, and a foreign-net
snapshot is never applied. The balance card spins until cache, Nostr,
or a scan has populated this network once — 'scanning' alone missed the
cache/Nostr window after wallet.load().
Co-Authored-By: Claude Fable 5 <[email protected]>
| -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, |
