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
andmy-opt-dynamic
,registers 6 JSON RPC methods
my-foo
,my-options
,my-info
,my-log
my-json-rpc-error
andmy-notify
,declares 1 custom notification topic
my-topic
,subscribes to the custom notification topic
my-topic
and to the builtin notification topicinvoice_creation
,asks to be consulted for the
peer_connected
hook andinitializes 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