Subscribe to connect notifications with pyln-client

LNROOM #16May 16, 2023

In this episode we write a Python plugin with pyln-client package which subscribes to the notification topics connect and invoice_creation.

Transcript with corrections and improvements

In this episode we write a Python plugin with pyln-client package which subscribes to the notification topics connect and invoice_creation.

getmanifest request

connect notifications are JSON-RPC requests we receive (if we've subscribed to) from lightningd each time we connect to another peer. They have no id field and its params field contains the information about the node we just connected to. Here is an example:

{
  "jsonrpc": "2.0",
  "method": "connect",
  "params": {
    "id": "0271aecf3075ce72f2df479b60fc1db98d4d780c66fa495013614054554e1bb2bf",
    "direction": "out",
    "address": {
      "type": "ipv4",
      "address": "127.0.0.1",
      "port": 7272
    }
  }
}

invoice_creation notifications are JSON-RPC requests we receive (if we've subscribed to) from lightningd each time we create an invoice. They have no id field and the params field contains the invoice details. Here is an example:

{
  "jsonrpc": "2.0",
  "method": "invoice_creation",
  "params": {
    "invoice_creation": {
      "msat": "100000000msat",
      "preimage": "88042f007f02283571abbc40aca8b4302643415e85c71413177ef139b4276970",
      "label": "inv"
    }
  }
}

When the plugin is started, it receives a getmanifest request from lightningd like this one

{
  "jsonrpc": "2.0",
  "id": 187,
  "method": "getmanifest",
  "params": {
    "allow-deprecated-apis": false
  }
}

and if the plugin wants to subscribe to connect and invoice_creation notifications topics, it just have to reply to that request with a response that sets the subscriptions field of the result member to the array ["connect", "invoice_creation"] like this:

{
  "jsonrpc": "2.0",
  "id": 187,
  "result": {
    "dynamic": True,
    "options": [],
    "rpcmethods": [],
    "subscriptions": ["connect", "invoice_creation"]
  }
}

This is what we are going to do with pyln-client Python package.

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
...

Our setup for this episode is:

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

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] 1460972
[2] 1461006
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'

Subscribe to connect

In the file myplugin.py we have a working plugin written with pyln-client (thought it does nothing so far):

#!/usr/bin/env python

from pyln.client import Plugin
import json

plugin = Plugin()

plugin.run()

We use the line @plugin.subscribe("connect") to tell pyln-client that we want to subscribe to connect notification topic. And the function defined after that line will be used to handle connect notifications received from lightningd. In our case we write the params of the connect notification (which are the function arguments id, direction and address) that we receive in the file /tmp/plugin:

So our plugin is now:

#!/usr/bin/env python

from pyln.client import Plugin
import json

plugin = Plugin()

@plugin.subscribe("connect")
def connect_func(plugin,id, direction, address, **kwargs):
    params = {
        "id": id,
        "direction": direction,
        "address": address
    }
    with open("/tmp/myplugin", "a") as myplugin:
        myplugin.write("connect params: " + json.dumps(params) + "\n")

plugin.run()

Let's start our pyln-client plugin and connect the node l1 and l2:

(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l2-cli -F getinfo | rg 'id|binding'
id=039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0
binding[0].type=ipv4
binding[0].address=127.0.0.1
binding[0].port=7272
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli connect 039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0@127.0.0.1:7272
{
   "id": "039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0",
   "features": "08a000080269a2",
   "direction": "out",
   "address": {
      "type": "ipv4",
      "address": "127.0.0.1",
      "port": 7272
   }
}

We check that the file /tmp/plugin contains

connect params: {"id": "039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0", "direction": "out", "address": {"type": "ipv4", "address": "127.0.0.1", "port": 7272}}

indicating that we've successfully subscribed to connect notification topic.

Subscribe to invoice_creation

Let's modify our plugin to subscribe to invoice_creation notification topic:

#!/usr/bin/env python

from pyln.client import Plugin
import json

plugin = Plugin()

@plugin.subscribe("connect")
def connect_func(plugin,id, direction, address, **kwargs):
    params = {
        "id": id,
        "direction": direction,
        "address": address
    }
    with open("/tmp/myplugin", "a") as myplugin:
        myplugin.write("connect params: " + json.dumps(params) + "\n")

@plugin.subscribe("invoice_creation")
def invoice_creation_func(plugin, invoice_creation, **kwargs):
    params = {
        "invoice_creation": invoice_creation
    }
    with open("/tmp/myplugin", "a") as myplugin:
        myplugin.write("invoice_creation params: " + json.dumps(params) + "\n")

plugin.run()

Back to our terminal, we restart the plugin and create an invoice with the node l1:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/myplugin-pyln.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli invoice 0.001btc inv pizza
{
   "payment_hash": "fde8a7df1924d00e96312d765117088d97bd1886b310b14a93a342902f6a7482",
   "expires_at": 1684848791,
   "bolt11": "lnbcrt1m1pjx8pshsp5wx6c04s24ak9yj2f90ph4j7wlsu460x0xqt5e6d2sgknh6u23skqpp5lh520hceyngqa93394m9z9cg3ktm6xyxkvgtzj5n5dpfqtm2wjpqdqgwp5h57npxqyjw5qcqp29qyysgq3ewslg3tc38uexd0dy7mm5nqzhml85sap777scpynudjvxkamkv5srr343y8uvzsyjy4lc0ff3t3uxgxly0l4msyr9yk5kskuplqewspglvvs9",
   "payment_secret": "71b587d60aaf6c5249492bc37acbcefc395d3ccf30174ce9aa822d3beb8a8c2c",
   "warning_capacity": "Insufficient incoming channel capacity to pay invoice"
}

The file /tmp/plugin is now:

connect params: {"id": "039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0", "direction": "out", "address": {"type": "ipv4", "address": "127.0.0.1", "port": 7272}}
invoice_creation params: {"invoice_creation": {"msat": "100000000msat", "preimage": "4ec62babb7d48f494d9945cdfdbd942f387467da6b804d84c0428b49bbdf0844", "label": "inv"}}

We are done!

Terminal session

We ran the following commands in this order:

$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pyln-client
$ ./setup.sh
$ source lightning/contrib/startup_regtest.sh
$ start_ln
$ alias l1-cli
$ l1-cli plugin start $(pwd)/myplugin.py
$ l2-cli -F getinfo | rg 'id|binding'
$ l1-cli connect 039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0@127.0.0.1:7272
$ l1-cli plugin start $(pwd)/myplugin.py
$ l1-cli invoice 0.001btc inv pizza

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

◉ tony@tony:~/lnroom:
$ python -m venv .venv
◉ tony@tony:~/lnroom:
$ source .venv/bin/activate
(.venv) ◉ tony@tony:~/lnroom:
$ pip install pyln-client
Collecting pyln-client
  Using cached pyln_client-23.5-py3-none-any.whl (35 kB)
Collecting pyln-bolt7>=1.0
  Using cached pyln_bolt7-1.0.246-py3-none-any.whl (18 kB)
Collecting pyln-proto>=0.12
  Using cached pyln_proto-23.5-py3-none-any.whl
Collecting coincurve<18.0.0,>=17.0.0
  Using cached coincurve-17.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.3 MB)
Collecting cryptography<37.0.0,>=36.0.1
  Using cached cryptography-36.0.2-cp36-abi3-manylinux_2_24_x86_64.whl (3.6 MB)
Collecting PySocks<2.0.0,>=1.7.1
  Using cached PySocks-1.7.1-py3-none-any.whl (16 kB)
Collecting bitstring<4.0.0,>=3.1.9
  Using cached bitstring-3.1.9-py3-none-any.whl (38 kB)
Collecting base58<3.0.0,>=2.1.1
  Using cached base58-2.1.1-py3-none-any.whl (5.6 kB)
Collecting asn1crypto
  Using cached asn1crypto-1.5.1-py2.py3-none-any.whl (105 kB)
Collecting cffi>=1.3.0
  Using cached cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (441 kB)
Collecting pycparser
  Using cached pycparser-2.21-py2.py3-none-any.whl (118 kB)
Installing collected packages: bitstring, asn1crypto, PySocks, pyln-bolt7, pycparser, base58, cffi, cryptography, coincurve, pyln-proto, pyln-client
Successfully installed PySocks-1.7.1 asn1crypto-1.5.1 base58-2.1.1 bitstring-3.1.9 cffi-1.15.1 coincurve-17.0.0 cryptography-36.0.2 pycparser-2.21 pyln-bolt7-1.0.246 pyln-client-23.5 pyln-proto-23.5
(.venv) ◉ tony@tony:~/lnroom:
$ ./setup.sh
Ubuntu 22.04.2 LTS
Python 3.10.6
lightningd v23.02.2
pyln-client  23.5
(.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] 1460972
[2] 1461006
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": [
      {
         "name": "/usr/local/libexec/c-lightning/plugins/autoclean",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/chanbackup",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/bcli",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/commando",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/funder",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/topology",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/keysend",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/offers",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/pay",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/txprepare",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/spenderp",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/sql",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/bookkeeper",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/home/tony/lnroom/myplugin.py",
         "active": true,
         "dynamic": true
      }
   ]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l2-cli -F getinfo | rg 'id|binding'
id=039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0
binding[0].type=ipv4
binding[0].address=127.0.0.1
binding[0].port=7272
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli connect 039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0@127.0.0.1:7272
{
   "id": "039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0",
   "features": "08a000080269a2",
   "direction": "out",
   "address": {
      "type": "ipv4",
      "address": "127.0.0.1",
      "port": 7272
   }
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
   "command": "start",
   "plugins": [
      {
         "name": "/usr/local/libexec/c-lightning/plugins/autoclean",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/chanbackup",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/bcli",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/commando",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/funder",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/topology",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/keysend",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/offers",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/pay",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/txprepare",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/spenderp",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/sql",
         "active": true,
         "dynamic": true
      },
      {
         "name": "/usr/local/libexec/c-lightning/plugins/bookkeeper",
         "active": true,
         "dynamic": false
      },
      {
         "name": "/home/tony/lnroom/myplugin.py",
         "active": true,
         "dynamic": true
      }
   ]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli invoice 0.001btc inv pizza
{
   "payment_hash": "fde8a7df1924d00e96312d765117088d97bd1886b310b14a93a342902f6a7482",
   "expires_at": 1684848791,
   "bolt11": "lnbcrt1m1pjx8pshsp5wx6c04s24ak9yj2f90ph4j7wlsu460x0xqt5e6d2sgknh6u23skqpp5lh520hceyngqa93394m9z9cg3ktm6xyxkvgtzj5n5dpfqtm2wjpqdqgwp5h57npxqyjw5qcqp29qyysgq3ewslg3tc38uexd0dy7mm5nqzhml85sap777scpynudjvxkamkv5srr343y8uvzsyjy4lc0ff3t3uxgxly0l4msyr9yk5kskuplqewspglvvs9",
   "payment_secret": "71b587d60aaf6c5249492bc37acbcefc395d3ccf30174ce9aa822d3beb8a8c2c",
   "warning_capacity": "Insufficient incoming channel capacity to pay invoice"
}

Source code

myplugin.py

#!/usr/bin/env python

from pyln.client import Plugin
import json

plugin = Plugin()

@plugin.subscribe("connect")
def connect_func(plugin,id, direction, address, **kwargs):
    params = {
        "id": id,
        "direction": direction,
        "address": address
    }
    with open("/tmp/myplugin", "a") as myplugin:
        myplugin.write("connect params: " + json.dumps(params) + "\n")

@plugin.subscribe("invoice_creation")
def invoice_creation_func(plugin, invoice_creation, **kwargs):
    params = {
        "invoice_creation": invoice_creation
    }
    with open("/tmp/myplugin", "a") as myplugin:
        myplugin.write("invoice_creation params: " + json.dumps(params) + "\n")

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"

connect.json

{
  "jsonrpc": "2.0",
  "method": "connect",
  "params": {
    "id": "0271aecf3075ce72f2df479b60fc1db98d4d780c66fa495013614054554e1bb2bf",
    "direction": "out",
    "address": {
      "type": "ipv4",
      "address": "127.0.0.1",
      "port": 7272
    }
  }
}

invoice_creation.json

{
  "jsonrpc": "2.0",
  "method": "invoice_creation",
  "params": {
    "invoice_creation": {
      "msat": "100000000msat",
      "preimage": "88042f007f02283571abbc40aca8b4302643415e85c71413177ef139b4276970",
      "label": "inv"
    }
  }
}

myplugin

connect params: {"id": "039b780a84d36584ac0d7c84d10a962a1c5b35d2775650454c2a843368970932b0", "direction": "out", "address": {"type": "ipv4", "address": "127.0.0.1", "port": 7272}}
invoice_creation params: {"invoice_creation": {"msat": "100000000msat", "preimage": "4ec62babb7d48f494d9945cdfdbd942f387467da6b804d84c0428b49bbdf0844", "label": "inv"}}

Resources