diff options
| author | Adam Soltys <[email protected]> | 2026-07-06 06:19:06 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-06 06:19:06 -0700 |
| commit | 1043ca529e859b41f27d80c51b80ac897773a5df (patch) | |
| tree | 0b50b41b6490a52d83f65f572dd8e8a321bb045d /src | |
| parent | 1139c6beafb1199c149bd1f51a46d817e1d46173 (diff) | |
| download | coinosv3-1043ca529e859b41f27d80c51b80ac897773a5df.tar.gz coinosv3-1043ca529e859b41f27d80c51b80ac897773a5df.zip | |
Sync ark funds across devices
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 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/ark.js | 78 | ||||
| -rw-r--r-- | src/features/sync.js | 26 | ||||
| -rw-r--r-- | src/nostr.js | 14 | ||||
| -rw-r--r-- | src/wallet.js | 8 |
4 files changed, 124 insertions, 2 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index fe1aab3..2f39462 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -37,10 +37,69 @@ export function installArkWallet(wallet) { }); } +// Merge two ark states so devices can't clobber each other through the sync +// snapshot: vtxos union by id with 'spent' winning (a spend on any device +// sticks), actions/movements/gifts union by id, counters take the max (so two +// devices never reuse a key index). +export function mergeArkStates(a, b) { + if (!a) return b; + if (!b) return a; + const out = { ...a }; + // ADDITIVE union only: bring in vtxos this device is missing (the whole + // point — board/change coins that live only where they were created), but + // NEVER let a remote snapshot change the state of a vtxo we already track. + // Spend detection is each device's own job via reconcile() against the + // server (the authority), so a stale-or-wrong "spent" on one device can't + // propagate and permanently poison the others. A newly-merged-in vtxo that + // was actually spent elsewhere gets caught by the reconcile on next connect + // / send-intent, exactly like any other drift. + const vtxos = new Map((a.vtxos || []).map((v) => [v.id, { ...v }])); + for (const rv of b.vtxos || []) if (!vtxos.has(rv.id)) vtxos.set(rv.id, { ...rv }); + out.vtxos = [...vtxos.values()]; + const unionById = (x = [], y = []) => { + const ids = new Set(x.map((i) => i.id)); + return [...x, ...y.filter((i) => !ids.has(i.id))]; + }; + out.actions = unionById(a.actions, b.actions); + out.movements = unionById(a.movements, b.movements).sort((m, n) => (m.ts || 0) - (n.ts || 0)); + const gifts = new Map((a.gifts || []).map((g) => [g.id, { ...g }])); + for (const rg of b.gifts || []) { + const lg = gifts.get(rg.id); + if (!lg) gifts.set(rg.id, { ...rg }); + else { lg.claimed = lg.claimed || rg.claimed; lg.revoked = lg.revoked || rg.revoked; } + } + out.gifts = [...gifts.values()]; + out.mailboxCheckpoint = Math.max(a.mailboxCheckpoint || 0, b.mailboxCheckpoint || 0); + out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1); + out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0); + return out; +} + export function arkFeature(ctx) { const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx; installArkWallet(wallet); // ark state storage lives outside the core wallet + // Ride the encrypted sync snapshot so ark funds follow the seed across + // devices. Board/change vtxo bytes exist only where they were created — + // without this a second device simply cannot see them. Merge-on-load (and + // merge-on-save below) keeps concurrent devices from clobbering coins. + wallet.registerCacheExtension({ + mergeAlways: true, // load() is a commutative merge — apply older snapshots too + save: () => { + const s = wallet.loadArkState(); + return s ? { arkState: s } : {}; + }, + load: (d) => { + if (!d.arkState) return; + const merged = mergeArkStates(wallet.loadArkState(), d.arkState); + wallet.saveArkState(merged); + // a snapshot can arrive after this feature initialized (or brought vtxos + // a live manager doesn't know) — reconnect so the balance shows + const novel = (d.arkState.vtxos || []).some((v) => !ark || !ark.state.vtxos.some((x) => x.id === v.id)); + if ((merged.vtxos || []).length && novel) setTimeout(() => initArk(), 0); + }, + }); + // One manager per open wallet; null when Ark is off in Settings, watch-only, // or not yet dialed (lazy: fresh wallets connect on first use). let ark = null; @@ -82,7 +141,19 @@ export function arkFeature(ctx) { const gen = arkInitGen; const mgr = new ArkManager({ account: wallet.account(), - storage: { load: () => wallet.loadArkState(), save: (s) => wallet.saveArkState(s) }, + storage: { + load: () => wallet.loadArkState(), + // merge-on-save: the MANAGER is authoritative for the state of the + // vtxos it tracks (its reconcile/spends must persist), so its state + // is the first arg and wins; storage only contributes vtxos the + // manager hasn't loaded yet (a snapshot merged in from another device + // between init and the next reinit). saveCache() then carries ark + // state into the snapshot -> debounced nostr publish. + save: (s) => { + wallet.saveArkState(mergeArkStates(s, wallet.loadArkState())); + try { wallet.saveCache(); } catch {} + }, + }, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), @@ -96,6 +167,11 @@ export function arkFeature(ctx) { startNwcFunding(mgr); // NWC bridge: honor funding requests within the allowance const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {}); tick(); + // Reconcile once on connect: a vtxo synced in from another device (or + // one this device held while a spend happened elsewhere) is checked + // against the server, so a stale spendable is caught here rather than + // only at send time. + mgr.reconcile().catch(() => {}); // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. mgr.startMailboxStream(); 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(); diff --git a/src/nostr.js b/src/nostr.js index 431bb1e..91005ec 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -186,6 +186,20 @@ export class NostrSync { } catch { return () => {}; } } + // Live subscription to our own (replaceable) state event, decrypted. + // Another device saving state delivers here within seconds. + subscribeStates(onState) { + if (!this.pk) return () => {}; + return this.subscribeEvents( + { kinds: [30078], authors: [this.pk], '#d': [this.dtag] }, + (ev) => { + try { + const v = JSON.parse(nip44.decrypt(ev.content, this.ck)); + if (v) onState(v); + } catch {} + }); + } + // Fetch the newest decrypted state across relays, or null. async fetch() { if (!this.sk) return null; diff --git a/src/wallet.js b/src/wallet.js index 93f5c39..ccb7b81 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1195,7 +1195,15 @@ export class Wallet { // Called from load() with its options (wallet switch / (re)open). registerLoadHook(fn) { this._loadHooks.push(fn); } // save() -> object merged into the cache snapshot; load(d) restores. + // An extension with mergeAlways:true has a commutative load() and is also + // fed snapshots OLDER than local state (see _mergeSnapshotExtensions). registerCacheExtension(e) { this._cacheExtensions.push(e); } + // Feed merge-safe extensions a snapshot that ISN'T newer than local state — + // last-writer-wins is right for rescannable chain data, but merge-style + // state (ark vtxos) must flow both directions. + _mergeSnapshotExtensions(d) { + for (const e of this._cacheExtensions) { try { if (e.mergeAlways) e.load(d); } catch {} } + } // Called with every saved state snapshot (e.g. to push it to sync relays). registerCacheSavedHook(fn) { this._cacheSavedHooks.push(fn); } lockedCoinIds() { |
