aboutsummaryrefslogtreecommitdiffstats
path: root/scripts/close-dead-channels.sh
blob: dda1efb9ac69e3c61ac36a53c460508f7345b53e (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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
#!/bin/bash
#
# Close the least-active Lightning channels that have recoverable local balance.
#
# Usage:
#   ./close-dead-channels.sh                # dry-run (default)
#   ./close-dead-channels.sh --execute      # actually close channels
#
# Options (set via env vars):
#   FUNDS_DIR     - path to balance snapshots (default: ~/coinos-server/data/funds)
#   COUNT         - number of channels to close (default: 10)
#   MIN_LOCAL_PCT - minimum local balance % to consider (default: 20)
#   MONTHS        - how many months of history to analyze (default: 3)
#   CLOSE_TIMEOUT - seconds to wait for cooperative close (default: 1)
#   EXCLUDE       - comma-separated SCIDs or alias substrings to skip

set -euo pipefail

FUNDS_DIR="${FUNDS_DIR:-$HOME/coinos-server/data/funds}"
COUNT="${COUNT:-10}"
MIN_LOCAL_PCT="${MIN_LOCAL_PCT:-20}"
MONTHS="${MONTHS:-3}"
CLOSE_TIMEOUT="${CLOSE_TIMEOUT:-1}"
EXCLUDE="${EXCLUDE:-}"
EXECUTE=false

if [[ "${1:-}" == "--execute" ]]; then
  EXECUTE=true
fi

python3 - "$FUNDS_DIR" "$COUNT" "$MIN_LOCAL_PCT" "$MONTHS" "$EXCLUDE" << 'PYEOF'
import json, subprocess, os, re, sys
from datetime import datetime, timedelta
from collections import defaultdict

FUNDS_DIR = sys.argv[1]
COUNT = int(sys.argv[2])
MIN_LOCAL_PCT = float(sys.argv[3])
MONTHS = int(sys.argv[4])
EXCLUDE = [x.strip().lower() for x in sys.argv[5].split(",") if x.strip()]

def parse_date(fn):
    fn = fn.replace('balances_', '')
    m = re.match(r'(\d{2})-(\d{2})-(\d{2})-(\d{2}):(\d{2}):(\d{2})\.json', fn)
    if not m:
        return None
    month, day, year, hour, minute, sec = [int(x) for x in m.groups()]
    try:
        return datetime(year + 2000, month, day, hour, minute, sec)
    except:
        return None

def fmt(s):
    if abs(s) >= 1_000_000:
        return f"{s/1_000_000:.2f}M"
    elif abs(s) >= 1_000:
        return f"{s/1_000:.1f}k"
    return str(s)

# Sample balance files every ~4 hours over the lookback period
files = sorted(os.listdir(FUNDS_DIR))
cutoff = datetime.now() - timedelta(days=MONTHS * 30)
recent = [(d, f) for f in files if (d := parse_date(f)) and d >= cutoff]
recent.sort()

sampled = []
last = None
for d, f in recent:
    if last is None or (d - last).total_seconds() >= 4 * 3600:
        sampled.append((d, f))
        last = d

print(f"Analyzed {len(sampled)} snapshots over {MONTHS} months\n")

# Build movement history per channel
channel_history = defaultdict(list)
for d, f in sampled:
    try:
        with open(os.path.join(FUNDS_DIR, f)) as fh:
            data = json.load(fh)
    except:
        continue
    for ch in data.get("channels", []):
        scid = ch.get("short_channel_id", "")
        if scid:
            channel_history[scid].append(
                (d, ch["our_amount_msat"], ch["amount_msat"], ch.get("state", ""))
            )

# Get currently open channels
result = subprocess.run(
    ["docker", "exec", "cl", "lightning-cli", "listpeerchannels"],
    capture_output=True, text=True, timeout=15,
)
peerchans = json.loads(result.stdout)

# Resolve aliases
aliases = {}
def get_alias(peer_id):
    if peer_id in aliases:
        return aliases[peer_id]
    try:
        r = subprocess.run(
            ["docker", "exec", "cl", "lightning-cli", "listnodes", peer_id],
            capture_output=True, text=True, timeout=10,
        )
        nd = json.loads(r.stdout)
        a = nd["nodes"][0].get("alias", peer_id[:20] + "...") if nd.get("nodes") else peer_id[:20] + "..."
    except:
        a = peer_id[:20] + "..."
    aliases[peer_id] = a
    return a

# Score each open channel
candidates = []
for ch in peerchans.get("channels", []):
    scid = ch.get("short_channel_id", "")
    state = ch.get("state", "")
    if not scid or state != "CHANNELD_NORMAL":
        continue

    capacity = ch.get("total_msat", 0) // 1000
    local = ch.get("to_us_msat", 0) // 1000
    if capacity == 0:
        continue
    local_pct = 100 * local / capacity
    if local_pct < MIN_LOCAL_PCT:
        continue

    history = channel_history.get(scid, [])
    normal = [(d, o, t) for d, o, t, s in history if s == "CHANNELD_NORMAL"]
    movement = 0
    if len(normal) >= 2:
        for i in range(1, len(normal)):
            movement += abs(normal[i][1] - normal[i - 1][1])

    candidates.append({
        "scid": scid,
        "peer_id": ch.get("peer_id", ""),
        "capacity": capacity,
        "local": local,
        "remote": capacity - local,
        "local_pct": local_pct,
        "movement": movement // 1000,
    })

candidates.sort(key=lambda x: x["movement"])

# Apply exclusions and take bottom N
selected = []
for c in candidates:
    if len(selected) >= COUNT:
        break
    alias = get_alias(c["peer_id"])
    skip = False
    for exc in EXCLUDE:
        if exc in c["scid"].lower() or exc in alias.lower():
            skip = True
            break
    if skip:
        print(f"  SKIP (excluded): {c['scid']}  {alias}")
        continue
    c["alias"] = alias
    selected.append(c)

# Print results
print(f"{'SCID':<18} {'Peer':<30} {'Capacity':>10} {'Local':>10} {'Remote':>10} {'Local%':>7} {'3mo Vol':>10}")
print("-" * 105)
for c in selected:
    print(
        f"{c['scid']:<18} {c['alias']:<30} {fmt(c['capacity']):>10} "
        f"{fmt(c['local']):>10} {fmt(c['remote']):>10} {c['local_pct']:>6.1f}% "
        f"{fmt(c['movement']):>10}"
    )

total_local = sum(c["local"] for c in selected)
total_cap = sum(c["capacity"] for c in selected)
print(f"\nRecoverable local: {fmt(total_local)} sats | Capacity freed: {fmt(total_cap)} sats")

# Output SCIDs for the shell to close
scid_list = " ".join(c["scid"] for c in selected)
print(f"\nSCIDS={scid_list}")
PYEOF

# Parse SCIDs from python output
SCIDS=$(python3 - "$FUNDS_DIR" "$COUNT" "$MIN_LOCAL_PCT" "$MONTHS" "$EXCLUDE" << 'PYEOF' 2>/dev/null | tail -1
import json, subprocess, os, re, sys
from datetime import datetime, timedelta
from collections import defaultdict

FUNDS_DIR = sys.argv[1]
COUNT = int(sys.argv[2])
MIN_LOCAL_PCT = float(sys.argv[3])
MONTHS = int(sys.argv[4])
EXCLUDE = [x.strip().lower() for x in sys.argv[5].split(",") if x.strip()]

def parse_date(fn):
    fn = fn.replace('balances_', '')
    m = re.match(r'(\d{2})-(\d{2})-(\d{2})-(\d{2}):(\d{2}):(\d{2})\.json', fn)
    if not m: return None
    mo, d, y, h, mi, s = [int(x) for x in m.groups()]
    try: return datetime(y+2000, mo, d, h, mi, s)
    except: return None

files = sorted(os.listdir(FUNDS_DIR))
cutoff = datetime.now() - timedelta(days=MONTHS*30)
recent = [(d, f) for f in files if (d := parse_date(f)) and d >= cutoff]
recent.sort()
sampled, last = [], None
for d, f in recent:
    if last is None or (d - last).total_seconds() >= 4*3600:
        sampled.append((d, f)); last = d

ch_hist = defaultdict(list)
for d, f in sampled:
    try:
        with open(os.path.join(FUNDS_DIR, f)) as fh: data = json.load(fh)
    except: continue
    for ch in data.get("channels", []):
        scid = ch.get("short_channel_id", "")
        if scid: ch_hist[scid].append((d, ch["our_amount_msat"], ch["amount_msat"], ch.get("state","")))

r = subprocess.run(["docker","exec","cl","lightning-cli","listpeerchannels"], capture_output=True, text=True, timeout=15)
peerchans = json.loads(r.stdout)

aliases = {}
def get_alias(pid):
    if pid in aliases: return aliases[pid]
    try:
        r2 = subprocess.run(["docker","exec","cl","lightning-cli","listnodes",pid], capture_output=True, text=True, timeout=10)
        nd = json.loads(r2.stdout)
        a = nd["nodes"][0].get("alias", pid[:20]) if nd.get("nodes") else pid[:20]
    except: a = pid[:20]
    aliases[pid] = a; return a

cands = []
for ch in peerchans.get("channels", []):
    scid = ch.get("short_channel_id",""); state = ch.get("state","")
    if not scid or state != "CHANNELD_NORMAL": continue
    cap = ch.get("total_msat",0)//1000; loc = ch.get("to_us_msat",0)//1000
    if cap == 0: continue
    pct = 100*loc/cap
    if pct < MIN_LOCAL_PCT: continue
    hist = ch_hist.get(scid, [])
    norm = [(d,o,t) for d,o,t,s in hist if s=="CHANNELD_NORMAL"]
    mv = sum(abs(norm[i][1]-norm[i-1][1]) for i in range(1,len(norm)))//1000 if len(norm)>=2 else 0
    cands.append({"scid":scid,"peer_id":ch.get("peer_id",""),"movement":mv})

cands.sort(key=lambda x: x["movement"])
sel = []
for c in cands:
    if len(sel) >= COUNT: break
    alias = get_alias(c["peer_id"])
    skip = any(e in c["scid"].lower() or e in alias.lower() for e in EXCLUDE)
    if not skip: sel.append(c["scid"])

print(" ".join(sel))
PYEOF
)

if [[ "$EXECUTE" != "true" ]]; then
  echo ""
  echo "DRY RUN - to actually close, run:"
  echo "  $0 --execute"
  exit 0
fi

echo ""
echo "Closing channels..."
for scid in $SCIDS; do
  echo "  Closing $scid ..."
  docker exec cl lightning-cli close "$scid" "$CLOSE_TIMEOUT" 2>&1 | head -5
  echo ""
done

echo "Done."
ss="p">, "scanNoCamera": "No se pudo acceder a la cámara.", "scanNoDecoder": "El escaneo de QR necesita conexión la primera vez que se usa.", "scanUnrecognized": "No se pudo leer una dirección de Bitcoin de ese QR.", "broadcastScanned": "¿Transmitir la transacción?", "broadcastNow": "Transmitir", "scanOffline": "Estás sin conexión — vuelve a conectarte para transmitir esta transacción.", "loadingHistory": "Cargando transacciones…", "explorer": "Explorador de bloques", "explorerDesc": "El servidor que esta billetera consulta para saldos e historial. Apúntalo a tu propio nodo para máxima privacidad.", "explorerUrl": "URL del servidor", "explorerUrlHint": "Base REST de Esplora / electrs, p. ej. https://mempool.space/api", "home": "Inicio", "createNew": "Crear nuevo", "importExisting": "Importar existente", "genIntro": "Genera una nueva frase semilla de 12 palabras. Anótala. Es la única forma de recuperar este monedero.", "generateSeed": "Generar frase semilla", "writeDownWarn": "⚠ Anota estas 12 palabras en papel, en orden. Cualquiera que las tenga puede gastar tus fondos.", "copyPhrase": "Copiar frase", "regenerate": "Regenerar", "verifyBackup": "Verificar copia", "skipVerification": "Omitir verificación", "confirmBackupIntro": "Confirma tu copia introduciendo las palabras solicitadas.", "wordN": "Palabra n.º {n}", "reenterPassphrase": "Reintroduce la contraseña", "back": "← Atrás", "openWallet": "Abrir monedero", "wordsMismatch": "Esas palabras no coinciden con tu frase. Revisa tu copia.", "passphraseMismatch": "Esa contraseña no coincide con la que introdujiste.", "seedPhrase": "Frase semilla", "seedPlaceholder": "escribe tus palabras separadas por espacios", "optionalPassphrase": "Contraseña opcional", "leaveBlank": "déjalo en blanco si no se usa", "show": "Mostrar", "hide": "Ocultar", "invalidSeed": "Frase semilla no válida — revisa la ortografía y el orden.", "keysNote": "Las claves nunca salen de tu navegador. Funciona sin conexión. Guarda esta página y ábrela desde tu equipo.", "balance": "Saldo", "pending": "Pendiente", "tabReceive": "Recibir", "tabSend": "Enviar", "tabHistory": "Historial", "tabSettings": "Ajustes", "copyAddress": "Copiar dirección", "paymentReceived": "¡Pago recibido!", "tapToProceed": "Toca en cualquier lugar para continuar", "recipient": "Destinatario", "recipients": "Destinatarios", "max": "Máx", "feeRate": "Tarifa", "feeEconomy": "Económica", "feeNormal": "Normal", "feePriority": "Prioritaria", "feeCustom": "Personalizada", "selectedRate": "Tarifa elegida: {n} sat/vB", "coinSelection": "Selección de monedas", "automatic": "Automática", "manual": "Manual", "noCoins": "No hay monedas disponibles.", "nSelected": "{n} seleccionadas", "addRecipient": "+ Añadir destinatario", "reviewTx": "Revisar transacción", "remove": "Eliminar", "enterRecipientAddr": "Introduce una dirección de destino.", "enterAddrForN": "Introduce una dirección para el destinatario {n}.", "enterValidAmtForN": "Introduce un importe válido para el destinatario {n}.", "selectCoin": "Selecciona al menos una moneda para gastar.", "networkFee": "Comisión de red", "offlineSignNote": "Sin conexión: firma ahora y difunde la transacción desde un dispositivo conectado.", "signBroadcast": "Firmar y difundir", "signTx": "Firmar transacción", "broadcastFailed": "Falló la difusión: {msg}", "signingFailed": "Falló la firma: {msg}", "done": "Listo", "txSignedNote": "✓ Transacción firmada. Difunde el código de abajo desde un dispositivo conectado (p. ej. mempool.space → Broadcast).", "transactionId": "ID de transacción", "signedTxRaw": "Transacción firmada (código)", "copyHex": "Copiar código", "downloadLabel": "Descargar", "showQrAirgap": "Mostrar QR (para transferencia sin conexión)", "txBroadcast": "✓ ¡Transacción difundida!", "copyTxid": "Copiar ID de transacción", "viewOnMempool": "Ver en el explorador de bloques ↗", "noTxYet": "Aún no hay transacciones.", "received": "Recibido", "sent": "Enviado", "pendingTag": "⏳ pendiente", "awaitingConfirmation": "esperando confirmación", "feeShort": "comisión {x}", "historyOffline": "El historial no está disponible sin conexión.", "status": "Estado", "confirmed": "Confirmada", "confirmedTag": "confirmada", "pendingInMempool": "Pendiente (en mempool)", "block": "Bloque", "date": "Fecha", "copyId": "Copiar ID", "backToHistory": "← Volver al historial", "recoveryPhrase": "Frase de recuperación", "recoveryWarn": "⚠ Cualquiera que vea estas palabras puede robar tus fondos. Asegúrate de que nadie mire antes de mostrarlas.", "bip39Passphrase": "Contraseña", "copyPassphrase": "Copiar contraseña", "revealRecovery": "Mostrar frase de recuperación", "offlineTransfer": "Transferencia sin conexión", "offlineTransferDesc": "Exporta tus monedas en un dispositivo conectado, luego importa el archivo en uno sin conexión (con este monedero + semilla) para firmar sin internet.", "exportSnapshot": "⤓ Exportar copia", "importSnapshot": "Importar copia…", "rescan": "Reescanear", "rescanDesc": "Tu dirección actual se actualiza automáticamente. Reescanea solo si enviaste un pago a una dirección antigua (reutilizada) y no aparece.", "rescanWallet": "Reescanear monedero", "scanning": "Escaneando…", "language": "Idioma", "retry": "Reintentar", "copied": "Copiado al portapapeles", "snapshotExported": "Copia exportada", "importedNCoins": "{n} moneda(s) importada(s)", "unmatchedSuffix": " · {n} dirección(es) no pertenecen a este monedero", "importFailed": "Falló la importación: {msg}", "copy": "Copiar", "switchUnit": "Cambiar BTC / sats", "footerMadeBy": "hecho por", "footerSourceOn": "código disponible en", "bumpFee": "Aumentar comisión", "bumpConfirm": "¿Reemplazar con una comisión más alta?", "replaceTx": "Reemplazar", "bumpInsufficient": "Fondos insuficientes para aumentar a esta tasa. Prueba una tasa más baja.", "giftWelcome": "Te han enviado Bitcoin", "giftWelcomeBody": "Esta billetera ahora es tuya. Cualquiera que tenga su frase de recuperación controla las monedas, así que haz una copia de seguridad antes de hacer cualquier otra cosa.", "giftBackup": "Hacer copia de seguridad de esta billetera", "giftSkip": "Omitir por ahora", "giftBackedUp": "Ya la anoté", "giftLink": "Regala algo de Bitcoin", "giftLinkDesc": "Prefirma un importe como enlace. Quien lo abra reclama el Bitcoin en una billetera totalmente nueva que solo esa persona controla.", "giftLinkWarn": "⚠ Cualquiera que abra este enlace puede reclamar el importe, así que compártelo solo con la persona a quien se lo regalas. Mantén los importes pequeños.", "giftLinkReveal": "Crear enlace de regalo", "copyLink": "Copiar enlace", "giftAmountLabel": "Importe a regalar", "giftAmountInvalid": "Introduce al menos {n}.", "giftAnother": "Crear otro", "giftReserved": "Enlaces de regalo sin reclamar ({n})", "claimBody": "Reclámalo en una billetera totalmente nueva que solo tú controlas.", "claimBtn": "Reclamarlo", "claimFailed": "Este regalo ya ha sido reclamado o ya no está disponible.", "claimedTitle": "¡Reclamado!", "claimedBody": "Tu Bitcoin está en camino. Haz una copia de seguridad de tu frase de recuperación para mantenerlo a salvo.", "giftRevoke": "Revocar ahora", "giftRevoked": "Regalo revocado", "giftReclaim": "Reclamar", "giftReclaimPrompt": "Reclamar libera esta moneda para tu próximo pago, pero el enlace seguirá siendo reclamable hasta que la gastes. ¿Pagar una comisión de red para revocarlo ahora mismo?", "giftReclaimed": "Moneda reclamada", "giftRevokeConfirm": "¿Revocar este regalo ahora? Esto devuelve la moneda a tu monedero y desactiva el enlace.", "giftReclaimedTag": "aún reclamable", "giftMinNote": "Mínimo {n} con las comisiones actuales, para que supere el polvo al reclamarse.", "saveDevice": "Guardar en este dispositivo", "forgetDevice": "Guardado · olvidar", "savedTag": "guardado", "setPassword": "Establecer una contraseña", "setPasswordDesc": "Cifra este monedero en este dispositivo. La necesitarás para desbloquear los monederos guardados más adelante.", "enterPassword": "Introduce tu contraseña", "enterPasswordDesc": "Desbloquea tus monederos guardados para continuar.", "password": "Contraseña", "confirmPassword": "Confirmar contraseña", "pwTooShort": "Usa al menos 8 caracteres.", "pwMismatch": "Las contraseñas no coinciden.", "pwWrong": "Contraseña incorrecta.", "unlockSaved": "Desbloquear monederos guardados", "unlockSavedDesc": "Introduce tu contraseña para abrir los monederos guardados en este dispositivo.", "unlock": "Desbloquear", "useAnotherWallet": "Usar otro monedero", "save": "Guardar", "importLabel": "Frase de recuperación o clave", "importPlaceholder": "Tus 12 o 24 palabras, o un xpub / xprv", "invalidImport": "Eso no es una frase de recuperación, xpub o xprv válida.", "importedKey": "Clave importada", "importedKeyNote": "Esta cartera se importó desde una clave privada extendida, así que no tiene frase de recuperación. Guarda esa clave para conservar la cartera.", "clearAll": "Borrar todo", "clearAllWarn": "⚠ Esto elimina todas las carteras de este dispositivo. Cualquier cartera cuya frase de recuperación o clave no hayas respaldado se perderá para siempre.", "rename": "Renombrar", "darkMode": "Modo oscuro", "lightMode": "Modo claro" }