aboutsummaryrefslogtreecommitdiffstats
path: root/scripts/ln-liquid-arb.ts
blob: 02d0aa1ec600ab33e67bb8c3e14c12c42b6278aa (plain) (blame)
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);