Simple CLN bookkeeper web app powered by lnsocket & Golang - part 1
In this live we build a simple CLN bookkeeper web app which exposes the data we get from the commands bkpr-listbalances
and bkpr-listincome
. We write it in Go using lnsocket
library. All of this is made possible thanks to commando
and bookkeeper
plugins. We finish building this app in the episode #20 of LNROOM.
Transcript with corrections and improvements
The Go application we're are going to write looks like this:
when the
Accounts
button is clicked the data presented comes frombkpr-listbalances
CLN command that we run on our node by sending acommando
message usinglnsocket
library.when the
Income Events
button is clicked the data presented comes frombkpr-listincome
CLN command that we run on our node by sending acommando
message usinglnsocket
library.
For the dynamism of the UI we use HTMX and hyperscript.
Done during the live
Custom Lightning Network running on regtest
Before we started that live session, I created a custom Lightning Network running on regtest with:
2 nodes
l1
andl2
,one channel from the node
l1
to the nodel2
and another channel froml2
tol1
,I made some payment from
l1
tol2
andl2
tol1
,and also a withdrawal by the node
l1
.
This way we have some data to work with and we can reproduce them.
Here is how we produce that Lightning network.
We 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 connect the node l1
and l2
using the handy command connect
(from
lightning/contrib/startup_regtest.sh
) like this:
◉ tony@tony:~/clnlive:
$ connect 1 2
{
"id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"features": "08a0000a0269a2",
"direction": "out",
"address": {
"type": "ipv4",
"address": "127.0.0.1",
"port": 7272
}
}
Then we fund the node l1
and a channel from l1
to l2
using the command
fund_nodes
(from lightning/contrib/startup_regtest.sh
):
◉ tony@tony:~/clnlive:
$ fund_nodes
Mining into address bcrt1qxehk2f0rknajvny4cajsuwmd94vvakz2apx7x2... done.
bitcoind balance: 50.00000000
Waiting for lightning node funds... found.
Funding channel from node 1 to node 2. Waiting for confirmation... done.
Finally, we can run the script lnregtest.bash:
◉ tony@tony:~/clnlive:
$ ./lnregtest.bash
...
Note that the script lnregtest.bash
assumes that we ran the
previous commands above.
allow-deprecated-apis
The commando
API changed a little bit in CLN v23.02 and lnsocket
took
into account this changes just after we did that live session (add new
required fields for commando jsonrpc #21).
So in that video, we set allow-deprecated-apis
to true
in the config
file of the node l1
:
network=regtest
log-level=debug
log-file=/tmp/l1-regtest/log
addr=localhost:7171
allow-deprecated-apis=false
We stop the node l1
and restart it with that new config:
◉ tony@tony:~/clnlive:
$ l1-cli stop
"Shutdown complete"
◉ tony@tony:~/clnlive:
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon
Note that this is no longer necessary.
Connect to l1 with lnsocket
To connect to the node l1
we need its node id, host and port. We get
that information by running:
◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
"id": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
"alias": "VIOLETSPATULA",
"color": "03e216",
"num_peers": 1,
"num_pending_channels": 0,
"num_active_channels": 2,
"num_inactive_channels": 0,
"address": [],
"binding": [
{
"type": "ipv4",
"address": "127.0.0.1",
"port": 7171
}
],
"version": "v23.05.2",
"blockheight": 117,
"network": "regtest",
"fees_collected_msat": 0,
"lightning-dir": "/tmp/l1-regtest/regtest",
"our_features": {
"init": "08a0000a0269a2",
"node": "88a0000a0269a2",
"channel": "",
"invoice": "02000002024100"
}
}
To connect to the node l1
with main.go
program using lnsocket
, we
first define the struct ln
with lnsocket.LNSocket()
call, we generate
a key for main.go
with ln.GenKey()
method and we try to connect to the
node l1
calling the method ln.ConnectAndInit()
to which we pass the
correct information about the node l1
. Finally, at the end we wait 10
seconds before the program terminate to let us observe that l1
is
connected to that program:
package main
import (
"fmt"
"time"
lnsocket "github.com/jb55/lnsocket/go"
)
var HOSTNAME = "127.0.0.1:7171"
var NODEID = "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181"
func main() {
ln := lnsocket.LNSocket{}
ln.GenKey()
err := ln.ConnectAndInit(HOSTNAME, NODEID)
if err != nil {fmt.Println("not connected to peer")}
time.Sleep(10 * time.Second)
}
Before we run main.go
, we switch to another terminal and check that
the node l1
is only connected to one node being the node l2
:
# TERMINAL 2
◉ tony@tony:~/clnlive:
$ alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
"peers": [
{
"id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"connected": true,
"num_channels": 2,
"netaddr": [
"127.0.0.1:7272"
],
"features": "08a0000a0269a2",
"channels": [...]
}
]
}
In the terminal 1 we run main.go
◉ tony@tony:~/clnlive:
$ go run main.go
and we see in the terminal 2 that l1
is now connected to a second node
(being main.go
):
# TERMINAL 2
◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
"peers": [
{
"id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"connected": true,
"num_channels": 2,
"netaddr": [
"127.0.0.1:7272"
],
"features": "08a0000a0269a2",
"channels": [...]
},
{
"id": "030b1736a879486b03aa77fbbf386e38e34568d7096122fd1e3d3a29da047cbf90",
"connected": true,
"num_channels": 0,
"netaddr": [
"127.0.0.1:54270"
],
"features": "",
"channels": []
}
]
}
Send a getinfo commando message to l1
Let's see how to send commando messages to the node l1
asking it to run
the method getinfo
.
To do that we need a rune which authorizes main.go
to run the getinfo
method using commando
messages. We can generate a unrestricted rune
like this:
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune
{
"rune": "BhOlLq7GtF8EfoeipYpXzB-WAY3O4odwu_JeZqZy9Rc9MQ==",
"unique_id": "1",
"warning_unrestricted_rune": "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."
}
Now, with that rune and the method ln.Rpc()
we can send a commando
message with getinfo
as method to run:
package main
import (
"fmt"
"time"
lnsocket "github.com/jb55/lnsocket/go"
)
var RUNE = "BhOlLq7GtF8EfoeipYpXzB-WAY3O4odwu_JeZqZy9Rc9MQ=="
var HOSTNAME = "127.0.0.1:7171"
var NODEID = "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181"
func main() {
ln := lnsocket.LNSocket{}
ln.GenKey()
err := ln.ConnectAndInit(HOSTNAME, NODEID)
if err != nil {fmt.Println("not connected to peer")}
body, _ :=ln.Rpc(RUNE, "getinfo", "[]")
fmt.Println(body)
}
Back to our terminal, we run main.go
and get the information about the
node l1
printed out (in the result
field):
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:getinfo#43","result":{"id":"03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181","alias":"VIOLETSPATULA","color":"03e216","num_peers":2,"num_pending_channels":0,"num_active_channels":2,"num_inactive_channels":0,"address":[],"binding":[{"type":"ipv4","address":"127.0.0.1","port":7171}],"version":"v23.05.2","blockheight":117,"network":"regtest","fees_collected_msat":0,"lightning-dir":"/tmp/l1-regtest/regtest","our_features":{"init":"08a0000a0269a2","node":"88a0000a0269a2","channel":"","invoice":"02000002024100"}}}
Chat
The bookkeeper plugin is written in C, what is the interface between that code in C and this code in Go?
So you're kinda like writing another plugin in Go that is communicating with the Lightning node and manipulating the output of the bookkeeper plugin but not communicating with that bookkeeper plugin directly?
bkpr-listbalances
By running the command bkpr-listbalances
we can see that the node l1
has one onchain account and two opened channels:
◉ tony@tony:~/clnlive:
$ l1-cli bkpr-listbalances
{
"accounts": [
{
"account": "wallet",
"balances": [
{
"balance_msat": 293999705000,
"coin_type": "bcrt"
}
]
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"peer_id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"we_opened": true,
"account_closed": false,
"account_resolved": false,
"balances": [
{
"balance_msat": 999900000,
"coin_type": "bcrt"
}
]
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"peer_id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"we_opened": false,
"account_closed": false,
"account_resolved": false,
"balances": [
{
"balance_msat": 180000,
"coin_type": "bcrt"
}
]
}
]
}
If we replace getinfo
by bkpr-listbalances
in main.go
...
func main() {
...
body, _ :=ln.Rpc(RUNE, "bkpr-listbalances", "[]")
fmt.Println(body)
}
we get the same information as above by running main.go
program:
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:bkpr-listbalances#45","result":{"accounts":[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]}}
◉ tony@tony:~/clnlive:
Rune that only authorize the methods starting by bkpr-
As we are writing an application which only uses bookkeeper commands, we don't want to use an unrestricted rune.
To generate a new rune restricted to the methods starting by bkpr-
,
we run the following command:
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune null '[["method^bkpr-"]]'
{
"rune": "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=",
"unique_id": "2"
}
Now, we replace in main.go
the unrestricted run by this new rune and
we also replace bkpr-listbalances
by getinfo
in order to check that
this rune doesn't authorize main.go
to run getinfo
on the node l1
using commando
messages:
var RUNE = "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0="
...
func main() {
...
body, _ :=ln.Rpc(RUNE, "getinfo", "[]")
fmt.Println(body)
}
Back to our terminal we get the following expected error:
◉ tony@tony:~/clnlive:
$ go run main.go
{"error":{"code":19537,"message":"Not authorized: method does not start with bkpr-"}}
To have human understable information about runes we can use the
command decode
like this:
◉ tony@tony:~/clnlive:
$ l1-cli -k decode string=vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=
{
"type": "rune",
"unique_id": "2",
"string": "bf17d0339e687cd084561ed4f447a46c124ddfd590b63ba91678aaca592da6b3:=2&method^bkpr-",
"restrictions": [
{
"alternatives": [
"method^bkpr-"
],
"summary": "method (of command) starts with 'bkpr-'"
}
],
"valid": true
}
Before we move on, let's replace getinfo
by bkpr-listbalances
in
main.go
var RUNE = "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0="
...
func main() {
...
body, _ :=ln.Rpc(RUNE, "bkpr-listbalances", "[]")
fmt.Println(body)
}
and check that everything works as expected:
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:bkpr-listbalances#50","result":{"accounts":[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]}}
Get the http server running
We use the library net/http
to start a server on localhost on
port 8080.
In the home page (root /
), we print foo
. To do that, we define a
http.HandlerFunc
named myHandler
which writes foo
to its argument w
.
The function myHandler
is used in http.HandleFunc()
method to produce
the home page (root /
). Finally, we start the server with
http.ListenAndServe()
method:
package main
import (
"fmt"
"log"
"net/http"
lnsocket "github.com/jb55/lnsocket/go"
)
var RUNE = "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0="
var HOSTNAME = "127.0.0.1:7171"
var NODEID = "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181"
func myHandler (w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, "foo")
}
func main() {
ln := lnsocket.LNSocket{}
ln.GenKey()
err := ln.ConnectAndInit(HOSTNAME, NODEID)
if err != nil {fmt.Println("not connected to peer")}
http.HandleFunc("/", myHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
and by visiting http://localhost:8080
in the browser.
raw bkpr-listbalances served at the home page
As we would like to pass ln
struct and RUNE
variable to the
http.Handler
function that we pass to http.HandleFunc
, we define the
function makeHomeHandler
that takes as argument &ln
and RUNE
and
returns and http.Handler
closure which write bar
to its argument w
:
package main
import (
"fmt"
"log"
"net/http"
lnsocket "github.com/jb55/lnsocket/go"
)
var RUNE = "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0="
var HOSTNAME = "127.0.0.1:7171"
var NODEID = "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181"
func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, "bar")
}
}
func main() {
ln := lnsocket.LNSocket{}
ln.GenKey()
err := ln.ConnectAndInit(HOSTNAME, NODEID)
if err != nil {fmt.Println("not connected to peer")}
http.HandleFunc("/", makeHomeHandler(&ln, RUNE))
log.Fatal(http.ListenAndServe(":8080", nil))
}
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
and by visiting http://localhost:8080
in the browser.
Now, in the closure returned by makeHomeHandler
, we do a commando
request to the node l1
that runs bkpr-listbalances
and we write the
answer we get to w
(http.ResponseWriter
):
...
func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
body, _ :=ln.Rpc(RUNE, "bkpr-listbalances", "[]")
fmt.Fprintln(w, body)
}
}
...
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
and by visiting http://localhost:8080
in the browser and by seeing
that bkpr-listbalances
data are printed.
Add html template to our http server
We use the builtin Go library html/template
for the html template.
And the template for the home page will be defined in the file
index.html
and we start with this skeleton:
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="description" content="CLN Bookkeeper Web App" />
<link rel="stylesheet" type="text/css" href="/assets/bkpr.css" />
<script src="https://unpkg.com/htmx.org@1.9.4"></script>
<script src="https://unpkg.com/hyperscript.org@0.9.9"></script>
<title>CLN Bookkeeper Web App</title>
</head>
<body>
<h1 id="header">CLN Bookkeeper</h1>
<div id="content">
<div id="tabs">
<div id="tab-accounts"
class="tab"
>
Accounts
</div>
<div>|</div>
<div id="tab-listincome"
class="tab"
>
Income Events
</div>
</div>
<div id="accounts-or-listincome">
<!-- ... -->
</div>
</div>
</body>
</html>
To use the template index.html
in the home page we import
html/template
and we do the following modifications in main.go
:
...
import (
"fmt"
"log"
"net/http"
"html/template"
lnsocket "github.com/jb55/lnsocket/go"
)
...
func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tpl, _ := template.ParseFiles("index.html")
tpl.Execute(w, nil)
}
}
...
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
and by visiting http://localhost:8080
in the browser and by seeing
that the template has been used in the home page.
In tpl.Execute()
call we can pass bkpr-listconfigs
data instead of nil
like this
...
func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
body, _ :=ln.Rpc(RUNE, "bkpr-listbalances", "[]")
tpl, _ := template.ParseFiles("index.html")
tpl.Execute(w, body)
}
}
...
and to get that data used in the template we add {{.}}
in the file
index.html
where we want the data to be used:
...
<div id="accounts-or-listincome">
{{.}}
</div>
...
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
and by visiting http://localhost:8080
in the browser and by seeing
that bkpr-listbalances
data are printed along with the template.
Now instead of passing the raw string body
containing
bkpr-listbalances
data directly to the template we transform body
into
an array of accounts of type Account
named accounts
that we pass to
the template:
package main
import (
"fmt"
"log"
"net/http"
"html/template"
"github.com/tidwall/gjson"
lnsocket "github.com/jb55/lnsocket/go"
)
// var RUNE = "BhOlLq7GtF8EfoeipYpXzB-WAY3O4odwu_JeZqZy9Rc9MQ=="
var RUNE = "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0="
var HOSTNAME = "127.0.0.1:7171"
var NODEID = "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181"
type Account struct {
Account string
BalanceMsat string
}
func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
body, _ := ln.Rpc(RUNE, "bkpr-listbalances", "[]")
accArr := gjson.Get(body, "result.accounts").Array()
accounts := make([]Account, len(accArr))
for i, account := range accArr {
accounts[i] = Account{
Account: account.Get("account").String(),
BalanceMsat: account.Get("balances.0.balance_msat").String(),
}
}
tpl, _ := template.ParseFiles("index.html")
tpl.Execute(w, accounts)
}
}
...
We also need to modify index.html
template:
<div id="accounts-or-listincome">
<li class="account">
<div>Account: {{.Account}}</div>
<div>Balance: {{.BalanceMsat}} msat</div>
</li>
</div>
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
and by visiting http://localhost:8080
in the browser and by seeing
that bkpr-listbalances
data are printed.
Add css file
Now that we get the template system working, let's add some CSS to make the UI more pleasant.
To use the CSS file bkpr.css defined in the directory assets
we use
the method http.Handle
and http.FileServer
as follow:
...
func main() {
ln := lnsocket.LNSocket{}
ln.GenKey()
err := ln.ConnectAndInit(HOSTNAME, NODEID)
if err != nil {fmt.Println("not connected to peer")}
http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets"))))
http.HandleFunc("/", makeHomeHandler(&ln, RUNE))
log.Fatal(http.ListenAndServe(":8080", nil))
}
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
and by visiting http://localhost:8080
in the browser.
Note that in that css file we defined only 4 type of tags describing
the movement descriptor of the coins present in the data returned by
bkpr-listincome
:
.tag-onchain_fee {background-color: #7CB2DF;}
.tag-invoice {background-color: #ffe08a;}
.tag-deposit {background-color: #DFA87C;}
.tag-withdrawal {background-color: #E9A5A9;}
If you want the complete list of these tags and their meaning you can check:
coin_movement notification topic documentation (or in the source code lightning:common/coin_mvt.c):
deposit
,withdrawal
,penalty
,invoice
,routed
,pushed
,channel_open
,channel_close
,delayed_to_us
,htlc_timeout
,htlc_fulfill
,htlc_tx
,to_wallet
,ignored
,anchor
,to_them
,penalized
,stolen
,to_miner
,opener
,lease_fee
,leased
,stealable
,channel_proposed
andlightning:plugins/bkpr/account_entry.c:
journal_entry
,penalty_adj
,invoice_fee
,rebalance_fee
.
Use htmx to swap divs
Now:
if we click on
Accounts
we will swap the content of the div with idaccounts-or-listincome
with the html returned at the root/accounts
(but as we haven't assigned yet the root/accounts
a handler function, an ajax request to that root returns the home page) andif we click on
Income events
we will swap the content of the div with idaccounts-or-listincome
with the html returned at the root/listincome
(we assigned that root a handler function just below).
To do that we use the attributes hx-get
, hx-swap
and hx-target
provided by HTMX library like this in index.html
template:
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<h1 id="header">CLN Bookkeeper</h1>
<div id="content">
<div id="tabs">
<div id="tab-accounts"
class="tab selected"
hx-get="/accounts"
hx-swap="innerHTML"
hx-target="#accounts-or-listincome"
>
Accounts
</div>
<div>|</div>
<div id="tab-listincome"
class="tab"
hx-get="/listincome"
hx-swap="innerHTML"
hx-target="#accounts-or-listincome"
>
Income Events
</div>
</div>
<div id="accounts-or-listincome">
...
</div>
</div>
</body>
</html>
We also have to define the new root /listincome
which will return foo
:
func listincomeHandler (w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, "foo")
}
...
func main() {
...
http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets"))))
http.HandleFunc("/", makeHomeHandler(&ln, RUNE))
http.HandleFunc("/listincome", listincomeHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
by visiting http://localhost:8080
in the browser and by clicking on
Accounts
and Income Events
buttons.
Done after the live
Define handler for /accounts root
When we click on Accounts
we want to swap the content of the div with
id accounts-or-listincome
with the html returned at the root /accounts
.
So we have to assign that root a handler function.
First we create the fragment template accounts
in index.html
template
file using {{block ...}}
construct such that we can use it in or Go
code:
...
<div id="accounts-or-listincome">
{{block "accounts" .}}
<ul id="accounts">
{{range .}}
<li class="account">
<div>Account: {{.Account}}</div>
<div>Balance: {{.BalanceMsat}} msat</div>
</li>
{{end}}
</ul>
{{end}}
</div>
...
Now we can define the function makeAccountsHandler
which returns a
closure that uses the fragment template accounts
(using
tpl.ExecuteTemplate()
method) and we assigned that closure to the root
/accounts
like this:
...
func makeAccountsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
body, _ := ln.Rpc(RUNE, "bkpr-listbalances", "[]")
accArr := gjson.Get(body, "result.accounts").Array()
accounts := make([]Account, len(accArr))
for i, account := range accArr {
accounts[i] = Account{
Account: account.Get("account").String(),
BalanceMsat: account.Get("balances.0.balance_msat").String(),
}
}
tpl, _ := template.ParseFiles("index.html")
tpl.ExecuteTemplate(w, "accounts", accounts)
}
}
...
func main() {
...
http.HandleFunc("/accounts", makeAccountsHandler(&ln, RUNE))
log.Fatal(http.ListenAndServe(":8080", nil))
}
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
by visiting http://localhost:8080
in the browser and by clicking on
Accounts
and Income Events
buttons.
We can see in main.go
that we have unnecessary duplicated code in the
function makeHomeHandler
and makeAccountsHandler
.
Let's do a bit of refactoring.
We define the new function listAccounts
which returns the array of
accounts that we will pass as argument to the methods tpl.Execute()
and tpl.ExecuteTemplate()
.
...
type Account struct {
Account string
BalanceMsat string
}
func listAccounts(ln *lnsocket.LNSocket, rune string) []Account {
body, _ := ln.Rpc(RUNE, "bkpr-listbalances", "[]")
accArr := gjson.Get(body, "result.accounts").Array()
accounts := make([]Account, len(accArr))
for i, account := range accArr {
accounts[i] = Account{
Account: account.Get("account").String(),
BalanceMsat: account.Get("balances.0.balance_msat").String(),
}
}
return accounts
}
func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tpl, _ := template.ParseFiles("index.html")
tpl.Execute(w, listAccounts(ln, rune))
}
}
func makeAccountsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tpl, _ := template.ParseFiles("index.html")
tpl.ExecuteTemplate(w, "accounts", listAccounts(ln, rune))
}
}
...
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
by visiting http://localhost:8080
in the browser and by clicking on
Accounts
and Income Events
buttons.
Use hyperscript to toggle the class selected in 'Accounts' and 'Income Events' divs
Let's update index.html
template with a bit of hyperscript in order to
toggle .selected
class when we click on the divs Accounts
and Income Events
.
We add hyperscript snippets as value of the attribute _
like this:
<div id="tabs">
<div id="tab-accounts"
class="tab selected"
hx-get="/accounts"
hx-swap="innerHTML"
hx-target="#accounts-or-listincome"
_="on click
add .selected to me
remove .selected from #tab-listincome"
>
Accounts
</div>
<div>|</div>
<div id="tab-listincome"
class="tab"
hx-get="/listincome"
hx-swap="innerHTML"
hx-target="#accounts-or-listincome"
_="on click
add .selected to me
remove .selected from #tab-accounts"
>
Income Events
</div>
</div>
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
by visiting http://localhost:8080
in the browser and by clicking on
Accounts
and Income Events
buttons.
bkpr-listincome
In that section we write the code for the data returned by
bkpr-listincomes
command that we ask the node l1
to run by sending it
a commando
message.
Let's take a look at the data returned by bkpr-listincomes
command:
◉ tony@tony:~/clnlive:
$ l1-cli bkpr-listincome
{
"income_events": [
{
"account": "wallet",
"tag": "deposit",
"credit_msat": 100000000000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691069981,
"outpoint": "4404f8982b99b2a8b424aa4a4069a2ac102ee09539a70b9413f0fe7f52273a8b:1"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 10000,
"currency": "bcrt",
"timestamp": 1691070075,
"description": "pizza",
"payment_id": "e3b10008930c43d884dbbe7fc3e410f7d07264b1b17951fcc5928daa828612b7"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 20000,
"currency": "bcrt",
"timestamp": 1691070077,
"description": "pizza",
"payment_id": "5b37b6acc53bbda10a71e734ee3b195a6c01e6aa561c58c70468569e473b422a"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 30000,
"currency": "bcrt",
"timestamp": 1691070079,
"description": "pizza",
"payment_id": "8b64023279d255ab7cad79ae9c6fb88bd687edc27df688ab482c0f6e667d76cf"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 40000,
"currency": "bcrt",
"timestamp": 1691070082,
"description": "pizza",
"payment_id": "24bc4ff483dda7256bfdb8b908f5a730e1174a3fba061a6f142b3c1166243237"
},
{
"account": "wallet",
"tag": "deposit",
"credit_msat": 200000000000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070102,
"outpoint": "12811f534a59262fdd007e64425298c0bff472493bdf6c43d027d1b6cfa4626e:1"
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"tag": "invoice",
"credit_msat": 50000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070165,
"description": "pizza",
"payment_id": "6e8490f0129f89c5a5d0427008d5c1d987ed6623542a74d7c60d6068262c31ba"
},
{
"account": "wallet",
"tag": "withdrawal",
"credit_msat": 0,
"debit_msat": 5000000000,
"currency": "bcrt",
"timestamp": 1691070167,
"outpoint": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e:0"
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"tag": "invoice",
"credit_msat": 60000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070167,
"description": "pizza",
"payment_id": "c4a85c2f5279a7fbd53ffe520b9ba9bcffa1d7ae4c680b027273af6f6e444ca2"
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"tag": "invoice",
"credit_msat": 70000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070169,
"description": "pizza",
"payment_id": "3502d6e21dbda004677854be1c96101d10c94a2cbf063a8c61a545627194eabc"
},
{
"account": "wallet",
"tag": "onchain_fee",
"credit_msat": 0,
"debit_msat": 141000,
"currency": "bcrt",
"timestamp": 1691070193,
"txid": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "onchain_fee",
"credit_msat": 0,
"debit_msat": 154000,
"currency": "bcrt",
"timestamp": 1691070012,
"txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7"
}
]
}
For each income events we are going to use only the field account
,
tag
, credit_msat
and debit_msat
.
Here is the code that we add to main.go
to take care of
bkpr-listincome
data:
...
type IncomeEvent struct {
Account string
Tag string
CreditMsat int64
DebitMsat int64
}
func makeIncomeEventsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
body, _ := ln.Rpc(RUNE, "bkpr-listincome", "[]")
incEvtArr := gjson.Get(body, "result.income_events").Array()
incomeEvents := make([]IncomeEvent, len(incEvtArr))
for i, incomeEvent := range incEvtArr {
incomeEvents[i] = IncomeEvent{
Account: abbrevAccount(incomeEvent.Get("account").String()),
Tag: incomeEvent.Get("tag").String(),
CreditMsat: incomeEvent.Get("credit_msat").Int(),
DebitMsat: incomeEvent.Get("debit_msat").Int(),
}
}
tpl, _ := template.ParseFiles("listincome.html")
tpl.Execute(w, incomeEvents)
}
}
func main() {
...
http.HandleFunc("/listincome", makeIncomeEventsHandler(&ln, RUNE))
http.HandleFunc("/accounts", makeAccountsHandler(&ln, RUNE))
log.Fatal(http.ListenAndServe(":8080", nil))
}
To get that code working we define the template listincome.html
like
this:
<ul id="income-events">
{{range .}}
<li class="income-event">
<div class="income-event-left">
<div>{{.Account}}</div>
<div class="tag tag-{{.Tag}}">{{.Tag}}</div>
</div>
{{if .CreditMsat}}
<div class="credit">{{.CreditMsat}} msat</div>
{{else}}
<div>-{{.DebitMsat}} msat</div>
{{end}}
</li>
{{end}}
</ul>
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
by visiting http://localhost:8080
in the browser and by clicking on
Accounts
and Income Events
buttons.
Abbreviate account names if too long
As account names can be channel id, they can be large and take too much space in the UI. In that section we are going to abbreviate them.
To do so we define the function abbrevAccount
that we use in
listAccounts
and in makeIncomeEventsHandler
functions:
...
func abbrevAccount(acc string) string{
if len(acc) > 15 {
return acc[:6] + "..." + acc[len(acc) - 6:]
} else {
return acc
}
}
func listAccounts(ln *lnsocket.LNSocket, rune string) []Account {
...
for i, account := range accArr {
accounts[i] = Account{
Account: abbrevAccount(account.Get("account").String()),
...
}
}
...
}
...
func makeIncomeEventsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
...
for i, incomeEvent := range incEvtArr {
incomeEvents[i] = IncomeEvent{
Account: abbrevAccount(incomeEvent.Get("account").String()),
...
}
}
...
}
}
...
Back to our terminal we verify that this is working by running
◉ tony@tony:~/clnlive:
$ go run main.go
by visiting http://localhost:8080
in the browser and by clicking on
Accounts
and Income Events
buttons.
We are done!
Terminal session
We ran the following commands in this order:
$ source lightning/contrib/startup_regtest.sh
$ start_ln
$ connect 1 2
$ fund_nodes
$ ./lnregtest.bash
$ l1-cli stop
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon
$ l1-cli getinfo | jq -r .id
$ l1-cli commando-rune
$ l1-cli getinfo
$ go run main.go
$ alias l1-cli
$ go run main.go
$ l1-cli commando-rune
$ go run main.go
$ l1-cli bkpr-listbalances
$ go run main.go
$ l1-cli commando-rune null '[["method^bkpr-"]]'
$ go run main.go
$ l1-cli -k decode string=vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=
$ go run main.go
$ l1-cli bkpr-listincome
$ go run main.go
And below you can read the terminal session (command lines and outputs):
◉ 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
◉ tony@tony:~/clnlive:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 4304
[2] 4345
WARNING: eatmydata not found: instal it for faster testing
Commands:
l1-cli, l1-log,
l2-cli, l2-log,
bt-cli, stop_ln, fund_nodes
◉ tony@tony:~/clnlive:
$ connect 1 2
{
"id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"features": "08a0000a0269a2",
"direction": "out",
"address": {
"type": "ipv4",
"address": "127.0.0.1",
"port": 7272
}
}
◉ tony@tony:~/clnlive:
$ fund_nodes
Mining into address bcrt1qxehk2f0rknajvny4cajsuwmd94vvakz2apx7x2... done.
bitcoind balance: 50.00000000
Waiting for lightning node funds... found.
Funding channel from node 1 to node 2. Waiting for confirmation... done.
◉ tony@tony:~/clnlive:
$ ./lnregtest.bash
{
"destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"payment_hash": "e3b10008930c43d884dbbe7fc3e410f7d07264b1b17951fcc5928daa828612b7",
"created_at": 1691070073.682,
"parts": 1,
"amount_msat": 10000,
"amount_sent_msat": 10000,
"payment_preimage": "70c4903543a3eb23c3daaeb8fa0139ffbfa8786392ca6e4b7126428e2878b1a2",
"status": "complete"
}
{
"destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"payment_hash": "5b37b6acc53bbda10a71e734ee3b195a6c01e6aa561c58c70468569e473b422a",
"created_at": 1691070075.044,
"parts": 1,
"amount_msat": 20000,
"amount_sent_msat": 20000,
"payment_preimage": "0006dd84451a955668994041b566dfa04708ba8fffdb92f4fdc003cae87de894",
"status": "complete"
}
{
"destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"payment_hash": "8b64023279d255ab7cad79ae9c6fb88bd687edc27df688ab482c0f6e667d76cf",
"created_at": 1691070076.861,
"parts": 1,
"amount_msat": 30000,
"amount_sent_msat": 30000,
"payment_preimage": "c7b4741eae1e2af83ee5b3397963e057aa6c613c86b5c17bed091961ce6f4b68",
"status": "complete"
}
12811f534a59262fdd007e64425298c0bff472493bdf6c43d027d1b6cfa4626e
[
"4326b52dc362707df8237c3125d03903387a82c79b0f6a733438e842ee568f16"
]
{
"destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"payment_hash": "24bc4ff483dda7256bfdb8b908f5a730e1174a3fba061a6f142b3c1166243237",
"created_at": 1691070080.124,
"parts": 1,
"amount_msat": 40000,
"amount_sent_msat": 40000,
"payment_preimage": "0b3dc7c583277f993dc59861ad03c2014e4f28be4ada8b737a6b5a383028ec83",
"status": "complete"
}
d1f9103197dd0d278769010f9dd04dd83392ed78e6c65fcdc3e662127b737c3b
[
"0d63a7623787ad784329ad59e035d8dad835f54e64ebf3cb4a1c251f4cf00d78"
]
{
"tx": "020000000001013b7c737b1262e6c3cd5fc6e678ed9233d84dd09d0f016987270ddd973110f9d10100000000fdffffff02269ee6050000000016001492095c4cbc3839afe174c7c176feca857904b5d240420f0000000000220020b5312f60134bbbf25402d9ae14b760ee140b8ced21ba72a91d763de440d2c9e10247304402204b567f08715d96a1b7cd952b7408f6d3c2e4e28fda831231b6dbe63431dcb365022004e3231e303f5f76996ec784c2032e6f532ac64203ca5fd43b174a42323283fc012102f21f74af832dcdcff562817f20db099eeccf8b0949c1cd7a6fca9210af9f8f396e000000",
"txid": "a1d659a4006711bb0fad3ac8a089f7ed24aa9c2de850991f6a6bc2024c956188",
"channel_id": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"outnum": 1
}
[
"5d5d6f6699bc6c9623f3a7559a3a3f7a2f8a922164cebb22dfbef770ab4910fd",
"0757bf936b2d2670b582e85d197d46b2fe87b96978e5aee05b06880801358e34",
"401b0513718f642bebe82b573e8e7604e3b5c42a7f4810cc61ea558fa5fcf942",
"47066959d8a85b7f9e3018e86c2e00ae61ccafd968a7bed154370acb4483e1ed",
"0dc2a78aad146ec9949a229266fa730097b585d280100ba14ede325175fa52b7",
"180575484301375083b7ead73a7a7c95c24e904b9562a4f54649fb64b2efe6a8"
]
{
"destination": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
"payment_hash": "6e8490f0129f89c5a5d0427008d5c1d987ed6623542a74d7c60d6068262c31ba",
"created_at": 1691070163.957,
"parts": 1,
"amount_msat": 50000,
"amount_sent_msat": 50000,
"payment_preimage": "600b7f1be35e0267d7a4fca25854005bdc72b4fb6b281371b5936a05353d42b0",
"status": "complete"
}
{
"destination": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
"payment_hash": "c4a85c2f5279a7fbd53ffe520b9ba9bcffa1d7ae4c680b027273af6f6e444ca2",
"created_at": 1691070165.307,
"parts": 1,
"amount_msat": 60000,
"amount_sent_msat": 60000,
"payment_preimage": "8669337dd3d474e9e05fd69ad18d192498c91e535fa38524652d9eb362ada3a3",
"status": "complete"
}
{
"tx": "0200000001c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397c0000000000fdffffff02404b4c000000000016001481d7d1e0b9f24212399251d48c8020cf9025f8bf59529a05000000001600145136cd6a7861c8920fe0907513fcc85dda76fef274000000",
"txid": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e",
"psbt": "cHNidP8BAgQCAAAAAQMEdAAAAAEEAQEBBQECAQYBAwH7BAIAAAAAAQDqAgAAAAABAYs6J1J//vATlAunOZXgLhCsomlASqoktKiymSuY+AREAQAAAAD9////Aiae5gUAAAAAFgAUKSbJiqWXQo5V+1ampLIHFvXMwvNAQg8AAAAAACIAIBIdQLfd6ueoerZTZyPNfnkV20CnudmvJo3mCDIb3yoFAkcwRAIgVvgr0O+uky+6BsCjNKQ9eJnkdS3wFLM+AOuyWcWaTdcCIE54jqzrMQkt8zpQOX5OulXjQsKGf7XGP/1O2etPMwGJASEDaNQcKKaCKCEHpi+928ncgytwpU1s4vKlUthcr58XAGRmAAAAAQEfJp7mBQAAAAAWABQpJsmKpZdCjlX7VqaksgcW9czC8yICAhGE6BfcIS1Uo6BykP+91Xr8ljP+aQPU/E3ekwhG11IgRzBEAiAaQjbunzCnxKZUeFJ2wqf1ZpFbx17GlQfZ1DNUxlQBdgIgW/DafJW/e2SMc8R38MfcXMKxmuELcjCbyrULf3pzm9cBIgYCEYToF9whLVSjoHKQ/73VevyWM/5pA9T8Td6TCEbXUiAIKSbJigAAAAABDiDHFWMrpZAlKe4JlYj5yhLs+HfATVg4SbwIwX3NAoA5fAEPBAAAAAABEAT9////AAEDCEBLTAAAAAAAAQQWABSB19HgufJCEjmSUdSMgCDPkCX4vwz8CWxpZ2h0bmluZwQCAAEAIgICzW1JalR2c0OqYy6Tcds5ecvW/ZXSCP/GF/u2rT+zIisIUTbNagYAAAABAwhZUpoFAAAAAAEEFgAUUTbNanhhyJIP4JB1E/zIXdp2/vIA"
}
[
"50abdf6cb5c04f1d2c5df21b8a7c9dc93341132c7d51b0c22252c87e4bb1f325"
]
{
"destination": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
"payment_hash": "3502d6e21dbda004677854be1c96101d10c94a2cbf063a8c61a545627194eabc",
"created_at": 1691070167.937,
"parts": 1,
"amount_msat": 70000,
"amount_sent_msat": 70000,
"payment_preimage": "167ae1f808c22107e2d5ebe152fccd9f4d882b151b44457f21a339b79c161a5e",
"status": "complete"
}
◉ tony@tony:~/clnlive:
$ l1-cli stop
"Shutdown complete"
◉ tony@tony:~/clnlive:
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon
◉ tony@tony:~/clnlive:
$ l1-cli getinfo | jq -r .id
03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune
{
"rune": "IY9EVF2zTD8-9UMIEyov4DWCmE3Y4XQwoC5vcywqMnM9MA==",
"unique_id": "0",
"warning_unrestricted_rune": "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."
}
◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
"id": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
"alias": "VIOLETSPATULA",
"color": "03e216",
"num_peers": 1,
"num_pending_channels": 0,
"num_active_channels": 2,
"num_inactive_channels": 0,
"address": [],
"binding": [
{
"type": "ipv4",
"address": "127.0.0.1",
"port": 7171
}
],
"version": "v23.05.2",
"blockheight": 117,
"network": "regtest",
"fees_collected_msat": 0,
"lightning-dir": "/tmp/l1-regtest/regtest",
"our_features": {
"init": "08a0000a0269a2",
"node": "88a0000a0269a2",
"channel": "",
"invoice": "02000002024100"
}
}
◉ tony@tony:~/clnlive:
$ go run main.go
◉ tony@tony:~/clnlive:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive:
$ go run main.go
{"error":{"code":19537,"message":"Not authorized: Invalid rune"}}
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune
{
"rune": "BhOlLq7GtF8EfoeipYpXzB-WAY3O4odwu_JeZqZy9Rc9MQ==",
"unique_id": "1",
"warning_unrestricted_rune": "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:getinfo#43","result":{"id":"03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181","alias":"VIOLETSPATULA","color":"03e216","num_peers":2,"num_pending_channels":0,"num_active_channels":2,"num_inactive_channels":0,"address":[],"binding":[{"type":"ipv4","address":"127.0.0.1","port":7171}],"version":"v23.05.2","blockheight":117,"network":"regtest","fees_collected_msat":0,"lightning-dir":"/tmp/l1-regtest/regtest","our_features":{"init":"08a0000a0269a2","node":"88a0000a0269a2","channel":"","invoice":"02000002024100"}}}
◉ tony@tony:~/clnlive:
$ l1-cli bkpr-listbalances
{
"accounts": [
{
"account": "wallet",
"balances": [
{
"balance_msat": 293999705000,
"coin_type": "bcrt"
}
]
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"peer_id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"we_opened": true,
"account_closed": false,
"account_resolved": false,
"balances": [
{
"balance_msat": 999900000,
"coin_type": "bcrt"
}
]
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"peer_id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"we_opened": false,
"account_closed": false,
"account_resolved": false,
"balances": [
{
"balance_msat": 180000,
"coin_type": "bcrt"
}
]
}
]
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:bkpr-listbalances#45","result":{"accounts":[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]}}
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune null '[["method^bkpr-"]]'
{
"rune": "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=",
"unique_id": "2"
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"error":{"code":19537,"message":"Not authorized: method does not start with bkpr-"}}
◉ tony@tony:~/clnlive:
$ l1-cli -k decode string=vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=
{
"type": "rune",
"unique_id": "2",
"string": "bf17d0339e687cd084561ed4f447a46c124ddfd590b63ba91678aaca592da6b3:=2&method^bkpr-",
"restrictions": [
{
"alternatives": [
"method^bkpr-"
],
"summary": "method (of command) starts with 'bkpr-'"
}
],
"valid": true
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:bkpr-listbalances#50","result":{"accounts":[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]}}
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]
[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]
[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ l1-cli bkpr-listincome
{
"income_events": [
{
"account": "wallet",
"tag": "deposit",
"credit_msat": 100000000000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691069981,
"outpoint": "4404f8982b99b2a8b424aa4a4069a2ac102ee09539a70b9413f0fe7f52273a8b:1"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 10000,
"currency": "bcrt",
"timestamp": 1691070075,
"description": "pizza",
"payment_id": "e3b10008930c43d884dbbe7fc3e410f7d07264b1b17951fcc5928daa828612b7"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 20000,
"currency": "bcrt",
"timestamp": 1691070077,
"description": "pizza",
"payment_id": "5b37b6acc53bbda10a71e734ee3b195a6c01e6aa561c58c70468569e473b422a"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 30000,
"currency": "bcrt",
"timestamp": 1691070079,
"description": "pizza",
"payment_id": "8b64023279d255ab7cad79ae9c6fb88bd687edc27df688ab482c0f6e667d76cf"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "invoice",
"credit_msat": 0,
"debit_msat": 40000,
"currency": "bcrt",
"timestamp": 1691070082,
"description": "pizza",
"payment_id": "24bc4ff483dda7256bfdb8b908f5a730e1174a3fba061a6f142b3c1166243237"
},
{
"account": "wallet",
"tag": "deposit",
"credit_msat": 200000000000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070102,
"outpoint": "12811f534a59262fdd007e64425298c0bff472493bdf6c43d027d1b6cfa4626e:1"
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"tag": "invoice",
"credit_msat": 50000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070165,
"description": "pizza",
"payment_id": "6e8490f0129f89c5a5d0427008d5c1d987ed6623542a74d7c60d6068262c31ba"
},
{
"account": "wallet",
"tag": "withdrawal",
"credit_msat": 0,
"debit_msat": 5000000000,
"currency": "bcrt",
"timestamp": 1691070167,
"outpoint": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e:0"
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"tag": "invoice",
"credit_msat": 60000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070167,
"description": "pizza",
"payment_id": "c4a85c2f5279a7fbd53ffe520b9ba9bcffa1d7ae4c680b027273af6f6e444ca2"
},
{
"account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"tag": "invoice",
"credit_msat": 70000,
"debit_msat": 0,
"currency": "bcrt",
"timestamp": 1691070169,
"description": "pizza",
"payment_id": "3502d6e21dbda004677854be1c96101d10c94a2cbf063a8c61a545627194eabc"
},
{
"account": "wallet",
"tag": "onchain_fee",
"credit_msat": 0,
"debit_msat": 141000,
"currency": "bcrt",
"timestamp": 1691070193,
"txid": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e"
},
{
"account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"tag": "onchain_fee",
"credit_msat": 0,
"debit_msat": 154000,
"currency": "bcrt",
"timestamp": 1691070012,
"txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7"
}
]
}
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
c71...97d
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
# TERMINAL 2
◉ tony@tony:~/clnlive:
$ alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
"peers": [
{
"id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"connected": true,
"num_channels": 2,
"netaddr": [
"127.0.0.1:7272"
],
"features": "08a0000a0269a2",
"channels": [
{
"state": "CHANNELD_NORMAL",
"scratch_txid": "df7f375bb6b7bed653e7093dc7ff4da3ee940559eb19ec3f011eb2c8adcd16b4",
"last_tx_fee_msat": 283000,
"feerate": {
"perkw": 253,
"perkb": 1012
},
"owner": "channeld",
"short_channel_id": "103x1x1",
"direction": 1,
"channel_id": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"funding_txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7",
"funding_outnum": 1,
"close_to_addr": "bcrt1qap7tds3y99l76k2uut8gy03w866nxgkqwwdvj5",
"close_to": "0014e87cb6c224297fed595ce2ce823e2e3eb53322c0",
"private": false,
"opener": "local",
"alias": {
"local": "4789751x13272092x51577",
"remote": "4204164x15552480x64151"
},
"features": [
"option_static_remotekey"
],
"funding": {
"local_funds_msat": 1000000000,
"remote_funds_msat": 0,
"pushed_msat": 0
},
"to_us_msat": 999900000,
"min_to_us_msat": 999900000,
"max_to_us_msat": 1000000000,
"total_msat": 1000000000,
"fee_base_msat": 1,
"fee_proportional_millionths": 10,
"dust_limit_msat": 546000,
"max_total_htlc_in_msat": 18446744073709551615,
"their_reserve_msat": 10000000,
"our_reserve_msat": 10000000,
"spendable_msat": 989360000,
"receivable_msat": 0,
"minimum_htlc_in_msat": 0,
"minimum_htlc_out_msat": 0,
"maximum_htlc_out_msat": 990000000,
"their_to_self_delay": 6,
"our_to_self_delay": 6,
"max_accepted_htlcs": 483,
"state_changes": [
{
"timestamp": "2023-08-03T13:40:12.413Z",
"old_state": "CHANNELD_AWAITING_LOCKIN",
"new_state": "CHANNELD_NORMAL",
"cause": "user",
"message": "Lockin complete"
}
],
"status": [
"CHANNELD_NORMAL:Reconnected, and reestablished.",
"CHANNELD_NORMAL:Channel ready for use. Channel announced."
],
"in_payments_offered": 0,
"in_offered_msat": 0,
"in_payments_fulfilled": 0,
"in_fulfilled_msat": 0,
"out_payments_offered": 4,
"out_offered_msat": 100000,
"out_payments_fulfilled": 4,
"out_fulfilled_msat": 100000,
"htlcs": []
},
{
"state": "CHANNELD_NORMAL",
"scratch_txid": "a812d1089ae5745800275954e8311f6ec894bb144c7fa53588ef6016782a43eb",
"last_tx_fee_msat": 363000,
"feerate": {
"perkw": 253,
"perkb": 1012
},
"owner": "channeld",
"short_channel_id": "111x1x1",
"direction": 1,
"channel_id": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"funding_txid": "a1d659a4006711bb0fad3ac8a089f7ed24aa9c2de850991f6a6bc2024c956188",
"funding_outnum": 1,
"close_to_addr": "bcrt1qn7al6f9g772rvlf6fycty2w9nwzqknqscmtrhs",
"close_to": "00149fbbfd24a8f794367d3a4930b229c59b840b4c10",
"private": false,
"opener": "remote",
"alias": {
"local": "11018327x10385621x23688",
"remote": "6702703x15773145x25355"
},
"features": [
"option_static_remotekey"
],
"funding": {
"local_funds_msat": 0,
"remote_funds_msat": 1000000000,
"pushed_msat": 0
},
"to_us_msat": 180000,
"min_to_us_msat": 0,
"max_to_us_msat": 180000,
"total_msat": 1000000000,
"fee_base_msat": 1,
"fee_proportional_millionths": 10,
"dust_limit_msat": 546000,
"max_total_htlc_in_msat": 18446744073709551615,
"their_reserve_msat": 10000000,
"our_reserve_msat": 10000000,
"spendable_msat": 0,
"receivable_msat": 989280000,
"minimum_htlc_in_msat": 0,
"minimum_htlc_out_msat": 0,
"maximum_htlc_out_msat": 990000000,
"their_to_self_delay": 6,
"our_to_self_delay": 6,
"max_accepted_htlcs": 483,
"state_changes": [
{
"timestamp": "2023-08-03T13:42:13.163Z",
"old_state": "CHANNELD_AWAITING_LOCKIN",
"new_state": "CHANNELD_NORMAL",
"cause": "remote",
"message": "Lockin complete"
}
],
"status": [
"CHANNELD_NORMAL:Reconnected, and reestablished.",
"CHANNELD_NORMAL:Channel ready for use. Channel announced."
],
"in_payments_offered": 3,
"in_offered_msat": 180000,
"in_payments_fulfilled": 3,
"in_fulfilled_msat": 180000,
"out_payments_offered": 0,
"out_offered_msat": 0,
"out_payments_fulfilled": 0,
"out_fulfilled_msat": 0,
"htlcs": []
}
]
}
]
}
◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
"peers": [
{
"id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
"connected": true,
"num_channels": 2,
"netaddr": [
"127.0.0.1:7272"
],
"features": "08a0000a0269a2",
"channels": [
{
"state": "CHANNELD_NORMAL",
"scratch_txid": "df7f375bb6b7bed653e7093dc7ff4da3ee940559eb19ec3f011eb2c8adcd16b4",
"last_tx_fee_msat": 283000,
"feerate": {
"perkw": 253,
"perkb": 1012
},
"owner": "channeld",
"short_channel_id": "103x1x1",
"direction": 1,
"channel_id": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
"funding_txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7",
"funding_outnum": 1,
"close_to_addr": "bcrt1qap7tds3y99l76k2uut8gy03w866nxgkqwwdvj5",
"close_to": "0014e87cb6c224297fed595ce2ce823e2e3eb53322c0",
"private": false,
"opener": "local",
"alias": {
"local": "4789751x13272092x51577",
"remote": "4204164x15552480x64151"
},
"features": [
"option_static_remotekey"
],
"funding": {
"local_funds_msat": 1000000000,
"remote_funds_msat": 0,
"pushed_msat": 0
},
"to_us_msat": 999900000,
"min_to_us_msat": 999900000,
"max_to_us_msat": 1000000000,
"total_msat": 1000000000,
"fee_base_msat": 1,
"fee_proportional_millionths": 10,
"dust_limit_msat": 546000,
"max_total_htlc_in_msat": 18446744073709551615,
"their_reserve_msat": 10000000,
"our_reserve_msat": 10000000,
"spendable_msat": 989360000,
"receivable_msat": 0,
"minimum_htlc_in_msat": 0,
"minimum_htlc_out_msat": 0,
"maximum_htlc_out_msat": 990000000,
"their_to_self_delay": 6,
"our_to_self_delay": 6,
"max_accepted_htlcs": 483,
"state_changes": [
{
"timestamp": "2023-08-03T13:40:12.413Z",
"old_state": "CHANNELD_AWAITING_LOCKIN",
"new_state": "CHANNELD_NORMAL",
"cause": "user",
"message": "Lockin complete"
}
],
"status": [
"CHANNELD_NORMAL:Reconnected, and reestablished.",
"CHANNELD_NORMAL:Channel ready for use. Channel announced."
],
"in_payments_offered": 0,
"in_offered_msat": 0,
"in_payments_fulfilled": 0,
"in_fulfilled_msat": 0,
"out_payments_offered": 4,
"out_offered_msat": 100000,
"out_payments_fulfilled": 4,
"out_fulfilled_msat": 100000,
"htlcs": []
},
{
"state": "CHANNELD_NORMAL",
"scratch_txid": "a812d1089ae5745800275954e8311f6ec894bb144c7fa53588ef6016782a43eb",
"last_tx_fee_msat": 363000,
"feerate": {
"perkw": 253,
"perkb": 1012
},
"owner": "channeld",
"short_channel_id": "111x1x1",
"direction": 1,
"channel_id": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
"funding_txid": "a1d659a4006711bb0fad3ac8a089f7ed24aa9c2de850991f6a6bc2024c956188",
"funding_outnum": 1,
"close_to_addr": "bcrt1qn7al6f9g772rvlf6fycty2w9nwzqknqscmtrhs",
"close_to": "00149fbbfd24a8f794367d3a4930b229c59b840b4c10",
"private": false,
"opener": "remote",
"alias": {
"local": "11018327x10385621x23688",
"remote": "6702703x15773145x25355"
},
"features": [
"option_static_remotekey"
],
"funding": {
"local_funds_msat": 0,
"remote_funds_msat": 1000000000,
"pushed_msat": 0
},
"to_us_msat": 180000,
"min_to_us_msat": 0,
"max_to_us_msat": 180000,
"total_msat": 1000000000,
"fee_base_msat": 1,
"fee_proportional_millionths": 10,
"dust_limit_msat": 546000,
"max_total_htlc_in_msat": 18446744073709551615,
"their_reserve_msat": 10000000,
"our_reserve_msat": 10000000,
"spendable_msat": 0,
"receivable_msat": 989280000,
"minimum_htlc_in_msat": 0,
"minimum_htlc_out_msat": 0,
"maximum_htlc_out_msat": 990000000,
"their_to_self_delay": 6,
"our_to_self_delay": 6,
"max_accepted_htlcs": 483,
"state_changes": [
{
"timestamp": "2023-08-03T13:42:13.163Z",
"old_state": "CHANNELD_AWAITING_LOCKIN",
"new_state": "CHANNELD_NORMAL",
"cause": "remote",
"message": "Lockin complete"
}
],
"status": [
"CHANNELD_NORMAL:Reconnected, and reestablished.",
"CHANNELD_NORMAL:Channel ready for use. Channel announced."
],
"in_payments_offered": 3,
"in_offered_msat": 180000,
"in_payments_fulfilled": 3,
"in_fulfilled_msat": 180000,
"out_payments_offered": 0,
"out_offered_msat": 0,
"out_payments_fulfilled": 0,
"out_fulfilled_msat": 0,
"htlcs": []
}
]
},
{
"id": "030b1736a879486b03aa77fbbf386e38e34568d7096122fd1e3d3a29da047cbf90",
"connected": true,
"num_channels": 0,
"netaddr": [
"127.0.0.1:54270"
],
"features": "",
"channels": []
}
]
}
◉ tony@tony:~/clnlive:
$
Source code
main.go
package main
import (
"fmt"
"log"
"net/http"
"html/template"
"github.com/tidwall/gjson"
lnsocket "github.com/jb55/lnsocket/go"
)
// var RUNE = "BhOlLq7GtF8EfoeipYpXzB-WAY3O4odwu_JeZqZy9Rc9MQ=="
var RUNE = "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0="
var HOSTNAME = "127.0.0.1:7171"
var NODEID = "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181"
type Account struct {
Account string
BalanceMsat string
}
func abbrevAccount(acc string) string{
if len(acc) > 15 {
return acc[:6] + "..." + acc[len(acc) - 6:]
} else {
return acc
}
}
func listAccounts(ln *lnsocket.LNSocket, rune string) []Account {
body, _ := ln.Rpc(RUNE, "bkpr-listbalances", "[]")
accArr := gjson.Get(body, "result.accounts").Array()
accounts := make([]Account, len(accArr))
for i, account := range accArr {
accounts[i] = Account{
Account: abbrevAccount(account.Get("account").String()),
BalanceMsat: account.Get("balances.0.balance_msat").String(),
}
}
return accounts
}
func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tpl, _ := template.ParseFiles("index.html")
tpl.Execute(w, listAccounts(ln, rune))
}
}
func makeAccountsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tpl, _ := template.ParseFiles("index.html")
tpl.ExecuteTemplate(w, "accounts", listAccounts(ln, rune))
}
}
type IncomeEvent struct {
Account string
Tag string
CreditMsat int64
DebitMsat int64
}
func makeIncomeEventsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
body, _ := ln.Rpc(RUNE, "bkpr-listincome", "[]")
incEvtArr := gjson.Get(body, "result.income_events").Array()
incomeEvents := make([]IncomeEvent, len(incEvtArr))
for i, incomeEvent := range incEvtArr {
incomeEvents[i] = IncomeEvent{
Account: abbrevAccount(incomeEvent.Get("account").String()),
Tag: incomeEvent.Get("tag").String(),
CreditMsat: incomeEvent.Get("credit_msat").Int(),
DebitMsat: incomeEvent.Get("debit_msat").Int(),
}
}
tpl, _ := template.ParseFiles("listincome.html")
tpl.Execute(w, incomeEvents)
}
}
func main() {
ln := lnsocket.LNSocket{}
ln.GenKey()
err := ln.ConnectAndInit(HOSTNAME, NODEID)
if err != nil {fmt.Println("not connected to peer")}
http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets"))))
http.HandleFunc("/", makeHomeHandler(&ln, RUNE))
http.HandleFunc("/listincome", makeIncomeEventsHandler(&ln, RUNE))
http.HandleFunc("/accounts", makeAccountsHandler(&ln, RUNE))
log.Fatal(http.ListenAndServe(":8080", nil))
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="description" content="CLN Bookkeeper Web App" />
<link rel="stylesheet" type="text/css" href="/assets/bkpr.css" />
<script src="https://unpkg.com/htmx.org@1.9.4"></script>
<script src="https://unpkg.com/hyperscript.org@0.9.9"></script>
<title>CLN Bookkeeper Web App</title>
</head>
<body>
<h1 id="header">CLN Bookkeeper</h1>
<div id="content">
<div id="tabs">
<div id="tab-accounts"
class="tab selected"
hx-get="/accounts"
hx-swap="innerHTML"
hx-target="#accounts-or-listincome"
_="on click
add .selected to me
remove .selected from #tab-listincome"
>
Accounts
</div>
<div>|</div>
<div id="tab-listincome"
class="tab"
hx-get="/listincome"
hx-swap="innerHTML"
hx-target="#accounts-or-listincome"
_="on click
add .selected to me
remove .selected from #tab-accounts"
>
Income Events
</div>
</div>
<div id="accounts-or-listincome">
{{block "accounts" .}}
<ul id="accounts">
{{range .}}
<li class="account">
<div>Account: {{.Account}}</div>
<div>Balance: {{.BalanceMsat}} msat</div>
</li>
{{end}}
</ul>
{{end}}
</div>
</div>
</body>
</html>
listincome.html
<ul id="income-events">
{{range .}}
<li class="income-event">
<div class="income-event-left">
<div>{{.Account}}</div>
<div class="tag tag-{{.Tag}}">{{.Tag}}</div>
</div>
{{if .CreditMsat}}
<div class="credit">{{.CreditMsat}} msat</div>
{{else}}
<div>-{{.DebitMsat}} msat</div>
{{end}}
</li>
{{end}}
</ul>
go.mod
module test
go 1.19
require (
github.com/jb55/lnsocket/go v0.0.0-20230517173613-b7d9bce6c787
github.com/tidwall/gjson v1.15.0
)
require (
github.com/aead/siphash v1.0.1 // indirect
github.com/btcsuite/btcd v0.23.1 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect
github.com/btcsuite/btcd/btcutil v1.1.1 // indirect
github.com/btcsuite/btcd/btcutil/psbt v1.1.4 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
github.com/btcsuite/btcwallet v0.15.1 // indirect
github.com/btcsuite/btcwallet/wallet/txauthor v1.2.3 // indirect
github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect
github.com/btcsuite/btcwallet/wallet/txsizes v1.1.0 // indirect
github.com/btcsuite/btcwallet/walletdb v1.4.0 // indirect
github.com/btcsuite/btcwallet/wtxmgr v1.5.0 // indirect
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/decred/dcrd/lru v1.0.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/kkdai/bstream v1.0.0 // indirect
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect
github.com/lightninglabs/neutrino v0.14.2 // indirect
github.com/lightningnetwork/lnd v0.15.0-beta // indirect
github.com/lightningnetwork/lnd/clock v1.1.0 // indirect
github.com/lightningnetwork/lnd/queue v1.1.0 // indirect
github.com/lightningnetwork/lnd/ticker v1.1.0 // indirect
github.com/lightningnetwork/lnd/tlv v1.0.3 // indirect
github.com/lightningnetwork/lnd/tor v1.0.1 // indirect
github.com/miekg/dns v1.1.43 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
)
bkpr.css
/* reset */
html,
body,
p,
ol,
ul,
li,
dl,
dt,
dd,
blockquote,
figure,
fieldset,
legend,
textarea,
pre,
iframe,
hr,
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
padding: 0;
}
*, *::before, *::after{
box-sizing: border-box;
}
ul {
padding-left: 2em;
list-style: disc;
}
ul ul {
margin-top: 0;
margin-bottom: 0;
}
ol {
padding-left: 2em;
list-style: decimal;
}
li p {
margin: 0;
}
li {
margin-top: 0.25em;
}
p, blockquote, ul, ol, code,
dl, table, pre, details {
margin-bottom: 16px;
margin-top: 0;
}
/* bkpr specific */
#header {
text-align: center;
margin: 1.2em;
}
#content {
margin: auto;
max-width: 600px;
}
#tabs {
margin-bottom: 1.2em;
padding: auto;
display: flex;
gap: 1em;
justify-content: center;
flex-direction: horizontal;
}
.selected {
text-decoration: underline;
}
.tab:hover {
cursor: pointer;
}
#accounts {
width: 100%;
margin: auto;
display: flex;
flex-direction: column;
padding-left: 0em;
}
.account {
padding: 0.6em;
border-radius: 0.6em;
list-style-type: none;
background-color: #F5F5F5;
margin-bottom: 8px;
width: 100%;
}
#income-events {
width: 100%;
margin: auto;
display: flex;
flex-direction: column;
padding-left: 0em;
}
.income-event {
display: flex;
flex-direction: horizontal;
justify-content: space-between;
padding: 0.6em;
border-radius: 0.6em;
list-style-type: none;
background-color: #F5F5F5;
margin-bottom: 8px;
width: 100%;
}
.income-event-left {
display: flex;
flex-direction: horizontal;
align-items: baseline;
gap: 1em;
}
.credit {
padding: 0.2em 0.4em 0.2em 0.4em;
border-radius: 0.3em;
list-style-type: none;
font-size: bold;
background-color: #00B89C;
color: white;
}
.tag {
padding: 0.2em 0.4em 0.2em 0.4em;
border-radius: 0.6em;
}
.tag-onchain_fee {
background-color: #7CB2DF;
}
.tag-invoice {
background-color: #ffe08a;
}
.tag-deposit {
background-color: #DFA87C;
}
.tag-withdrawal {
background-color: #E9A5A9;
}
lnregtest.bash
#!/usr/bin/env bash
# we assume we've already funded default bitcoin wallet and
# l1 wallet node and one channel from l1 to l2 using `fund_nodes`
# from `contrib/startup_regtest.sh`
l1_cli(){
lightning-cli --lightning-dir=/tmp/l1-regtest $@
}
l2_cli(){
lightning-cli --lightning-dir=/tmp/l2-regtest $@
}
# l1 pays 3 invoices to l2
inv_1=$(l2_cli invoice 10000 inv-1 pizza)
inv_2=$(l2_cli invoice 20000 inv-2 pizza)
inv_3=$(l2_cli invoice 30000 inv-3 pizza)
bolt11_1=$(echo $inv_1 | jq -r .bolt11)
bolt11_2=$(echo $inv_2 | jq -r .bolt11)
bolt11_3=$(echo $inv_3 | jq -r .bolt11)
l1_cli pay $bolt11_1
l1_cli pay $bolt11_2
l1_cli pay $bolt11_3
# bitcoin default wallet address
bitcoin_default_wallet_addr=$(bitcoin-cli -regtest -rpcwallet=default getnewaddress)
# fund l1 wallet with 2btc
l1_addr=$(l1_cli newaddr | jq -r .bech32)
bitcoin-cli -regtest -rpcwallet=default sendtoaddress $l1_addr 2
bitcoin-cli -regtest generatetoaddress 1 $bitcoin_default_wallet_addr
# l1 pays 1 invoices to l2
inv_4=$(l2_cli invoice 40000 inv-4 pizza)
bolt11_4=$(echo $inv_4 | jq -r .bolt11)
l1_cli pay $bolt11_4
# open a channel from l2 to l1
l2_addr=$(l2_cli newaddr | jq -r .bech32)
bitcoin-cli -regtest -rpcwallet=default sendtoaddress $l2_addr 1
bitcoin-cli -regtest generatetoaddress 1 $bitcoin_default_wallet_addr
while ! lightning-cli -F --lightning-dir=/tmp/l2-regtest listfunds | grep -q "outputs"
do
sleep 1
done
l1_node_id=$(l1_cli getinfo | jq -r .id)
l2_cli fundchannel $l1_node_id 1000000
bitcoin-cli -regtest generatetoaddress 6 $bitcoin_default_wallet_addr
sleep 60 # should be enough to get the channel confirmed
# l2 pays 2 invoices to l1
inv_5=$(l1_cli invoice 50000 inv-5 pizza)
inv_6=$(l1_cli invoice 60000 inv-6 pizza)
bolt11_5=$(echo $inv_5 | jq -r .bolt11)
bolt11_6=$(echo $inv_6 | jq -r .bolt11)
l2_cli pay $bolt11_5
l2_cli pay $bolt11_6
# l1 withdraw 5000000sat
l1_cli withdraw $bitcoin_default_wallet_addr 5000000
bitcoin-cli -regtest generatetoaddress 1 $bitcoin_default_wallet_addr
# l2 pays 1 invoice to l1
inv_7=$(l1_cli invoice 70000 inv-7 pizza)
bolt11_7=$(echo $inv_7 | jq -r .bolt11)
l2_cli pay $bolt11_7