1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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<string, string>();
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<string, Record<string, Rail>>();
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);
|