Overview of pyln-client implementation - LightningRpc - Part 3

LNROOM #13May 02, 2023

In this episode, we look a pyln-client Python package implementation focusing specifically on LightningRpc class. This class implements a RPC client for the lightningd daemon. This RPC client connects to the lightningd daemon through a unix domain socket and passes calls through.

Transcript with corrections and improvements

myplugin.py

To understand how LightningRpc class is implemented let's consider myplugin.py plugin:

#!/usr/bin/env python

from pyln.client import Plugin

plugin = Plugin()

@plugin.method("node-id")
def node_id_func(plugin):
    node_id = plugin.rpc.getinfo()["id"]
    return {
        "node_id": node_id
    }

plugin.run()

This plugin registers the JSON-RPC method node-id to lightningd which returns the node id of the node running the plugin.

This is possible because of the rpc property of the instance plugin which is instantiated with LightningRpc class. This class implements many methods that let us send JSON-RPC requests to lightningd. For instance, calling getinfo method of plugin.rpc sends the JSON-RPC request getinfo to lightningd.

Install pyln-client

Let's install pyln-client in .venv Python virtual environment:

◉ tony@tony:~/lnroom:
$ python -m venv .venv
◉ tony@tony:~/lnroom:
$ source .venv/bin/activate
(.venv) ◉ tony@tony:~/lnroom:
$ pip install pyln-client
...

Setup

Here is my setup

(.venv) ◉ tony@tony:~/lnroom:
$ ./setup.sh
Ubuntu 22.04.2 LTS
Python 3.10.6
lightningd v23.02.2
pyln-client  23.2

Start 2 Lightning nodes running on regtest

Let's start two Lightning nodes running on the Bitcoin regtest chain by sourcing the script lightning/contrib/startup_regtest.sh provided in CLN repository and by running the command start_ln:

(.venv) ◉ tony@tony:~/lnroom:
$ source lightning/contrib/startup_regtest.sh
lightning-cli is /usr/local/bin/lightning-cli
lightningd is /usr/local/bin/lightningd
Useful commands:
  start_ln 3: start three nodes, l1, l2, l3
  connect 1 2: connect l1 and l2
  fund_nodes: connect all nodes with channels, in a row
  stop_ln: shutdown
  destroy_ln: remove ln directories
(.venv) ◉ tony@tony:~/lnroom:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 580181
[2] 580216
WARNING: eatmydata not found: instal it for faster testing
Commands:
        l1-cli, l1-log,
        l2-cli, l2-log,
        bt-cli, stop_ln, fund_nodes

We can check that l1-cli is just an alias for lightning-cli with the base directory being /tmp/l1-regtest:

(.venv) ◉ tony@tony:~/lnroom:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'

Start myplugin.py

Let's start our plugin and try it out:

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "node_id": "0218e76c49ccfa224b3932eb493ac25f367583e4ff6472430ab5429460b0ddc17e"
}

Plugin._init and LightningRpc

In myplugin.py, when plugin.rpc is instantiated?

This happens when we run the plugin using plugin.run() expression. This expression starts an IO loop with lightningd when we start the plugin. First we receive a getmanifest request to which we reply to. After, when lightningd is ready to communicate with us (the plugin), it sends us the init request that contains informations about the node running the plugin, like lightning-dir directory and the rpc-file filename which we can use to communicate with lightningd via Unix sockets.

When the plugin receives the init request, Plugin._init is the method used to reply to that request. In that method, the properties rpc_filename and lightning_dir are set and used to construct the socket path path. And the rpc property is set as an instance of the class LightningRpc like this:

class Plugin(object):
    ...
    def _init(self, options: Dict[str, JSONType],
              configuration: Dict[str, JSONType],
              request: Request) -> JSONType:
        ...
        self.rpc_filename = verify_str(configuration, 'rpc-file')
        self.lightning_dir = verify_str(configuration, 'lightning-dir')

        path = os.path.join(self.lightning_dir, self.rpc_filename)
        self.rpc = LightningRpc(path)
        ...

Let's modify Plugin._init (in the file plugin.py in .venv virtual environment) such that it writes the socket path path to /tmp/myplugin_out file:

class Plugin(object):
    ...
    def _init(self, options: Dict[str, JSONType],
              configuration: Dict[str, JSONType],
              request: Request) -> JSONType:
        ...
        self.rpc_filename = verify_str(configuration, 'rpc-file')
        self.lightning_dir = verify_str(configuration, 'lightning-dir')

        path = os.path.join(self.lightning_dir, self.rpc_filename)
        with open("/tmp/myplugin_out", "a") as output:
            output.write(path)
        self.rpc = LightningRpc(path)
        ...

Back to our terminal we restart our plugin in order to take into account the changes we made in pyln-client package:

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin stop $(pwd)/myplugin.py
{
   "command": "stop",
   "result": "Successfully stopped myplugin.py."
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}

As the lightningd sent the init request to our plugin when we started it, the socket path to connect to our node has been written in /tmp/myplugin_out file:

/tmp/l1-regtest/regtest/lightning-rpc

myplugin.py and Unix sockets

The best way I found to try to understand how LightningRpc is implemented and how to share it with you is to implement it (a light version and only for the getinfo request) and to compare our implementation with pyln-client implementation.

Let's modify myplugin.py such that we get the same result but without using LightningRpc method.

First, let's see if we can get the socket path. To do that we modify myplugin.py to be:

#!/usr/bin/env python

from pyln.client import Plugin
import os
plugin = Plugin()

@plugin.method("node-id")
def node_id_func(plugin):
    path = os.path.join(plugin.lightning_dir, plugin.rpc_filename)
    return {
        "path": path
    }

plugin.run()

We restart our plugin

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin stop $(pwd)/myplugin.py
...
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
...

and call node-id method which returns us the socket path to talk to our node:

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "path": "/tmp/l1-regtest/regtest/lightning-rpc"
}

Now let's use the socket library to connect via Unix socket to lightningd. We use that socket connection to send the following getinfo request serialized

getinfo = {
    "jsonrpc": "2.0",
    "method": "getinfo",
    "params": [],
    "id": "1"
}

and receive it in the variable resp that we finally returns.

The plugin myplugin.py is now:

#!/usr/bin/env python

from pyln.client import Plugin
import os, socket, json
plugin = Plugin()

@plugin.method("node-id")
def node_id_func(plugin):
    path = os.path.join(plugin.lightning_dir, plugin.rpc_filename)
    getinfo = {
        "jsonrpc": "2.0",
        "method": "getinfo",
        "params": [],
        "id": "1"
    }
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(path)
        s.sendall(bytearray(json.dumps(getinfo), "utf-8"))
        resp = s.recv(4096)
    return {
        "resp": repr(resp)
    }

plugin.run()

We restart our plugin

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin stop $(pwd)/myplugin.py
...
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
...

and call node-id method which returns us the getinfo response:

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "resp": "b'{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"id\":\"0218e76c49ccfa224b3932eb493ac25f367583e4ff6472430ab5429460b0ddc17e\",\"alias\":\"ANGRYBAGEL\",\"color\":\"0218e7\",\"num_peers\":0,\"num_pending_channels\":0,\"num_active_channels\":0,\"num_inactive_channels\":0,\"address\":[],\"binding\":[{\"type\":\"ipv4\",\"address\":\"127.0.0.1\",\"port\":7171}],\"version\":\"v23.02.2\",\"blockheight\":1,\"network\":\"regtest\",\"fees_collected_msat\":0,\"lightning-dir\":\"/tmp/l1-regtest/regtest\",\"our_features\":{\"init\":\"08a000080269a2\",\"node\":\"88a000080269a2\",\"channel\":\"\",\"invoice\":\"02000000024100\"}}}\\n\\n'"
}

Finally, we modify slightly myplugin.py implementation to get the node id out of the getinfo response and we returns it:

#!/usr/bin/env python

from pyln.client import Plugin
import os, socket, json
plugin = Plugin()

@plugin.method("node-id")
def node_id_func(plugin):
    path = os.path.join(plugin.lightning_dir, plugin.rpc_filename)
    getinfo = {
        "jsonrpc": "2.0",
        "method": "getinfo",
        "params": [],
        "id": "1"
    }
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(path)
        s.sendall(bytearray(json.dumps(getinfo), "utf-8"))
        resp = s.recv(4096)
    node_id = json.loads(resp)["result"]["id"]
    return {
        "node_id": node_id
    }

plugin.run()

We restart our plugin and call node-id method which returns the node id:

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "node_id": "0218e76c49ccfa224b3932eb493ac25f367583e4ff6472430ab5429460b0ddc17e"
}

Now our implementation of myplugin.py returns the same result as the first implementation which uses LightningRpc class and the whole implementation of pyln-client.

This implementation can be compared with the elements in the next section (This part of the video is quite tricky to get right when transcripted, so I don't do it).

pyln-client source code

During the demo, we've looked at Plugin._init method of pyln-client package:

class Plugin(object):
    ...
    def _init(self, options: Dict[str, JSONType],
              configuration: Dict[str, JSONType],
              request: Request) -> JSONType:

        def verify_str(d: Dict[str, JSONType], key: str) -> str:
            v = d.get(key)
            if not isinstance(v, str):
                raise ValueError("Wrong argument to init: expected {key} to be"
                                 " a string, got {v}".format(key=key, v=v))
            return v

        def verify_bool(d: Dict[str, JSONType], key: str) -> bool:
            v = d.get(key)
            if not isinstance(v, bool):
                raise ValueError("Wrong argument to init: expected {key} to be"
                                 " a bool, got {v}".format(key=key, v=v))
            return v

        self.rpc_filename = verify_str(configuration, 'rpc-file')
        self.lightning_dir = verify_str(configuration, 'lightning-dir')

        path = os.path.join(self.lightning_dir, self.rpc_filename)
        self.rpc = LightningRpc(path)
        self.startup = verify_bool(configuration, 'startup')
        for name, value in options.items():
            self.options[name]['value'] = value

        # Dispatch the plugin's init handler if any
        if self.child_init:
            return self._exec_func(self.child_init, request)
        return None

We also looked at LightningRpc.connect and LightningRpc.getinfo methods of pyln-client package:

class LightningRpc(UnixDomainSocketRpc):
    ...
    def connect(self, peer_id, host=None, port=None):
        """
        Connect to {peer_id} at {host} and {port}.
        """
        payload = {
            "id": peer_id,
            "host": host,
            "port": port
        }
        return self.call("connect", payload)
    ...
    def getinfo(self):
        """
        Show information about this node.
        """
        return self.call("getinfo")

We also looked at UnixDomainSocketRpc._writeobj, UnixDomainSocketRpc._readobj and UnixDomainSocketRpc.call methods of pyln-client package:

class UnixDomainSocketRpc(object):
    ...
    def _writeobj(self, sock, obj):
        s = json.dumps(obj, ensure_ascii=False, cls=self.encoder_cls)
        sock.sendall(bytearray(s, 'UTF-8'))

    def _readobj(self, sock, buff=b''):
        """Read a JSON object, starting with buff; returns object and any buffer left over."""
        while True:
            parts = buff.split(b'\n\n', 1)
            if len(parts) == 1:
                # Didn't read enough.
                b = sock.recv(max(1024, len(buff)))
                buff += b
                if len(b) == 0:
                    return {'error': 'Connection to RPC server lost.'}, buff
            else:
                buff = parts[1]
                obj, _ = self.decoder.raw_decode(parts[0].decode("UTF-8"))
                return obj, buff

    ...
    def call(self, method, payload=None, cmdprefix=None, filter=None):
        """Generic call API: you can set cmdprefix here, or set self.cmdprefix
        before the call is made.

        """
        self.logger.debug("Calling %s with payload %r", method, payload)

        if payload is None:
            payload = {}
        # Filter out arguments that are None
        if isinstance(payload, dict):
            payload = {k: v for k, v in payload.items() if v is not None}

        this_id = self.get_json_id(method, cmdprefix)
        self.next_id += 1

        # FIXME: we open a new socket for every readobj call...
        sock = UnixSocket(self.socket_path)

        buf = b''

        if self._notify is not None:
            # Opt into the notifications support
            self._writeobj(sock, {
                "jsonrpc": "2.0",
                "method": "notifications",
                "id": this_id + "+notify-enable",
                "params": {
                    "enable": True
                },
            })
            # FIXME: Notification schema support?
            _, buf = self._readobj(sock, buf)

        request = {
            "jsonrpc": "2.0",
            "method": method,
            "params": payload,
            "id": this_id,
        }

        if filter is None:
            filter = self._filter
        if filter is not None:
            request["filter"] = filter

        self._writeobj(sock, request)
        while True:
            resp, buf = self._readobj(sock, buf)
            id = resp.get("id", None)
            meth = resp.get("method", None)

            if meth == 'message' and self._notify is not None:
                n = resp['params']
                self._notify(
                    message=n.get('message', None),
                    progress=n.get('progress', None),
                    request=request
                )
                continue

            if meth is None or id is None:
                break

        self.logger.debug("Received response for %s call: %r", method, resp)
        if 'id' in resp and resp['id'] != this_id:
            raise ValueError("Malformed response, id is not {}: {}.".format(this_id, resp))
        sock.close()

        if not isinstance(resp, dict):
            raise ValueError("Malformed response, response is not a dictionary %s." % resp)
        elif "error" in resp:
            raise RpcError(method, payload, resp['error'])
        elif "result" not in resp:
            raise ValueError("Malformed response, \"result\" missing.")
        return resp["result"]

We also looked at the class UnixSocket of pyln-client package:

class UnixSocket(object):
    """A wrapper for socket.socket that is specialized to unix sockets.

    Some OS implementations impose restrictions on the Unix sockets.

     - On linux OSs the socket path must be shorter than the in-kernel buffer
       size (somewhere around 100 bytes), thus long paths may end up failing
       the `socket.connect` call.

    This is a small wrapper that tries to work around these limitations.

    """

    def __init__(self, path: str):
        self.path = path
        self.sock: Optional[socket.SocketType] = None
        self.connect()

    def connect(self) -> None:
        try:
            self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            self.sock.connect(str(self.path))
        except OSError as e:
            self.close()

            if (e.args[0] == "AF_UNIX path too long" and os.uname()[0] == "Linux"):
                # If this is a Linux system we may be able to work around this
                # issue by opening our directory and using `/proc/self/fd/` to
                # get a short alias for the socket file.
                #
                # This was heavily inspired by the Open vSwitch code see here:
                # https://github.com/openvswitch/ovs/blob/master/python/ovs/socket_util.py

                dirname = os.path.dirname(self.path)
                basename = os.path.basename(self.path)

                # Open an fd to our home directory, that we can then find
                # through `/proc/self/fd` and access the contents.
                dirfd = os.open(dirname, os.O_DIRECTORY | os.O_RDONLY)
                short_path = "/proc/self/fd/%d/%s" % (dirfd, basename)
                self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                self.sock.connect(short_path)
            else:
                # There is no good way to recover from this.
                raise

    def close(self) -> None:
        if self.sock is not None:
            self.sock.close()
        self.sock = None

    def sendall(self, b: bytes) -> None:
        if self.sock is None:
            raise socket.error("not connected")

        self.sock.sendall(b)

    def recv(self, length: int) -> bytes:
        if self.sock is None:
            raise socket.error("not connected")

        return self.sock.recv(length)

    def __del__(self) -> None:
        self.close()

Terminal session

We ran the following commands in this order:

$ source .venv/bin/activate
$ ./setup.sh
$ source lightning/contrib/startup_regtest.sh
$ start_ln
$ alias l1-cli
$ l1-cli plugin start $(pwd)/myplugin.py
$ l1-cli node-id
$ l1-cli plugin stop $(pwd)/myplugin.py
$ l1-cli plugin start $(pwd)/myplugin.py
$ l1-cli plugin stop $(pwd)/myplugin.py
$ l1-cli plugin start $(pwd)/myplugin.py
$ l1-cli node-id
$ l1-cli plugin stop $(pwd)/myplugin.py
$ l1-cli plugin start $(pwd)/myplugin.py
$ l1-cli node-id
$ l1-cli plugin stop $(pwd)/myplugin.py
$ l1-cli plugin start $(pwd)/myplugin.py
$ l1-cli node-id

And below you can read the terminal session (command lines and outputs):

◉ tony@tony:~/lnroom:
$ source .venv/bin/activate
(.venv) ◉ tony@tony:~/lnroom:
$ ./setup.sh
Ubuntu 22.04.2 LTS
Python 3.10.6
lightningd v23.02.2
pyln-client  23.2
(.venv) ◉ tony@tony:~/lnroom:
$ source lightning/contrib/startup_regtest.sh
lightning-cli is /usr/local/bin/lightning-cli
lightningd is /usr/local/bin/lightningd
Useful commands:
  start_ln 3: start three nodes, l1, l2, l3
  connect 1 2: connect l1 and l2
  fund_nodes: connect all nodes with channels, in a row
  stop_ln: shutdown
  destroy_ln: remove ln directories
(.venv) ◉ tony@tony:~/lnroom:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 580181
[2] 580216
WARNING: eatmydata not found: instal it for faster testing
Commands:
        l1-cli, l1-log,
        l2-cli, l2-log,
        bt-cli, stop_ln, fund_nodes
(.venv) ◉ tony@tony:~/lnroom:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "node_id": "0218e76c49ccfa224b3932eb493ac25f367583e4ff6472430ab5429460b0ddc17e"
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin stop $(pwd)/myplugin.py
{
   "command": "stop",
   "result": "Successfully stopped myplugin.py."
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin stop $(pwd)/myplugin.py
{
   "command": "stop",
   "result": "Successfully stopped myplugin.py."
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "path": "/tmp/l1-regtest/regtest/lightning-rpc"
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin stop $(pwd)/myplugin.py
{
   "command": "stop",
   "result": "Successfully stopped myplugin.py."
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "resp": "b'{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"id\":\"0218e76c49ccfa224b3932eb493ac25f367583e4ff6472430ab5429460b0ddc17e\",\"alias\":\"ANGRYBAGEL\",\"color\":\"0218e7\",\"num_peers\":0,\"num_pending_channels\":0,\"num_active_channels\":0,\"num_inactive_channels\":0,\"address\":[],\"binding\":[{\"type\":\"ipv4\",\"address\":\"127.0.0.1\",\"port\":7171}],\"version\":\"v23.02.2\",\"blockheight\":1,\"network\":\"regtest\",\"fees_collected_msat\":0,\"lightning-dir\":\"/tmp/l1-regtest/regtest\",\"our_features\":{\"init\":\"08a000080269a2\",\"node\":\"88a000080269a2\",\"channel\":\"\",\"invoice\":\"02000000024100\"}}}\\n\\n'"
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin stop $(pwd)/myplugin.py
{
   "command": "stop",
   "result": "Successfully stopped myplugin.py."
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli node-id
{
   "node_id": "0218e76c49ccfa224b3932eb493ac25f367583e4ff6472430ab5429460b0ddc17e"
}

Source code

myplugin.py

#!/usr/bin/env python

from pyln.client import Plugin
import os, socket, json
plugin = Plugin()

@plugin.method("node-id")
def node_id_func(plugin):
    # node_id = plugin.rpc.getinfo()["id"]
    path = os.path.join(plugin.lightning_dir, plugin.rpc_filename)
    getinfo = {
        "jsonrpc": "2.0",
        "method": "getinfo",
        "params": [],
        "id": "1"
    }
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(path)
        s.sendall(bytearray(json.dumps(getinfo), "utf-8"))
        resp = s.recv(4096)
    node_id = json.loads(resp)["result"]["id"]
    return {
        "node_id": node_id
    }

plugin.run()

setup.sh

#!/usr/bin/env bash

ubuntu=$(lsb_release -ds)
lightningd=$(lightningd --version | xargs printf "lightningd %s\n")
python=$(python --version)
pyln_client=$(pip list | rg pyln-client)

printf "%s\n%s\n%s\n%s\n" "$ubuntu" "$python" "$lightningd" "$pyln_client"

Resources