#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.10" # dependencies = [ # "pyln-client>=25.9.3", # "requests[socks]>=2.34.0", # "packaging>=26.2", # ] # /// from pyln.client import Plugin, Millisatoshi from packaging import version from collections import namedtuple from operator import attrgetter from summary_avail import trace_availability, addpeer import pyln.client import requests import threading import time import pickle import sys plugin = Plugin(autopatch=True) datastore_key = ["summary", "avail"] Channel = namedtuple( "Channel", [ "total", "ours", "theirs", "pid", "private", "connected", "scid", "avail", "base", "ppm", ], ) Charset = namedtuple( "Charset", ["double_left", "left", "bar", "mid", "right", "double_right", "empty"] ) draw_boxch = Charset("╟", "├", "─", "┼", "┤", "╢", "║") draw_ascii = Charset("#", "[", "-", "+", "]", "#", "|") class PeerThread(threading.Thread): def __init__(self): super().__init__() self.daemon = True def run(self): # delay initial execution, so peers have a chance to connect on startup time.sleep(plugin.avail_interval) while True: try: rpcpeers = plugin.rpc.listpeers() trace_availability(plugin, rpcpeers) write_datastore(plugin) plugin.log( "[PeerThread] Peerstate wrote to datastore. Sleeping now...", "debug", ) time.sleep(plugin.avail_interval) except Exception as ex: plugin.log("[PeerThread] " + str(ex), "warn") class PriceThread(threading.Thread): def __init__(self, proxies): super().__init__() self.daemon = True self.proxies = proxies def run(self): while True: try: # NOTE: Bitstamp has a DNS/Proxy issues that can return 404 # Workaround: retry up to 5 times with a delay for _ in range(5): r = requests.get( "https://www.bitstamp.net/api/v2/ticker/btc{}".format( plugin.currency.lower() ), proxies=self.proxies, ) if not r.status_code == 200: time.sleep(1) continue break plugin.fiat_per_btc = float(r.json()["last"]) except Exception as ex: plugin.log("[PriceThread] " + str(ex), "warn") # Six hours is more than often enough for polling time.sleep(6 * 3600) def to_fiatstr(msat: Millisatoshi): return "{}{:.2f}".format( plugin.currency_prefix, int(msat) / 10**11 * plugin.fiat_per_btc ) # appends an output table header that explains fields and capacity def append_header(table, max_msat): short_str = Millisatoshi(max_msat).to_approx_str() draw = plugin.draw table.append( "%c%-13sOUT/OURS %c IN/THEIRS%12s%c SCID FLAG BASE PPM AVAIL ALIAS" % (draw.left, short_str, draw.mid, short_str, draw.right) ) # TODO: cln and pyln-client needs to be updated to get additional usage info. # currently `usage` is generated by pyln-client.Plugin by just reading the args. @plugin.method("summary") def summary(plugin, exclude="", sortkey=None, ascii=None): """Gets summary information about this node. Pass a list of scids to the {exclude} parameter to exclude some channels from the outputs. Sort the result by using the {sortkey} parameter that can be one of 'total', 'ours', 'theirs', 'scid' (default), 'avail', 'base', 'ppm'.") """ # Sets ascii mode for this and future requests (if requested) if ascii is not None: if ascii: plugin.draw = draw_ascii else: plugin.draw = draw_boxch reply = {} info = plugin.rpc.getinfo() funds = plugin.rpc.listfunds() peers = plugin.rpc.listpeers()["peers"] # Make it stand out if we're not on mainnet. if info["network"] != "bitcoin": reply["network"] = info["network"].upper() if hasattr(plugin, "my_address") and plugin.my_address: reply["my_address"] = plugin.my_address else: reply["warning_no_address"] = "NO PUBLIC ADDRESSES" utxos = [ int(f["amount_msat"]) for f in funds["outputs"] if f["status"] == "confirmed" ] reply["num_utxos"] = len(utxos) utxo_amount = Millisatoshi(sum(utxos)) reply["utxo_amount"] = utxo_amount.to_btc_str() avail_out = Millisatoshi(0) avail_in = Millisatoshi(0) chans = [] reply["num_channels"] = 0 reply["num_connected"] = 0 reply["num_gossipers"] = 0 for p in peers: pid = p["id"] channels = [] if "channels" in p: channels = p["channels"] elif "num_channels" in p and p["num_channels"] > 0: channels = plugin.rpc.listpeerchannels(pid)["channels"] addpeer(plugin, p) active_channel = False for c in channels: if c["state"] != "CHANNELD_NORMAL": continue active_channel = True if c["short_channel_id"] in exclude: continue if p["connected"]: reply["num_connected"] += 1 if c["our_reserve_msat"] < c["to_us_msat"]: to_us = c["to_us_msat"] - c["our_reserve_msat"] else: to_us = Millisatoshi(0) avail_out += to_us # We have to derive amount to them to_them = c["total_msat"] - c["to_us_msat"] if c["their_reserve_msat"] < to_them: to_them = to_them - c["their_reserve_msat"] else: to_them = Millisatoshi(0) avail_in += to_them reply["num_channels"] += 1 chans.append( Channel( c["total_msat"], to_us, to_them, pid, c["private"], p["connected"], c["short_channel_id"], plugin.persist["p"][pid]["a"], Millisatoshi(c["fee_base_msat"]), c["fee_proportional_millionths"], ) ) if not active_channel and p["connected"]: reply["num_gossipers"] += 1 reply["avail_out"] = avail_out.to_btc_str() reply["avail_in"] = avail_in.to_btc_str() reply["fees_collected"] = Millisatoshi(info["fees_collected_msat"]).to_btc_str() if plugin.fiat_per_btc > 0: reply["utxo_amount"] += " ({})".format(to_fiatstr(utxo_amount)) reply["avail_out"] += " ({})".format(to_fiatstr(avail_out)) reply["avail_in"] += " ({})".format(to_fiatstr(avail_in)) reply["fees_collected"] += " ({})".format( to_fiatstr(info["fees_collected_msat"]) ) if len(chans) > 0: if sortkey is None or sortkey.lower() not in Channel._fields: sortkey = plugin.sortkey chans = sorted(chans, key=attrgetter(sortkey.lower())) reply["channels_flags"] = "P:private O:offline" reply["channels"] = ["\n"] biggest = max(max(int(c.ours), int(c.theirs)) for c in chans) append_header(reply["channels"], biggest) for c in chans: # Create simple line graph, 47 chars wide. our_len = int(round(int(c.ours) / biggest * 23)) their_len = int(round(int(c.theirs) / biggest * 23)) # We put midpoint in the middle. draw = plugin.draw mid = draw.mid if our_len == 0: left = "{:>23}".format("") mid = draw.double_left else: left = "{:>23}".format(draw.left + draw.bar * (our_len - 1)) if their_len == 0: right = "{:23}".format("") # Both 0 is a special case. if our_len == 0: mid = draw.empty else: mid = draw.double_right else: right = "{:23}".format(draw.bar * (their_len - 1) + draw.right) s = left + mid + right # output short channel id, so things can be copyNpasted easily s += " {:14} ".format(c.scid) extra = "" if c.private: extra += "P" else: extra += "_" if not c.connected: extra += "O" else: extra += "_" s += "[{}] ".format(extra) # append fees s += " {:4}".format(c.base.millisatoshis) s += " {:5} ".format(c.ppm) # append 24hr availability s += "{:4.0%} ".format(c.avail) # append alias or id node = plugin.rpc.listnodes(c.pid)["nodes"] if len(node) != 0 and "alias" in node[0]: s += node[0]["alias"] else: s += c.pid[0:32] reply["channels"].append(s) # Make modern lightning-cli format this human-readble by default! reply["format-hint"] = "simple" return reply def new_datastore(): return {"p": {}, "r": 0, "v": 1} # see summary_avail.py for structure def check_datastore(obj): if "v" in obj and type(obj["v"]) is int and obj["v"] == 1: return True return False def load_datastore(plugin): entries = plugin.rpc.listdatastore(key=datastore_key)["datastore"] if len(entries) == 0: plugin.log(f"Creating a new datastore '{datastore_key}'", "debug") return new_datastore() persist = pickle.loads(bytearray.fromhex(entries[0]["hex"])) if not check_datastore(persist): plugin.log(f"Dismissing old datastore '{datastore_key}'", "debug") return new_datastore() plugin.log( f"Reopened datastore '{datastore_key}' with {persist['r']} " f"runs and {len(persist['p'])} entries", "debug", ) return persist def write_datastore(plugin): hexstr = pickle.dumps(plugin.persist).hex() plugin.rpc.datastore(key=datastore_key, hex=hexstr, mode="create-or-replace") @plugin.init() def init(options, configuration, plugin): plugin.sortkey = options["summary-sortkey"] if plugin.sortkey not in Channel._fields: plugin.sortkey = "scid" # default to 'scid' on unknown keys plugin.currency = options["summary-currency"] plugin.currency_prefix = options["summary-currency-prefix"] plugin.fiat_per_btc = 0 plugin.avail_interval = float(options["summary-availability-interval"]) plugin.avail_window = 60 * 60 * int(options["summary-availability-window"]) plugin.persist = load_datastore(plugin) plugin.draw = draw_ascii # __version__ was introduced in 0.0.7.1, with utf8 passthrough support. if hasattr(pyln.client, "__version__") and version.parse( pyln.client.__version__ ) >= version.parse("0.0.7.1"): plugin.draw = draw_boxch if options.get("summary-ascii"): plugin.draw = draw_ascii info = plugin.rpc.getinfo() config = plugin.rpc.listconfigs() if "always-use-proxy" in config and config["always-use-proxy"]: paddr = config["proxy"] # Default port in 9050 if ":" not in paddr: paddr += ":9050" proxies = {"https": "socks5h://" + paddr, "http": "socks5h://" + paddr} else: proxies = None # Measure availability PeerThread().start() # Try to grab conversion price PriceThread(proxies).start() # Prefer IPv4, otherwise take any to give out address. best_address = None for a in info["address"]: if best_address is None: best_address = a elif a["type"] == "ipv4" and best_address["type"] != "ipv4": best_address = a if best_address: plugin.my_address = info["id"] + "@" + best_address["address"] if best_address["port"] != 9735: plugin.my_address += ":" + str(best_address["port"]) else: plugin.my_address = None plugin.log("Plugin summary.py initialized") @plugin.subscribe("shutdown") def on_rpc_command_callback(plugin, **kwargs): # FIXME: Writing datastore does not work on exit, as daemon is already lost. # plugin.log("Writing out datastore before shutting down") # write_datastore(plugin) sys.exit() plugin.add_option( "summary-currency", "USD", "What currency should I look up on btcaverage?" ) plugin.add_option("summary-currency-prefix", "USD $", "What prefix to use for currency") plugin.add_option( "summary-availability-interval", 300, "How often in seconds the availability should be calculated.", ) plugin.add_option( "summary-availability-window", 72, "How many hours the availability should be averaged over.", "int", ) plugin.add_option( "summary-sortkey", "scid", 'Sort the channels list by a namedtuple key, defaults to "scid".', ) plugin.add_option( "summary-ascii", False, "If ascii mode should be enabled by default", "flag" ) plugin.run()