aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js41
-rw-r--r--src/features/ark.js9
-rw-r--r--tools/ln-receive-twin-test.js31
3 files changed, 75 insertions, 6 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 6741237..a2907c5 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -79,6 +79,38 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0;
// Renewals another device has in flight, as this device should show them:
// not finished there, and not already finished as far as this device's
// history knows (the "Renewed" row synced ahead of the final telling).
+// A Lightning receive the ASP settles is claimed as an arkoor — and that same
+// coin can reach the mailbox too, where it used to be recorded again as a
+// plain receive: one payment, two "Received" rows, and a celebration that
+// added them up (The Daniel saw 2,010 for 1,005). The claim now stamps its
+// coins' ids on its ln-receive movement; a receive movement for one of those
+// coins is the same money and is left out. An ln-receive recorded before the
+// ids were stamped is matched by amount within a few minutes. Derived, never
+// deleted: a synced copy from another device can't bring the twin back.
+export function lnFoldedIds(movements) {
+ const out = new Set();
+ const lns = (movements || []).filter((m) => m.type === 'ln-receive' && m.status === 'complete');
+ if (!lns.length) return out;
+ const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId);
+ for (const ln of lns) {
+ if (ln.vtxoIds && ln.vtxoIds.length) {
+ for (const r of recs) if (ln.vtxoIds.includes(r.vtxoId)) out.add(r.id);
+ continue;
+ }
+ let best = null;
+ for (const r of recs) {
+ // (a receive the registrar's receipts tagged viaLn came over the
+ // name@ path — its own payment, not this one's twin)
+ if (out.has(r.id) || r.viaLn || r.amountSat !== ln.amountSat) continue;
+ const dt = Math.abs((r.ts || 0) - (ln.ts || 0));
+ if (dt > 10 * 60_000) continue;
+ if (!best || dt < Math.abs((best.ts || 0) - (ln.ts || 0))) best = r;
+ }
+ if (best) out.add(best.id);
+ }
+ return out;
+}
+
export function remoteRenewalsInFlight(state) {
const settled = new Set((state.movements || [])
.filter((m) => m.type === 'refresh' && m.status === 'complete' && m.unlockHash).map((m) => m.unlockHash));
@@ -438,8 +470,9 @@ export class ArkManager {
unseenReceives() {
const ack = this.state.receiveAckTs || 0;
const cutoff = Date.now() - 2 * 3600 * 1000;
+ const folded = lnFoldedIds(this.state.movements);
return this.state.movements.filter((m) =>
- ['receive', 'ln-receive'].includes(m.type) && m.status === 'complete' && m.ts > ack && m.ts > cutoff);
+ ['receive', 'ln-receive'].includes(m.type) && m.status === 'complete' && m.ts > ack && m.ts > cutoff && !folded.has(m.id));
}
ackReceives() {
this.state.receiveAckTs = Date.now();
@@ -583,7 +616,7 @@ export class ArkManager {
// The vtxo beat the message here (a mirror or snapshot merge carried
// it) — the money is present but the story is missing. Backfill the
// receive movement so history matches the balance.
- if (!this.state.movements.some((x) => x.vtxoId === v.id)) {
+ if (!this.state.movements.some((x) => x.vtxoId === v.id || (x.vtxoIds && x.vtxoIds.includes(v.id)))) {
this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(this._decoded(this._vtxo(v.id)), tip)) });
changed = true;
}
@@ -1797,13 +1830,15 @@ export class ArkManager {
});
await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {});
let total = 0;
+ const vtxoIds = [];
for (const bts of outBytes) {
const v = decodeVtxo(bts);
this._addVtxo(v, bts, action.keyIndex);
total += v.amountSat;
+ vtxoIds.push(v.id);
}
action.step = 'done';
- this._movement({ type: 'ln-receive', amountSat: total, status: 'complete', invoice: action.invoice });
+ this._movement({ type: 'ln-receive', amountSat: total, status: 'complete', invoice: action.invoice, vtxoIds });
this._save();
}
}
diff --git a/src/features/ark.js b/src/features/ark.js
index 2aef8c5..13481da 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -6,7 +6,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 } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -727,8 +727,9 @@ export function arkFeature(ctx) {
try {
const receipts = await Promise.resolve(ctx.hook('namesLnReceipts')).catch(() => null);
if (!receipts || !receipts.length || ark !== mgr || !mgr.state) return;
+ const twins = lnFoldedIds(mgr.state.movements);
const moves = (mgr.state.movements || [])
- .filter((m) => m.type === 'receive' && m.status === 'complete');
+ .filter((m) => m.type === 'receive' && m.status === 'complete' && !twins.has(m.id));
const used = new Set(moves.filter((m) => m.lnHash).map((m) => m.lnHash));
let changed = false;
for (const r of receipts) {
@@ -3796,7 +3797,7 @@ export function arkFeature(ctx) {
if (a) await Promise.race([mgr.driveLn(a.id).catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
return mgr.lnLookup(q);
},
- arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; },
+ arkMovements() { const s = arkStateNow(); if (!s) return []; const twins = lnFoldedIds(s.movements); return (s.movements || []).filter((m) => !twins.has(m.id)); },
// The wallet's reusable ark receive address (BIP-353 names publish it).
async arkStaticAddress() {
const mgr = await connectArk();
@@ -3960,7 +3961,9 @@ export function arkFeature(ctx) {
// participation), and only ones that COST something get a row — a free
// renewal is net zero, invisible money movement, noise.
const seenPay = new Set();
+ const lnTwins = lnFoldedIds(s.movements); // a claimed LN receive's coin, told again by the mailbox
const moves = (s.movements || [])
+ .filter((m) => !lnTwins.has(m.id))
.filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive', 'refresh', 'reconcile'].includes(m.type)
&& (m.status === 'complete' || (m.type === 'send' && m.status === 'failed')))
.filter((m) => m.type !== 'refresh' || m.feeSat > 0 || m.manual) // a renewal you asked for is worth a row even when free
diff --git a/tools/ln-receive-twin-test.js b/tools/ln-receive-twin-test.js
new file mode 100644
index 0000000..bbec971
--- /dev/null
+++ b/tools/ln-receive-twin-test.js
@@ -0,0 +1,31 @@
+// One Lightning receive, told twice: the claim's ln-receive and the mailbox's
+// plain receive of the same coin (The Daniel: two "Received 1,005" rows and a
+// 2,010 celebration). lnFoldedIds names the twin; nothing else is folded.
+// Run: bun tools/ln-receive-twin-test.js
+import { lnFoldedIds } from '../src/ark/manager.js';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); 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 });
+const ln = (id, sat, ts, extra = {}) => ({ id, type: 'ln-receive', status: 'complete', amountSat: sat, ts, ...extra });
+
+let f = lnFoldedIds([ln('L', 1005, T, { vtxoIds: ['v-R'] }), rcv('R', 1005, T + 900)]);
+check('a claim stamped with its coin folds the mailbox\'s receive of it', f.has('R') && f.size === 1);
+f = lnFoldedIds([rcv('R', 1005, T - 400), ln('L', 1005, T, { vtxoIds: ['v-R'] })]);
+check('...whichever came first', f.has('R'));
+f = lnFoldedIds([ln('L', 1005, T, { vtxoIds: ['v-X'] }), rcv('R', 1005, T + 900)]);
+check('a stamped claim never folds some other coin of the same amount', f.size === 0);
+f = lnFoldedIds([ln('L', 1005, T), rcv('R', 1005, T + 60_000)]);
+check('an unstamped (older) claim folds the same amount within minutes', f.has('R'));
+f = lnFoldedIds([ln('L', 1005, T), rcv('R', 1005, T + 30 * 60_000)]);
+check('...not half an hour apart', f.size === 0);
+f = lnFoldedIds([ln('L', 1005, T), rcv('R', 1006, T + 1000)]);
+check('...nor a different amount', f.size === 0);
+f = lnFoldedIds([ln('L', 1005, T), rcv('R', 1005, T + 1000, { viaLn: true })]);
+check('...nor a name@ receive the registrar\'s receipts claimed', f.size === 0);
+f = lnFoldedIds([ln('L1', 500, T), ln('L2', 500, T + 5000), rcv('R1', 500, T + 1000), rcv('R2', 500, T + 6000)]);
+check('two claims of one amount fold one receive each', f.has('R1') && f.has('R2') && f.size === 2);
+f = lnFoldedIds([rcv('R', 1005, T), rcv('S', 2000, T)]);
+check('plain receives with no claim stay', f.size === 0);
+console.log(ok ? '\n✅ a claimed LN receive is told once' : '\n❌ failed');
+process.exit(ok ? 0 : 1);