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
Accountsbutton is clicked the data presented comes frombkpr-listbalancesCLN command that we run on our node by sending acommandomessage usinglnsocketlibrary.
when the
Income Eventsbutton is clicked the data presented comes frombkpr-listincomeCLN command that we run on our node by sending acommandomessage usinglnsocketlibrary.
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
l1andl2,one channel from the node
l1to the nodel2and another channel froml2tol1,I made some payment from
l1tol2andl2tol1,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_proposedandlightning:plugins/bkpr/account_entry.c:
journal_entry,penalty_adj,invoice_fee,rebalance_fee.
Use htmx to swap divs
Now:
if we click on
Accountswe will swap the content of the div with idaccounts-or-listincomewith the html returned at the root/accounts(but as we haven't assigned yet the root/accountsa handler function, an ajax request to that root returns the home page) andif we click on
Income eventswe will swap the content of the div with idaccounts-or-listincomewith 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