Write a Core Lightning plugin in Javascript

LNROOM #17July 12, 2023

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,

  1. lightningd writes to the stdin stream of the plugin to send it messages and

  2. the 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:

  1. When lightningd starts myplugin.js, it sends the getmanifest request to myplugin.js. myplugin.js replies to lightningd with a response containing the declaration of the startup option foo_opt and the information to register mymethod JSON-RPC method.

  2. Then lightningd sends the init request to myplugin.js. This tells myplugin.js that lightningd is ready to communicate. That init request contains informations that the plugin might needs to work correctly. For instance, myplugin.js needs to know the value of the startup option foo_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 the init request.

  3. Then myplugin.js starts an IO loop and waits in its stdin stream incoming requests from lightningd:

    • (a). A client sends a mymethod request to lightningd. As myplugin.js has registered mymethod JSON-RPC method to lightningd, lightningd forwards that request to myplugin.js,

    • (b). myplugin.js receives that request, builds a response and sends it back to lightningd,

    • (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();

Resources