Overview of pyln-client implementation - LightningRpc - Part 3
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"