aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js14
-rw-r--r--src/features/sync.js11
-rw-r--r--src/nostr.js17
-rw-r--r--src/wallet.js1
4 files changed, 33 insertions, 10 deletions
diff --git a/src/app.js b/src/app.js
index 2730211..4912ea1 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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,