diff options
| author | Adam Soltys <[email protected]> | 2026-07-06 07:25:28 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-06 07:25:28 -0700 |
| commit | 7d06c1b7801579da05e919c76a33c2f9b05f995d (patch) | |
| tree | 4acb8a726c6935536895fcc2872d2f9cc0c48ffd | |
| parent | 1043ca529e859b41f27d80c51b80ac897773a5df (diff) | |
| download | coinosv3-7d06c1b7801579da05e919c76a33c2f9b05f995d.tar.gz coinosv3-7d06c1b7801579da05e919c76a33c2f9b05f995d.zip | |
Ark sync: anti-entropy so a device can't clobber another's coins
All devices share one replaceable-event slot (same seed -> same nostr
key), so a device that published its snapshot before merging in
another device's ark state wiped those coins from the slot — exactly
what stranded a phone showing no ark balance. Fix: on receiving any
snapshot, union it with local and, if we hold vtxos the snapshot
lacked, re-publish the superset. The slot converges to the union
regardless of publish order; the guard (mergedN>remoteN) stops the
push-back once every device agrees, so no loop.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | src/features/ark.js | 28 |
1 files changed, 20 insertions, 8 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 2f39462..961b1a0 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -80,9 +80,13 @@ export function arkFeature(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. + // devices. Board/change vtxo bytes exist only where they were created — a + // second device can't otherwise see them. All devices share ONE replaceable + // event slot (same seed -> same nostr key), so a naive publish can clobber + // another device's coins. Anti-entropy fixes that: on receiving a snapshot, + // union it with local; if it was missing coins we know (or brought coins we + // didn't), re-publish the superset. The slot converges to the union no + // matter who publishes when. wallet.registerCacheExtension({ mergeAlways: true, // load() is a commutative merge — apply older snapshots too save: () => { @@ -91,12 +95,20 @@ export function arkFeature(ctx) { }, load: (d) => { if (!d.arkState) return; - const merged = mergeArkStates(wallet.loadArkState(), d.arkState); + const local = wallet.loadArkState(); + const merged = mergeArkStates(local, 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); + const mergedN = (merged.vtxos || []).length; + const remoteN = (d.arkState.vtxos || []).length; + const localN = (local && local.vtxos || []).length; + // We know vtxos this snapshot lacked — push our superset back up so the + // sender (and the shared slot) learns them. Guarded by mergedN>remoteN so + // it can't loop once every device has converged to the union. + if (mergedN > remoteN) { try { wallet.saveCache(); } catch {} } + // The merge brought vtxos our live manager doesn't have — (re)connect so + // the balance actually shows. + const novel = mergedN > localN || (mergedN && (!ark || !ark.state)); + if (mergedN && novel) setTimeout(() => initArk(), 0); }, }); |
