diff options
Diffstat (limited to 'scripts/ln-liquid-arb.ts')
| -rw-r--r-- | scripts/ln-liquid-arb.ts | 126 |
1 files changed, 126 insertions, 0 deletions
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<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); |
