From 1043ca529e859b41f27d80c51b80ac897773a5df Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 6 Jul 2026 06:19:06 -0700 Subject: Sync ark funds across devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ark vtxos lived only in per-device storage, so a second device with the same seed saw the right on-chain balance (that rides the snapshot) but none of its ark funds — board/change vtxos exist only where created. Ark state now rides the encrypted nostr snapshot as a merge-safe cache extension: additive union (bring in vtxos a device is missing) with each device's own server reconcile() as the authority for spent-ness, so a stale or wrong 'spent' on one device can never propagate and poison the others. A connect-time reconcile catches coins spent elsewhere while a device was away, and a live subscription to our own replaceable event merges another device's new coins within seconds. New seam: registerCacheExtension({mergeAlways:true}) extensions get fed snapshots that aren't newer than local state too (last-writer-wins is right for rescannable chain data, wrong for merge-style vtxo state), and nostr.subscribeStates for the live path. Verified two same-seed devices over a mini relay: B sees A's boarded balance with zero mailbox activity, spends a synced vtxo, A converges to a stable balance with the spent vtxo correctly marked. Co-Authored-By: Claude Fable 5 --- src/features/sync.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) (limited to 'src/features/sync.js') diff --git a/src/features/sync.js b/src/features/sync.js index 9e77664..fa3b50c 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -70,7 +70,9 @@ export function installSyncWallet(wallet) { this.emit(); return true; } - // Our local copy is newer (or equal) — push it up so the relay catches up. + // 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 }, @@ -81,6 +83,28 @@ 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. + 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) => { + if (!v.netName || v.netName !== wallet.netName) return; + if ((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 wallet.registerCacheSavedHook((snap) => { const sync = getSyncConfig(); -- cgit v1.2.3