LIVE #10 - 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-listbalancesandbkpr-listincome. We write it in Go usinglnsocketlibrary. All of this is made possible thanks tocommandoandbookkeeperplugins. 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:
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
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
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
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
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
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
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:
Back to our terminal we verify that this is working by running
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
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
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_proposedand - lightning: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) and - if 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
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
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
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
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
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
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
Resources¶
- https://github.com/jb55/lnsocket
- (lnsocket) add new required fields for commando jsonrpc #21
- https://github.com/tidwall/gjson
- https://golangforall.com/en/post/templates.html
- https://htmx.org/docs/
- https://hyperscript.org/img/hyperscript-cheatsheet.pdf
- https://github.com/ElementsProject/lightning
- lightning:contrib/startup_regtest.sh
- lightning:doc/lightning-bkpr-listbalances.7.md
- lightning:doc/lightning-bkpr-listincome.7.md