clnplugin-clj

clnplugin-clj is the only Core Lightning plugin library which lets you attach a socket REPL to the plugin's process, connect to it and modify its implementation while it's running.

All this is possible thanks to Clojure and the JVM.

Assuming you have Clojure and babashka (bb) installed on your machine, and a CLN node running, you can get a clnplugin-clj plugin running just by issuing the following 2 commands (in an empty directory):

$ curl -s -L https://clnplugin.tonyaldon.com/np | bb 1>/dev/null 2>&1
$ lightning-cli plugin start $(pwd)/myplugin

As a result, some JSON RPC methods have been added to lightningd and now you can for instance call my-foo method by running:

$ lightning-cli my-foo
{
   "bar": "baz"
}

In fact, myplugin plugin

  • defines 3 options my-opt, my-opt-multi and my-opt-dynamic,

  • registers 6 JSON RPC methods my-foo, my-options, my-info, my-log my-json-rpc-error and my-notify,

  • declares 1 custom notification topic my-topic,

  • subscribes to the custom notification topic my-topic and to the builtin notification topic invoice_creation,

  • asks to be consulted for the peer_connected hook and

  • initializes the plugin with a :init-fn function.

All of this can be looked up in the file src/myplugin.clj (created by np script) that we reproduce below (skipping the 281 lines of comments):

(ns myplugin
  (:require [clnplugin-clj :as plugin])
  (:require [clnrpc-clj :as rpc])
  (:gen-class))

(def plugin
  (atom {:options {:my-opt {:type "string"
                            :description "some description"
                            :default "my-opt-default"}
                   :my-opt-multi {:type "string"
                                  :multi true}
                   :my-opt-dynamic {:type "int"
                                    :dynamic true}}
         :rpcmethods
         {:my-foo {:fn (fn [params req plugin] {:bar "baz"})}
          :my-options
          {:fn (fn [params req plugin]
                 {:my-opt (plugin/get-option plugin :my-opt)
                  :my-opt-multi (plugin/get-option plugin :my-opt-multi)
                  :my-opt-dynamic (plugin/get-option plugin :my-opt-dynamic)})}
          :my-info
          {:fn (fn [params req plugin]
                 {:id (:id (rpc/getinfo @plugin))
                  :offline (-> (rpc/call @plugin "listconfigs")
                               :configs :offline)
                  :config (get-in @plugin [:init :configuration])})}
          :my-log
          {:fn (fn [params req plugin]
                 (let [{:keys [message level]}
                       (plugin/params->map [:message :level] params)]
                   (plugin/log (or message "default message")
                               (or level "info")
                               plugin)))}
          :my-json-rpc-error
          {:fn (fn [params req plugin]
                 (let [{:keys [p-req p-opt]}
                       (plugin/params->map [:p-req :p-opt] params)]
                   (if (nil? p-req)
                     (throw
                      (ex-info "" {:error {:code "-100"
                                           :message "'p-req' param is required"
                                           :request req}}))
                     {:p-req p-req :p-opt p-opt})))}
          :my-notify
          {:fn (fn [params req plugin]
                 (let [p {:msg "some message" :data "some data"}]
                   (plugin/notify "my-topic" p plugin)))}}
         :notifications ["my-topic"]
         :subscriptions
         {:my-topic {:fn (fn [params req plugin]
                           (plugin/log (format "%s" req) plugin))}
          :invoice_creation {:fn (fn [params req plugin]
                                   (plugin/log (format "%s" req) plugin))}}
         :hooks
         {:peer_connected
          {:before ["my-plugin-foo"]
           :after  ["my-plugin-bar" "my-plugin-baz"]
           :fn (fn [params req plugin]
                 (plugin/log (format "peer-id: %s" (get-in params [:peer :id]))
                             plugin)
                 {:result "continue"})}}
         :init-fn
         (fn [params req plugin]
           (if (= (plugin/get-option plugin :my-opt) "disable")
             (throw (ex-info "To start the plugin, don't set 'my-opt' to 'disable'." {}))
             (plugin/log (format "%s" req) plugin)))}))

(defn -main [& args]
  (plugin/run plugin))

Maybe we want to modify our plugin. We can for instance visit src/myplugin.clj file and change :fn function of :my-foo map in :rpcmethods map to the following function that always returns the string "Nothing fancy so far":

(fn [params req plugin] "Nothing fancy so far")

Now, let's stop and restart myplugin

$ lightning-cli plugin stop $(pwd)/myplugin
$ lightning-cli plugin start $(pwd)/myplugin

and call my-foo method:

$ lightning-cli my-foo
"Nothing fancy so far"

If you've read this far, maybe it's because you want to see a bit of magic!

So let's go. Let's see how we can modify myplugin while it's running.

To do this, we modify myplugin bash script and instead of using the command line

clojure -M --main myplugin

to run our plugin, we uses the following which attach a socket REPL server to our plugin's process:

clojure -J-Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -M --main myplugin

Specifically, myplugin bash script is now this:

#!/usr/bin/env bash

cd ${0%/*}
# clojure -M --main myplugin
clojure -J-Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -M --main myplugin

It's time to restart myplugin plugin:

$ lightning-cli plugin stop $(pwd)/myplugin
$ lightning-cli plugin start $(pwd)/myplugin

We can check that nothing change so far and we still have this:

$ lightning-cli my-foo
"Nothing fancy so far"

Now we enter in the magic part!

Let's connect to localhost at port 5555 with nc (we can do this with any client or better with your editor integration):

$ nc localhost 5555
user=>

Notice that we have user=> prompt which expect us to provide Clojure code. user is because we are in user namespace. Our plugin is defined in myplugin namespace, so let's switch to that namespace:

myplugin=> (ns myplugin)
nil

Now, let's get the current value of the option my-opt that we defined:

myplugin=> (plugin/get-option plugin :my-opt)
"my-opt-default"

Why don't we look at the state of plugin atom?

myplugin=> (clojure.pprint/pprint @plugin)
{:init
 {:options {:my-opt "my-opt-default"},
  :configuration
  {:lightning-dir "/tmp/l1-regtest/regtest",
   :rpc-file "lightning-rpc",
   :startup false,
   :network "regtest",
   :feature_set
   {:init "080000000000000000000000000008a0882a0a69a2",
    :node "080000000000000000000000000088a0882a0a69a2",
    :channel "",
    :invoice "02000022024100"}}},
 :hooks
 {:peer_connected
  {:before ["my-plugin-foo"],
   :after ["my-plugin-bar" "my-plugin-baz"],
   :fn
   #object[myplugin$fn__11031 0x5d9c7cf0 "myplugin$fn__11031@5d9c7cf0"]}},
 :getmanifest {:allow-deprecated-apis false},
 :subscriptions
 {:my-topic
  {:fn
   #object[myplugin$fn__11027 0x1ca9f04b "myplugin$fn__11027@1ca9f04b"]},
  :invoice_creation
  {:fn
   #object[myplugin$fn__11029 0x58094be8 "myplugin$fn__11029@58094be8"]}},
 :notifications ["my-topic"],
 :dynamic true,
 :init-fn
 #object[myplugin$fn__11033 0x1fe0a1ee "myplugin$fn__11033@1fe0a1ee"],
 :_resps #<Agent@21a5351e: nil>,
 :rpcmethods
 {:my-foo
  {:fn
   #object[myplugin$fn__11011 0x725da2c9 "myplugin$fn__11011@725da2c9"]},
  :my-options
  {:fn
   #object[myplugin$fn__11013 0x6019f161 "myplugin$fn__11013@6019f161"]},
  :my-info
  {:fn
   #object[myplugin$fn__11015 0x7a6cc35d "myplugin$fn__11015@7a6cc35d"]},
  :my-log
  {:fn
   #object[myplugin$fn__11017 0x72e914cb "myplugin$fn__11017@72e914cb"]},
  :my-json-rpc-error
  {:fn
   #object[myplugin$fn__11022 0x6d3dbbb5 "myplugin$fn__11022@6d3dbbb5"]},
  :my-notify
  {:fn
   #object[myplugin$fn__11025 0x4200cd8a "myplugin$fn__11025@4200cd8a"]},
  :setconfig
  {:fn
   #object[clnplugin_clj$setconfig_BANG_ 0x5eea98bc "clnplugin_clj$setconfig_BANG_@5eea98bc"]}},
 :options
 {:my-opt
  {:type "string",
   :description "some description",
   :default "my-opt-default",
   :value "my-opt-default"},
  :my-opt-multi {:type "string", :multi true},
  :my-opt-dynamic {:type "int", :dynamic true}},
 :socket-file "/tmp/l1-regtest/regtest/lightning-rpc",
 :_out
 #object[java.io.OutputStreamWriter 0x649f80e6 "java.io.OutputStreamWriter@649f80e6"]}
nil

Interesting but how about modifying the plugin?

OK, let's do that.

Let's redefine my-foo method such that it returns the string "THIS IS MAGIC!":

myplugin=> (plugin/dev-set-rpcmethod plugin :my-foo (fn [params req plugin] "THIS IS MAGIC!")))
{...}

Now, let's close the connection, call my-foo command and observe that we've modified the plugin while it was running:

$ lightning-cli my-foo
"THIS IS MAGIC!"

That's it!

Almost!

What about distributing a plugin we wrote with clnplugin-clj?

We can compile it into an uberjar file. In fact this is the purpose of the file build.clj that np script created:

$ tree
.
├── build.clj
├── deps.edn
├── myplugin
├── np
└── src
    └── myplugin.clj

So to compile our plugin, we can run the following command

$ clj -T:build plugin

which produces the uberjar file target/myplugin.jar that we start with java command in target/myplugin script.

Specifically, after stopping myplugin like this

$ lightning-cli plugin stop $(pwd)/myplugin

we can restart it but this time using the uberjar file like this:

$ l1-cli plugin start $(pwd)/target/myplugin