aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-25 13:11:14 -0700
committerAdam Soltys <[email protected]>2026-10-25 13:11:14 -0700
commit5e16e4ea9418854e97b8fb2588798693733dfeb4 (patch)
treee5e6350f00508753b9591c1fd9d63d700812f047 /src
parent67e184a659c6a4b3251a57e0a862e3435bfda751 (diff)
downloadcoinosv3-5e16e4ea9418854e97b8fb2588798693733dfeb4.tar.gz
coinosv3-5e16e4ea9418854e97b8fb2588798693733dfeb4.zip
A payment that arrives as several coins is one Received row: a claimed gift held in two coins (dust padding split the sender's 886 into 595 + 291) showed as two receives. Receives stamp their mailbox message and the history folds a message's coins together; older rows fold on the same delivery instant
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js14
-rw-r--r--src/features/ark.js22
2 files changed, 34 insertions, 2 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 810808c..aea6020 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -92,6 +92,16 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0;
// claim recorded before it stamped them — the same amount within minutes).
// Derived at read time, never deleted: a synced copy from another device
// can't bring a twin back.
+// Two receive rows that are one payment delivered as several coins (a gift
+// held in two swept at once, a dust-padded split): the same mailbox message
+// (`batch`), or for rows recorded before the stamp, the same delivery instant.
+// A receive the registrar's receipts tagged ⚡ is its own payment.
+export function sameDelivery(a, b) {
+ return !!a && !!b && a.type === 'receive' && b.type === 'receive'
+ && a.status === 'complete' && b.status === 'complete' && !a.viaLn && !b.viaLn
+ && (a.batch || b.batch ? a.batch === b.batch : !!a.ts && a.ts === b.ts);
+}
+
export function lnFoldedIds(movements) {
const out = new Set();
const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId);
@@ -805,9 +815,13 @@ export class ArkManager {
// same message concurrently (validation awaits in between), and only the
// copy that actually added the vtxo may record the receive movement.
if (!this._addVtxo(v, v._raw.bytes, 0)) continue;
+ // `batch` names the mailbox message: one payment can arrive as several
+ // coins (a sweep of a gift held in two, a dust-padded split), and the
+ // history folds a message's coins back into one row
this._movement({
type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id,
ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)),
+ ...(m.checkpoint ? { batch: String(m.checkpoint) } : {}),
});
changed = true;
}
diff --git a/src/features/ark.js b/src/features/ark.js
index beaf049..5ed9a9d 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -8,7 +8,7 @@ import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
-import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, sameDelivery, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -1670,6 +1670,7 @@ export function arkFeature(ctx) {
})(),
m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null,
m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null,
+ m.type === 'receive' ? coinListRows(t('arkVtxoId'), m.vtxoIds) : null,
// the coins this payment consumed — a send can gather up to 24 inputs
coinListRows(t('arkVtxoInputs'), m.inputIds),
// An offboard's fee is two different pockets — the miners' cut for the
@@ -4289,7 +4290,22 @@ export function arkFeature(ctx) {
for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id);
const out = [];
const seenCoin = new Set();
+ // One payment delivered as several coins (a gift held in two, a
+ // dust-padded split) is one row: receives from the same mailbox
+ // message (`batch`; older rows: the same delivery instant) fold.
+ const folded = new Map();
for (const m of moves) {
+ if (m.type === 'receive' && sameDelivery(out[out.length - 1], m)) {
+ const last = out[out.length - 1];
+ if (!folded.has(last.id)) {
+ const f = { ...last, vtxoIds: [last.vtxoId], vtxoId: null };
+ out[out.length - 1] = f; folded.set(f.id, f);
+ }
+ const f = folded.get(last.id);
+ f.amountSat += m.amountSat;
+ f.vtxoIds.push(m.vtxoId);
+ continue;
+ }
if (m.type !== 'reconcile') { out.push(m); continue; }
if (m.vtxoId && (seenCoin.has(m.vtxoId) || accounted.has(m.vtxoId))) continue;
if (m.vtxoId) seenCoin.add(m.vtxoId);
@@ -4302,6 +4318,7 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
+ this._histFolded = folded; // a folded row's detail shows the whole payment
return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) })));
},
historyDetail() {
@@ -4321,7 +4338,8 @@ export function arkFeature(ctx) {
if (done) return arkMoveDetailView(done);
if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a));
}
- const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
+ if (s) this.historyEntries(); // (memoized) keeps the folded rows current
+ const m = s ? (this._histFolded && this._histFolded.get(ui.arkMoveDetail)) || (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
if (m) return arkMoveDetailView(m);
ui.arkMoveDetail = null;
return null;