From be896868de55ef82b268d8c9f92c3f0ba857fed8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 4 Jun 2026 12:14:15 -0700 Subject: analytics: missing-history audit + lightning->liquid arb finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit-missing-history.ts: read-only audit classifying every user's payment history as clean / recoverable (record in arc not db) / lost (gone from both). Full-scan finding: of 6,330 users with history, 77% are "arc-only" which is the NORMAL archived state — the payments route reads via gf() (archive fallback) so those render fine in the app. True loss is 58 users / 59 records (0.02%). So there is no widespread history loss; "missing history" reports are almost always either dropped receive-callbacks (record never written, like apk403's 1100 sats) or one of the rare 59. ln-liquid-arb.ts: finds users funding via lightning and withdrawing via liquid (the pattern draining the liquid hot wallet / forcing threshold cuts). Reports per-user through-flow + net liquid drainers. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/ln-liquid-arb.ts | 126 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 scripts/ln-liquid-arb.ts (limited to 'scripts/ln-liquid-arb.ts') diff --git a/scripts/ln-liquid-arb.ts b/scripts/ln-liquid-arb.ts new file mode 100644 index 00000000..02d0aa1e --- /dev/null +++ b/scripts/ln-liquid-arb.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env bun +// Find users exhibiting the "lightning in, liquid out" pattern that drains the +// liquid hot wallet (forcing the liquid threshold down). For each user in the +// window, sum receives/sends per rail and surface those who take in meaningful +// lightning and push out meaningful liquid. +// +// Usage: bun scripts/ln-liquid-arb.ts [--days N] [--min SATS] [--top N] + +import { createClient } from "redis"; + +const argDays = Number(process.argv[process.argv.indexOf("--days") + 1]) || 14; +const MIN = Number(process.argv[process.argv.indexOf("--min") + 1]) || 500_000; +const TOP = Number(process.argv[process.argv.indexOf("--top") + 1]) || 30; +const cutoff = Date.now() - argDays * 24 * 60 * 60 * 1000; + +const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } }); +await db.connect(); + +const fmt = (n: number) => n.toLocaleString("en-US"); + +// uid -> username +const uname = new Map(); +async function loadNames() { + let batch: string[] = []; + const drain = async () => { + if (!batch.length) return; + const vals = await db.mGet(batch); + for (const v of vals) { + if (!v || v[0] !== "{") continue; + try { const u = JSON.parse(v); if (u.id && u.username) uname.set(u.id, u.username); } catch {} + } + batch = []; + }; + for await (const k of db.scanIterator({ MATCH: "user:*", COUNT: 1000 })) { + batch.push(k as string); + if (batch.length >= 500) await drain(); + } + await drain(); +} +await loadNames(); +const nameOf = (uid: string) => uname.get(uid) || uid.slice(0, 8); + +// Per user per rail: { in, out } sats +type Rail = { in: number; out: number; inN: number; outN: number }; +const RAILS = ["lightning", "liquid", "bitcoin", "internal", "bolt12", "fund"]; +const stats = new Map>(); +const ensure = (uid: string) => { + let s = stats.get(uid); + if (!s) { + s = {}; + for (const r of RAILS) s[r] = { in: 0, out: 0, inN: 0, outN: 0 }; + stats.set(uid, s); + } + return s; +}; + +let scanned = 0; +let batch: string[] = []; +const drainP = async () => { + if (!batch.length) return; + const vals = await db.mGet(batch); + for (const v of vals) { + if (!v || v[0] !== "{") continue; + let p: any; try { p = JSON.parse(v); } catch { continue; } + if (!p.created || p.created < cutoff || typeof p.amount !== "number" || !p.uid) continue; + const rail = p.type; + if (!RAILS.includes(rail)) continue; + const s = ensure(p.uid)[rail]; + if (p.amount >= 0) { s.in += p.amount; s.inN++; } + else { s.out += Math.abs(p.amount); s.outN++; } + } + batch = []; +}; +for await (const k of db.scanIterator({ MATCH: "payment:*", COUNT: 2000 })) { + scanned++; + batch.push(k as string); + if (batch.length >= 1000) await drainP(); +} +await drainP(); + +// The arb pattern: significant lightning IN and significant liquid OUT. +const flagged = [...stats.entries()] + .map(([uid, s]) => ({ + uid, + lnIn: s.lightning.in, + lqOut: s.liquid.out, + bolt12In: s.bolt12.in, + arb: Math.min(s.lightning.in + s.bolt12.in, s.liquid.out), // overlap = the through-flow + s, + })) + .filter((x) => x.lqOut >= MIN && x.lnIn + x.bolt12In >= MIN) + .sort((a, b) => b.arb - a.arb) + .slice(0, TOP); + +console.log("═".repeat(72)); +console.log(` LIGHTNING-IN / LIQUID-OUT pattern — last ${argDays} days (min ${fmt(MIN)} sats each side)`); +console.log("═".repeat(72)); +console.log(` walked ${fmt(scanned)} payment keys\n`); + +if (!flagged.length) { + console.log(" No users matched the pattern at this threshold."); +} else { + console.log(" user ln_in bolt12_in liquid_out through-flow"); + for (const x of flagged) { + console.log( + ` ${nameOf(x.uid).padEnd(20)} ${fmt(x.lnIn).padStart(11)} ${fmt(x.bolt12In).padStart(11)} ${fmt(x.lqOut).padStart(13)} ${fmt(x.arb).padStart(13)}`, + ); + } + const totalThrough = flagged.reduce((s, x) => s + x.arb, 0); + console.log(`\n Combined through-flow (ln/bolt12 in -> liquid out): ${fmt(totalThrough)} sats`); +} + +// Also: who are the biggest NET liquid drainers regardless of funding source? +const netLiquid = [...stats.entries()] + .map(([uid, s]) => ({ uid, net: s.liquid.out - s.liquid.in, out: s.liquid.out })) + .filter((x) => x.net >= MIN) + .sort((a, b) => b.net - a.net) + .slice(0, 15); +console.log("\n" + "─".repeat(72)); +console.log(" TOP NET LIQUID DRAINERS (liquid_out - liquid_in, any funding source)"); +console.log("─".repeat(72)); +for (const x of netLiquid) { + console.log(` ${nameOf(x.uid).padEnd(20)} net out ${fmt(x.net).padStart(13)} (gross out ${fmt(x.out)})`); +} +console.log("═".repeat(72)); +process.exit(0); -- cgit v1.2.3