aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-06 06:19:06 -0700
committerAdam Soltys <[email protected]>2026-07-06 06:19:06 -0700
commit1043ca529e859b41f27d80c51b80ac897773a5df (patch)
tree0b50b41b6490a52d83f65f572dd8e8a321bb045d
parent1139c6beafb1199c149bd1f51a46d817e1d46173 (diff)
downloadcoinosv3-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]>
-rw-r--r--src/features/ark.js78
-rw-r--r--src/features/sync.js26
-rw-r--r--src/nostr.js14
-rw-r--r--src/wallet.js8
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() {