Write a Core Lightning plugin in Javascript
In this episode, we write a Core Lightning plugin in Javascript that registers a JSON-RPC method. To do so we use clightningjs
Javascript package.
Transcript with corrections and improvements
Let's add the method mymethod
to CLN by writing a dynamic
Javascript plugin called myplugin.js
using clightningjs
When we start the plugin myplugin.js
with the option foo_opt
set to
BAR
like this
◉ tony@tony:~/lnroom:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/myplugin.js foo_opt=BAR
where l1-cli
is an alias for lightning-cli
--lightning-dir=/tmp/l1-regtest
, we expect mymethod
method called with
the parameters foo1=bar1
and foo2=bar2
to gives us the following
◉ tony@tony:~/lnroom:
$ l1-cli -k mymethod foo1=bar1 foo2=bar2
{
"node_id": "029999ce6a1b74cf077f503b81bb4d95af34114363bdac5922a9b353ce0449bac4",
"options": {
"foo_opt": "BAR"
},
"cli_params": {
"foo1": "bar1",
"foo2": "bar2"
}
}
where the value of node_id
is the ID of the node l1
.
Core Lightning plugin system
Before implementing that plugin, let's describe Core Lightning plugin system.
Once lightningd
has started the plugin as a subprocess, the plugin and
lightningd
communicate using pipes. Specifically,
lightningd
writes to the stdin stream of the plugin to send it messages andthe plugin writes to its stdout stream to send messages to
lightningd
.
To understand each other they use the JSON-RPC protocol.
That communcation can be represented like this:
-> stdin
| |
lightningd plugin
| |
<- stdout
Now that we know how lightningd
and plugins communicate with each
other, let's take a look at the life cycle of a plugin like
myplugin.js
which registers a JSON-RPC method called mymethod
:
When
lightningd
startsmyplugin.js
, it sends thegetmanifest
request tomyplugin.js
.myplugin.js
replies tolightningd
with a response containing the declaration of the startup optionfoo_opt
and the information to registermymethod
JSON-RPC method.Then
lightningd
sends theinit
request tomyplugin.js
. This tellsmyplugin.js
thatlightningd
is ready to communicate. Thatinit
request contains informations that the plugin might needs to work correctly. For instance,myplugin.js
needs to know the value of the startup optionfoo_opt
and to know the Unix socket file to use to connect to the node and to send JSON-RPC requests in order to retrieve the node id. Those informations are available in theinit
request.Then
myplugin.js
starts an IO loop and waits in its stdin stream incoming requests fromlightningd
:(a). A client sends a
mymethod
request tolightningd
. Asmyplugin.js
has registeredmymethod
JSON-RPC method tolightningd
,lightningd
forwards that request tomyplugin.js
,(b).
myplugin.js
receives that request, builds a response and sends it back tolightningd
,(c). Finally,
lightningd
forwards that response to the client,(d). We repeat (a) to (c).
Setup
Here is my setup:
lightningd v23.05.2
node v16.13.0
clightningjs 0.2.2
Ubuntu 22.04.2 LTS
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
:
◉ tony@tony:~/lnroom:
$ source lightning/contrib/startup_regtest.sh
...
◉ tony@tony:~/lnroom:
$ start_ln
...
We can check that l1-cli
is just an alias for lightning-cli
with the
base directory being /tmp/l1-regtest
:
◉ tony@tony:~/lnroom:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
Installing and using clightningjs
To install clightningjs
we run the following command:
◉ tony@tony:~/lnroom:
$ npm install clightningjs
In the file myplugin.js
, we require the class Plugin
from
clightningjs
, then we instantiate the object plugin
with the class
Plugin
and finally we start the I/O loop with plugin.start()
:
#!/usr/bin/env node
const Plugin = require('clightningjs');
const plugin = new Plugin();
plugin.start();
Once we've made myplugin.js
file executable, we can start the plugin
myplugin.js
like this (thought it does nothing):
◉ tony@tony:~/lnroom:
$ chmod +x myplugin.js
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...
{
"name": "/home/tony/lnroom/myplugin.js",
"active": true,
"dynamic": true
}
]
}
Declare mymethod JSON-RPC method
Now let's declare mymethod
method which always returns the json object
{"foo": "bar"}
:
#!/usr/bin/env node
const Plugin = require('clightningjs');
const plugin = new Plugin();
function mymethodFunc(params) {
return {'foo', 'bar'}
plugin.addMethod('mymethod', mymethodFunc, 'usage', 'description');
plugin.start();
Let's restart the plugin
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...]
}
and call mymethod
method by running the following command:
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"foo": "bar"
}
Not so bad. We know how to register JSON-RPC commands.
Let's continue.
Add foo_opt startup option to myplugin.js
We can declare startup options to lightningd
with addOption
method of
plugin
object. After we receive the init
request from lightningd
,
clightningjs
takes care to put the startup options we've declared to
lightningd
in the object plugin.options
.
For instance, we can declare the startup option foo_opt
with
plugin.addOption
and we can access foo_opt
option with plugin.options
in mymethodFunc
function:
#!/usr/bin/env node
const Plugin = require('clightningjs');
const plugin = new Plugin();
function mymethodFunc(params) {
const fooOpt = plugin.options['foo_opt'];
return {"foo_opt": fooOpt}
plugin.addMethod('mymethod', mymethodFunc, 'usage', 'description');
plugin.addOption('foo_opt', 'bar', 'description');
plugin.start();
We restart the plugin and call mymethod
method:
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"foo_opt": {
"default": "bar",
"description": "description",
"type": "string",
"value": "bar"
}
}
We got the object lightningd
sent us in the init
request.
If we just want the value of foo_opt
option we change myplugin.js
to
this:
#!/usr/bin/env node
const Plugin = require('clightningjs');
const plugin = new Plugin();
function mymethodFunc(params) {
const fooOpt = plugin.options['foo_opt'].value;
return {"foo_opt": fooOpt}
plugin.addMethod('mymethod', mymethodFunc, 'usage', 'description');
plugin.addOption('foo_opt', 'bar', 'description');
plugin.start();
Now let's restart myplugin.js
plugin with the startup option foo_opt
set to BAR
and call mymethod
method:
◉ tony@tony:~/lnroom:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/myplugin.js foo_opt=BAR
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"foo_opt": "BAR"
}
Great! We know how to declare startup options and use them.
Get the node id of the node l1 running myplugin.js
Let's see how we can get the node id of l1
node, the node running the
plugin.
We can get that node id by doing a getinfo
RPC call to the node.
Indeed, in the I/O loop of our plugin (started with plugin.start()
) the
plugin first answers to the getmanifest
request and then answers to the
init
request. Just before sending the init
response, clightningjs
instantiate plugin.rpc
property with the class RpcWrapper
. This
allows us to do JSON-RPC call to the node running the plugin like
this:
plugin.rpc.call(...);
As plugin.rpc.call
method is async we have to declare mymethodFunc
async. So we modify myplugin.js
like this in order to return the node
id of the node l1
running the plugin:
#!/usr/bin/env node
const Plugin = require('clightningjs');
const plugin = new Plugin();
async function mymethodFunc(params) {
const fooOpt = plugin.options['foo_opt'].value;
const getinfo = await plugin.rpc.call('getinfo', {});
return {"node_id": getinfo['id']}
plugin.addMethod('mymethod', mymethodFunc, 'usage', 'description');
plugin.addOption('foo_opt', 'bar', 'description');
plugin.start();
Back to our terminal, we restart our plugin and call mymethod
method
which returns l1
's node id:
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"node_id": "027e52e2cf16cec77b60d0e252f0f299cb05917c2db6f508aa327a1a45376233c6"
}
We can check that we got the correct id by calling directly getinfo
method:
◉ tony@tony:~/lnroom:
$ l1-cli getinfo | jq -r .id
027e52e2cf16cec77b60d0e252f0f299cb05917c2db6f508aa327a1a45376233c6
Complete implementation
Now we just have to put all the pieces together like this:
#!/usr/bin/env node
const Plugin = require('clightningjs');
const plugin = new Plugin();
async function mymethodFunc(params) {
const fooOpt = plugin.options['foo_opt'].value;
const getinfo = await plugin.rpc.call('getinfo', {});
return {
"node_id": getinfo['id'],
"options": {
"foo_opt": fooOpt
},
"cli_params": params
};
}
plugin.addMethod('mymethod', mymethodFunc, 'usage', 'description');
plugin.addOption('foo_opt', 'bar', 'description');
plugin.start();
Note that if we pass positional parameters to mymethod
, the argument
params
of mymethodFunc
will be an array and if we pass key/value pair
parameters to mymethod
, the argument params
of mymethodFunc
will be an
object.
Therefore, after restarting our plugin myplugin.js
with the startup
option foo_opt
set to BAR
like this
◉ tony@tony:~/lnroom:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/myplugin.js foo_opt=BAR
{
"command": "start",
"plugins": [...]
}
and calling mymethod
method with key/value pairs foo1=bar1
and
foo2=bar2
we get the following:
◉ tony@tony:~/lnroom:
$ l1-cli -k mymethod foo1=bar1 foo2=bar2
{
"node_id": "027e52e2cf16cec77b60d0e252f0f299cb05917c2db6f508aa327a1a45376233c6",
"options": {
"foo_opt": "BAR"
},
"cli_params": {
"foo1": "bar1",
"foo2": "bar2"
}
}
We are done!
Terminal session
We ran the following commands in this order:
$ ls
$ source lightning/contrib/startup_regtest.sh
$ start_ln
$ alias l1-cli
$ npm install clightningjs
$ chmod +x myplugin.js
$ l1-cli plugin start $(pwd)/myplugin.js
$ l1-cli plugin start $(pwd)/myplugin.js
$ l1-cli mymethod
$ l1-cli plugin start $(pwd)/myplugin.js
$ l1-cli mymethod
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/myplugin.js foo_opt=BAR
$ l1-cli mymethod
$ l1-cli plugin start $(pwd)/myplugin.js
$ l1-cli mymethod
$ l1-cli getinfo | jq -r .id
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/myplugin.js foo_opt=BAR
$ l1-cli -k mymethod foo1=bar1 foo2=bar2
And below you can read the terminal session (command lines and outputs):
◉ tony@tony:~/lnroom:
$ ls
lightning/ myplugin.js notes.org
◉ 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
◉ tony@tony:~/lnroom:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
error code: -35
error message:
Wallet "default" is already loaded.
[1] 1468431
[2] 1468465
WARNING: eatmydata not found: install it for faster testing
Commands:
l1-cli, l1-log,
l2-cli, l2-log,
bt-cli, stop_ln, fund_nodes
◉ tony@tony:~/lnroom:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/lnroom:
$ npm install clightningjs
◉ tony@tony:~/lnroom:
$ chmod +x myplugin.js
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...
{
"name": "/home/tony/lnroom/myplugin.js",
"active": true,
"dynamic": true
}
]
}
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"foo": "bar"
}
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"foo_opt": {
"default": "bar",
"description": "description",
"type": "string",
"value": "bar"
}
}
◉ tony@tony:~/lnroom:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/myplugin.js foo_opt=BAR
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"foo_opt": "BAR"
}
◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.js
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli mymethod
{
"node_id": "027e52e2cf16cec77b60d0e252f0f299cb05917c2db6f508aa327a1a45376233c6"
}
◉ tony@tony:~/lnroom:
$ l1-cli getinfo | jq -r .id
027e52e2cf16cec77b60d0e252f0f299cb05917c2db6f508aa327a1a45376233c6
◉ tony@tony:~/lnroom:
$ l1-cli -k plugin subcommand=start plugin=$(pwd)/myplugin.js foo_opt=BAR
{
"command": "start",
"plugins": [...]
}
◉ tony@tony:~/lnroom:
$ l1-cli -k mymethod foo1=bar1 foo2=bar2
{
"node_id": "027e52e2cf16cec77b60d0e252f0f299cb05917c2db6f508aa327a1a45376233c6",
"options": {
"foo_opt": "BAR"
},
"cli_params": {
"foo1": "bar1",
"foo2": "bar2"
}
}
Source code
myplugin.js
#!/usr/bin/env node
const Plugin = require('clightningjs');
const plugin = new Plugin();
async function mymethodFunc(params) {
const fooOpt = plugin.options['foo_opt'].value;
const getinfo = await plugin.rpc.call('getinfo', {});
return {
"node_id": getinfo['id'],
"options": {
"foo_opt": fooOpt
},
"cli_params": params
};
}
plugin.addMethod('mymethod', mymethodFunc, 'usage', 'description');
plugin.addOption('foo_opt', 'bar', 'description');
plugin.start();