diff options
| -rw-r--r-- | src/ark/manager.js | 14 | ||||
| -rw-r--r-- | src/features/ark.js | 22 | ||||
| -rw-r--r-- | tools/receive-batch-fold-test.js | 20 |
3 files changed, 54 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; diff --git a/tools/receive-batch-fold-test.js b/tools/receive-batch-fold-test.js new file mode 100644 index 0000000..8e38265 --- /dev/null +++ b/tools/receive-batch-fold-test.js @@ -0,0 +1,20 @@ +// One payment delivered as several coins — a gift whose 886 sats sat in two +// (595 + 291, dust padding) swept in one claim — is one Received row. +// sameDelivery decides which receive rows fold; nothing else does. +// Run: bun tools/receive-batch-fold-test.js +import { sameDelivery } from '../src/ark/manager.js'; +let ok = true; +const check = (n, c) => { console.log(` ${c ? '✓' : '✗'} ${n}`); if (!c) ok = false; }; +const T = 1_790_000_000_000; +const rcv = (id, sat, ts, extra = {}) => ({ id, type: 'receive', status: 'complete', amountSat: sat, ts, vtxoId: 'v-' + id, ...extra }); +check('two coins of one mailbox message fold', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7' }))); +check('...even when their instants differ (live, no checkpoint time)', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T + 40, { batch: '7' }))); +check('two messages stay two rows, same instant or not', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '8' }))); +check('a stamped row never folds an unstamped one', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T))); +check('older rows (no stamp) fold on the same delivery instant', sameDelivery(rcv('a', 595, T), rcv('b', 291, T))); +check('...not a millisecond apart', !sameDelivery(rcv('a', 595, T), rcv('b', 291, T + 1))); +check('a ⚡ receive is its own payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', viaLn: true }))); +check('a rejected coin is not part of the payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', status: 'rejected' }))); +check('only receives fold', !sameDelivery({ ...rcv('a', 595, T), type: 'send' }, rcv('b', 291, T))); +console.log(ok ? '\n✅ one delivery, one row' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
