diff options
| author | Adam Soltys <[email protected]> | 2026-08-30 19:20:41 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-30 19:20:41 -0700 |
| commit | 698b6b5431d31a54ddf30c234f95a92bf5c8bf0d (patch) | |
| tree | 43fd7b8515e80d25ae2876a4c49d326e85b9b3e7 | |
| parent | bd4adcb87d1fe05ce9631b51ee529016eb71a154 (diff) | |
| download | coinosv3-698b6b5431d31a54ddf30c234f95a92bf5c8bf0d.tar.gz coinosv3-698b6b5431d31a54ddf30c234f95a92bf5c8bf0d.zip | |
The cooney ghost spend gets laid to rest on every device: the ark-state merge now lets a movement's late-learned fields through — inputIds, unlockHash, and a fee that upgrades a recorded zero — where the by-id union used to keep each device's first copy forever, so the rescue's post-hoc enrichment never propagated and other devices painted the renewal's 16 consumed inputs as a fresh red 'Spent on another device −29,014'
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
| -rw-r--r-- | src/features/ark.js | 20 |
1 files changed, 19 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 1fd49ae..5a97ae1 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -175,11 +175,29 @@ export function mergeArkStates(a, b) { return [...x, ...y.filter((i) => !ids.has(i.id))]; }; out.actions = unionById(a.actions, b.actions); + // Union keeps the local copy of a movement forever — but a movement can be + // ENRICHED after other devices synced it (the cooney rescue stamped + // inputIds/unlockHash/fee onto its renewal movement post-hoc, and those + // fields are what lets history exclude the matching "spent on another + // device" rows). Adopt fields the local copy lacks; never overwrite ones + // it has. + const bM = new Map((b.movements || []).map((m) => [m.id, m])); + const enriched = (a.movements || []).map((lm) => { + const rm = bM.get(lm.id); + if (!rm) return lm; + const take = {}; + if (rm.inputIds != null && lm.inputIds == null) take.inputIds = rm.inputIds; + if (rm.unlockHash != null && lm.unlockHash == null) take.unlockHash = rm.unlockHash; + // a fee learned late (the rescue computed it from inputs − outputs) + // upgrades a recorded zero — the row only renders when the fee is real + if ((rm.feeSat || 0) > 0 && !(lm.feeSat > 0)) take.feeSat = rm.feeSat; + return Object.keys(take).length ? { ...lm, ...take } : lm; + }); // Two devices (or the push and the poll, pre-guard) can each record the // SAME lightning payment under different movement ids — same preimage is // the same payment, keep the earliest telling of it. const seenPay = new Set(); - out.movements = unionById(a.movements, b.movements) + out.movements = unionById(enriched, b.movements) .sort((m, n) => (m.ts || 0) - (n.ts || 0)) .filter((m) => { if (!m.type || !m.type.startsWith('ln-') || m.status !== 'complete') return true; |
