aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-06 07:38:38 -0700
committerAdam Soltys <[email protected]>2026-07-06 07:38:38 -0700
commitd6da40c92ee8e76708b9230c404688c431f879a0 (patch)
treef2df12eb3275a8e5908b788b51e4529fed96fec1 /src
parent7d06c1b7801579da05e919c76a33c2f9b05f995d (diff)
downloadcoinosv3-d6da40c92ee8e76708b9230c404688c431f879a0.tar.gz
coinosv3-d6da40c92ee8e76708b9230c404688c431f879a0.zip
Per-device sync slots: devices never share a replaceable event
The clobber was structural: all devices published to one replaceable event (same seed -> same nostr key -> one slot), so whoever saved last won and a device saving before it merged wiped the others' coins. Now each device publishes to its OWN d-tag (<base>:d:<deviceId>, deviceId random per browser in localStorage), and readers pull ALL of the identity's kind-30078 events — every device slot plus the legacy shared tag for migration — merging ark vtxos across all of them and applying the newest full snapshot for on-chain state. No device can overwrite another's slot, so anti-entropy is no longer load-bearing (kept as belt-and-suspenders). Live subscription drops its #d filter to see all slots; publish and the echo-guard use the per-device tag. Verified: two same-seed devices produce two distinct relay slots that coexist, B sees A's funds, both converge after a spend. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/features/sync.js62
-rw-r--r--src/nostr.js62
2 files changed, 80 insertions, 44 deletions
diff --git a/src/features/sync.js b/src/features/sync.js
index fa3b50c..1f3414d 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, syncDtag } from '../nostr.js';
+import { NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag, deviceDtag, isOurDtag } from '../nostr.js';
import { t } from '../i18n.js';
export function installSyncWallet(wallet) {
@@ -50,31 +50,29 @@ 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;
+ let all;
try {
- remote = await this.nostr.fetch();
+ all = await this.nostr.fetchAllStates();
} catch {
return false;
}
- if (!remote) return false;
- // A snapshot for another network must never apply here. Legacy events
- // (published before snapshots carried netName) are ambiguous — they can
- // hold ANY network's state under the shared d-tag, and applying one
- // poisoned a mainnet wallet with regtest coins — so they are never
- // applied either; a full scan is safer than a wrong snapshot.
- if (remote.netName !== this.netName) return false;
- if ((remote.savedAt || 0) > (this._savedAt || 0)) {
- this._applySnapshot(remote);
- this.saveCache(); // mirror into localStorage
+ // Each device publishes to its OWN slot now, so there can be several
+ // snapshots for this network. Keep only ours-for-this-network: the
+ // netName inside is the real guard against cross-network bleed (legacy
+ // shared-tag events are ambiguous), the d-tag check filters the rest.
+ const mine = all.filter((s) => s.state.netName === this.netName && isOurDtag(s.dtag, this.netName));
+ if (!mine.length) return false;
+ // Merge every device's merge-safe extension state (ark vtxos union) — a
+ // device sees ALL devices' coins, not only the newest snapshot's.
+ for (const s of mine) this._mergeSnapshotExtensions(s.state);
+ // Apply the newest FULL snapshot for the rescannable on-chain state.
+ const newest = mine[0].state; // fetchAllStates returns newest-first
+ if ((newest.savedAt || 0) > (this._savedAt || 0)) {
+ this._applySnapshot(newest); // re-runs extension loads (idempotent merge)
this.emit();
- return true;
}
- // Our local copy is newer (or equal) — but merge-safe extension state
- // (ark vtxos) still flows in from the older snapshot before we push up.
- this._mergeSnapshotExtensions(remote);
- if ((this._savedAt || 0) > (remote.savedAt || 0)) this.saveCache();
- return true; // remote existed, so no full scan needed
+ this.saveCache(); // persist the merged result + republish our own slot
+ return true;
},
});
@@ -83,36 +81,36 @@ export function installSyncWallet(wallet) {
if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase);
else wallet.nostr.unload();
});
- // Live cross-device state: subscribe to our own replaceable event so a save
- // on another device merges here within seconds. Only merge-safe extension
- // state (ark vtxos) applies live — full snapshots stay a load-time affair —
- // which also guarantees every snapshot we publish is a superset, so the
- // replaceable event on the relay never loses another device's coins.
+ // Live cross-device state: subscribe to ALL our devices' slots so a save on
+ // another device merges here within seconds. Merge-safe extension state (ark
+ // vtxos union) applies live; full snapshots stay a load-time affair. With
+ // per-device slots no device overwrites another's, so this only ever adds.
let stateUnsub = null;
wallet.registerRealtimeHook({
start: () => {
const sync = getSyncConfig();
if (!sync.enabled || !wallet.nostr.pk) return;
wallet.nostr.setRelays(sync.relays);
- wallet.nostr.setDtag(syncDtag(wallet.netName));
if (stateUnsub) { try { stateUnsub(); } catch {} }
- stateUnsub = wallet.nostr.subscribeStates((v) => {
+ stateUnsub = wallet.nostr.subscribeStates((v, dtag) => {
if (!v.netName || v.netName !== wallet.netName) return;
- if ((v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo
+ if (!isOurDtag(dtag, wallet.netName)) return;
+ if (dtag === deviceDtag(wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo
wallet._mergeSnapshotExtensions(v);
});
},
stop: () => { if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } },
});
- // push every saved snapshot to the relays (debounced) unless sync is off
+ // push every saved snapshot to the relays (debounced) unless sync is off —
+ // to THIS DEVICE's own slot, so it never clobbers another device's.
wallet.registerCacheSavedHook((snap) => {
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);
+ // bind the per-device d-tag now: the debounced publish must keep this
+ // snapshot's network even if the wallet switches networks before it fires
+ const dtag = deviceDtag(wallet.netName);
clearTimeout(wallet._nostrPubTimer);
wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap, dtag), 2500);
});
diff --git a/src/nostr.js b/src/nostr.js
index 91005ec..e73aa5d 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -79,10 +79,33 @@ 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.
+// Each network syncs under its own d-tag base — a shared one would let a signet
+// snapshot overwrite mainnet state (and vice versa). Mainnet keeps the bare tag
+// existing wallets already publish under.
export const syncDtag = (netName) => (!netName || netName === 'mainnet' ? DTAG : `${DTAG}:${netName}`);
+
+// Per-DEVICE d-tag: every device publishes to its OWN replaceable-event slot
+// (`<base>:d:<deviceId>`), so no device can ever clobber another's snapshot.
+// Readers pull ALL of the identity's kind-30078 events and merge across them,
+// so a device sees every other device's coins. The legacy shared-tag events
+// (from before this) are still read and merged — they just never get written
+// to again.
+const DEVICE_KEY = 'btc-wallet-device-id';
+function deviceId() {
+ try {
+ let id = localStorage.getItem(DEVICE_KEY);
+ if (!id) { id = bytesToHexLocal(randomBytes(8)); localStorage.setItem(DEVICE_KEY, id); }
+ return id;
+ } catch { return 'ephemeral'; }
+}
+const bytesToHexLocal = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
+export const deviceDtag = (netName) => `${syncDtag(netName)}:d:${deviceId()}`;
+// True if `evtDtag` is one of OUR slots for `netName` (our device, another
+// device, or the legacy shared tag) — used to select events to merge.
+export const isOurDtag = (evtDtag, netName) => {
+ const base = syncDtag(netName);
+ return evtDtag === base || evtDtag.startsWith(base + ':d:');
+};
const SYNC_KEY = 'btc-wallet-sync';
export const DEFAULT_SYNC_RELAYS = ['wss://relay.coinos.io'];
@@ -186,28 +209,43 @@ export class NostrSync {
} catch { return () => {}; }
}
- // Live subscription to our own (replaceable) state event, decrypted.
- // Another device saving state delivers here within seconds.
+ // Live subscription to ALL our devices' state events (no #d filter), each
+ // decrypted and delivered. A save on any device — under any per-device tag —
+ // arrives here within seconds. The caller filters by network.
subscribeStates(onState) {
if (!this.pk) return () => {};
return this.subscribeEvents(
- { kinds: [30078], authors: [this.pk], '#d': [this.dtag] },
+ { kinds: [30078], authors: [this.pk] },
(ev) => {
try {
const v = JSON.parse(nip44.decrypt(ev.content, this.ck));
- if (v) onState(v);
+ const dtag = (ev.tags.find((t) => t[0] === 'd') || [])[1] || '';
+ if (v) onState(v, dtag);
} catch {}
});
}
- // Fetch the newest decrypted state across relays, or null.
+ // Fetch the newest decrypted state (kept for callers that want a single
+ // best snapshot). Prefers our own device's slot, else the newest of ours.
async fetch() {
- if (!this.sk) return null;
+ const all = await this.fetchAllStates();
+ return all.length ? all[0].state : null;
+ }
+
+ // All of this identity's decrypted state snapshots, newest first, each with
+ // its d-tag. The reader merges ark state across every device and applies the
+ // newest full snapshot for the (rescannable) on-chain state.
+ async fetchAllStates() {
+ if (!this.sk) return [];
let events;
- try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk], '#d': [this.dtag] }, { maxWait: 6000 }); } catch { return null; }
+ try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk] }, { maxWait: 6000 }); } catch { return []; }
+ const out = [];
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 {}
+ try {
+ const state = JSON.parse(nip44.decrypt(e.content, this.ck));
+ if (state) out.push({ state, dtag: (e.tags.find((t) => t[0] === 'd') || [])[1] || '', created_at: e.created_at });
+ } catch {}
}
- return null;
+ return out;
}
}