diff options
| -rw-r--r-- | scripts/audit-wiped-history.ts | 85 | ||||
| -rw-r--r-- | scripts/rebalance-economics.ts | 96 |
2 files changed, 181 insertions, 0 deletions
diff --git a/scripts/audit-wiped-history.ts b/scripts/audit-wiped-history.ts new file mode 100644 index 00000000..d4e94645 --- /dev/null +++ b/scripts/audit-wiped-history.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env bun +// Find accounts whose payment history index was wiped — the changeid bug +// signature, distinct from the harmless arc-only archived state. +// +// Two buckets: +// wiped-with-balance: balance > 0 but :payments list is empty in BOTH db and +// arc. Real funds, no visible history. These are the changeid-rekeyed +// accounts (password == "reset") whose list entries got dropped. Highest +// priority — user sees a balance with no transactions. +// wiped-no-balance: :payments empty in both, balance 0, but has :invoices +// (so they did transact once). History gone but nothing owed; lower priority. +// +// Read-only. Usage: bun scripts/audit-wiped-history.ts [--full] [--examples N] + +import { createClient } from "redis"; + +const full = process.argv.includes("--full"); +const exN = Number(process.argv[process.argv.indexOf("--examples") + 1]) || 30; + +const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } }); +const arc = createClient({ url: "redis://127.0.0.1:6380", socket: { reconnectStrategy: () => false } }); +await db.connect(); +await arc.connect(); + +let scanned = 0; +const wipedWithBal: { name: string; uid: string; bal: number; reset: boolean }[] = []; +const wipedNoBal: { name: string; uid: string; invoices: number }[] = []; +let cleanCount = 0; + +const t0 = Date.now(); +for await (const batch of db.scanIterator({ MATCH: "user:*", COUNT: 2000 })) { + // This redis client version yields ARRAYS of keys per iteration, not single keys. + const keys = Array.isArray(batch) ? batch : [batch]; + for (const key of keys) { + const uid = (key as string).slice("user:".length); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-/.test(uid)) continue; // skip username pointers + scanned++; + + const dbLen = await db.lLen(`${uid}:payments`); + if (dbLen > 0) { cleanCount++; continue; } // has visible history + + // Empty in db — is it also empty in arc, or just archived? + const arcLen = await arc.lLen(`${uid}:payments`); + if (arcLen > 0) { cleanCount++; continue; } // renders via gf(), fine + + // :payments empty in both. Does it matter? Check balance + whether they ever transacted. + const bal = parseInt((await db.get(`balance:${uid}`)) || "0", 10); + const invLen = await db.lLen(`${uid}:invoices`); + + if (bal > 0) { + const u = await db.get(`user:${uid}`); + let name = uid.slice(0, 8), reset = false; + if (u && u[0] === "{") { try { const j = JSON.parse(u); name = j.username || name; reset = j.password === "reset"; } catch {} } + wipedWithBal.push({ name, uid, bal, reset }); + } else if (invLen > 0) { + const u = await db.get(`user:${uid}`); + let name = uid.slice(0, 8); + if (u && u[0] === "{") { try { name = JSON.parse(u).username || name; } catch {} } + wipedNoBal.push({ name, uid, invoices: invLen }); + } + } +} +const secs = ((Date.now() - t0) / 1000).toFixed(1); + +wipedWithBal.sort((a, b) => b.bal - a.bal); + +console.log("═".repeat(70)); +console.log(` WIPED-HISTORY AUDIT — scanned ${scanned} users in ${secs}s`); +console.log("═".repeat(70)); +console.log(`\n Accounts with BALANCE but no history (priority — funds visible, no txns):`); +console.log(` count: ${wipedWithBal.length}`); +const totalBal = wipedWithBal.reduce((s, x) => s + x.bal, 0); +console.log(` total balance affected: ${totalBal.toLocaleString()} sats`); +console.log(` (of which password=="reset" / changeid'd: ${wipedWithBal.filter((x) => x.reset).length})`); +console.log(""); +for (const x of wipedWithBal.slice(0, exN)) { + console.log(` ${x.name.padEnd(24)} ${(x.bal + "").padStart(12)} sats ${x.reset ? "[changeid]" : ""} ${x.uid}`); +} + +console.log(`\n Accounts wiped, no balance (had invoices, lower priority): ${wipedNoBal.length}`); +for (const x of wipedNoBal.slice(0, 10)) { + console.log(` ${x.name.padEnd(24)} ${x.invoices} invoices ${x.uid}`); +} +console.log("═".repeat(70)); +process.exit(0); diff --git a/scripts/rebalance-economics.ts b/scripts/rebalance-economics.ts new file mode 100644 index 00000000..0d4bf6e2 --- /dev/null +++ b/scripts/rebalance-economics.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun +// Models whether the lightning->liquid "bridge" usage is fee-positive for us +// at a given rebalance batch size. +// +// The mechanic: +// - A user funds via Lightning (we receive into our LN channels) and withdraws +// via Liquid (we pay out of our Liquid hot wallet). Net: LN balance grows, +// Liquid balance shrinks. We charge config.fee.liquid (0.1%) on the liquid +// send, NOT offset by their credit:liquid (they bank credit:lightning from +// the receive, which is a different key) — so we collect ~0.1% of throughput. +// - To refill Liquid we must rebalance: close LN channels (on-chain BTC back to +// our cl wallet) then peg-in to Liquid. That costs on-chain fees, amortized +// over how much we move per rebalance. +// +// Revenue from bridging X sats of throughput = X * liquid_fee. +// Cost to rebalance that X = (channel closes to free X) + 1 peg-in tx. +// Break-even: revenue >= cost. +// +// Usage: bun scripts/rebalance-economics.ts [--feerate SAT_VB] [--avg-channel BTC] [--liquid-fee 0.001] + +const arg = (k: string, d: number) => { + const i = process.argv.indexOf(k); + return i >= 0 ? Number(process.argv[i + 1]) : d; +}; + +const SATS = 100_000_000; +const liquidFee = arg("--liquid-fee", 0.001); // config.fee.liquid = 0.1% +const feerate = arg("--feerate", 1.01); // sat/vB; current mutual_close ~1.01 +const avgChannelBtc = arg("--avg-channel", 0.3); // avg local balance freed per close + +// tx vsizes (vBytes) +const MUTUAL_CLOSE_VB = 170; // 1 funding input + 2 outputs +const PEGIN_VB = 150; // consolidate freed outputs -> 1 peg-in send (BTC side) + +function closesNeeded(btc: number) { + return Math.max(1, Math.ceil(btc / avgChannelBtc)); +} + +function rebalanceCostSats(btc: number) { + const closes = closesNeeded(btc); + const closeFees = closes * MUTUAL_CLOSE_VB * feerate; + const peginFee = PEGIN_VB * feerate; // one batched peg-in per rebalance + return { closes, closeFees, peginFee, total: closeFees + peginFee }; +} + +function revenueSats(btc: number) { + return btc * SATS * liquidFee; +} + +console.log("═".repeat(70)); +console.log(" LIGHTNING -> LIQUID BRIDGE ECONOMICS"); +console.log(` liquid fee ${(liquidFee * 100).toFixed(2)}% | close feerate ${feerate} sat/vB | avg channel ${avgChannelBtc} BTC`); +console.log("═".repeat(70)); +console.log( + "\n batch revenue closes close+pegin cost net margin", +); +console.log(" " + "-".repeat(64)); + +for (const btc of [0.1, 0.25, 0.5, 1, 2, 5, 10]) { + const rev = revenueSats(btc); + const c = rebalanceCostSats(btc); + const net = rev - c.total; + const margin = (net / rev) * 100; + const tag = net >= 0 ? "PROFIT" : "LOSS"; + console.log( + ` ${(btc + " BTC").padEnd(8)} ${(Math.round(rev) + "").padStart(9)}s ${(c.closes + "").padStart(4)} ${(Math.round(c.total) + "").padStart(10)}s ${(Math.round(net) + "").padStart(8)}s ${margin.toFixed(1).padStart(6)}% ${tag}`, + ); +} + +// Break-even batch size: revenue(btc) = cost(btc). With closes ~ btc/avgChannel, +// cost grows ~linearly too, so solve numerically. +let beBtc = 0; +for (let b = 0.001; b <= 50; b += 0.001) { + if (revenueSats(b) >= rebalanceCostSats(b).total) { + beBtc = b; + break; + } +} +console.log("\n " + "-".repeat(64)); +if (beBtc) { + console.log(` Break-even: rebalancing >= ${beBtc.toFixed(3)} BTC per batch is fee-positive.`); +} else { + console.log(` Never breaks even in range — cost scales with throughput as fast as revenue.`); +} + +// The deeper truth: because closes scale with throughput, per-sat margin is +// roughly constant. Show the asymptotic per-BTC economics. +const big = rebalanceCostSats(10); +const bigRev = revenueSats(10); +console.log( + `\n At scale (per 1 BTC bridged): revenue ${Math.round(revenueSats(1))}s, ` + + `rebalance cost ~${Math.round(rebalanceCostSats(1).total)}s, ` + + `net ~${Math.round(revenueSats(1) - rebalanceCostSats(1).total)}s`, +); +console.log("═".repeat(70)); +process.exit(0); |
