Simple CLN bookkeeper web app powered by lnsocket & Golang - part 1

LIVE #10August 03, 2023

In this live we build a simple CLN bookkeeper web app which exposes the data we get from the commands bkpr-listbalances and bkpr-listincome. We write it in Go using lnsocket library. All of this is made possible thanks to commando and bookkeeper plugins. We finish building this app in the episode #20 of LNROOM.

Transcript with corrections and improvements

The Go application we're are going to write looks like this:

  • when the Accounts button is clicked the data presented comes from bkpr-listbalances CLN command that we run on our node by sending a commando message using lnsocket library.

    /img/2023-08-03-live-0010-bkpr-accounts.png

  • when the Income Events button is clicked the data presented comes from bkpr-listincome CLN command that we run on our node by sending a commando message using lnsocket library.

    /img/2023-08-03-live-0010-bkpr-income-events.png

For the dynamism of the UI we use HTMX and hyperscript.

Done during the live

Custom Lightning Network running on regtest

Before we started that live session, I created a custom Lightning Network running on regtest with:

  • 2 nodes l1 and l2,

  • one channel from the node l1 to the node l2 and another channel from l2 to l1,

  • I made some payment from l1 to l2 and l2 to l1,

  • 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:

Use htmx to swap divs

Now:

  1. if we click on Accounts we will swap the content of the div with id accounts-or-listincome with the html returned at the root /accounts (but as we haven't assigned yet the root /accounts a handler function, an ajax request to that root returns the home page) and

  2. if we click on Income events we will swap the content of the div with id accounts-or-listincome with the html returned at the root /listincome (we assigned that root a handler function just below).

To do that we use the attributes hx-get, hx-swap and hx-target provided by HTMX library like this in index.html template:

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
  </head>
  <body>
    <h1 id="header">CLN Bookkeeper</h1>
    <div id="content">
      <div id="tabs">
        <div id="tab-accounts"
             class="tab selected"
             hx-get="/accounts"
             hx-swap="innerHTML"
             hx-target="#accounts-or-listincome"
             >
          Accounts
        </div>
        <div>|</div>
        <div id="tab-listincome"
             class="tab"
             hx-get="/listincome"
             hx-swap="innerHTML"
             hx-target="#accounts-or-listincome"
             >
          Income Events
        </div>
      </div>
      <div id="accounts-or-listincome">
        ...
      </div>
    </div>
  </body>
</html>

We also have to define the new root /listincome which will return foo:

func listincomeHandler (w http.ResponseWriter, req *http.Request) {
  fmt.Fprintln(w, "foo")
}
...
func main() {
  ...
  http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets"))))
  http.HandleFunc("/", makeHomeHandler(&ln, RUNE))
  http.HandleFunc("/listincome", listincomeHandler)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

Back to our terminal we verify that this is working by running

◉ tony@tony:~/clnlive:
$ go run main.go

by visiting http://localhost:8080 in the browser and by clicking on Accounts and Income Events buttons.

Done after the live

Define handler for /accounts root

When we click on Accounts we want to swap the content of the div with id accounts-or-listincome with the html returned at the root /accounts.

So we have to assign that root a handler function.

First we create the fragment template accounts in index.html template file using {{block ...}} construct such that we can use it in or Go code:

...
<div id="accounts-or-listincome">
  {{block "accounts" .}}
  <ul id="accounts">
    {{range .}}
    <li class="account">
      <div>Account: {{.Account}}</div>
      <div>Balance: {{.BalanceMsat}} msat</div>
    </li>
    {{end}}
  </ul>
  {{end}}
</div>
...

Now we can define the function makeAccountsHandler which returns a closure that uses the fragment template accounts (using tpl.ExecuteTemplate() method) and we assigned that closure to the root /accounts like this:

...
func makeAccountsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    body, _ := ln.Rpc(RUNE, "bkpr-listbalances", "[]")
    accArr := gjson.Get(body, "result.accounts").Array()
    accounts := make([]Account, len(accArr))
    for i, account := range accArr {
      accounts[i] = Account{
        Account: account.Get("account").String(),
        BalanceMsat: account.Get("balances.0.balance_msat").String(),
      }
    }
    tpl, _ := template.ParseFiles("index.html")
    tpl.ExecuteTemplate(w, "accounts", accounts)
  }
}
...
func main() {
  ...
  http.HandleFunc("/accounts", makeAccountsHandler(&ln, RUNE))
  log.Fatal(http.ListenAndServe(":8080", nil))
}

Back to our terminal we verify that this is working by running

◉ tony@tony:~/clnlive:
$ go run main.go

by visiting http://localhost:8080 in the browser and by clicking on Accounts and Income Events buttons.

We can see in main.go that we have unnecessary duplicated code in the function makeHomeHandler and makeAccountsHandler.

Let's do a bit of refactoring.

We define the new function listAccounts which returns the array of accounts that we will pass as argument to the methods tpl.Execute() and tpl.ExecuteTemplate().

...
type Account struct {
  Account string
  BalanceMsat string
}

func listAccounts(ln *lnsocket.LNSocket, rune string) []Account {
  body, _ := ln.Rpc(RUNE, "bkpr-listbalances", "[]")
  accArr := gjson.Get(body, "result.accounts").Array()
  accounts := make([]Account, len(accArr))
  for i, account := range accArr {
    accounts[i] = Account{
      Account: account.Get("account").String(),
      BalanceMsat: account.Get("balances.0.balance_msat").String(),
    }
  }
  return accounts
}

func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    tpl, _ := template.ParseFiles("index.html")
    tpl.Execute(w, listAccounts(ln, rune))
  }
}

func makeAccountsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    tpl, _ := template.ParseFiles("index.html")
    tpl.ExecuteTemplate(w, "accounts", listAccounts(ln, rune))
  }
}
...

Back to our terminal we verify that this is working by running

◉ tony@tony:~/clnlive:
$ go run main.go

by visiting http://localhost:8080 in the browser and by clicking on Accounts and Income Events buttons.

Use hyperscript to toggle the class selected in 'Accounts' and 'Income Events' divs

Let's update index.html template with a bit of hyperscript in order to toggle .selected class when we click on the divs Accounts and Income Events.

We add hyperscript snippets as value of the attribute _ like this:

<div id="tabs">
  <div id="tab-accounts"
       class="tab selected"
       hx-get="/accounts"
       hx-swap="innerHTML"
       hx-target="#accounts-or-listincome"
       _="on click
          add .selected to me
          remove .selected from #tab-listincome"
       >
    Accounts
  </div>
  <div>|</div>
  <div id="tab-listincome"
       class="tab"
       hx-get="/listincome"
       hx-swap="innerHTML"
       hx-target="#accounts-or-listincome"
       _="on click
          add .selected to me
          remove .selected from #tab-accounts"
       >
    Income Events
  </div>
</div>

Back to our terminal we verify that this is working by running

◉ tony@tony:~/clnlive:
$ go run main.go

by visiting http://localhost:8080 in the browser and by clicking on Accounts and Income Events buttons.

bkpr-listincome

In that section we write the code for the data returned by bkpr-listincomes command that we ask the node l1 to run by sending it a commando message.

Let's take a look at the data returned by bkpr-listincomes command:

◉ tony@tony:~/clnlive:
$ l1-cli bkpr-listincome
{
   "income_events": [
      {
         "account": "wallet",
         "tag": "deposit",
         "credit_msat": 100000000000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691069981,
         "outpoint": "4404f8982b99b2a8b424aa4a4069a2ac102ee09539a70b9413f0fe7f52273a8b:1"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 10000,
         "currency": "bcrt",
         "timestamp": 1691070075,
         "description": "pizza",
         "payment_id": "e3b10008930c43d884dbbe7fc3e410f7d07264b1b17951fcc5928daa828612b7"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 20000,
         "currency": "bcrt",
         "timestamp": 1691070077,
         "description": "pizza",
         "payment_id": "5b37b6acc53bbda10a71e734ee3b195a6c01e6aa561c58c70468569e473b422a"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 30000,
         "currency": "bcrt",
         "timestamp": 1691070079,
         "description": "pizza",
         "payment_id": "8b64023279d255ab7cad79ae9c6fb88bd687edc27df688ab482c0f6e667d76cf"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 40000,
         "currency": "bcrt",
         "timestamp": 1691070082,
         "description": "pizza",
         "payment_id": "24bc4ff483dda7256bfdb8b908f5a730e1174a3fba061a6f142b3c1166243237"
      },
      {
         "account": "wallet",
         "tag": "deposit",
         "credit_msat": 200000000000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070102,
         "outpoint": "12811f534a59262fdd007e64425298c0bff472493bdf6c43d027d1b6cfa4626e:1"
      },
      {
         "account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
         "tag": "invoice",
         "credit_msat": 50000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070165,
         "description": "pizza",
         "payment_id": "6e8490f0129f89c5a5d0427008d5c1d987ed6623542a74d7c60d6068262c31ba"
      },
      {
         "account": "wallet",
         "tag": "withdrawal",
         "credit_msat": 0,
         "debit_msat": 5000000000,
         "currency": "bcrt",
         "timestamp": 1691070167,
         "outpoint": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e:0"
      },
      {
         "account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
         "tag": "invoice",
         "credit_msat": 60000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070167,
         "description": "pizza",
         "payment_id": "c4a85c2f5279a7fbd53ffe520b9ba9bcffa1d7ae4c680b027273af6f6e444ca2"
      },
      {
         "account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
         "tag": "invoice",
         "credit_msat": 70000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070169,
         "description": "pizza",
         "payment_id": "3502d6e21dbda004677854be1c96101d10c94a2cbf063a8c61a545627194eabc"
      },
      {
         "account": "wallet",
         "tag": "onchain_fee",
         "credit_msat": 0,
         "debit_msat": 141000,
         "currency": "bcrt",
         "timestamp": 1691070193,
         "txid": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "onchain_fee",
         "credit_msat": 0,
         "debit_msat": 154000,
         "currency": "bcrt",
         "timestamp": 1691070012,
         "txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7"
      }
   ]
}

For each income events we are going to use only the field account, tag, credit_msat and debit_msat.

Here is the code that we add to main.go to take care of bkpr-listincome data:

...
type IncomeEvent struct {
  Account string
  Tag string
  CreditMsat int64
  DebitMsat int64
}

func makeIncomeEventsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    body, _ := ln.Rpc(RUNE, "bkpr-listincome", "[]")
    incEvtArr := gjson.Get(body, "result.income_events").Array()
    incomeEvents := make([]IncomeEvent, len(incEvtArr))
    for i, incomeEvent := range incEvtArr {
      incomeEvents[i] = IncomeEvent{
        Account: abbrevAccount(incomeEvent.Get("account").String()),
        Tag: incomeEvent.Get("tag").String(),
        CreditMsat: incomeEvent.Get("credit_msat").Int(),
        DebitMsat: incomeEvent.Get("debit_msat").Int(),
      }
    }
    tpl, _ := template.ParseFiles("listincome.html")
    tpl.Execute(w, incomeEvents)
  }
}

func main() {
  ...
  http.HandleFunc("/listincome", makeIncomeEventsHandler(&ln, RUNE))
  http.HandleFunc("/accounts", makeAccountsHandler(&ln, RUNE))
  log.Fatal(http.ListenAndServe(":8080", nil))
}

To get that code working we define the template listincome.html like this:

<ul id="income-events">
  {{range .}}
  <li class="income-event">
    <div class="income-event-left">
      <div>{{.Account}}</div>
      <div class="tag tag-{{.Tag}}">{{.Tag}}</div>
    </div>
    {{if .CreditMsat}}
    <div class="credit">{{.CreditMsat}} msat</div>
    {{else}}
    <div>-{{.DebitMsat}} msat</div>
    {{end}}
  </li>
  {{end}}
</ul>

Back to our terminal we verify that this is working by running

◉ tony@tony:~/clnlive:
$ go run main.go

by visiting http://localhost:8080 in the browser and by clicking on Accounts and Income Events buttons.

Abbreviate account names if too long

As account names can be channel id, they can be large and take too much space in the UI. In that section we are going to abbreviate them.

To do so we define the function abbrevAccount that we use in listAccounts and in makeIncomeEventsHandler functions:

...
func abbrevAccount(acc string) string{
  if len(acc) > 15 {
    return acc[:6] + "..." + acc[len(acc) - 6:]
  } else {
    return acc
  }
}

func listAccounts(ln *lnsocket.LNSocket, rune string) []Account {
  ...
  for i, account := range accArr {
    accounts[i] = Account{
      Account: abbrevAccount(account.Get("account").String()),
      ...
    }
  }
  ...
}
...
func makeIncomeEventsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    ...
    for i, incomeEvent := range incEvtArr {
      incomeEvents[i] = IncomeEvent{
        Account: abbrevAccount(incomeEvent.Get("account").String()),
        ...
      }
    }
    ...
  }
}
...

Back to our terminal we verify that this is working by running

◉ tony@tony:~/clnlive:
$ go run main.go

by visiting http://localhost:8080 in the browser and by clicking on Accounts and Income Events buttons.

We are done!

Terminal session

We ran the following commands in this order:

$ source lightning/contrib/startup_regtest.sh
$ start_ln
$ connect 1 2
$ fund_nodes
$ ./lnregtest.bash
$ l1-cli stop
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon
$ l1-cli getinfo | jq -r .id
$ l1-cli commando-rune
$ l1-cli getinfo
$ go run main.go
$ alias l1-cli
$ go run main.go
$ l1-cli commando-rune
$ go run main.go
$ l1-cli bkpr-listbalances
$ go run main.go
$ l1-cli commando-rune null '[["method^bkpr-"]]'
$ go run main.go
$ l1-cli -k decode string=vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=
$ go run main.go
$ l1-cli bkpr-listincome
$ go run main.go

And below you can read the terminal session (command lines and outputs):

◉ tony@tony:~/clnlive:
$ source lightning/contrib/startup_regtest.sh
lightning-cli is /usr/local/bin/lightning-cli
lightningd is /usr/local/bin/lightningd
Useful commands:
  start_ln 3: start three nodes, l1, l2, l3
  connect 1 2: connect l1 and l2
  fund_nodes: connect all nodes with channels, in a row
  stop_ln: shutdown
  destroy_ln: remove ln directories
◉ tony@tony:~/clnlive:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 4304
[2] 4345
WARNING: eatmydata not found: instal it for faster testing
Commands:
        l1-cli, l1-log,
        l2-cli, l2-log,
        bt-cli, stop_ln, fund_nodes
◉ tony@tony:~/clnlive:
$ connect 1 2
{
   "id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
   "features": "08a0000a0269a2",
   "direction": "out",
   "address": {
      "type": "ipv4",
      "address": "127.0.0.1",
      "port": 7272
   }
}
◉ tony@tony:~/clnlive:
$ fund_nodes
Mining into address bcrt1qxehk2f0rknajvny4cajsuwmd94vvakz2apx7x2... done.
bitcoind balance: 50.00000000
Waiting for lightning node funds... found.
Funding channel from node 1 to node 2. Waiting for confirmation... done.
◉ tony@tony:~/clnlive:
$ ./lnregtest.bash
{
   "destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
   "payment_hash": "e3b10008930c43d884dbbe7fc3e410f7d07264b1b17951fcc5928daa828612b7",
   "created_at": 1691070073.682,
   "parts": 1,
   "amount_msat": 10000,
   "amount_sent_msat": 10000,
   "payment_preimage": "70c4903543a3eb23c3daaeb8fa0139ffbfa8786392ca6e4b7126428e2878b1a2",
   "status": "complete"
}
{
   "destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
   "payment_hash": "5b37b6acc53bbda10a71e734ee3b195a6c01e6aa561c58c70468569e473b422a",
   "created_at": 1691070075.044,
   "parts": 1,
   "amount_msat": 20000,
   "amount_sent_msat": 20000,
   "payment_preimage": "0006dd84451a955668994041b566dfa04708ba8fffdb92f4fdc003cae87de894",
   "status": "complete"
}
{
   "destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
   "payment_hash": "8b64023279d255ab7cad79ae9c6fb88bd687edc27df688ab482c0f6e667d76cf",
   "created_at": 1691070076.861,
   "parts": 1,
   "amount_msat": 30000,
   "amount_sent_msat": 30000,
   "payment_preimage": "c7b4741eae1e2af83ee5b3397963e057aa6c613c86b5c17bed091961ce6f4b68",
   "status": "complete"
}
12811f534a59262fdd007e64425298c0bff472493bdf6c43d027d1b6cfa4626e
[
  "4326b52dc362707df8237c3125d03903387a82c79b0f6a733438e842ee568f16"
]
{
   "destination": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
   "payment_hash": "24bc4ff483dda7256bfdb8b908f5a730e1174a3fba061a6f142b3c1166243237",
   "created_at": 1691070080.124,
   "parts": 1,
   "amount_msat": 40000,
   "amount_sent_msat": 40000,
   "payment_preimage": "0b3dc7c583277f993dc59861ad03c2014e4f28be4ada8b737a6b5a383028ec83",
   "status": "complete"
}
d1f9103197dd0d278769010f9dd04dd83392ed78e6c65fcdc3e662127b737c3b
[
  "0d63a7623787ad784329ad59e035d8dad835f54e64ebf3cb4a1c251f4cf00d78"
]
{
   "tx": "020000000001013b7c737b1262e6c3cd5fc6e678ed9233d84dd09d0f016987270ddd973110f9d10100000000fdffffff02269ee6050000000016001492095c4cbc3839afe174c7c176feca857904b5d240420f0000000000220020b5312f60134bbbf25402d9ae14b760ee140b8ced21ba72a91d763de440d2c9e10247304402204b567f08715d96a1b7cd952b7408f6d3c2e4e28fda831231b6dbe63431dcb365022004e3231e303f5f76996ec784c2032e6f532ac64203ca5fd43b174a42323283fc012102f21f74af832dcdcff562817f20db099eeccf8b0949c1cd7a6fca9210af9f8f396e000000",
   "txid": "a1d659a4006711bb0fad3ac8a089f7ed24aa9c2de850991f6a6bc2024c956188",
   "channel_id": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
   "outnum": 1
}
[
  "5d5d6f6699bc6c9623f3a7559a3a3f7a2f8a922164cebb22dfbef770ab4910fd",
  "0757bf936b2d2670b582e85d197d46b2fe87b96978e5aee05b06880801358e34",
  "401b0513718f642bebe82b573e8e7604e3b5c42a7f4810cc61ea558fa5fcf942",
  "47066959d8a85b7f9e3018e86c2e00ae61ccafd968a7bed154370acb4483e1ed",
  "0dc2a78aad146ec9949a229266fa730097b585d280100ba14ede325175fa52b7",
  "180575484301375083b7ead73a7a7c95c24e904b9562a4f54649fb64b2efe6a8"
]
{
   "destination": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
   "payment_hash": "6e8490f0129f89c5a5d0427008d5c1d987ed6623542a74d7c60d6068262c31ba",
   "created_at": 1691070163.957,
   "parts": 1,
   "amount_msat": 50000,
   "amount_sent_msat": 50000,
   "payment_preimage": "600b7f1be35e0267d7a4fca25854005bdc72b4fb6b281371b5936a05353d42b0",
   "status": "complete"
}
{
   "destination": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
   "payment_hash": "c4a85c2f5279a7fbd53ffe520b9ba9bcffa1d7ae4c680b027273af6f6e444ca2",
   "created_at": 1691070165.307,
   "parts": 1,
   "amount_msat": 60000,
   "amount_sent_msat": 60000,
   "payment_preimage": "8669337dd3d474e9e05fd69ad18d192498c91e535fa38524652d9eb362ada3a3",
   "status": "complete"
}
{
   "tx": "0200000001c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397c0000000000fdffffff02404b4c000000000016001481d7d1e0b9f24212399251d48c8020cf9025f8bf59529a05000000001600145136cd6a7861c8920fe0907513fcc85dda76fef274000000",
   "txid": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e",
   "psbt": "cHNidP8BAgQCAAAAAQMEdAAAAAEEAQEBBQECAQYBAwH7BAIAAAAAAQDqAgAAAAABAYs6J1J//vATlAunOZXgLhCsomlASqoktKiymSuY+AREAQAAAAD9////Aiae5gUAAAAAFgAUKSbJiqWXQo5V+1ampLIHFvXMwvNAQg8AAAAAACIAIBIdQLfd6ueoerZTZyPNfnkV20CnudmvJo3mCDIb3yoFAkcwRAIgVvgr0O+uky+6BsCjNKQ9eJnkdS3wFLM+AOuyWcWaTdcCIE54jqzrMQkt8zpQOX5OulXjQsKGf7XGP/1O2etPMwGJASEDaNQcKKaCKCEHpi+928ncgytwpU1s4vKlUthcr58XAGRmAAAAAQEfJp7mBQAAAAAWABQpJsmKpZdCjlX7VqaksgcW9czC8yICAhGE6BfcIS1Uo6BykP+91Xr8ljP+aQPU/E3ekwhG11IgRzBEAiAaQjbunzCnxKZUeFJ2wqf1ZpFbx17GlQfZ1DNUxlQBdgIgW/DafJW/e2SMc8R38MfcXMKxmuELcjCbyrULf3pzm9cBIgYCEYToF9whLVSjoHKQ/73VevyWM/5pA9T8Td6TCEbXUiAIKSbJigAAAAABDiDHFWMrpZAlKe4JlYj5yhLs+HfATVg4SbwIwX3NAoA5fAEPBAAAAAABEAT9////AAEDCEBLTAAAAAAAAQQWABSB19HgufJCEjmSUdSMgCDPkCX4vwz8CWxpZ2h0bmluZwQCAAEAIgICzW1JalR2c0OqYy6Tcds5ecvW/ZXSCP/GF/u2rT+zIisIUTbNagYAAAABAwhZUpoFAAAAAAEEFgAUUTbNanhhyJIP4JB1E/zIXdp2/vIA"
}
[
  "50abdf6cb5c04f1d2c5df21b8a7c9dc93341132c7d51b0c22252c87e4bb1f325"
]
{
   "destination": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
   "payment_hash": "3502d6e21dbda004677854be1c96101d10c94a2cbf063a8c61a545627194eabc",
   "created_at": 1691070167.937,
   "parts": 1,
   "amount_msat": 70000,
   "amount_sent_msat": 70000,
   "payment_preimage": "167ae1f808c22107e2d5ebe152fccd9f4d882b151b44457f21a339b79c161a5e",
   "status": "complete"
}
◉ tony@tony:~/clnlive:
$ l1-cli stop
"Shutdown complete"
◉ tony@tony:~/clnlive:
$ lightningd --lightning-dir=/tmp/l1-regtest --daemon
◉ tony@tony:~/clnlive:
$ l1-cli getinfo | jq -r .id
03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune
{
   "rune": "IY9EVF2zTD8-9UMIEyov4DWCmE3Y4XQwoC5vcywqMnM9MA==",
   "unique_id": "0",
   "warning_unrestricted_rune": "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."
}
◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181",
   "alias": "VIOLETSPATULA",
   "color": "03e216",
   "num_peers": 1,
   "num_pending_channels": 0,
   "num_active_channels": 2,
   "num_inactive_channels": 0,
   "address": [],
   "binding": [
      {
         "type": "ipv4",
         "address": "127.0.0.1",
         "port": 7171
      }
   ],
   "version": "v23.05.2",
   "blockheight": 117,
   "network": "regtest",
   "fees_collected_msat": 0,
   "lightning-dir": "/tmp/l1-regtest/regtest",
   "our_features": {
      "init": "08a0000a0269a2",
      "node": "88a0000a0269a2",
      "channel": "",
      "invoice": "02000002024100"
   }
}
◉ tony@tony:~/clnlive:
$ go run main.go
◉ tony@tony:~/clnlive:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive:
$ go run main.go
{"error":{"code":19537,"message":"Not authorized: Invalid rune"}}
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune
{
   "rune": "BhOlLq7GtF8EfoeipYpXzB-WAY3O4odwu_JeZqZy9Rc9MQ==",
   "unique_id": "1",
   "warning_unrestricted_rune": "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:getinfo#43","result":{"id":"03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181","alias":"VIOLETSPATULA","color":"03e216","num_peers":2,"num_pending_channels":0,"num_active_channels":2,"num_inactive_channels":0,"address":[],"binding":[{"type":"ipv4","address":"127.0.0.1","port":7171}],"version":"v23.05.2","blockheight":117,"network":"regtest","fees_collected_msat":0,"lightning-dir":"/tmp/l1-regtest/regtest","our_features":{"init":"08a0000a0269a2","node":"88a0000a0269a2","channel":"","invoice":"02000002024100"}}}
◉ tony@tony:~/clnlive:
$ l1-cli bkpr-listbalances
{
   "accounts": [
      {
         "account": "wallet",
         "balances": [
            {
               "balance_msat": 293999705000,
               "coin_type": "bcrt"
            }
         ]
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "peer_id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
         "we_opened": true,
         "account_closed": false,
         "account_resolved": false,
         "balances": [
            {
               "balance_msat": 999900000,
               "coin_type": "bcrt"
            }
         ]
      },
      {
         "account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
         "peer_id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
         "we_opened": false,
         "account_closed": false,
         "account_resolved": false,
         "balances": [
            {
               "balance_msat": 180000,
               "coin_type": "bcrt"
            }
         ]
      }
   ]
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:bkpr-listbalances#45","result":{"accounts":[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]}}
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune null '[["method^bkpr-"]]'
{
   "rune": "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=",
   "unique_id": "2"
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"error":{"code":19537,"message":"Not authorized: method does not start with bkpr-"}}
◉ tony@tony:~/clnlive:
$ l1-cli -k decode string=vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0=
{
   "type": "rune",
   "unique_id": "2",
   "string": "bf17d0339e687cd084561ed4f447a46c124ddfd590b63ba91678aaca592da6b3:=2&method^bkpr-",
   "restrictions": [
      {
         "alternatives": [
            "method^bkpr-"
         ],
         "summary": "method (of command) starts with 'bkpr-'"
      }
   ],
   "valid": true
}
◉ tony@tony:~/clnlive:
$ go run main.go
{"jsonrpc":"2.0","id":"(null)commando:bkpr-listbalances#50","result":{"accounts":[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]}}
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]
[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]
[{"account":"wallet","balances":[{"balance_msat":293999705000,"coin_type":"bcrt"}]},{"account":"c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":true,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":999900000,"coin_type":"bcrt"}]},{"account":"8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0","peer_id":"0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382","we_opened":false,"account_closed":false,"account_resolved":false,"balances":[{"balance_msat":180000,"coin_type":"bcrt"}]}]
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ l1-cli bkpr-listincome
{
   "income_events": [
      {
         "account": "wallet",
         "tag": "deposit",
         "credit_msat": 100000000000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691069981,
         "outpoint": "4404f8982b99b2a8b424aa4a4069a2ac102ee09539a70b9413f0fe7f52273a8b:1"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 10000,
         "currency": "bcrt",
         "timestamp": 1691070075,
         "description": "pizza",
         "payment_id": "e3b10008930c43d884dbbe7fc3e410f7d07264b1b17951fcc5928daa828612b7"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 20000,
         "currency": "bcrt",
         "timestamp": 1691070077,
         "description": "pizza",
         "payment_id": "5b37b6acc53bbda10a71e734ee3b195a6c01e6aa561c58c70468569e473b422a"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 30000,
         "currency": "bcrt",
         "timestamp": 1691070079,
         "description": "pizza",
         "payment_id": "8b64023279d255ab7cad79ae9c6fb88bd687edc27df688ab482c0f6e667d76cf"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "invoice",
         "credit_msat": 0,
         "debit_msat": 40000,
         "currency": "bcrt",
         "timestamp": 1691070082,
         "description": "pizza",
         "payment_id": "24bc4ff483dda7256bfdb8b908f5a730e1174a3fba061a6f142b3c1166243237"
      },
      {
         "account": "wallet",
         "tag": "deposit",
         "credit_msat": 200000000000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070102,
         "outpoint": "12811f534a59262fdd007e64425298c0bff472493bdf6c43d027d1b6cfa4626e:1"
      },
      {
         "account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
         "tag": "invoice",
         "credit_msat": 50000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070165,
         "description": "pizza",
         "payment_id": "6e8490f0129f89c5a5d0427008d5c1d987ed6623542a74d7c60d6068262c31ba"
      },
      {
         "account": "wallet",
         "tag": "withdrawal",
         "credit_msat": 0,
         "debit_msat": 5000000000,
         "currency": "bcrt",
         "timestamp": 1691070167,
         "outpoint": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e:0"
      },
      {
         "account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
         "tag": "invoice",
         "credit_msat": 60000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070167,
         "description": "pizza",
         "payment_id": "c4a85c2f5279a7fbd53ffe520b9ba9bcffa1d7ae4c680b027273af6f6e444ca2"
      },
      {
         "account": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
         "tag": "invoice",
         "credit_msat": 70000,
         "debit_msat": 0,
         "currency": "bcrt",
         "timestamp": 1691070169,
         "description": "pizza",
         "payment_id": "3502d6e21dbda004677854be1c96101d10c94a2cbf063a8c61a545627194eabc"
      },
      {
         "account": "wallet",
         "tag": "onchain_fee",
         "credit_msat": 0,
         "debit_msat": 141000,
         "currency": "bcrt",
         "timestamp": 1691070193,
         "txid": "e31469500f8c0ac33cb044577cbc8b77de048e288b2f1b860521c8f66e3de12e"
      },
      {
         "account": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
         "tag": "onchain_fee",
         "credit_msat": 0,
         "debit_msat": 154000,
         "currency": "bcrt",
         "timestamp": 1691070012,
         "txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7"
      }
   ]
}
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
c71...97d
^Csignal: interrupt
◉ tony@tony:~/clnlive:
$ go run main.go
^Csignal: interrupt
# TERMINAL 2

◉ tony@tony:~/clnlive:
$ alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "peers": [
      {
         "id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
         "connected": true,
         "num_channels": 2,
         "netaddr": [
            "127.0.0.1:7272"
         ],
         "features": "08a0000a0269a2",
         "channels": [
            {
               "state": "CHANNELD_NORMAL",
               "scratch_txid": "df7f375bb6b7bed653e7093dc7ff4da3ee940559eb19ec3f011eb2c8adcd16b4",
               "last_tx_fee_msat": 283000,
               "feerate": {
                  "perkw": 253,
                  "perkb": 1012
               },
               "owner": "channeld",
               "short_channel_id": "103x1x1",
               "direction": 1,
               "channel_id": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
               "funding_txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7",
               "funding_outnum": 1,
               "close_to_addr": "bcrt1qap7tds3y99l76k2uut8gy03w866nxgkqwwdvj5",
               "close_to": "0014e87cb6c224297fed595ce2ce823e2e3eb53322c0",
               "private": false,
               "opener": "local",
               "alias": {
                  "local": "4789751x13272092x51577",
                  "remote": "4204164x15552480x64151"
               },
               "features": [
                  "option_static_remotekey"
               ],
               "funding": {
                  "local_funds_msat": 1000000000,
                  "remote_funds_msat": 0,
                  "pushed_msat": 0
               },
               "to_us_msat": 999900000,
               "min_to_us_msat": 999900000,
               "max_to_us_msat": 1000000000,
               "total_msat": 1000000000,
               "fee_base_msat": 1,
               "fee_proportional_millionths": 10,
               "dust_limit_msat": 546000,
               "max_total_htlc_in_msat": 18446744073709551615,
               "their_reserve_msat": 10000000,
               "our_reserve_msat": 10000000,
               "spendable_msat": 989360000,
               "receivable_msat": 0,
               "minimum_htlc_in_msat": 0,
               "minimum_htlc_out_msat": 0,
               "maximum_htlc_out_msat": 990000000,
               "their_to_self_delay": 6,
               "our_to_self_delay": 6,
               "max_accepted_htlcs": 483,
               "state_changes": [
                  {
                     "timestamp": "2023-08-03T13:40:12.413Z",
                     "old_state": "CHANNELD_AWAITING_LOCKIN",
                     "new_state": "CHANNELD_NORMAL",
                     "cause": "user",
                     "message": "Lockin complete"
                  }
               ],
               "status": [
                  "CHANNELD_NORMAL:Reconnected, and reestablished.",
                  "CHANNELD_NORMAL:Channel ready for use. Channel announced."
               ],
               "in_payments_offered": 0,
               "in_offered_msat": 0,
               "in_payments_fulfilled": 0,
               "in_fulfilled_msat": 0,
               "out_payments_offered": 4,
               "out_offered_msat": 100000,
               "out_payments_fulfilled": 4,
               "out_fulfilled_msat": 100000,
               "htlcs": []
            },
            {
               "state": "CHANNELD_NORMAL",
               "scratch_txid": "a812d1089ae5745800275954e8311f6ec894bb144c7fa53588ef6016782a43eb",
               "last_tx_fee_msat": 363000,
               "feerate": {
                  "perkw": 253,
                  "perkb": 1012
               },
               "owner": "channeld",
               "short_channel_id": "111x1x1",
               "direction": 1,
               "channel_id": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
               "funding_txid": "a1d659a4006711bb0fad3ac8a089f7ed24aa9c2de850991f6a6bc2024c956188",
               "funding_outnum": 1,
               "close_to_addr": "bcrt1qn7al6f9g772rvlf6fycty2w9nwzqknqscmtrhs",
               "close_to": "00149fbbfd24a8f794367d3a4930b229c59b840b4c10",
               "private": false,
               "opener": "remote",
               "alias": {
                  "local": "11018327x10385621x23688",
                  "remote": "6702703x15773145x25355"
               },
               "features": [
                  "option_static_remotekey"
               ],
               "funding": {
                  "local_funds_msat": 0,
                  "remote_funds_msat": 1000000000,
                  "pushed_msat": 0
               },
               "to_us_msat": 180000,
               "min_to_us_msat": 0,
               "max_to_us_msat": 180000,
               "total_msat": 1000000000,
               "fee_base_msat": 1,
               "fee_proportional_millionths": 10,
               "dust_limit_msat": 546000,
               "max_total_htlc_in_msat": 18446744073709551615,
               "their_reserve_msat": 10000000,
               "our_reserve_msat": 10000000,
               "spendable_msat": 0,
               "receivable_msat": 989280000,
               "minimum_htlc_in_msat": 0,
               "minimum_htlc_out_msat": 0,
               "maximum_htlc_out_msat": 990000000,
               "their_to_self_delay": 6,
               "our_to_self_delay": 6,
               "max_accepted_htlcs": 483,
               "state_changes": [
                  {
                     "timestamp": "2023-08-03T13:42:13.163Z",
                     "old_state": "CHANNELD_AWAITING_LOCKIN",
                     "new_state": "CHANNELD_NORMAL",
                     "cause": "remote",
                     "message": "Lockin complete"
                  }
               ],
               "status": [
                  "CHANNELD_NORMAL:Reconnected, and reestablished.",
                  "CHANNELD_NORMAL:Channel ready for use. Channel announced."
               ],
               "in_payments_offered": 3,
               "in_offered_msat": 180000,
               "in_payments_fulfilled": 3,
               "in_fulfilled_msat": 180000,
               "out_payments_offered": 0,
               "out_offered_msat": 0,
               "out_payments_fulfilled": 0,
               "out_fulfilled_msat": 0,
               "htlcs": []
            }
         ]
      }
   ]
}
◉ tony@tony:~/clnlive:
$ l1-cli listpeers
{
   "peers": [
      {
         "id": "0262e4c2059fe147015b33b316157f303c8e54e8c40b02b08a8ba593ef558af382",
         "connected": true,
         "num_channels": 2,
         "netaddr": [
            "127.0.0.1:7272"
         ],
         "features": "08a0000a0269a2",
         "channels": [
            {
               "state": "CHANNELD_NORMAL",
               "scratch_txid": "df7f375bb6b7bed653e7093dc7ff4da3ee940559eb19ec3f011eb2c8adcd16b4",
               "last_tx_fee_msat": 283000,
               "feerate": {
                  "perkw": 253,
                  "perkb": 1012
               },
               "owner": "channeld",
               "short_channel_id": "103x1x1",
               "direction": 1,
               "channel_id": "c715632ba5902529ee099588f9ca12ecf877c04d583849bc08c17dcd0280397d",
               "funding_txid": "7c398002cd7dc108bc4938584dc077f8ec12caf9889509ee292590a52b6315c7",
               "funding_outnum": 1,
               "close_to_addr": "bcrt1qap7tds3y99l76k2uut8gy03w866nxgkqwwdvj5",
               "close_to": "0014e87cb6c224297fed595ce2ce823e2e3eb53322c0",
               "private": false,
               "opener": "local",
               "alias": {
                  "local": "4789751x13272092x51577",
                  "remote": "4204164x15552480x64151"
               },
               "features": [
                  "option_static_remotekey"
               ],
               "funding": {
                  "local_funds_msat": 1000000000,
                  "remote_funds_msat": 0,
                  "pushed_msat": 0
               },
               "to_us_msat": 999900000,
               "min_to_us_msat": 999900000,
               "max_to_us_msat": 1000000000,
               "total_msat": 1000000000,
               "fee_base_msat": 1,
               "fee_proportional_millionths": 10,
               "dust_limit_msat": 546000,
               "max_total_htlc_in_msat": 18446744073709551615,
               "their_reserve_msat": 10000000,
               "our_reserve_msat": 10000000,
               "spendable_msat": 989360000,
               "receivable_msat": 0,
               "minimum_htlc_in_msat": 0,
               "minimum_htlc_out_msat": 0,
               "maximum_htlc_out_msat": 990000000,
               "their_to_self_delay": 6,
               "our_to_self_delay": 6,
               "max_accepted_htlcs": 483,
               "state_changes": [
                  {
                     "timestamp": "2023-08-03T13:40:12.413Z",
                     "old_state": "CHANNELD_AWAITING_LOCKIN",
                     "new_state": "CHANNELD_NORMAL",
                     "cause": "user",
                     "message": "Lockin complete"
                  }
               ],
               "status": [
                  "CHANNELD_NORMAL:Reconnected, and reestablished.",
                  "CHANNELD_NORMAL:Channel ready for use. Channel announced."
               ],
               "in_payments_offered": 0,
               "in_offered_msat": 0,
               "in_payments_fulfilled": 0,
               "in_fulfilled_msat": 0,
               "out_payments_offered": 4,
               "out_offered_msat": 100000,
               "out_payments_fulfilled": 4,
               "out_fulfilled_msat": 100000,
               "htlcs": []
            },
            {
               "state": "CHANNELD_NORMAL",
               "scratch_txid": "a812d1089ae5745800275954e8311f6ec894bb144c7fa53588ef6016782a43eb",
               "last_tx_fee_msat": 363000,
               "feerate": {
                  "perkw": 253,
                  "perkb": 1012
               },
               "owner": "channeld",
               "short_channel_id": "111x1x1",
               "direction": 1,
               "channel_id": "8861954c02c26b6a1f9950e82d9caa24edf789a0c83aad0fbb116700a459d6a0",
               "funding_txid": "a1d659a4006711bb0fad3ac8a089f7ed24aa9c2de850991f6a6bc2024c956188",
               "funding_outnum": 1,
               "close_to_addr": "bcrt1qn7al6f9g772rvlf6fycty2w9nwzqknqscmtrhs",
               "close_to": "00149fbbfd24a8f794367d3a4930b229c59b840b4c10",
               "private": false,
               "opener": "remote",
               "alias": {
                  "local": "11018327x10385621x23688",
                  "remote": "6702703x15773145x25355"
               },
               "features": [
                  "option_static_remotekey"
               ],
               "funding": {
                  "local_funds_msat": 0,
                  "remote_funds_msat": 1000000000,
                  "pushed_msat": 0
               },
               "to_us_msat": 180000,
               "min_to_us_msat": 0,
               "max_to_us_msat": 180000,
               "total_msat": 1000000000,
               "fee_base_msat": 1,
               "fee_proportional_millionths": 10,
               "dust_limit_msat": 546000,
               "max_total_htlc_in_msat": 18446744073709551615,
               "their_reserve_msat": 10000000,
               "our_reserve_msat": 10000000,
               "spendable_msat": 0,
               "receivable_msat": 989280000,
               "minimum_htlc_in_msat": 0,
               "minimum_htlc_out_msat": 0,
               "maximum_htlc_out_msat": 990000000,
               "their_to_self_delay": 6,
               "our_to_self_delay": 6,
               "max_accepted_htlcs": 483,
               "state_changes": [
                  {
                     "timestamp": "2023-08-03T13:42:13.163Z",
                     "old_state": "CHANNELD_AWAITING_LOCKIN",
                     "new_state": "CHANNELD_NORMAL",
                     "cause": "remote",
                     "message": "Lockin complete"
                  }
               ],
               "status": [
                  "CHANNELD_NORMAL:Reconnected, and reestablished.",
                  "CHANNELD_NORMAL:Channel ready for use. Channel announced."
               ],
               "in_payments_offered": 3,
               "in_offered_msat": 180000,
               "in_payments_fulfilled": 3,
               "in_fulfilled_msat": 180000,
               "out_payments_offered": 0,
               "out_offered_msat": 0,
               "out_payments_fulfilled": 0,
               "out_fulfilled_msat": 0,
               "htlcs": []
            }
         ]
      },
      {
         "id": "030b1736a879486b03aa77fbbf386e38e34568d7096122fd1e3d3a29da047cbf90",
         "connected": true,
         "num_channels": 0,
         "netaddr": [
            "127.0.0.1:54270"
         ],
         "features": "",
         "channels": []
      }
   ]
}
◉ tony@tony:~/clnlive:
$

Source code

main.go

package main

import (
  "fmt"
  "log"
  "net/http"
  "html/template"
  "github.com/tidwall/gjson"
  lnsocket "github.com/jb55/lnsocket/go"
)

// var RUNE = "BhOlLq7GtF8EfoeipYpXzB-WAY3O4odwu_JeZqZy9Rc9MQ=="
var RUNE = "vxfQM55ofNCEVh7U9EekbBJN39WQtjupFniqylktprM9MiZtZXRob2ReYmtwci0="
var HOSTNAME = "127.0.0.1:7171"
var NODEID = "03e216b2e609dea193e1a8d2db41c24a0e2279c3a656e0b220ab96c00e0b7c6181"

type Account struct {
  Account string
  BalanceMsat string
}

func abbrevAccount(acc string) string{
  if len(acc) > 15 {
    return acc[:6] + "..." + acc[len(acc) - 6:]
  } else {
    return acc
  }
}

func listAccounts(ln *lnsocket.LNSocket, rune string) []Account {
  body, _ := ln.Rpc(RUNE, "bkpr-listbalances", "[]")
  accArr := gjson.Get(body, "result.accounts").Array()
  accounts := make([]Account, len(accArr))
  for i, account := range accArr {
    accounts[i] = Account{
      Account: abbrevAccount(account.Get("account").String()),
      BalanceMsat: account.Get("balances.0.balance_msat").String(),
    }
  }
  return accounts
}

func makeHomeHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    tpl, _ := template.ParseFiles("index.html")
    tpl.Execute(w, listAccounts(ln, rune))
  }
}

func makeAccountsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    tpl, _ := template.ParseFiles("index.html")
    tpl.ExecuteTemplate(w, "accounts", listAccounts(ln, rune))
  }
}

type IncomeEvent struct {
  Account string
  Tag string
  CreditMsat int64
  DebitMsat int64
}

func makeIncomeEventsHandler(ln *lnsocket.LNSocket, rune string) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
    body, _ := ln.Rpc(RUNE, "bkpr-listincome", "[]")
    incEvtArr := gjson.Get(body, "result.income_events").Array()
    incomeEvents := make([]IncomeEvent, len(incEvtArr))
    for i, incomeEvent := range incEvtArr {
      incomeEvents[i] = IncomeEvent{
        Account: abbrevAccount(incomeEvent.Get("account").String()),
        Tag: incomeEvent.Get("tag").String(),
        CreditMsat: incomeEvent.Get("credit_msat").Int(),
        DebitMsat: incomeEvent.Get("debit_msat").Int(),
      }
    }
    tpl, _ := template.ParseFiles("listincome.html")
    tpl.Execute(w, incomeEvents)
  }
}

func main() {
  ln := lnsocket.LNSocket{}
  ln.GenKey()
  err := ln.ConnectAndInit(HOSTNAME, NODEID)
  if err != nil {fmt.Println("not connected to peer")}
  http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets"))))
  http.HandleFunc("/", makeHomeHandler(&ln, RUNE))
  http.HandleFunc("/listincome", makeIncomeEventsHandler(&ln, RUNE))
  http.HandleFunc("/accounts", makeAccountsHandler(&ln, RUNE))
  log.Fatal(http.ListenAndServe(":8080", nil))
}

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <meta name="description" content="CLN Bookkeeper Web App" />
    <link rel="stylesheet" type="text/css" href="/assets/bkpr.css" />
    <script src="https://unpkg.com/htmx.org@1.9.4"></script>
    <script src="https://unpkg.com/hyperscript.org@0.9.9"></script>
    <title>CLN Bookkeeper Web App</title>
  </head>
  <body>
    <h1 id="header">CLN Bookkeeper</h1>
    <div id="content">
      <div id="tabs">
        <div id="tab-accounts"
             class="tab selected"
             hx-get="/accounts"
             hx-swap="innerHTML"
             hx-target="#accounts-or-listincome"
             _="on click
                add .selected to me
                remove .selected from #tab-listincome"
             >
          Accounts
        </div>
        <div>|</div>
        <div id="tab-listincome"
             class="tab"
             hx-get="/listincome"
             hx-swap="innerHTML"
             hx-target="#accounts-or-listincome"
             _="on click
                add .selected to me
                remove .selected from #tab-accounts"
             >
          Income Events
        </div>
      </div>
      <div id="accounts-or-listincome">
        {{block "accounts" .}}
        <ul id="accounts">
          {{range .}}
          <li class="account">
            <div>Account: {{.Account}}</div>
            <div>Balance: {{.BalanceMsat}} msat</div>
          </li>
          {{end}}
        </ul>
        {{end}}
      </div>
    </div>
  </body>
</html>

listincome.html

<ul id="income-events">
  {{range .}}
  <li class="income-event">
    <div class="income-event-left">
      <div>{{.Account}}</div>
      <div class="tag tag-{{.Tag}}">{{.Tag}}</div>
    </div>
    {{if .CreditMsat}}
    <div class="credit">{{.CreditMsat}} msat</div>
    {{else}}
    <div>-{{.DebitMsat}} msat</div>
    {{end}}
  </li>
  {{end}}
</ul>

go.mod

module test

go 1.19

require (
  github.com/jb55/lnsocket/go v0.0.0-20230517173613-b7d9bce6c787
  github.com/tidwall/gjson v1.15.0
)

require (
  github.com/aead/siphash v1.0.1 // indirect
  github.com/btcsuite/btcd v0.23.1 // indirect
  github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect
  github.com/btcsuite/btcd/btcutil v1.1.1 // indirect
  github.com/btcsuite/btcd/btcutil/psbt v1.1.4 // indirect
  github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
  github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
  github.com/btcsuite/btcwallet v0.15.1 // indirect
  github.com/btcsuite/btcwallet/wallet/txauthor v1.2.3 // indirect
  github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect
  github.com/btcsuite/btcwallet/wallet/txsizes v1.1.0 // indirect
  github.com/btcsuite/btcwallet/walletdb v1.4.0 // indirect
  github.com/btcsuite/btcwallet/wtxmgr v1.5.0 // indirect
  github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect
  github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect
  github.com/davecgh/go-spew v1.1.1 // indirect
  github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect
  github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
  github.com/decred/dcrd/lru v1.0.0 // indirect
  github.com/go-errors/errors v1.0.1 // indirect
  github.com/kkdai/bstream v1.0.0 // indirect
  github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect
  github.com/lightninglabs/neutrino v0.14.2 // indirect
  github.com/lightningnetwork/lnd v0.15.0-beta // indirect
  github.com/lightningnetwork/lnd/clock v1.1.0 // indirect
  github.com/lightningnetwork/lnd/queue v1.1.0 // indirect
  github.com/lightningnetwork/lnd/ticker v1.1.0 // indirect
  github.com/lightningnetwork/lnd/tlv v1.0.3 // indirect
  github.com/lightningnetwork/lnd/tor v1.0.1 // indirect
  github.com/miekg/dns v1.1.43 // indirect
  github.com/tidwall/match v1.1.1 // indirect
  github.com/tidwall/pretty v1.2.0 // indirect
  golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
  golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect
  golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
  golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
)

bkpr.css

/* reset */

html,
body,
p,
ol,
ul,
li,
dl,
dt,
dd,
blockquote,
figure,
fieldset,
legend,
textarea,
pre,
iframe,
hr,
h1,
h2,
h3,
h4,
h5,
h6 {
  margin: 0;
  padding: 0;
}

*, *::before, *::after{
  box-sizing: border-box;
}

ul {
  padding-left: 2em;
  list-style: disc;
}

ul ul {
  margin-top: 0;
  margin-bottom: 0;
}

ol {
  padding-left: 2em;
  list-style: decimal;
}

li p {
  margin: 0;
}

li {
  margin-top: 0.25em;
}

p, blockquote, ul, ol, code,
dl, table, pre, details {
  margin-bottom: 16px;
  margin-top: 0;
}


/* bkpr specific */

#header {
  text-align: center;
  margin: 1.2em;
}

#content {
  margin: auto;
  max-width: 600px;
}

#tabs {
  margin-bottom: 1.2em;
  padding: auto;
  display: flex;
  gap: 1em;
  justify-content: center;
  flex-direction: horizontal;
}

.selected {
  text-decoration: underline;
}

.tab:hover {
  cursor: pointer;
}

#accounts {
  width: 100%;
  margin: auto;
  display: flex;
  flex-direction: column;
  padding-left: 0em;
}

.account {
  padding: 0.6em;
  border-radius: 0.6em;
  list-style-type: none;
  background-color: #F5F5F5;
  margin-bottom: 8px;
  width: 100%;
}

#income-events {
  width: 100%;
  margin: auto;
  display: flex;
  flex-direction: column;
  padding-left: 0em;
}

.income-event {
  display: flex;
  flex-direction: horizontal;
  justify-content: space-between;
  padding: 0.6em;
  border-radius: 0.6em;
  list-style-type: none;
  background-color: #F5F5F5;
  margin-bottom: 8px;
  width: 100%;
}

.income-event-left {
  display: flex;
  flex-direction: horizontal;
  align-items: baseline;
  gap: 1em;
}

.credit {
  padding: 0.2em 0.4em 0.2em 0.4em;
  border-radius: 0.3em;
  list-style-type: none;
  font-size: bold;
  background-color: #00B89C;
  color: white;
}

.tag {
  padding: 0.2em 0.4em 0.2em 0.4em;
  border-radius: 0.6em;
}

.tag-onchain_fee {
  background-color: #7CB2DF;
}

.tag-invoice {
  background-color: #ffe08a;
}

.tag-deposit {
  background-color: #DFA87C;
}

.tag-withdrawal {
  background-color: #E9A5A9;
}

lnregtest.bash

#!/usr/bin/env bash

# we assume we've already funded default bitcoin wallet and
# l1 wallet node and one channel from l1 to l2 using `fund_nodes`
# from `contrib/startup_regtest.sh`

l1_cli(){
    lightning-cli --lightning-dir=/tmp/l1-regtest $@
}

l2_cli(){
    lightning-cli --lightning-dir=/tmp/l2-regtest $@
}

# l1 pays 3 invoices to l2

inv_1=$(l2_cli invoice 10000 inv-1 pizza)
inv_2=$(l2_cli invoice 20000 inv-2 pizza)
inv_3=$(l2_cli invoice 30000 inv-3 pizza)
bolt11_1=$(echo $inv_1 | jq -r .bolt11)
bolt11_2=$(echo $inv_2 | jq -r .bolt11)
bolt11_3=$(echo $inv_3 | jq -r .bolt11)
l1_cli pay $bolt11_1
l1_cli pay $bolt11_2
l1_cli pay $bolt11_3

# bitcoin default wallet address

bitcoin_default_wallet_addr=$(bitcoin-cli -regtest -rpcwallet=default getnewaddress)

# fund l1 wallet with 2btc

l1_addr=$(l1_cli newaddr | jq -r .bech32)
bitcoin-cli -regtest -rpcwallet=default sendtoaddress $l1_addr 2
bitcoin-cli -regtest generatetoaddress 1 $bitcoin_default_wallet_addr

# l1 pays 1 invoices to l2

inv_4=$(l2_cli invoice 40000 inv-4 pizza)
bolt11_4=$(echo $inv_4 | jq -r .bolt11)
l1_cli pay $bolt11_4

# open a channel from l2 to l1

l2_addr=$(l2_cli newaddr | jq -r .bech32)
bitcoin-cli -regtest -rpcwallet=default sendtoaddress $l2_addr 1
bitcoin-cli -regtest generatetoaddress 1 $bitcoin_default_wallet_addr

while ! lightning-cli -F --lightning-dir=/tmp/l2-regtest listfunds | grep -q "outputs"
do
                sleep 1
done

l1_node_id=$(l1_cli getinfo | jq -r .id)
l2_cli fundchannel $l1_node_id 1000000
bitcoin-cli -regtest generatetoaddress 6 $bitcoin_default_wallet_addr
sleep 60 # should be enough to get the channel confirmed

# l2 pays 2 invoices to l1

inv_5=$(l1_cli invoice 50000 inv-5 pizza)
inv_6=$(l1_cli invoice 60000 inv-6 pizza)
bolt11_5=$(echo $inv_5 | jq -r .bolt11)
bolt11_6=$(echo $inv_6 | jq -r .bolt11)
l2_cli pay $bolt11_5
l2_cli pay $bolt11_6

# l1 withdraw 5000000sat

l1_cli withdraw $bitcoin_default_wallet_addr 5000000
bitcoin-cli -regtest generatetoaddress 1 $bitcoin_default_wallet_addr

# l2 pays 1 invoice to l1

inv_7=$(l1_cli invoice 70000 inv-7 pizza)
bolt11_7=$(echo $inv_7 | jq -r .bolt11)
l2_cli pay $bolt11_7

Resources