cln-plugins/persistent-channels/persistent-channels.py
2026-05-12 22:00:29 +02:00

127 lines
3.5 KiB
Python
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "pyln-client>=25.9.3"
# ]
# ///
from pyln.client import Plugin, RpcError
from threading import Timer
import os
import json
import traceback
plugin = Plugin()
def load_state(path):
try:
state = json.loads(open(path, "r").read())
except Exception:
print("Could not read state file, creating a new one.")
return {"channels": {}}
return state
def save_state(path, state):
"""Atomically save the new state to the state_file."""
tmppath = path + ".tmp"
with open(tmppath, "w") as f:
f.write(json.dumps(state, indent=2))
os.rename(tmppath, path)
def is_connectable(rpc, node_id):
nodes = rpc.listnodes(node_id)
if len(nodes) == 0:
return False
print(nodes)
def maybe_open_channel(desired, rpc):
peers = rpc.listpeers(desired["node_id"])["peers"]
if "satoshi" in desired:
desired["amount"] = "{}sat".format(desired["satoshi"])
del desired["satoshi"]
if peers == []:
# Need to connect first, and then open a channel
# if not is_connectable(rpc, desired['node_id']):
# print("No address known for {}, cannot connect.".format(desired['node_id']))
try:
rpc.connect(desired["node_id"])
except RpcError as re:
print("Could not connect to peer: {}".format(re.error))
return
peer = rpc.listpeers(desired["node_id"])["peers"][0]
else:
peer = peers[0]
channel_states = [c["state"] for c in peer["channels"]]
if peer is None or len(peer["channels"]) == 0:
# Just open it, we don't have one yet
# TODO(cdecker) Check balance before actually opening
rpc.fundchannel(**desired)
elif "CHANNELD_NORMAL" in channel_states:
# Already in the desired state, nothing to do.
return
elif channel_states == ["ONCHAIND"]:
# If our only channel is in state ONCHAIND it's probably time
# to open a new one
rpc.connect(desired["node_id"])
rpc.fundchannel(**desired)
def check_channels(plugin):
"""Load actual and desired states, and try to reconcile."""
state = load_state(plugin.state_file)
print(state)
for c in state["channels"].values():
try:
maybe_open_channel(c, plugin.rpc)
except Exception:
plugin.log(f"Error attempting to open a channel with {c['node_id']}.")
traceback.print_exc()
Timer(30, check_channels, args=[plugin]).start()
@plugin.method("addpersistentchannel")
def add_persistent_channel(node_id, satoshi, plugin, feerate="normal", announce=True):
"""Add a persistent channel to the state map.
The persistent-channels plugin will ensure that the channel is
opened as soon as possible and re-opened should it get closed. The
parameters are identical to `fundchannel`.
"""
state = load_state(plugin.state_file)
state["channels"][node_id] = {
"node_id": node_id,
"satoshi": satoshi,
"feerate": feerate,
"announce": announce,
}
save_state(plugin.state_file, state)
maybe_open_channel(state["channels"][node_id], plugin.rpc)
@plugin.init()
def init(options, configuration, plugin):
# This is the file in which we'll store all of our state (mostly
# desired channels for now)
plugin.state_file = os.path.join(
configuration["lightning-dir"], "persistent-channels.json"
)
check_channels(plugin)
plugin.run()