From 698b6b5431d31a54ddf30c234f95a92bf5c8bf0d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 30 Aug 2026 19:20:41 -0700 Subject: 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' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr --- src/features/ark.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) (limited to 'src') 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; -- cgit v1.2.3