Core lightning rpc_command hook, pay command and BOLT11 invoice

LIVE #6June 08, 2023

In this live we write a plugin that limits the amount a node can send (using the builtin pay command) to a BOLT11 invoice. This is possible thanks to Core Lightning hook system and specifically the hook rpc_command.

Transcript with corrections and improvements

In this live we write a plugin that limits the amount a node can send (using the builtin pay command) to a BOLT11 invoice.

Specifically, we write the plugin pay-up-to.py such that when we start it with the startup option limit set to 0.001btc for instance, we can't pay invoices higher than that threshold. Here an example where l1-cli and l2-cli are aliases for lightning-cli command with --lightning-dir set respectively to /tmp/l1-regtest and /tmp/l2-regtest:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/pay-up-to.py limit=0.001btc
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.002btc inv "too expensive pizza"
lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh
{
   "invoice_too_high": "0.002btc",
   "maximum_is": "0.001btc",
   "bolt11": "lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh"
}

rpc_command hook

The hook system allows plugins to register to some events that can happened in lightningd and ask lightningd to be consulted to decide what to do next.

A list of those events can be found in the documentation.

Regarding the rpc_command hook, when a plugin registers to it, each time a client send a JSON-RPC request to lightningd, lightningd forwards it to the plugin and waits for the plugin to tell it what to do next. The plugin can answer to lightningd in 4 ways:

  1. I don't care about that request do what you were supposed to do,

  2. I modified the request, now do what you were supposed to do but with the modified request,

  3. Take that response and give it to the client,

  4. Take that error and give it to the client.

This can be visualize like this:

                                           (if registered to
                                            rcp_command hook)
                sends a                       forwards the
          JSON-RPC request                  JSON-RPC request
┌───────┐------------------>┌──────────┐------------------------>┌───────────┐
│client │                   │lightningd│                         │a-plugin.py│
└───────┘<------------------└──────────┘<------------------------└───────────┘
                                          - result.result         ("continue")
                                          - result.replace        (...)
                                          - result.return.result  (...)
                                          - result.return.error   (...)

Let's write some code.

Install pyln-client and start 2 Lightning nodes running on regtest

Let's install pyln-client in a Python virtual environment

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

and start two Lightning nodes running on the Bitcoin regtest chain and check the alias of the command l1-cli:

(.venv) ◉ tony@tony:~/clnlive:
$ 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:~/clnlive:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 1163253
[2] 1163288
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:~/clnlive:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'

Register to rpc_command hook

We use the Python decorators @plugin.hook("rpc_command") to register to rpc_command hook. The function bellow that decorators is used to build the payload of the JSON-RPC response we send back to lightningd each time we receive a rpc_command request. By returning the dictionary {"result": "continue"}, the plugin tells lightningd "I don't care about that request do what you were supposed to do":

#!/usr/bin/env python

from pyln.client import Plugin
import json, re, time

plugin = Plugin()

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    return {"result": "continue"}

plugin.run()

We jump back in our terminal and start our pay-up-to.py plugin like this:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.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/clnlive/pay-up-to.py",
         "active": true,
         "dynamic": true
      }
   ]
}

We use ps to check that our plugin is running:

(.venv) ◉ tony@tony:~/clnlive:
$ ps -ax | rg pay-up
1163640 pts/0    S      0:00 python /home/tony/clnlive/pay-up-to.py
1163658 pts/0    S+     0:00 rg pay-up

And finally, we call the command getinfo and check that everything is working correctly:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03776915f5da2d5801e08639f9da607d51926ae705da1348c06c6ba2a3bfb159bc",
   "alias": "BIZARRESPAWN",
   "color": "037769",
   "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"
   }
}

While this is working we can't really see that the getinfo request has been forwarded to pay-up-to.py plugin.

Make the commands hang 2 seconds

Let's make the commands hang 2 seconds each time they are used by a client. This will make our example more tangible:

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    time.sleep(2)
    return {"result": "continue"}

Let's restart our plugin:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}

Now when we call any JSON-RPC command we can see that they hang during 2 seconds before returning the expected response. This is the case for instance for l1-cli getinfo and l1-cli listpeers.

Take over l1 node

So far the plugin let lightningd take care of the answer even when we made it slow.

Now, we modify rpc_command_func like this

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    return {"return": {"result": {"BOOM": "I took over your node"}}}

so that each time a client sends a JSON-RPC request it will get the following response:

{
   "BOOM": "I took over your node"
}

Let's restart our plugin

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}

and send the requests getinfo, listpeers and stop:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "BOOM": "I took over your node"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "BOOM": "I took over your node"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli stop
{
   "BOOM": "I took over your node"
}

The hook rpc_command hook is so powerful, that we've just made our node useless. We can't even stop it with the stop command.

Let's stop the node l1 by killing its associated process and restart it with lightningd command:

(.venv) ◉ tony@tony:~/clnlive:
$ ps -ax | rg lightningd
1163255 pts/0    S      0:00 lightningd --lightning-dir=/tmp/l1-regtest
1163291 pts/0    S      0:00 lightningd --lightning-dir=/tmp/l2-regtest
1163998 pts/0    S+     0:00 rg lightningd
(.venv) ◉ tony@tony:~/clnlive:
$ kill -9 1163255
lightning/contrib/startup_regtest.sh: line 85: 1163255 Killed                  $EATMYDATA "$LIGHTNINGD" "--lightning-dir=/tmp/l$i-$network"
[1]-  Exit 137                test -f "/tmp/l$i-$network/lightningd-$network.pid" || $EATMYDATA "$LIGHTNINGD" "--lightning-dir=/tmp/l$i-$network"
(.venv) ◉ tony@tony:~/clnlive:
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon

We can check using ps that both nodes l1 and l2 are running again:

(.venv) ◉ tony@tony:~/clnlive:
$ ps -ax | rg lightningd
1163291 pts/0    S      0:00 lightningd --lightning-dir=/tmp/l2-regtest
1164016 ?        Ss     0:00 lightningd --lightning-dir=/tmp/l1-regtest --daemon
1164074 pts/0    S+     0:00 rg lightningd

Take over getinfo

Now we'll see how to just take over the command getinfo and let lightningd taking care of the other JSON-RPC commands.

This is possible by filtering on the method field of rpc_command argument in the function rpc_command_func.

How can we know that rpc_command has a method field?

Let's find out.

We modify rpc_command_func in order to print rpc_command value representation in the file /tmp/pay-up-to and we'll see the presence of that field:

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    with open("/tmp/pay-up-to", "a") as f:
        f.write(repr(rpc_command) + "\n\n")
    return {"result": "continue"}

Let's restart our plugin

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}

and call the commands getinfo and listpeers like this:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03776915f5da2d5801e08639f9da607d51926ae705da1348c06c6ba2a3bfb159bc",
   ...
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "peers": []
}

Now we can check that the request getinfo and listpeers have been written into the file /tmp/pay-up-to:

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1164167', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'getinfo', 'id': 'cli:getinfo#1164167', 'params': []}

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1164226', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'listpeers', 'id': 'cli:listpeers#1164226', 'params': []}

Note: I don't know why we also have notifications requests written in that file.

Now we take over the getinfo command only:

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    request = rpc_command
    if request["method"] == "getinfo":
        return {"return": {"result": {"BOOM": "I took 'getinfo'"}}}
    return {"result": "continue"}

Let's check in our terminal that we have implemented the expected behavior. We restart our plugin

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}

and call getinfo and listpeers commands:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "BOOM": "I took over 'getinfo'"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "peers": []
}

Chat

Can plugins be written in any language?

Have I understood correctly? The plugin is controling the result of getinfo but nothing else in the node.

Hooks and getmanifest

Before we continue writing our pay-up-to.py plugin, let's take a look at the getmanifest request/response between lightningd and the plugin when we start it.

When we start pay-up-to.py plugin, we receive (the plugin) a getmanifest request like this one:

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

In order to register to rpc_command hook we add {"name": "rpc_command"} to the array hooks in the field result of the response to the getmanifest. So, our response to the getmanifest request looks like this:

{
  "jsonrpc": "2.0",
  "id": 182,
  "result": {
    "dynamic": True,
    "options": [],
    "rpcmethods": [],
    "hooks": [{ "name": "rpc_command" }]
  }
}

In the case of the plugin pay-up-to.py, as we are using pyln-client package, pyln-client does it for us.

Open a channel between the node l1 and l2

As we are going to "modify" the command pay and try it to pay bolt11 invoices, we need a channel open between the node l1 and l2. We do this using the commands connect and fund_nodes provided by the script contrib/startup_regtest.sh from the lightning repository:

(.venv) ◉ tony@tony:~/clnlive:
$ connect 1 2
{
   "id": "03f8002413ce9a9aa7e75eb4eae40886ba27acc9448dfb8662cd1e92736f184f28",
   "features": "08a000080269a2",
   "direction": "out",
   "address": {
      "type": "ipv4",
      "address": "127.0.0.1",
      "port": 7272
   }
}
(.venv) ◉ tony@tony:~/clnlive:
$ fund_nodes
Mining into address bcrt1qlz7phgee89vg23fs54an94phxd9au5az9vn6rn... done.
bitcoind balance: 50.00000000
Waiting for lightning node funds... found.
Funding channel from node 1 to node 2. Waiting for confirmation... done.

Params type - array or object

We've seen before that the rpc_command argument in the function rpc_command_func corresponds to the JSON-RPC request that lightningd forwards to us. The params field of that request contained the parameters of the command and can be an ordered array or a key/value pairs object.

Let's observe that in the case of the pay command.

To do so we modify pay-up-to.py like this

#!/usr/bin/env python

from pyln.client import Plugin
import json, re, time

plugin = Plugin()

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    with open("/tmp/pay-up-to", "a") as f:
        f.write(repr(rpc_command) + "\n\n")
    return {"result": "continue"}

plugin.run()

such that every JSON-RPC requests sent to lightningd will be written in the file /tmp/pay-up-to.

Back to our terminal, we restart our plugin:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}

Let's call the pay command with ordered arguments (fake arguments):

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11inv
{
   "code": -32602,
   "message": "Invalid bolt11: Bad bech32 string"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11inv amountofinv
{
   "code": -32602,
   "message": "amount_msat|msatoshi: should be a millisatoshi amount: invalid token '\"amountofinv\"'"
}

We don't care about the errors, what we want to is the type of params field in the pay request. We can check, by looking at the file /tmp/pay-up-to, that in the case of ordered arguments, params is an array:

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1165357', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'pay', 'id': 'cli:pay#1165357', 'params': ['bolt11inv']}

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1165409', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'pay', 'id': 'cli:pay#1165409', 'params': ['bolt11inv', 'amountofinv']}

Now if we use -k flag and pass the argument by key/value pairs like this

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli -k pay bolt11=bolt11inv
{
   "code": -32602,
   "message": "Invalid bolt11: Bad bech32 string"
}

we see that params field in the JSON-RPC request is now a key/value pairs object:

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1165478', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'pay', 'id': 'cli:pay#1165478', 'params': {'bolt11': 'bolt11inv'}}

In the plugin pay-up-to.py, we'll treat only the case where params is an array with only one element: the bolt11 invoice.

Take over pay

Let's keep implementing our plugin.

As we want to modify the pay command, let's start by writing the logic that make the plugin takes over the pay command:

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    request = rpc_command
    if request["method"] == "pay":
        return {"return": {"result": {"BOOM": "I took 'pay'"}}}
    return {"result": "continue"}

We restart our plugin and check that those changes are effective:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03776915f5da2d5801e08639f9da607d51926ae705da1348c06c6ba2a3bfb159bc",
   ...
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11
{
   "BOOM": "I took over 'pay'"
}

bolt11 argument in pay command

In the plugin pay-up-to.py, we treat only the case where params is an array with only one element: the bolt11 invoice.

That means we don't treat the case where the bolt11 invoice has no amount specified. If we treat that case we would have to check for a second argument amount passed to the pay command.

Let's check if we can retrieve the bolt11 invoice:

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    request = rpc_command
    if request["method"] == "pay":
        bolt11 = request["params"][0]
        return {"return": {"result": {"BOOM": bolt11}}}
    return {"result": "continue"}

We restart our plugin and check that those changes are effective:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11
{
   "BOOM": "bolt11"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay boltfoo
{
   "BOOM": "boltfoo"
}

Human readable part of bolt11

I reproduce here part of bolt11 specification (Human-Readable Part, Creative Commons Attribution 4.0 International License)

The format for a Lightning invoice uses bech32 encoding.

[...]

The human-readable part of a Lightning invoice consists of two sections:

  1. prefix: ln + BIP-0173 currency prefix (e.g. lnbc for Bitcoin mainnet, lntb for Bitcoin testnet, lntbs for Bitcoin signet, and lnbcrt for Bitcoin regtest)

  2. amount: optional number in that currency, followed by an optional multiplier letter. The unit encoded here is the 'social' convention of a payment unit -- in the case of Bitcoin the unit is 'bitcoin' NOT satoshis.

The following multiplier letters are defined:

  • m (milli): multiply by 0.001

  • u (micro): multiply by 0.000001

  • n (nano): multiply by 0.000000001

  • p (pico): multiply by 0.000000000001

Examples

  • Please make a donation of any amount using rhash 0001020304050607080900010203040506070809000102030405060708090102 to me @03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad

    lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq8rkx3yf5tcsyz3d73gafnh3cax9rn449d9p5uxz9ezhhypd0elx87sjle52x86fux2ypatgddc6k63n7erqz25le42c4u4ecky03ylcqca784w
  • Please send $3 for a cup of nonsense (ナンセンス 1杯) to the same peer, within 1 minute

    lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny
  • Now send $24 for an entire list of things (hashed)

    lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqscc6gd6ql3jrc5yzme8v4ntcewwz5cnw92tz0pc8qcuufvq7khhr8wpald05e92xw006sq94mg8v2ndf4sefvf9sygkshp5zfem29trqq2yxxz7

Limit pay command to 0.001btc

Now we use the function amount that takes a bolt11 invoice with a mandatory amount and a mandatory multiplier (This is mandatory in our function but optional in bolt11 specification) to limit the amount we can pay to a bolt11 invoice.

We "hard code" the limit to 0.001btc and our plugin is now:

#!/usr/bin/env python

from pyln.client import Plugin
import json, re, time

plugin = Plugin()

def amount(bolt11):
    multiplier = {
        "m": 0.001,
        "u": 0.000001,
        "n": 0.000000001,
        "p": 0.000000000001
    }
    match = re.match(r"ln(?:bcrt|bc|tbs|tb)([0-9]+)(.)", bolt11)
    amount = match[1]
    mltp = match[2]
    return float(amount) * multiplier[mltp]

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    request = rpc_command
    if request["method"] == "pay":
        bolt11 = request["params"][0]
        if amount(bolt11) > 0.001:
            return {"return":
                    {"result":
                     {
                         "invoice_too_high": "TODO",
                         "maximum_is": "0.001btc",
                         "bolt11": "lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh"
                     }}}
    return {"result": "continue"}

plugin.run()

Note that the fields in the dictionary returned are all hard coded. We'll change this in a moment.

Back to our terminal, we restart pay-up-to.py plugin:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}

The node l2 creates a bolt11 invoice with an amount below the threshold 0.001btc

(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.0001btc inv-1 "foo"
{
   "payment_hash": "540063fe219eff8419a957a5db7af210b2cb170927b8dacbbff79731e5aa2236",
   "expires_at": 1686840846,
   "bolt11": "lnbcrt100u1pjgr6uwsp5ngfcwwgqnq4r4qqp8ny528fre7fp7c7xprhmrweplxqz79hz4wyspp52sqx8l3pnmlcgxdf27jak7hjzzevk9cfy7ud4jal77tnred2ygmqdq9vehk7xqyjw5qcqp29qyysgqjc8cel3q7ktv9kghjykkflcnyfq6mz94eeeycjaptd05hlsq37dznak8yegs624ppvk4ehhlj6lvvtrm24lpatc64u3zte0wgd57geqptq52sq",
   "payment_secret": "9a13873900982a3a80013cc9451d23cf921f63c608efb1bb21f9802f16e2ab89",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}

and so the node l1 can pay that invoice:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt100u1pjgr6uwsp5ngfcwwgqnq4r4qqp8ny528fre7fp7c7xprhmrweplxqz79hz4wyspp52sqx8l3pnmlcgxdf27jak7hjzzevk9cfy7ud4jal77tnred2ygmqdq9vehk7xqyjw5qcqp29qyysgqjc8cel3q7ktv9kghjykkflcnyfq6mz94eeeycjaptd05hlsq37dznak8yegs624ppvk4ehhlj6lvvtrm24lpatc64u3zte0wgd57geqptq52sq | jq
{
  "destination": "03f8002413ce9a9aa7e75eb4eae40886ba27acc9448dfb8662cd1e92736f184f28",
  "payment_hash": "540063fe219eff8419a957a5db7af210b2cb170927b8dacbbff79731e5aa2236",
  "created_at": 1686236056.629,
  "parts": 1,
  "amount_msat": 10000000,
  "amount_sent_msat": 10000000,
  "payment_preimage": "46947a15026a544ceff6b528c4983bdf5d19534f2ac0e37057c527e803e723ac",
  "status": "complete"
}

Now the node l2 creates a bolt11 invoice with an amount of 0.002btc superior to the threshold 0.001btc

(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.002btc inv-2 "foo"
{
   "payment_hash": "a50b36cf359e47bb08487f6dd759a2c38b076de4aff4c67ca1bab450e85d15f4",
   "expires_at": 1686840875,
   "bolt11": "lnbcrt2m1pjgr6atsp550tyv8rl20k6c8avd6c20z7u55x68vmycapd3v3gav7u6etq23eqpp5559ndne4nermkzzg0akawkdzcw9swm0y4l6vvl9ph269p6zazh6qdq9vehk7xqyjw5qcqp29qyysgq2zrjwxtyzzkxhw3727ccucxm8fmn3tcakf3qsdgze5ghru6h8eghwkp39lp0ve2jcds3lt74w3ktfvghkzuurppkjs6apl529t5wgtcppexjuf",
   "payment_secret": "a3d6461c7f53edac1fac6eb0a78bdca50da3b364c742d8b228eb3dcd65605472",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}

such that the node l1 can't pay that invoice as we can see below:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt2m1pjgr6atsp550tyv8rl20k6c8avd6c20z7u55x68vmycapd3v3gav7u6etq23eqpp5559ndne4nermkzzg0akawkdzcw9swm0y4l6vvl9ph269p6zazh6qdq9vehk7xqyjw5qcqp29qyysgq2zrjwxtyzzkxhw3727ccucxm8fmn3tcakf3qsdgze5ghru6h8eghwkp39lp0ve2jcds3lt74w3ktfvghkzuurppkjs6apl529t5wgtcppexjuf | jq
{
  "invoice_too_high": "TODO",
  "maximum_is": "0.001btc",
  "bolt11": "lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh"
}

Let's modify rpc_command_func such that the field invoice_too_high in the payload response sent back to lightningd contains the amount of the bolt11 invoice which is superior to the threshold 0.001btc:

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    request = rpc_command
    if request["method"] == "pay":
        bolt11 = request["params"][0]
        if amount(bolt11) > 0.001:
            return {"return":
                    {"result":
                     {
                         "invoice_too_high": str(amount(bolt11)) + "btc",
                         "maximum_is": "0.001btc",
                         "bolt11": "lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh"
                     }}}
    return {"result": "continue"}

Now we can restart our plugin, let the node l2 generates an invoice with an amount of 0.002btc (too high) and check that the plugin pay-up-to.py stops the payment and returns the too high amount of the invoice:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.002btc inv-3 "foo"
{
   "payment_hash": "a861020907fdbeae59397aceb098a1e83bfe95e14a75fa36dd7aadbec908bd75",
   "expires_at": 1686840947,
   "bolt11": "lnbcrt2m1pjgr6lnsp50zf893m9p7tt0jn5ulkgg3juetekhacfnn8npct5vpax3fyf5zfqpp54pssyzg8lkl2ukfe0t8tpx9paqala90pff6l5dka02kmajggh46sdq9vehk7xqyjw5qcqp29qyysgqahxaz5qwvqpekm5wf8ar53zrpjpplu7j9s2jwp300wug9tlu2t2hqnu3ecu34pff4h4yhtswj99w5z9cxj9hajm53pm4a0v3f60yr0cp7dq4x6",
   "payment_secret": "789272c7650f96b7ca74e7ec84465ccaf36bf7099ccf30e174607a68a489a092",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt2m1pjgr6lnsp50zf893m9p7tt0jn5ulkgg3juetekhacfnn8npct5vpax3fyf5zfqpp54pssyzg8lkl2ukfe0t8tpx9paqala90pff6l5dka02kmajggh46sdq9vehk7xqyjw5qcqp29qyysgqahxaz5qwvqpekm5wf8ar53zrpjpplu7j9s2jwp300wug9tlu2t2hqnu3ecu34pff4h4yhtswj99w5z9cxj9hajm53pm4a0v3f60yr0cp7dq4x6 | jq
{
  "invoice_too_high": "0.002btc",
  "maximum_is": "0.001btc",
  "bolt11": "lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh"
}

Chat

It seems easy to add plugins!

As I can check visually the amount why would I need a plugin like pay-up-to.py?

You can add custom metrics about the node activity.

(not in the video) Limit pay command to 'limit' which can be defined at plugin startup time

The last thing we can do to make our plugin more capable is to add a startup option, let say limit, that is used to set the threshold of the amount we can pay to an invoice. So far it was an hard coded value equal to 0.001btc.

With pyln-client package, this can be done

  1. using add_option method of the Plugin class to add a new startup option and

  2. using get_option method of the Plugin class to get the value of some options set after the init request/response communication between lightningd and the plugin when the plugin is started.

So the plugin pay-up-to.py is now implemented like this:

#!/usr/bin/env python

from pyln.client import Plugin
import json, re, time

plugin = Plugin()

def amount(bolt11):
    multiplier = {
        "m": 0.001,
        "u": 0.000001,
        "n": 0.000000001,
        "p": 0.000000000001
    }
    match = re.match(r"ln(?:bcrt|bc|tbs|tb)([0-9]+)(.)", bolt11)
    amount = match[1]
    mltp = match[2]
    return float(amount) * multiplier[mltp]

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    request = rpc_command
    limit = float(plugin.get_option("limit").strip("btc"))
    if request["method"] == "pay":
        bolt11 = request["params"][0]
        if amount(bolt11) > limit:
            return {"return":
                    {"result":
                     {
                         "invoice_too_high": str(amount(bolt11)) + "btc",
                         "maximum_is": str(limit) + "btc",
                         "bolt11": bolt11
                     }}}
    return {"result": "continue"}

plugin.add_option(name="limit",
                  default="0.001btc",
                  description="pay bolt11 invoice up to 'limit'")

plugin.run()

Note that we also modified bolt11 value in the payload returned by rpc_command_func function.

Back to our terminal, we can start pay-up-to.py plugin with limit startup option set to 0.001btc:

(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/pay-up-to.py limit=0.001btc
{
   "command": "start",
   "plugins": [...]
}

And we can check that the plugin works as expected:

(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.002btc inv-4 "foo"
{
   "payment_hash": "9c72b37ea796bf24420bb35084aedaeec78b2f6d17e205ddab8341b7b745b9bf",
   "expires_at": 1686842009,
   "bolt11": "lnbcrt2m1pjgruqesp5chxvzzc8w5j672yffcxyhrwcq9v90lk3feluxvh3nps37lnxpcrspp5n3etxl48j6ljgsstkdggftk6amrcktmdzl3qthdtsdqm0d69hxlsdq9vehk7xqyjw5qcqp29qyysgqfvy5pxmwehujtpdmw7w8k6qq0q4rnt3wsuty4600eeecz99fje23a64uyvvywtalukq8yktn7rv6m5swfdh3wkj98dykggsclxv3glqps0meex",
   "payment_secret": "c5ccc10b077525af28894e0c4b8dd8015857fed14e7fc332f198611f7e660e07",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt2m1pjgruqesp5chxvzzc8w5j672yffcxyhrwcq9v90lk3feluxvh3nps37lnxpcrspp5n3etxl48j6ljgsstkdggftk6amrcktmdzl3qthdtsdqm0d69hxlsdq9vehk7xqyjw5qcqp29qyysgqfvy5pxmwehujtpdmw7w8k6qq0q4rnt3wsuty4600eeecz99fje23a64uyvvywtalukq8yktn7rv6m5swfdh3wkj98dykggsclxv3glqps0meex
{
   "invoice_too_high": "0.002btc",
   "maximum_is": "0.001btc",
   "bolt11": "lnbcrt2m1pjgruqesp5chxvzzc8w5j672yffcxyhrwcq9v90lk3feluxvh3nps37lnxpcrspp5n3etxl48j6ljgsstkdggftk6amrcktmdzl3qthdtsdqm0d69hxlsdq9vehk7xqyjw5qcqp29qyysgqfvy5pxmwehujtpdmw7w8k6qq0q4rnt3wsuty4600eeecz99fje23a64uyvvywtalukq8yktn7rv6m5swfdh3wkj98dykggsclxv3glqps0meex"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.0001btc inv-5 "foo"
{
   "payment_hash": "6b107224561a7c5841e80d91721eed2480ff5431db6db3c887861ec058c2efb2",
   "expires_at": 1686842048,
   "bolt11": "lnbcrt100u1pjgruzqsp5cjgm6uhx28lqfm2lnzgkvk60grysr7l5c3ucr4hfaqkcndaypneqpp5dvg8yfzkrf79ss0gpkghy8hdyjq074p3mdkm8jy8sc0vqkxza7eqdq9vehk7xqyjw5qcqp29qyysgqqutvp6e7554xca6wjtqsnec4y4ayqd77cn6penu8ds4g6xp2q474t764ctk2s5prsdzr9mx8h74jc4mj27cnqg426xj5ym2a83qhh4qpnmcuvk",
   "payment_secret": "c491bd72e651fe04ed5f9891665b4f40c901fbf4c47981d6e9e82d89b7a40cf2",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt100u1pjgruzqsp5cjgm6uhx28lqfm2lnzgkvk60grysr7l5c3ucr4hfaqkcndaypneqpp5dvg8yfzkrf79ss0gpkghy8hdyjq074p3mdkm8jy8sc0vqkxza7eqdq9vehk7xqyjw5qcqp29qyysgqqutvp6e7554xca6wjtqsnec4y4ayqd77cn6penu8ds4g6xp2q474t764ctk2s5prsdzr9mx8h74jc4mj27cnqg426xj5ym2a83qhh4qpnmcuvk
{
   "destination": "03f8002413ce9a9aa7e75eb4eae40886ba27acc9448dfb8662cd1e92736f184f28",
   "payment_hash": "6b107224561a7c5841e80d91721eed2480ff5431db6db3c887861ec058c2efb2",
   "created_at": 1686237261.677,
   "parts": 1,
   "amount_msat": 10000000,
   "amount_sent_msat": 10000000,
   "payment_preimage": "200fd3cf48f433dfddc32a3e16d9fd022fde5c53aaa4dad94cc3e9767ff2d545",
   "status": "complete"
}

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
$ source lightning/contrib/startup_regtest.sh
$ start_ln
$ alias l1-cli
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ ps -ax | rg pay-up
$ l1-cli getinfo
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l1-cli getinfo
$ l1-cli listpeers
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l1-cli getinfo
$ l1-cli listpeers
$ l1-cli stop
$ ps -ax | rg lightningd
$ kill -9 1163255
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon
$ ps -ax | rg lightningd
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l1-cli getinfo
$ l1-cli listpeers
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l1-cli getinfo
$ l1-cli listpeers
$ connect 1 2
$ fund_nodes
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l1-cli pay bolt11inv
$ l1-cli pay bolt11inv amountofinv
$ l1-cli -k pay bolt11=bolt11inv
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l1-cli getinfo
$ l1-cli pay bolt11
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l1-cli pay bolt11
$ l1-cli pay boltfoo
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l2-cli invoice 0.0001btc inv-1 "foo"
$ l1-cli pay lnbcrt100u1pjgr6uwsp5ngfcwwgqnq4r4qqp8ny528fre7fp7c7xprhmrweplxqz79hz4wyspp52sqx8l3pnmlcgxdf27jak7hjzzevk9cfy7ud4jal77tnred2ygmqdq9vehk7xqyjw5qcqp29qyysgqjc8cel3q7ktv9kghjykkflcnyfq6mz94eeeycjaptd05hlsq37dznak8yegs624ppvk4ehhlj6lvvtrm24lpatc64u3zte0wgd57geqptq52sq | jq
$ l2-cli invoice 0.002btc inv-2 "foo"
$ l1-cli pay lnbcrt2m1pjgr6atsp550tyv8rl20k6c8avd6c20z7u55x68vmycapd3v3gav7u6etq23eqpp5559ndne4nermkzzg0akawkdzcw9swm0y4l6vvl9ph269p6zazh6qdq9vehk7xqyjw5qcqp29qyysgq2zrjwxtyzzkxhw3727ccucxm8fmn3tcakf3qsdgze5ghru6h8eghwkp39lp0ve2jcds3lt74w3ktfvghkzuurppkjs6apl529t5wgtcppexjuf | jq
$ l1-cli plugin start $(pwd)/pay-up-to.py
$ l2-cli invoice 0.002btc inv-3 "foo"
$ l1-cli pay lnbcrt2m1pjgr6lnsp50zf893m9p7tt0jn5ulkgg3juetekhacfnn8npct5vpax3fyf5zfqpp54pssyzg8lkl2ukfe0t8tpx9paqala90pff6l5dka02kmajggh46sdq9vehk7xqyjw5qcqp29qyysgqahxaz5qwvqpekm5wf8ar53zrpjpplu7j9s2jwp300wug9tlu2t2hqnu3ecu34pff4h4yhtswj99w5z9cxj9hajm53pm4a0v3f60yr0cp7dq4x6 | jq
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/pay-up-to.py limit=0.001btc
$ l2-cli invoice 0.002btc inv-4 "foo"
$ l1-cli pay lnbcrt2m1pjgruqesp5chxvzzc8w5j672yffcxyhrwcq9v90lk3feluxvh3nps37lnxpcrspp5n3etxl48j6ljgsstkdggftk6amrcktmdzl3qthdtsdqm0d69hxlsdq9vehk7xqyjw5qcqp29qyysgqfvy5pxmwehujtpdmw7w8k6qq0q4rnt3wsuty4600eeecz99fje23a64uyvvywtalukq8yktn7rv6m5swfdh3wkj98dykggsclxv3glqps0meex
$ l2-cli invoice 0.0001btc inv-5 "foo"
$ l1-cli pay lnbcrt100u1pjgruzqsp5cjgm6uhx28lqfm2lnzgkvk60grysr7l5c3ucr4hfaqkcndaypneqpp5dvg8yfzkrf79ss0gpkghy8hdyjq074p3mdkm8jy8sc0vqkxza7eqdq9vehk7xqyjw5qcqp29qyysgqqutvp6e7554xca6wjtqsnec4y4ayqd77cn6penu8ds4g6xp2q474t764ctk2s5prsdzr9mx8h74jc4mj27cnqg426xj5ym2a83qhh4qpnmcuvk

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

◉ tony@tony:~/clnlive:
$ python -m venv .venv
◉ tony@tony:~/clnlive:
$ source .venv/bin/activate
(.venv) ◉ tony@tony:~/clnlive:
$ pip install pyln-client
...
(.venv) ◉ tony@tony:~/clnlive:
$ 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:~/clnlive:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 1163253
[2] 1163288
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:~/clnlive:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.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/clnlive/pay-up-to.py",
         "active": true,
         "dynamic": true
      }
   ]
}
(.venv) ◉ tony@tony:~/clnlive:
$ ps -ax | rg pay-up
1163640 pts/0    S      0:00 python /home/tony/clnlive/pay-up-to.py
1163658 pts/0    S+     0:00 rg pay-up
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03776915f5da2d5801e08639f9da607d51926ae705da1348c06c6ba2a3bfb159bc",
   "alias": "BIZARRESPAWN",
   "color": "037769",
   "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"
   }
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03776915f5da2d5801e08639f9da607d51926ae705da1348c06c6ba2a3bfb159bc",
   "alias": "BIZARRESPAWN",
   "color": "037769",
   "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"
   }
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "peers": []
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "BOOM": "I took over your node"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "BOOM": "I took over your node"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli stop
{
   "BOOM": "I took over your node"
}
(.venv) ◉ tony@tony:~/clnlive:
$ ps -ax | rg lightningd
1163255 pts/0    S      0:00 lightningd --lightning-dir=/tmp/l1-regtest
1163291 pts/0    S      0:00 lightningd --lightning-dir=/tmp/l2-regtest
1163998 pts/0    S+     0:00 rg lightningd
(.venv) ◉ tony@tony:~/clnlive:
$ kill -9 1163255
lightning/contrib/startup_regtest.sh: line 85: 1163255 Killed                  $EATMYDATA "$LIGHTNINGD" "--lightning-dir=/tmp/l$i-$network"
[1]-  Exit 137                test -f "/tmp/l$i-$network/lightningd-$network.pid" || $EATMYDATA "$LIGHTNINGD" "--lightning-dir=/tmp/l$i-$network"
(.venv) ◉ tony@tony:~/clnlive:
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon
(.venv) ◉ tony@tony:~/clnlive:
$ ps -ax | rg lightningd
1163291 pts/0    S      0:00 lightningd --lightning-dir=/tmp/l2-regtest
1164016 ?        Ss     0:00 lightningd --lightning-dir=/tmp/l1-regtest --daemon
1164074 pts/0    S+     0:00 rg lightningd
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03776915f5da2d5801e08639f9da607d51926ae705da1348c06c6ba2a3bfb159bc",
   "alias": "BIZARRESPAWN",
   "color": "037769",
   "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"
   }
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "peers": []
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "BOOM": "I took over 'getinfo'"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "peers": []
}
(.venv) ◉ tony@tony:~/clnlive:
$ connect 1 2
{
   "id": "03f8002413ce9a9aa7e75eb4eae40886ba27acc9448dfb8662cd1e92736f184f28",
   "features": "08a000080269a2",
   "direction": "out",
   "address": {
      "type": "ipv4",
      "address": "127.0.0.1",
      "port": 7272
   }
}
(.venv) ◉ tony@tony:~/clnlive:
$ fund_nodes
Mining into address bcrt1qlz7phgee89vg23fs54an94phxd9au5az9vn6rn... done.
bitcoind balance: 50.00000000
Waiting for lightning node funds... found.
Funding channel from node 1 to node 2. Waiting for confirmation... done.
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11inv
{
   "code": -32602,
   "message": "Invalid bolt11: Bad bech32 string"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11inv amountofinv
{
   "code": -32602,
   "message": "amount_msat|msatoshi: should be a millisatoshi amount: invalid token '\"amountofinv\"'"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli -k pay bolt11=bolt11inv
{
   "code": -32602,
   "message": "Invalid bolt11: Bad bech32 string"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03776915f5da2d5801e08639f9da607d51926ae705da1348c06c6ba2a3bfb159bc",
   "alias": "BIZARRESPAWN",
   "color": "037769",
   "num_peers": 1,
   "num_pending_channels": 0,
   "num_active_channels": 1,
   "num_inactive_channels": 0,
   "address": [],
   "binding": [
      {
         "type": "ipv4",
         "address": "127.0.0.1",
         "port": 7171
      }
   ],
   "version": "v23.02.2",
   "blockheight": 108,
   "network": "regtest",
   "fees_collected_msat": 0,
   "lightning-dir": "/tmp/l1-regtest/regtest",
   "our_features": {
      "init": "08a000080269a2",
      "node": "88a000080269a2",
      "channel": "",
      "invoice": "02000000024100"
   }
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11
{
   "BOOM": "I took over 'pay'"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay bolt11
{
   "BOOM": "bolt11"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay boltfoo
{
   "BOOM": "boltfoo"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.0001btc inv-1 "foo"
{
   "payment_hash": "540063fe219eff8419a957a5db7af210b2cb170927b8dacbbff79731e5aa2236",
   "expires_at": 1686840846,
   "bolt11": "lnbcrt100u1pjgr6uwsp5ngfcwwgqnq4r4qqp8ny528fre7fp7c7xprhmrweplxqz79hz4wyspp52sqx8l3pnmlcgxdf27jak7hjzzevk9cfy7ud4jal77tnred2ygmqdq9vehk7xqyjw5qcqp29qyysgqjc8cel3q7ktv9kghjykkflcnyfq6mz94eeeycjaptd05hlsq37dznak8yegs624ppvk4ehhlj6lvvtrm24lpatc64u3zte0wgd57geqptq52sq",
   "payment_secret": "9a13873900982a3a80013cc9451d23cf921f63c608efb1bb21f9802f16e2ab89",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt100u1pjgr6uwsp5ngfcwwgqnq4r4qqp8ny528fre7fp7c7xprhmrweplxqz79hz4wyspp52sqx8l3pnmlcgxdf27jak7hjzzevk9cfy7ud4jal77tnred2ygmqdq9vehk7xqyjw5qcqp29qyysgqjc8cel3q7ktv9kghjykkflcnyfq6mz94eeeycjaptd05hlsq37dznak8yegs624ppvk4ehhlj6lvvtrm24lpatc64u3zte0wgd57geqptq52sq | jq
{
  "destination": "03f8002413ce9a9aa7e75eb4eae40886ba27acc9448dfb8662cd1e92736f184f28",
  "payment_hash": "540063fe219eff8419a957a5db7af210b2cb170927b8dacbbff79731e5aa2236",
  "created_at": 1686236056.629,
  "parts": 1,
  "amount_msat": 10000000,
  "amount_sent_msat": 10000000,
  "payment_preimage": "46947a15026a544ceff6b528c4983bdf5d19534f2ac0e37057c527e803e723ac",
  "status": "complete"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.002btc inv-2 "foo"
{
   "payment_hash": "a50b36cf359e47bb08487f6dd759a2c38b076de4aff4c67ca1bab450e85d15f4",
   "expires_at": 1686840875,
   "bolt11": "lnbcrt2m1pjgr6atsp550tyv8rl20k6c8avd6c20z7u55x68vmycapd3v3gav7u6etq23eqpp5559ndne4nermkzzg0akawkdzcw9swm0y4l6vvl9ph269p6zazh6qdq9vehk7xqyjw5qcqp29qyysgq2zrjwxtyzzkxhw3727ccucxm8fmn3tcakf3qsdgze5ghru6h8eghwkp39lp0ve2jcds3lt74w3ktfvghkzuurppkjs6apl529t5wgtcppexjuf",
   "payment_secret": "a3d6461c7f53edac1fac6eb0a78bdca50da3b364c742d8b228eb3dcd65605472",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt2m1pjgr6atsp550tyv8rl20k6c8avd6c20z7u55x68vmycapd3v3gav7u6etq23eqpp5559ndne4nermkzzg0akawkdzcw9swm0y4l6vvl9ph269p6zazh6qdq9vehk7xqyjw5qcqp29qyysgq2zrjwxtyzzkxhw3727ccucxm8fmn3tcakf3qsdgze5ghru6h8eghwkp39lp0ve2jcds3lt74w3ktfvghkzuurppkjs6apl529t5wgtcppexjuf | jq
{
  "invoice_too_high": "TODO",
  "maximum_is": "0.001btc",
  "bolt11": "lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli plugin start $(pwd)/pay-up-to.py
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.002btc inv-3 "foo"
{
   "payment_hash": "a861020907fdbeae59397aceb098a1e83bfe95e14a75fa36dd7aadbec908bd75",
   "expires_at": 1686840947,
   "bolt11": "lnbcrt2m1pjgr6lnsp50zf893m9p7tt0jn5ulkgg3juetekhacfnn8npct5vpax3fyf5zfqpp54pssyzg8lkl2ukfe0t8tpx9paqala90pff6l5dka02kmajggh46sdq9vehk7xqyjw5qcqp29qyysgqahxaz5qwvqpekm5wf8ar53zrpjpplu7j9s2jwp300wug9tlu2t2hqnu3ecu34pff4h4yhtswj99w5z9cxj9hajm53pm4a0v3f60yr0cp7dq4x6",
   "payment_secret": "789272c7650f96b7ca74e7ec84465ccaf36bf7099ccf30e174607a68a489a092",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt2m1pjgr6lnsp50zf893m9p7tt0jn5ulkgg3juetekhacfnn8npct5vpax3fyf5zfqpp54pssyzg8lkl2ukfe0t8tpx9paqala90pff6l5dka02kmajggh46sdq9vehk7xqyjw5qcqp29qyysgqahxaz5qwvqpekm5wf8ar53zrpjpplu7j9s2jwp300wug9tlu2t2hqnu3ecu34pff4h4yhtswj99w5z9cxj9hajm53pm4a0v3f60yr0cp7dq4x6 | jq
{
  "invoice_too_high": "0.002btc",
  "maximum_is": "0.001btc",
  "bolt11": "lnbcrt2m1pjgrk8...pm86hgrqq8rf6dh"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/pay-up-to.py limit=0.001btc
{
   "command": "start",
   "plugins": [...]
}
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.002btc inv-4 "foo"
{
   "payment_hash": "9c72b37ea796bf24420bb35084aedaeec78b2f6d17e205ddab8341b7b745b9bf",
   "expires_at": 1686842009,
   "bolt11": "lnbcrt2m1pjgruqesp5chxvzzc8w5j672yffcxyhrwcq9v90lk3feluxvh3nps37lnxpcrspp5n3etxl48j6ljgsstkdggftk6amrcktmdzl3qthdtsdqm0d69hxlsdq9vehk7xqyjw5qcqp29qyysgqfvy5pxmwehujtpdmw7w8k6qq0q4rnt3wsuty4600eeecz99fje23a64uyvvywtalukq8yktn7rv6m5swfdh3wkj98dykggsclxv3glqps0meex",
   "payment_secret": "c5ccc10b077525af28894e0c4b8dd8015857fed14e7fc332f198611f7e660e07",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt2m1pjgruqesp5chxvzzc8w5j672yffcxyhrwcq9v90lk3feluxvh3nps37lnxpcrspp5n3etxl48j6ljgsstkdggftk6amrcktmdzl3qthdtsdqm0d69hxlsdq9vehk7xqyjw5qcqp29qyysgqfvy5pxmwehujtpdmw7w8k6qq0q4rnt3wsuty4600eeecz99fje23a64uyvvywtalukq8yktn7rv6m5swfdh3wkj98dykggsclxv3glqps0meex
{
   "invoice_too_high": "0.002btc",
   "maximum_is": "0.001btc",
   "bolt11": "lnbcrt2m1pjgruqesp5chxvzzc8w5j672yffcxyhrwcq9v90lk3feluxvh3nps37lnxpcrspp5n3etxl48j6ljgsstkdggftk6amrcktmdzl3qthdtsdqm0d69hxlsdq9vehk7xqyjw5qcqp29qyysgqfvy5pxmwehujtpdmw7w8k6qq0q4rnt3wsuty4600eeecz99fje23a64uyvvywtalukq8yktn7rv6m5swfdh3wkj98dykggsclxv3glqps0meex"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l2-cli invoice 0.0001btc inv-5 "foo"
{
   "payment_hash": "6b107224561a7c5841e80d91721eed2480ff5431db6db3c887861ec058c2efb2",
   "expires_at": 1686842048,
   "bolt11": "lnbcrt100u1pjgruzqsp5cjgm6uhx28lqfm2lnzgkvk60grysr7l5c3ucr4hfaqkcndaypneqpp5dvg8yfzkrf79ss0gpkghy8hdyjq074p3mdkm8jy8sc0vqkxza7eqdq9vehk7xqyjw5qcqp29qyysgqqutvp6e7554xca6wjtqsnec4y4ayqd77cn6penu8ds4g6xp2q474t764ctk2s5prsdzr9mx8h74jc4mj27cnqg426xj5ym2a83qhh4qpnmcuvk",
   "payment_secret": "c491bd72e651fe04ed5f9891665b4f40c901fbf4c47981d6e9e82d89b7a40cf2",
   "warning_deadends": "Insufficient incoming capacity, once dead-end peers were excluded"
}
(.venv) ◉ tony@tony:~/clnlive:
$ l1-cli pay lnbcrt100u1pjgruzqsp5cjgm6uhx28lqfm2lnzgkvk60grysr7l5c3ucr4hfaqkcndaypneqpp5dvg8yfzkrf79ss0gpkghy8hdyjq074p3mdkm8jy8sc0vqkxza7eqdq9vehk7xqyjw5qcqp29qyysgqqutvp6e7554xca6wjtqsnec4y4ayqd77cn6penu8ds4g6xp2q474t764ctk2s5prsdzr9mx8h74jc4mj27cnqg426xj5ym2a83qhh4qpnmcuvk
{
   "destination": "03f8002413ce9a9aa7e75eb4eae40886ba27acc9448dfb8662cd1e92736f184f28",
   "payment_hash": "6b107224561a7c5841e80d91721eed2480ff5431db6db3c887861ec058c2efb2",
   "created_at": 1686237261.677,
   "parts": 1,
   "amount_msat": 10000000,
   "amount_sent_msat": 10000000,
   "payment_preimage": "200fd3cf48f433dfddc32a3e16d9fd022fde5c53aaa4dad94cc3e9767ff2d545",
   "status": "complete"
}

Source code

pay-up-to.py

#!/usr/bin/env python

from pyln.client import Plugin
import json, re, time

plugin = Plugin()

def amount(bolt11):
    multiplier = {
        "m": 0.001,
        "u": 0.000001,
        "n": 0.000000001,
        "p": 0.000000000001
    }
    match = re.match(r"ln(?:bcrt|bc|tbs|tb)([0-9]+)(.)", bolt11)
    amount = match[1]
    mltp = match[2]
    return float(amount) * multiplier[mltp]

@plugin.hook("rpc_command")
def rpc_command_func(plugin, rpc_command, **kwargs):
    request = rpc_command
    limit = float(plugin.get_option("limit").strip("btc"))
    if request["method"] == "pay":
        bolt11 = request["params"][0]
        if amount(bolt11) > limit:
            return {"return":
                    {"result":
                     {
                         "invoice_too_high": str(amount(bolt11)) + "btc",
                         "maximum_is": str(limit) + "btc",
                         "bolt11": bolt11
                     }}}
    return {"result": "continue"}

plugin.add_option(name="limit",
                  default="0.001btc",
                  description="pay bolt11 invoice up to 'limit'")

plugin.run()

pay-up-to

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1164167', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'getinfo', 'id': 'cli:getinfo#1164167', 'params': []}

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1164226', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'listpeers', 'id': 'cli:listpeers#1164226', 'params': []}

...

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1165357', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'pay', 'id': 'cli:pay#1165357', 'params': ['bolt11inv']}

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1165409', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'pay', 'id': 'cli:pay#1165409', 'params': ['bolt11inv', 'amountofinv']}

{'jsonrpc': '2.0', 'method': 'notifications', 'id': 'cli:notifications#1165478', 'params': {'enable': True}}

{'jsonrpc': '2.0', 'method': 'pay', 'id': 'cli:pay#1165478', 'params': {'bolt11': 'bolt11inv'}}

...

Resources