diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f5b03e1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,4 @@
+.PHONY: test
+
+test:
+ go test ./...
diff --git a/README.md b/README.md
index 0dc9d2a..0d861fd 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,8 @@ You should then see this screen:
_The menu doesn't do anything yet, that's all there is for now._
+To run the tests, run `make test` or `go test ./...` if you don't have `make`.
+
---
last updated: Jan 3, 2026
diff --git a/go.mod b/go.mod
index be21ae5..20a7928 100644
--- a/go.mod
+++ b/go.mod
@@ -3,9 +3,14 @@ module github.com/ekzyis/lntutor
go 1.24.4
require (
+ github.com/btcsuite/btcd/btcutil v1.1.6
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
+ github.com/stretchr/testify v1.11.1
+ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
+ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
)
require (
@@ -14,6 +19,7 @@ require (
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -22,8 +28,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index d1a20cb..7b5b1a0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,29 @@
+github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
+github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
+github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
+github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
+github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
+github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
+github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
+github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
+github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
+github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c=
+github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
+github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
+github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
+github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
+github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
+github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
+github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
+github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
+github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -14,8 +38,38 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
+github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
+github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
+github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -30,16 +84,78 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
+github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/lib/base58/base58.go b/lib/base58/base58.go
new file mode 100644
index 0000000..1037251
--- /dev/null
+++ b/lib/base58/base58.go
@@ -0,0 +1,37 @@
+package base58
+
+import (
+ "crypto/sha256"
+
+ "github.com/btcsuite/btcd/btcutil/base58"
+)
+
+// Encode encodes a byte slice to a modified base58 string. The checksum is
+// appended to the data.
+func Encode(data []byte) string {
+ data = append(data, checksum(data)...)
+ return base58.Encode(data)
+}
+
+// Decode decodes a modified base58 string to a byte slice. It does not verify
+// the checksum.
+func Decode(data string) []byte {
+ return base58.Decode(data)
+}
+
+// DecodeAddress decodes a base58 address and returns the network ID and the
+// ripemd160 hash of the pubkey or script.
+func DecodeAddress(addr string) (byte, []byte) {
+ decoded := Decode(addr)
+ netID := decoded[0]
+ hash160 := decoded[1 : len(decoded)-4]
+ // TODO: verify checksum?
+ // checkSum := decoded[len(decoded)-4:]
+ return netID, hash160
+}
+
+func checksum(data []byte) []byte {
+ h := sha256.Sum256(data)
+ h = sha256.Sum256(h[:])
+ return h[:4]
+}
diff --git a/lib/bech32/bech32.go b/lib/bech32/bech32.go
new file mode 100644
index 0000000..e481920
--- /dev/null
+++ b/lib/bech32/bech32.go
@@ -0,0 +1,238 @@
+package bech32
+
+import (
+ "errors"
+ "fmt"
+ "math/bits"
+
+ "github.com/btcsuite/btcd/btcutil/bech32"
+)
+
+const (
+ Version0 = bech32.Version0
+ VersionM = bech32.VersionM
+ VersionUnknown = bech32.VersionUnknown
+)
+
+var (
+ ErrInvalidChecksum = errors.New("invalid checksum")
+ ErrInvalidSeparatorIndex = errors.New("invalid separator index")
+ ErrMixedCase = errors.New("mixed case")
+ ErrInvalidLength = errors.New("invalid length")
+)
+
+// ======================
+// === encoding stuff ===
+// ======================
+
+type Base32Encoder interface {
+ EncodeBase32() ([]byte, error)
+}
+
+type BytesBase32Encoder struct {
+ data []byte
+}
+
+type UintBase32Encoder struct {
+ num uint
+ bitLen uint
+}
+
+type VarUintBase32Encoder struct {
+ num uint
+}
+
+var _ Base32Encoder = (*BytesBase32Encoder)(nil)
+var _ Base32Encoder = (*UintBase32Encoder)(nil)
+var _ Base32Encoder = (*VarUintBase32Encoder)(nil)
+
+func NewBytesBase32Encoder(data []byte) BytesBase32Encoder {
+ return BytesBase32Encoder{data: data}
+}
+
+func NewStringBase32Encoder(data string) BytesBase32Encoder {
+ return BytesBase32Encoder{data: []byte(data)}
+}
+
+func NewUintBase32Encoder(num, bitLen uint) UintBase32Encoder {
+ return UintBase32Encoder{num: num, bitLen: bitLen}
+}
+
+func NewVarUintBase32Encoder(num uint) VarUintBase32Encoder {
+ return VarUintBase32Encoder{num: num}
+}
+
+func (e BytesBase32Encoder) EncodeBase32() ([]byte, error) {
+ return bech32.ConvertBits(e.data, 8, 5, true)
+}
+
+// EncodeBase32 converts the uint value to a base32-encoded byte slice in
+// big-endian order. It includes zero padding to match the bit length.
+func (e UintBase32Encoder) EncodeBase32() ([]byte, error) {
+ num := e.num
+ bitLen := e.bitLen
+ base32Len := bitLen / 5
+
+ // this is not the same as bits.Len(num) > bitLen,
+ // since the division above will floor the result.
+ if bits.Len(num) > int(base32Len*5) {
+ return nil, fmt.Errorf(
+ "number too big to fit into 5-bit groups with %d total bits: %d (0b%b)",
+ bitLen, num, num,
+ )
+ }
+
+ numBase32 := make([]byte, base32Len)
+ // this fills the array from high to low indices, with the most significant
+ // bits ending up at the lowest index => big-endian order
+ for i := int(base32Len) - 1; i >= 0; i-- {
+ // store least significant 5 bits of num at the current index
+ numBase32[i] = byte(num & 0b11111)
+ // shift num right by 5 bits to process the next 5 bits of higher
+ // significance
+ num >>= 5
+ }
+
+ return numBase32, nil
+}
+
+// EncodeBase32 converts the uint value to a variable-length base32-encoded byte
+// slice in big-endian order.
+func (e VarUintBase32Encoder) EncodeBase32() ([]byte, error) {
+ num := e.num
+ var numBase32 []byte
+ for num > 0 {
+ numBase32 = append([]byte{byte(num & 0b11111)}, numBase32...)
+ num >>= 5
+ }
+ return numBase32, nil
+}
+
+// Encode encodes base32-encoded data into a bech32 string.
+func Encode(hrp string, data []byte) (string, error) {
+ return bech32.Encode(hrp, data)
+}
+
+// EncodeM encodes base32-encoded data into a bech32m string.
+func EncodeM(hrp string, data []byte) (string, error) {
+ return bech32.EncodeM(hrp, data)
+}
+
+// BytesToBech32 converts a byte slice to a bech32 string without the checksum
+// by mapping each byte to the corresponding character in the bech32 charset.
+func BytesToBech32Charset(data []byte) string {
+ charset := "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+ var s string
+ for _, b := range data {
+ s += string(charset[b])
+ }
+ return s
+}
+
+// ======================
+// === decoding stuff ===
+// ======================
+
+type Base32Decoder[T any] interface {
+ DecodeBase32() (T, error)
+}
+
+type BytesBase32Decoder struct {
+ data []byte
+}
+
+type UintBase32Decoder struct {
+ data []byte
+}
+
+var _ Base32Decoder[[]byte] = (*BytesBase32Decoder)(nil)
+var _ Base32Decoder[uint] = (*UintBase32Decoder)(nil)
+
+func NewBytesBase32Decoder(data []byte) BytesBase32Decoder {
+ return BytesBase32Decoder{data: data}
+}
+
+func NewUintBase32Decoder(data []byte) UintBase32Decoder {
+ return UintBase32Decoder{data: data}
+}
+
+// DecodeBase32 decodes the base32-encoded byte slice into a base256 byte slice.
+func (d BytesBase32Decoder) DecodeBase32() ([]byte, error) {
+ return bech32.ConvertBits(d.data, 5, 8, false)
+}
+
+// DecodeBase32 decodes the base32-encoded byte slice in big-endian order into a
+// uint value.
+func (e UintBase32Decoder) DecodeBase32() (uint, error) {
+ num := uint(0)
+ for i, b := range e.data {
+ // big-endian order: first byte is the most significant byte, so we
+ // shift it the most
+ num |= uint(b) << (5 * (len(e.data) - i - 1))
+ }
+
+ return num, nil
+}
+
+// Decode decodes a bech32 encoded string, returning the human-readable part and
+// the data part excluding the checksum. It validates the checksum.
+func Decode(bech string) (string, []byte, error) {
+ hrp, data, err := bech32.Decode(bech)
+ return hrp, data, wrapLibError(err)
+}
+
+// DecodeGeneric decodes a bech32 encoded string, returning the human-readable
+// part, the data part excluding the checksum, and the version. It validates the
+// checksum.
+func DecodeGeneric(bech string) (string, []byte, bech32.Version, error) {
+ hrp, data, version, err := bech32.DecodeGeneric(bech)
+ return hrp, data, version, wrapLibError(err)
+}
+
+// DecodeNoLimit decodes a bech32 encoded string, returning the human-readable
+// part and the data part excluding the checksum. It validates the checksum, but
+// does not validate against the BIP-173 maximum length allowed for bech32
+// strings.
+func DecodeNoLimit(bech string) (string, []byte, error) {
+ hrp, data, err := bech32.DecodeNoLimit(bech)
+ if err != nil {
+ return "", nil, wrapLibError(err)
+ }
+ return hrp, data, wrapLibError(err)
+}
+
+// wrapLibError returns bech32 library error structs as errors created by
+// errors.New() and returns them. These errors can be more conveniently used
+// with errors.Is(). If the error is unknown, it is returned unchanged.
+func wrapLibError(err error) error {
+ // TODO: don't lose information contained in original error messages
+
+ if errors.As(err, &bech32.ErrInvalidChecksum{}) {
+ return ErrInvalidChecksum
+ }
+
+ var sepErr bech32.ErrInvalidSeparatorIndex
+ if errors.As(err, &sepErr) {
+ return ErrInvalidSeparatorIndex
+ }
+
+ if errors.As(err, &bech32.ErrMixedCase{}) {
+ return ErrMixedCase
+ }
+
+ var lenErr bech32.ErrInvalidLength
+ if errors.As(err, &lenErr) {
+ return ErrInvalidLength
+ }
+
+ return err
+}
+
+// ===================
+// === other stuff ===
+// ===================
+
+// ConvertBits converts a byte slice from one bit length to another.
+func ConvertBits(data []byte, fromBits, toBits uint8, pad bool) ([]byte, error) {
+ return bech32.ConvertBits(data, fromBits, toBits, pad)
+}
diff --git a/lib/bitcoin/address.go b/lib/bitcoin/address.go
new file mode 100644
index 0000000..f5d18d4
--- /dev/null
+++ b/lib/bitcoin/address.go
@@ -0,0 +1,432 @@
+package bitcoin
+
+import (
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/decred/dcrd/dcrec/secp256k1/v4"
+ "github.com/ekzyis/lntutor/lib/base58"
+ "github.com/ekzyis/lntutor/lib/bech32"
+ liberr "github.com/ekzyis/lntutor/lib/error"
+ "github.com/ekzyis/lntutor/lightning/lntypes"
+ "golang.org/x/crypto/ripemd160"
+)
+
+var errInvalidLegacyAddress = errors.New("failed to decode as legacy address")
+var errNoWitnessVersion = errors.New("no witness version")
+var errInvalidWitnessVersion = errors.New("invalid witness version")
+var ErrUnknownVersion = errors.New("unknown version")
+
+var Version0 = bech32.Version0
+var VersionM = bech32.VersionM
+
+const (
+ segwitMainnetHRP = "bc"
+ segwitTestnetHRP = "tb"
+ segwitRegtestHRP = "bcrt"
+ segwitVersion0 byte = 0
+ segwitVersion1 byte = 1
+
+ p2pkhBolt11Version = 0x11
+ p2pkhMainnetVersion = 0x00 // starts with 1
+ p2pkhTestnetVersion = 0x6f // starts with m or n (same for regtest)
+ p2pkhSignetVersion = 0x3f // starts with S
+
+ p2shBolt11Version = 0x12
+ p2shMainnetVersion = 0x05 // starts with 3
+ p2shTestnetVersion = 0xc4 // starts with 2 (same for regtest)
+ p2shSignetVersion = 0x7b // starts with s
+)
+
+type Address interface {
+ // Encode encodes the address into a base58 string for legacy addresses, a
+ // bech32 string for segwit addresses with witness version 0, or a bech32m
+ // string for segwit addresses with witness version 1.
+ Encode() (string, error)
+
+ // EncodeBolt11 encodes the address into a base32-encoded byte slice, and
+ // includes the version byte for the address type.
+ EncodeBolt11() ([]byte, error)
+
+ // DecodeBolt11 decodes the address from a base32-encoded byte slice.
+ DecodeBolt11([]byte) error
+}
+
+type AddressBolt11Decoder struct {
+ addr *string
+ network lntypes.Network
+}
+
+type SegwitAddress struct {
+ Network lntypes.Network
+ Version byte
+ Program []byte
+}
+
+type P2PKAddress struct {
+ PubKey *secp256k1.PublicKey
+}
+
+type P2PKHAddress struct {
+ Network lntypes.Network
+ PubKeyHash []byte
+}
+
+type P2SHAddress struct {
+ Network lntypes.Network
+ ScriptHash []byte
+}
+
+var _ Address = (*SegwitAddress)(nil)
+var _ Address = (*P2PKAddress)(nil)
+var _ Address = (*P2PKHAddress)(nil)
+var _ Address = (*P2SHAddress)(nil)
+
+func NewAddressBolt11Decoder(addr *string, network lntypes.Network) AddressBolt11Decoder {
+ return AddressBolt11Decoder{addr: addr, network: network}
+}
+
+func (d AddressBolt11Decoder) DecodeBolt11(data []byte) error {
+ var a Address
+
+ version := data[0]
+ switch version {
+ case p2pkhBolt11Version:
+ a = &P2PKHAddress{Network: d.network}
+ case p2shBolt11Version:
+ a = &P2SHAddress{Network: d.network}
+ case segwitVersion0, segwitVersion1:
+ a = &SegwitAddress{Network: d.network, Version: version}
+ default:
+ return ErrUnknownVersion
+ }
+
+ err := a.DecodeBolt11(data)
+ if err != nil {
+ return fmt.Errorf("failed to decode address: %w", err)
+ }
+
+ *d.addr, err = a.Encode()
+ if err != nil {
+ return fmt.Errorf("failed to encode address: %w", err)
+ }
+
+ return nil
+}
+
+func (a *SegwitAddress) Encode() (string, error) {
+ var hrp string
+ switch a.Network {
+ case lntypes.NetworkMainnet:
+ hrp = segwitMainnetHRP
+ case lntypes.NetworkTestnet:
+ hrp = segwitTestnetHRP
+ case lntypes.NetworkRegtest:
+ hrp = segwitRegtestHRP
+ default:
+ return "", fmt.Errorf("invalid network: %s", a.Network)
+ }
+
+ progBase32, err := bech32.NewBytesBase32Encoder(a.Program).EncodeBase32()
+ if err != nil {
+ return "", fmt.Errorf("failed to encode witness program: %w", err)
+ }
+
+ data := append([]byte{a.Version}, progBase32...)
+
+ switch a.Version {
+ case segwitVersion0:
+ return bech32.Encode(hrp, data)
+ case segwitVersion1:
+ return bech32.EncodeM(hrp, data)
+ default:
+ return "", errInvalidWitnessVersion
+ }
+}
+
+func (a *SegwitAddress) EncodeBolt11() ([]byte, error) {
+ progBase32, err := bech32.NewBytesBase32Encoder(a.Program).EncodeBase32()
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode witness program: %w", err)
+ }
+ return append([]byte{a.Version}, progBase32...), nil
+}
+
+func (a *SegwitAddress) DecodeBolt11(data []byte) error {
+ if len(data) < 1 {
+ return errNoWitnessVersion
+ }
+
+ version, progBase32 := data[0], data[1:]
+ if version > 16 {
+ return errInvalidWitnessVersion
+ }
+ a.Version = version
+
+ progBase256, err := bech32.NewBytesBase32Decoder(progBase32).DecodeBase32()
+ if err != nil {
+ return fmt.Errorf("failed to decode witness program: %w", err)
+ }
+ a.Program = progBase256
+
+ return nil
+}
+
+func (a *P2PKAddress) Encode() (string, error) {
+ // TODO: implement
+ return "", liberr.ErrNotImplemented
+}
+
+func (a *P2PKAddress) EncodeBolt11() ([]byte, error) {
+ // TODO: implement
+ return nil, liberr.ErrNotImplemented
+}
+
+func (a *P2PKAddress) DecodeBolt11(data []byte) error {
+ // TODO: implement
+ return liberr.ErrNotImplemented
+}
+
+func (a *P2PKHAddress) Encode() (string, error) {
+ var version byte
+ switch a.Network {
+ case lntypes.NetworkMainnet:
+ version = p2pkhMainnetVersion
+ case lntypes.NetworkTestnet:
+ version = p2pkhTestnetVersion
+ case lntypes.NetworkSignet:
+ version = p2pkhSignetVersion
+ default:
+ return "", fmt.Errorf("invalid network: %s", a.Network)
+ }
+ data := append([]byte{version}, a.PubKeyHash...)
+ return base58.Encode(data), nil
+}
+
+func (a *P2PKHAddress) EncodeBolt11() ([]byte, error) {
+ pubKeyHashBase32, err := bech32.NewBytesBase32Encoder(a.PubKeyHash).EncodeBase32()
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode pubkey hash: %w", err)
+ }
+ return append([]byte{p2pkhBolt11Version}, pubKeyHashBase32...), nil
+}
+
+func (a *P2PKHAddress) DecodeBolt11(data []byte) error {
+ if len(data) < 1 {
+ return fmt.Errorf("invalid data length for P2PKH address: %d", len(data))
+ }
+
+ version, pubKeyHashBase32 := data[0], data[1:]
+
+ if version != p2pkhBolt11Version {
+ return fmt.Errorf("invalid version byte for P2PKH address: expected %d, got 0x%02x", p2pkhBolt11Version, version)
+ }
+
+ PubKeyHashBase256, err := bech32.NewBytesBase32Decoder(pubKeyHashBase32).DecodeBase32()
+ if err != nil {
+ return fmt.Errorf("failed to decode P2PKH address: %w", err)
+ }
+ a.PubKeyHash = PubKeyHashBase256
+
+ if len(a.PubKeyHash) != ripemd160.Size {
+ return fmt.Errorf("invalid data length for P2PKH address: expected %d, got %d", ripemd160.Size, len(a.PubKeyHash))
+ }
+
+ return nil
+}
+
+func (a *P2SHAddress) Encode() (string, error) {
+ var version byte
+ switch a.Network {
+ case lntypes.NetworkMainnet:
+ version = p2shMainnetVersion
+ case lntypes.NetworkTestnet:
+ version = p2shTestnetVersion
+ case lntypes.NetworkSignet:
+ version = p2shSignetVersion
+ default:
+ return "", fmt.Errorf("invalid network: %s", a.Network)
+ }
+ data := append([]byte{version}, a.ScriptHash...)
+ return base58.Encode(data), nil
+}
+
+func (a *P2SHAddress) EncodeBolt11() ([]byte, error) {
+ scriptHashBase32, err := bech32.NewBytesBase32Encoder(a.ScriptHash).EncodeBase32()
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode script hash: %w", err)
+ }
+ return append([]byte{p2shBolt11Version}, scriptHashBase32...), nil
+}
+
+// DecodeBolt11 decodes a P2SH address from the tagged field data of a bolt11
+// payment request. The byte slice must be in base32.
+func (a *P2SHAddress) DecodeBolt11(data []byte) error {
+ if len(data) < 1 {
+ return fmt.Errorf("invalid data length for P2SH address: %d", len(data))
+ }
+
+ version, scriptHashBase32 := data[0], data[1:]
+
+ if version != p2shBolt11Version {
+ return fmt.Errorf("invalid version byte for P2SH address: expected %d, got 0x%02x", p2shBolt11Version, version)
+ }
+
+ scriptHashBase256, err := bech32.NewBytesBase32Decoder(scriptHashBase32).DecodeBase32()
+ if err != nil {
+ return fmt.Errorf("failed to decode P2SH address: %w", err)
+ }
+ a.ScriptHash = scriptHashBase256
+
+ if len(a.ScriptHash) != ripemd160.Size {
+ return fmt.Errorf("invalid data length for P2SH address: expected %d, got %d", ripemd160.Size, len(a.ScriptHash))
+ }
+
+ return nil
+}
+
+// DecodeAddress decodes a base58 (legacy) or bech32 (segwit) address.
+func DecodeAddress(addr string) (Address, error) {
+ if isSegwitAddress(addr) {
+ return decodeSegwitAddress(addr)
+ }
+
+ a, err := decodeLegacyAddress(addr)
+ if err == errInvalidLegacyAddress {
+ // we don't wrap the error because we don't want to assume it's a legacy
+ // address if we failed to decode it as such.
+ return nil, fmt.Errorf("failed to decode address: %s", addr)
+ } else if err != nil {
+ return nil, fmt.Errorf("failed to decode address: %w", err)
+ }
+
+ return a, nil
+}
+
+func isSegwitAddress(addr string) bool {
+ for _, hrp := range []string{segwitMainnetHRP, segwitTestnetHRP, segwitRegtestHRP} {
+ if strings.HasPrefix(addr, hrp+"1") {
+ return true
+ }
+ }
+ return false
+}
+
+// decodeSegwitAddress decodes a segwit address - duh!
+func decodeSegwitAddress(addr string) (*SegwitAddress, error) {
+ hrp, data, bech32version, err := bech32.DecodeGeneric(addr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode segwit address: %w", err)
+ }
+
+ var network lntypes.Network
+ switch hrp {
+ case segwitMainnetHRP:
+ network = lntypes.NetworkMainnet
+ case segwitTestnetHRP:
+ network = lntypes.NetworkTestnet
+ case segwitRegtestHRP:
+ network = lntypes.NetworkRegtest
+ default:
+ return nil, fmt.Errorf(
+ "invalid hrp: expected %s, %s or %s, got %s",
+ segwitMainnetHRP, segwitTestnetHRP, segwitRegtestHRP, hrp,
+ )
+ }
+
+ // The first byte of the decoded address is the witness version, it must
+ // exist.
+ if len(data) < 1 {
+ return nil, errNoWitnessVersion
+ }
+
+ // ...and be <= 16.
+ version, progBase32 := data[0], data[1:]
+ if version > 16 {
+ return nil, errInvalidWitnessVersion
+ }
+
+ // The remaining characters of the address returned are grouped into words
+ // of 5 bits. In order to restore the original witness program bytes, we'll
+ // need to regroup into 8 bit words.
+ progBase256, err := bech32.ConvertBits(progBase32, 5, 8, false)
+ if err != nil {
+ return nil, err
+ }
+
+ // The regrouped data must be between 2 and 40 bytes.
+ if len(progBase256) < 2 || len(progBase256) > 40 {
+ return nil, fmt.Errorf("invalid data length")
+ }
+
+ // For witness version 0, address MUST be exactly 20 or 32 bytes.
+ if version == 0 && len(progBase256) != 20 && len(progBase256) != 32 {
+ return nil, fmt.Errorf("invalid data length for witness version 0: %v", len(progBase256))
+ }
+
+ // For witness version 0, the bech32 encoding must be used.
+ if version == 0 && bech32version != bech32.Version0 {
+ return nil, fmt.Errorf("invalid encoding for witness version 0: expected bech32, got %v", bech32version)
+ }
+
+ // For witness version 1, the bech32m encoding must be used.
+ if version == 1 && bech32version != bech32.VersionM {
+ return nil, fmt.Errorf("invalid encoding for witness version 1: expected bech32m, got %v", bech32version)
+ }
+
+ return &SegwitAddress{
+ Network: network,
+ Version: version,
+ Program: progBase256,
+ }, nil
+}
+
+func decodeLegacyAddress(addr string) (Address, error) {
+ // Serialized public keys are either 65 bytes (130 hex chars) if
+ // uncompressed/hybrid or 33 bytes (66 hex chars) if compressed.
+ isUncompressed := len(addr) == 130
+ isCompressed := len(addr) == 66
+ isPubKey := isUncompressed || isCompressed
+
+ if isPubKey {
+ serializedPubKey, err := hex.DecodeString(addr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode serialized public key: %w", err)
+ }
+
+ pubKey, err := secp256k1.ParsePubKey(serializedPubKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse public key: %w", err)
+ }
+
+ return &P2PKAddress{PubKey: pubKey}, nil
+ }
+
+ netID, hash160 := base58.DecodeAddress(addr)
+
+ if len(hash160) != ripemd160.Size {
+ return nil, errInvalidLegacyAddress
+ }
+
+ var network lntypes.Network
+ switch netID {
+ case p2pkhMainnetVersion, p2shMainnetVersion:
+ network = lntypes.NetworkMainnet
+ case p2pkhTestnetVersion, p2shTestnetVersion:
+ // could also be regtest since testnet magic bytes are the same
+ network = lntypes.NetworkTestnet
+ case p2pkhSignetVersion, p2shSignetVersion:
+ network = lntypes.NetworkSignet
+ }
+
+ switch netID {
+ case p2pkhMainnetVersion, p2pkhTestnetVersion, p2pkhSignetVersion:
+ return &P2PKHAddress{Network: network, PubKeyHash: hash160}, nil
+ case p2shMainnetVersion, p2shTestnetVersion, p2shSignetVersion:
+ return &P2SHAddress{Network: network, ScriptHash: hash160}, nil
+ default:
+ return nil, errInvalidLegacyAddress
+ }
+}
diff --git a/lib/error/error.go b/lib/error/error.go
new file mode 100644
index 0000000..f757ea7
--- /dev/null
+++ b/lib/error/error.go
@@ -0,0 +1,5 @@
+package liberr
+
+import "errors"
+
+var ErrNotImplemented = errors.New("not implemented")
diff --git a/lib/secp256k1/secp256k1.go b/lib/secp256k1/secp256k1.go
new file mode 100644
index 0000000..61af6d8
--- /dev/null
+++ b/lib/secp256k1/secp256k1.go
@@ -0,0 +1,125 @@
+package secp256k1
+
+import (
+ "crypto/sha256"
+ "fmt"
+
+ "github.com/decred/dcrd/dcrec/secp256k1/v4"
+ "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
+)
+
+// CompactECDSASignature is a compact ECDSA signature over secp256k1.
+type CompactECDSASignature struct {
+ RecoveryId byte
+ R [32]byte
+ S [32]byte
+}
+
+type Signer interface {
+ // CompactECDSASign returns a deterministic, compact, low-S ECDSA signature
+ // over secp256k1 according to RFC6979 and BIP62. The message will be hashed
+ // using sha256 before signing. The signature will reference a compressed
+ // public key.
+ CompactECDSASign(msg []byte) (*CompactECDSASignature, error)
+}
+
+type PrivateKeySigner struct {
+ privateKey *secp256k1.PrivateKey
+}
+
+const (
+ // compactSigMagicOffset is a value used when creating the compact signature
+ // recovery code inherited from Bitcoin and has no meaning, but has been
+ // retained for compatibility. For historical purposes, it was originally
+ // picked to avoid a binary representation that would allow compact
+ // signatures to be mistaken for other components.
+ compactSigMagicOffset = 27
+
+ // compactSigCompPubKey is a value used when creating the compact signature
+ // recovery code to indicate the original public key was compressed.
+ compactSigCompPubKey = 4
+)
+
+func NewPrivateKeySigner(bytes []byte) (Signer, error) {
+ // TODO: also check if private key is in range [1, N-1]
+ if len(bytes) != 32 {
+ return nil, fmt.Errorf("private key bytes must be 32 bytes long, got %d bytes", len(bytes))
+ }
+
+ return &PrivateKeySigner{
+ privateKey: secp256k1.PrivKeyFromBytes(bytes),
+ }, nil
+}
+
+// CompactECDSASign returns a deterministic, compact, low-S ECDSA signature over
+// secp256k1 according to RFC6979 and BIP62. The message will be hashed using
+// sha256 before signing. The signature will reference a compressed public key.
+func (s *PrivateKeySigner) CompactECDSASign(msg []byte) (*CompactECDSASignature, error) {
+ hash := sha256.Sum256(msg)
+
+ // flag for SignCompact to indicate if the produced signature should
+ // reference a compressed public key
+ compressed := true
+
+ // this will generate a deterministic, compact, low-S ECDSA signature
+ // according to RFC6979 and BIP62, referencing a compressed public key.
+ sig := ecdsa.SignCompact(s.privateKey, hash[:], compressed)
+
+ compactRecoveryId := sig[0]
+ publicKeyRecoverId := compactRecoveryId - compactSigMagicOffset
+ if compressed {
+ publicKeyRecoverId -= compactSigCompPubKey
+ }
+
+ return &CompactECDSASignature{
+ RecoveryId: publicKeyRecoverId,
+ R: [32]byte(sig[1:33]),
+ S: [32]byte(sig[33:65]),
+ }, nil
+}
+
+func NewCompactECDSASignatureFromBytes(sigBytes []byte) (*CompactECDSASignature, error) {
+ if len(sigBytes) != 65 {
+ return nil, fmt.Errorf("signature bytes must be 65 bytes long, got %d", len(sigBytes))
+ }
+ return &CompactECDSASignature{
+ RecoveryId: sigBytes[64] + compactSigMagicOffset + compactSigCompPubKey,
+ R: [32]byte(sigBytes[:32]),
+ S: [32]byte(sigBytes[32:64]),
+ }, nil
+}
+
+func (sig *CompactECDSASignature) Verify(msg []byte) bool {
+ var (
+ sigBytes = make([]byte, 65)
+ hash = sha256.Sum256(msg)
+ r = new(secp256k1.ModNScalar)
+ s = new(secp256k1.ModNScalar)
+ )
+
+ sigBytes[0] = sig.RecoveryId
+ copy(sigBytes[1:33], sig.R[:])
+ copy(sigBytes[33:65], sig.S[:])
+
+ pubKey, _, err := ecdsa.RecoverCompact(sigBytes, hash[:])
+ if err != nil {
+ return false
+ }
+
+ s.SetBytes(&sig.S)
+ r.SetBytes(&sig.R)
+ rawSig := ecdsa.NewSignature(r, s)
+ return rawSig.Verify(hash[:], pubKey)
+}
+
+func (sig *CompactECDSASignature) NegateS() *CompactECDSASignature {
+ s := new(secp256k1.ModNScalar)
+ s.SetBytes(&sig.S)
+ s.Negate()
+
+ return &CompactECDSASignature{
+ RecoveryId: sig.RecoveryId,
+ R: sig.R,
+ S: [32]byte(s.Bytes()),
+ }
+}
diff --git a/lightning/bolt09/bolt09.go b/lightning/bolt09/bolt09.go
new file mode 100644
index 0000000..c1b2c6e
--- /dev/null
+++ b/lightning/bolt09/bolt09.go
@@ -0,0 +1,153 @@
+package bolt09
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+)
+
+// Feature bits aren't implemented as bitmasks, because the maximum feature bit
+// can be 5114. They are limited by the 10 bits for data_length in the tagged
+// fields of bolt11 payment requests.
+//
+// So instead, we use map[FeatureBit]struct{} to represent features, inspired by
+// LND's implementation.
+//
+// I think bitmasks are really cool though :/
+
+type FeatureVector struct {
+ features map[FeatureBit]struct{}
+ maxBit int
+}
+
+type FeatureBit uint16
+
+const (
+ DataLossProtectRequired FeatureBit = 0
+ DataLossProtectOptional FeatureBit = 1
+ UpfrontShutdownScriptRequired FeatureBit = 4
+ UpfrontShutdownScriptOptional FeatureBit = 5
+ GossipQueriesRequired FeatureBit = 6
+ GossipQueriesOptional FeatureBit = 7
+ VarOnionOptinRequired FeatureBit = 8
+ VarOnionOptinOptional FeatureBit = 9
+ GossipQueriesExtendedRequired FeatureBit = 10
+ GossipQueriesExtendedOptional FeatureBit = 11
+ StaticRemotekeyRequired FeatureBit = 12
+ StaticRemotekeyOptional FeatureBit = 13
+ PaymentSecretRequired FeatureBit = 14
+ PaymentSecretOptional FeatureBit = 15
+ BasicMppRequired FeatureBit = 16
+ BasicMppOptional FeatureBit = 17
+ SupportLargeChannelRequired FeatureBit = 18
+ SupportLargeChannelOptional FeatureBit = 19
+ AnchorsRequired FeatureBit = 22
+ AnchorsOptional FeatureBit = 23
+ RouteBlindingRequired FeatureBit = 24
+ RouteBlindingOptional FeatureBit = 25
+ ShutdownAnysegwitRequired FeatureBit = 26
+ ShutdownAnysegwitOptional FeatureBit = 27
+ DualFundRequired FeatureBit = 28
+ DualFundOptional FeatureBit = 29
+ QuiesceRequired FeatureBit = 34
+ QuiesceOptional FeatureBit = 35
+ AttributionDataRequired FeatureBit = 36
+ AttributionDataOptional FeatureBit = 37
+ OnionMessagesRequired FeatureBit = 38
+ OnionMessagesOptional FeatureBit = 39
+ ProvideStorageRequired FeatureBit = 42
+ ProvideStorageOptional FeatureBit = 43
+ ChannelTypeRequired FeatureBit = 44
+ ChannelTypeOptional FeatureBit = 45
+ ScidAliasRequired FeatureBit = 46
+ ScidAliasOptional FeatureBit = 47
+ PaymentMetadataRequired FeatureBit = 48
+ PaymentMetadataOptional FeatureBit = 49
+ ZeroconfRequired FeatureBit = 50
+ ZeroconfOptional FeatureBit = 51
+ SimpleCloseRequired FeatureBit = 60
+ SimpleCloseOptional FeatureBit = 61
+)
+
+var (
+ ErrUnknownRequiredFeatureBit = errors.New("unknown required feature bit")
+)
+
+func NewFeatureVector(bits ...FeatureBit) *FeatureVector {
+ fv := &FeatureVector{features: make(map[FeatureBit]struct{})}
+ for _, bit := range bits {
+ fv.Set(bit)
+ }
+ return fv
+}
+
+// Set marks a feature as enabled in the vector.
+func (fv *FeatureVector) Set(bit FeatureBit) {
+ fv.features[bit] = struct{}{}
+ if int(bit) > fv.maxBit {
+ fv.maxBit = int(bit)
+ }
+}
+
+// Bytes returns the bytes of the feature vector in big-endian order.
+func (fv *FeatureVector) Bytes() []byte {
+ b := make([]byte, fv.maxBit/8+1)
+ for bit := range fv.features {
+ index := (len(b) - 1) - int(bit)/8
+ b[index] |= 1 << (bit % 8)
+ }
+ return b
+}
+
+// EncodeBolt11 returns the bytes of the feature vector in base32 encoding,
+// big-endian order. It will throw an error if the "it's okay to be odd"-rule
+// is violated.
+func (fv *FeatureVector) EncodeBolt11() ([]byte, error) {
+ b := make([]byte, fv.maxBit/5+1)
+ for bit := range fv.features {
+ if bit.IsUnknown() && bit.IsRequired() {
+ return nil, fmt.Errorf("%w: %v", ErrUnknownRequiredFeatureBit, bit)
+ }
+ index := (len(b) - 1) - int(bit)/5
+ b[index] |= 1 << (bit % 5)
+ }
+ return b, nil
+}
+
+// DecodeBolt11 decodes the byte slice to set the corresponding features in the
+// feature vector. The byte slice is expected to be in base32 encoding,
+// big-endian order.
+func (fv *FeatureVector) DecodeBolt11(data []byte) error {
+ var bits []FeatureBit
+
+ // big-endian -> little-endian, to make it easier which bit to set while
+ // iterating over the bytes
+ slices.Reverse(data)
+ for i, b := range data {
+ for j := 0; j < 5; j++ {
+ if b&(1<= 62
+}
+
+// IsRequired returns true if the feature bit is required, which means it's an even bit.
+func (b *FeatureBit) IsRequired() bool {
+ return *b%2 == 0
+}
diff --git a/lightning/bolt09/bolt09_test.go b/lightning/bolt09/bolt09_test.go
new file mode 100644
index 0000000..39390ea
--- /dev/null
+++ b/lightning/bolt09/bolt09_test.go
@@ -0,0 +1,21 @@
+package bolt09
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFeatureVector_Bytes(t *testing.T) {
+ assert := assert.New(t)
+ fv := NewFeatureVector(PaymentSecretRequired, VarOnionOptinRequired)
+ assert.Equal([]byte{0b01000001, 0b00000000}, fv.Bytes())
+}
+
+func TestFeatureVector_EncodeBolt11(t *testing.T) {
+ assert := assert.New(t)
+ fv := NewFeatureVector(PaymentSecretRequired, VarOnionOptinRequired)
+ base32, err := fv.EncodeBolt11()
+ assert.NoError(err)
+ assert.Equal([]byte{0b10000, 0b01000, 0b00000}, base32)
+}
diff --git a/lightning/bolt11/bolt11.go b/lightning/bolt11/bolt11.go
new file mode 100644
index 0000000..67072ca
--- /dev/null
+++ b/lightning/bolt11/bolt11.go
@@ -0,0 +1,98 @@
+package bolt11
+
+import (
+ "bytes"
+ "crypto/rand"
+ "fmt"
+ "time"
+
+ "github.com/ekzyis/lntutor/lib/bech32"
+ "github.com/ekzyis/lntutor/lib/secp256k1"
+ "github.com/ekzyis/lntutor/lightning/lntypes"
+)
+
+// NewPaymentRequest creates a new payment request with the given amount and
+// options. It returns an error if the payment request is invalid.
+func NewPaymentRequest(msats uint64, options ...func(*PaymentRequest)) (*PaymentRequest, error) {
+ var paymentSecret lntypes.Hash
+ // rand.Read never returns an error, and always fills the buffer entirely
+ // see https://pkg.go.dev/crypto/rand#Read
+ rand.Read(paymentSecret[:])
+
+ pr := &PaymentRequest{
+ Network: lntypes.NetworkMainnet,
+ Msats: lntypes.MilliSatoshi(msats),
+ Timestamp: time.Now(),
+ }
+
+ // default options
+ options = append(
+ []func(*PaymentRequest){
+ WithRandomPaymentSecret(),
+ WithRandomPaymentHash(),
+ WithDefaultExpiry(),
+ WithDefaultMinFinalCLTVExpiryDelta(),
+ WithDefaultDescription(),
+ },
+ options...,
+ )
+
+ for _, option := range options {
+ option(pr)
+ }
+
+ err := pr.validate()
+ if err != nil {
+ return nil, err
+ }
+
+ return pr, nil
+}
+
+func (pr *PaymentRequest) sign(signer secp256k1.Signer, buf *bytes.Buffer, hrp string) error {
+ // The signature is over the sha256 hash of hrp + data part encoded in
+ // base256.
+ bufBase256, err := bech32.ConvertBits(buf.Bytes(), 5, 8, true)
+ if err != nil {
+ return fmt.Errorf("failed to convert buffer to base256: %w", err)
+ }
+ // hrp as utf-8 bytes
+ msg := append([]byte(hrp), bufBase256...)
+
+ // this will hash the message before signing
+ sig, err := signer.CompactECDSASign(msg)
+ if err != nil {
+ return fmt.Errorf("failed to sign message: %w", err)
+ }
+
+ var sigBytes bytes.Buffer
+ sigBytes.Write(sig.R[:])
+ sigBytes.Write(sig.S[:])
+ sigBytes.WriteByte(sig.RecoveryId)
+ sigBase32, err := bech32.ConvertBits(sigBytes.Bytes(), 8, 5, true)
+ if err != nil {
+ return fmt.Errorf("failed to convert signature to base32: %w", err)
+ }
+
+ buf.Write(sigBase32)
+
+ return nil
+}
+
+// validate checks if the payment request contains all required fields: payment
+// hash, payment secret, description or description hash.
+func (pr *PaymentRequest) validate() error {
+ if pr.PaymentSecret.IsZero() {
+ return fmt.Errorf("%w: payment secret missing", ErrInvalidPaymentRequest)
+ }
+ if pr.PaymentHash.IsZero() {
+ return fmt.Errorf("%w: payment hash missing", ErrInvalidPaymentRequest)
+ }
+ if pr.Description == "" && pr.DescriptionHash.IsZero() {
+ return fmt.Errorf("%w: description and description hash are both missing", ErrInvalidPaymentRequest)
+ }
+ if pr.Description != "" && !pr.DescriptionHash.IsZero() {
+ return fmt.Errorf("%w: description and description hash are both present", ErrInvalidPaymentRequest)
+ }
+ return nil
+}
diff --git a/lightning/bolt11/bolt11_test.go b/lightning/bolt11/bolt11_test.go
new file mode 100644
index 0000000..bc41bc3
--- /dev/null
+++ b/lightning/bolt11/bolt11_test.go
@@ -0,0 +1,747 @@
+package bolt11
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ekzyis/lntutor/lib/bech32"
+ "github.com/ekzyis/lntutor/lib/secp256k1"
+ "github.com/ekzyis/lntutor/lightning/bolt09"
+ "github.com/ekzyis/lntutor/lightning/lntypes"
+ "github.com/stretchr/testify/assert"
+)
+
+var (
+ timestamp = time.Unix(1496314658, 0)
+ privKeyBytes, _ = hex.DecodeString("e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734")
+ paymentSecretBytes, _ = hex.DecodeString("1111111111111111111111111111111111111111111111111111111111111111")
+ paymentHashBytes, _ = hex.DecodeString("0001020304050607080900010203040506070809000102030405060708090102")
+
+ // this description is 200 bytes long, which isn't long enough to fall back
+ // to hashing, so we hash it ourselves to pass the test vectors
+ longDescription = "One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon"
+ longDescriptionHash = sha256.Sum256([]byte(longDescription))
+)
+
+type TestSigner struct{}
+
+// Like TestSigner, but it generates high-S signatures.
+type TestSigner2 struct{}
+
+func (s *TestSigner) CompactECDSASign(msg []byte) (*secp256k1.CompactECDSASignature, error) {
+ signer, err := secp256k1.NewPrivateKeySigner(privKeyBytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create test signer: %w", err)
+ }
+
+ return signer.CompactECDSASign(msg)
+}
+
+func (s *TestSigner2) CompactECDSASign(msg []byte) (*secp256k1.CompactECDSASignature, error) {
+ sig, err := (&TestSigner{}).CompactECDSASign(msg)
+ if err != nil {
+ return nil, err
+ }
+
+ // since sig is a low-S signature, negating S will produce a high-S
+ // signature
+ return sig.NegateS(), nil
+}
+
+func TestPaymentRequest_NewPaymentRequest_Defaults(t *testing.T) {
+ assert := assert.New(t)
+
+ before := time.Now()
+ pr, _ := NewPaymentRequest(1_000)
+ after := time.Now()
+
+ // check hrp
+ assert.Equalf(lntypes.NetworkMainnet, pr.Network, "network should be mainnet")
+ assert.Equalf(lntypes.MilliSatoshi(1_000), pr.Msats, "amount should be 1 sat")
+ hrp, err := pr.encodeHRP()
+ if !assert.NoError(err) {
+ return
+ }
+ assert.Equalf("lnbc10n", hrp, "hrp should be lnbc10n")
+
+ // check timestamp
+ assert.Falsef(pr.Timestamp.IsZero(), "timestamp should be set")
+ assert.Truef(before.Before(pr.Timestamp) && after.After(pr.Timestamp), "timestamp should be set to now")
+
+ // check other properties
+ assert.Equalf(3600*time.Second, pr.Expiry, "expiry should be 1 hour")
+ assert.Falsef(pr.PaymentHash.IsZero(), "payment hash should be set")
+ assert.Falsef(pr.PaymentSecret.IsZero(), "payment secret should be set")
+ assert.Truef(pr.Description != "", "description should be set")
+ assert.Truef(pr.DescriptionHash.IsZero(), "description hash should be zero")
+ assert.Truef(pr.FallbackAddress == "", "fallback address should be empty")
+
+ // encode pr as bech32
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ if !assert.NoError(err) {
+ return
+ }
+
+ // check hrp in bech32 encoded pr
+ assert.Truef(strings.HasPrefix(encoded, hrp), "hrp bech32 mismatch")
+ encoded = strings.TrimPrefix(encoded, hrp)
+
+ // check bech32 separator '1'
+ assert.Truef(strings.HasPrefix(encoded, "1"), "bech32 separator '1' missing")
+ encoded = strings.TrimPrefix(encoded, "1")
+
+ // check timestamp in bech32 encoded pr
+ timestampBase32, _ := NewUintBolt11Encoder(uint(pr.Timestamp.Unix()), 35).EncodeBolt11()
+ timestampBech32 := bech32.BytesToBech32Charset(timestampBase32)
+ assert.Truef(strings.HasPrefix(encoded, timestampBech32), "timestamp bech32 mismatch")
+ encoded = strings.TrimPrefix(encoded, timestampBech32)
+
+ // check tagged fields + signature + checksum
+
+ toBech32 := func(fieldType TaggedFieldType, data Bolt11Encoder) string {
+ dataBase32, _ := data.EncodeBolt11()
+ dataBase32Length, _ := NewUintBolt11Encoder(uint(len(dataBase32)), 10).EncodeBolt11()
+ return fmt.Sprintf("%s%s%s",
+ bech32.BytesToBech32Charset([]byte{fieldType}),
+ bech32.BytesToBech32Charset(dataBase32Length),
+ bech32.BytesToBech32Charset(dataBase32),
+ )
+ }
+
+ tfMatchers := []func(*string) bool{
+ func(encoded *string) bool {
+ if (*encoded)[0] != 'p' {
+ return false
+ }
+ paymentHashBech32 := toBech32(fieldTypeP, &pr.PaymentHash)
+ assert.Truef(strings.HasPrefix(*encoded, paymentHashBech32), "payment hash bech32 mismatch")
+ *encoded = strings.TrimPrefix(*encoded, paymentHashBech32)
+ return true
+ },
+ func(encoded *string) bool {
+ if (*encoded)[0] != 's' {
+ return false
+ }
+ paymentSecretBech32 := toBech32(fieldTypeS, &pr.PaymentSecret)
+ assert.Truef(strings.HasPrefix(*encoded, paymentSecretBech32), "payment secret bech32 mismatch")
+ *encoded = strings.TrimPrefix(*encoded, paymentSecretBech32)
+ return true
+ },
+ func(encoded *string) bool {
+ if (*encoded)[0] != 'x' {
+ return false
+ }
+ expiryBech32 := toBech32(fieldTypeX, NewVarUintBolt11Encoder(uint(pr.Expiry.Seconds())))
+ assert.Truef(strings.HasPrefix(*encoded, expiryBech32), "expiry bech32 mismatch")
+ *encoded = strings.TrimPrefix(*encoded, expiryBech32)
+ return true
+ },
+ func(encoded *string) bool {
+ if (*encoded)[0] != 'c' {
+ return false
+ }
+ minFinalCLTVExpiryDeltaBech32 := toBech32(fieldTypeC, NewVarUintBolt11Encoder(uint(pr.MinFinalCLTVExpiryDelta)))
+ assert.Truef(strings.HasPrefix(*encoded, minFinalCLTVExpiryDeltaBech32), "min_final_cltv_expiry_delta bech32 mismatch")
+ *encoded = strings.TrimPrefix(*encoded, minFinalCLTVExpiryDeltaBech32)
+ return true
+ },
+ func(encoded *string) bool {
+ if (*encoded)[0] != 'd' {
+ return false
+ }
+ descriptionBech32 := toBech32(fieldTypeD, NewStringBolt11Encoder(pr.Description))
+ assert.Truef(strings.HasPrefix(*encoded, descriptionBech32), "description bech32 mismatch")
+ *encoded = strings.TrimPrefix(*encoded, descriptionBech32)
+ return true
+ },
+ func(encoded *string) bool {
+ // signature is 64 bytes + 1 byte recovery id in base256
+ // => 104 bytes in base32
+ sigLength := (64 + 1) * 8 / 5
+ checkSumLength := 6
+ if len(*encoded) == sigLength+checkSumLength {
+ *encoded = ""
+ return true
+ }
+ return false
+ },
+ }
+
+ for encoded != "" {
+ match := false
+ for i, matcher := range tfMatchers {
+ if match = matcher(&encoded); match {
+ // don't use matcher again
+ tfMatchers = append(tfMatchers[:i], tfMatchers[i+1:]...)
+ break
+ }
+ }
+ if !match {
+ assert.FailNow("unknown data in encoded payment request")
+ }
+ }
+
+ assert.True(len(tfMatchers) == 0, "missing tagged fields in encoded payment request")
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_001(t *testing.T) {
+ // Please make a donation of any amount using payment_hash
+ // 0001020304050607080900010203040506070809000102030405060708090102 to me
+ // @03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad
+
+ assert := assert.New(t)
+
+ expected := "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql"
+
+ pr, _ := NewPaymentRequest(
+ 0,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("Please consider supporting this project"),
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_002(t *testing.T) {
+ // Please send $3 for a cup of coffee to the same peer, within one minute
+
+ assert := assert.New(t)
+
+ expected := "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh"
+
+ pr, _ := NewPaymentRequest(
+ 250_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("1 cup coffee"),
+ WithExpiry(60*time.Second),
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_003(t *testing.T) {
+ // Please send 0.0025 BTC for a cup of nonsense (ナンセンス 1杯) to the same
+ // peer, within one minute
+
+ assert := assert.New(t)
+
+ expected := "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr"
+
+ pr, _ := NewPaymentRequest(
+ 250_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("ナンセンス 1杯"),
+ WithExpiry(60*time.Second),
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_004(t *testing.T) {
+ // Now send $24 for an entire list of things (hashed)
+
+ assert := assert.New(t)
+
+ expected := "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44"
+
+ pr, _ := NewPaymentRequest(
+ 2_000_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescriptionHash(longDescriptionHash),
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_005(t *testing.T) {
+ // The same, on testnet, with a fallback address
+ // mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP
+
+ assert := assert.New(t)
+
+ expected := "lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8"
+
+ pr, _ := NewPaymentRequest(
+ 2_000_000_000,
+ WithNetwork(lntypes.NetworkTestnet),
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithDescriptionHash(longDescriptionHash),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithFallbackAddress("mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP"), // P2PKH
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_006(t *testing.T) {
+ // On mainnet, with fallback address 1RustyRX2oai4EYYDpQGWvEL62BBGqN9T with
+ // extra routing info to go via nodes
+ // 029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255 then
+ // 039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255
+
+ assert := assert.New(t)
+
+ expected := "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0"
+
+ pr, _ := NewPaymentRequest(
+ 2_000_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescriptionHash(longDescriptionHash),
+ WithFallbackAddress("1RustyRX2oai4EYYDpQGWvEL62BBGqN9T"), // P2PKH
+ WithRoutingHint(
+ lntypes.NewHopHint(
+ lntypes.MustParseNodePublicKeyFromHex("029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255"),
+ lntypes.MustParseShortChannelID("66051x263430x1800"),
+ lntypes.MilliSatoshi(1),
+ 20,
+ 3,
+ ),
+ lntypes.NewHopHint(
+ lntypes.MustParseNodePublicKeyFromHex("039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255"),
+ lntypes.MustParseShortChannelID("197637x395016x2314"),
+ lntypes.MilliSatoshi(2),
+ 30,
+ 4,
+ ),
+ ),
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_007(t *testing.T) {
+ // On mainnet, with fallback (P2SH) address
+ // 3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX
+
+ assert := assert.New(t)
+
+ expected := "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfppj3a24vwu6r8ejrss3axul8rxldph2q7z99qrsgqz6qsgww34xlatfj6e3sngrwfy3ytkt29d2qttr8qz2mnedfqysuqypgqex4haa2h8fx3wnypranf3pdwyluftwe680jjcfp438u82xqphf75ym"
+
+ pr, _ := NewPaymentRequest(
+ 2_000_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithDescriptionHash(longDescriptionHash),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithFallbackAddress("3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX"), // P2SH
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_008(t *testing.T) {
+ // On mainnet, with fallback (P2WPKH) address
+ // bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
+
+ assert := assert.New(t)
+
+ expected := "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfppqw508d6qejxtdg4y5r3zarvary0c5xw7k9qrsgqt29a0wturnys2hhxpner2e3plp6jyj8qx7548zr2z7ptgjjc7hljm98xhjym0dg52sdrvqamxdezkmqg4gdrvwwnf0kv2jdfnl4xatsqmrnsse"
+
+ pr, _ := NewPaymentRequest(
+ 2_000_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithDescriptionHash(longDescriptionHash),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithFallbackAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), // P2WPKH
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_009(t *testing.T) {
+ // On mainnet, with fallback (P2WSH) address
+ // bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3
+
+ assert := assert.New(t)
+
+ expected := "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfp4qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q9qrsgq9vlvyj8cqvq6ggvpwd53jncp9nwc47xlrsnenq2zp70fq83qlgesn4u3uyf4tesfkkwwfg3qs54qe426hp3tz7z6sweqdjg05axsrjqp9yrrwc"
+
+ pr, _ := NewPaymentRequest(
+ 2_000_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithDescriptionHash(longDescriptionHash),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithFallbackAddress("bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"), // P2WSH
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_010(t *testing.T) {
+ // On mainnet, with fallback (P2TR) address
+ // bc1pptdvg0d2nj99568qn6ssdy4cygnwuxgw2ukmnwgwz7jpqjz2kszse2s3lm
+
+ assert := assert.New(t)
+
+ expected := "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfp4pptdvg0d2nj99568qn6ssdy4cygnwuxgw2ukmnwgwz7jpqjz2kszs9qrsgqy606dznq28exnydt2r4c29y56xjtn3sk4mhgjtl4pg2y4ar3249rq4ajlmj9jy8zvlzw7cr8mggqzm842xfr0v72rswzq9xvr4hknfsqwmn6xd"
+
+ pr, _ := NewPaymentRequest(
+ 2_000_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescriptionHash(longDescriptionHash),
+ WithFallbackAddress("bc1pptdvg0d2nj99568qn6ssdy4cygnwuxgw2ukmnwgwz7jpqjz2kszse2s3lm"), // P2TR
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_011(t *testing.T) {
+ // Please send 0.00967878534 BTC for a list of items within one week, amount
+ // in pico-BTC
+
+ assert := assert.New(t)
+
+ expected := "lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz"
+
+ paymentHashBytes, _ := hex.DecodeString("462264ede7e14047e9b249da94fefc47f41f7d02ee9b091815a5506bc8abf75f")
+
+ pr, _ := NewPaymentRequest(
+ 967_878_534,
+ WithTimestamp(time.Unix(1572468703, 0)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("Blockstream Store: 88.85 USD for Blockstream Ledger Nano S x 1, \"Back In My Day\" Sticker x 2, \"I Got Lightning Working\" Sticker x 2 and 1 more items"),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithExpiry(604800*time.Second),
+ WithMinFinalCLTVExpiryDelta(10),
+ WithRoutingHint(
+ lntypes.NewHopHint(
+ lntypes.MustParseNodePublicKeyFromHex("03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7"),
+ lntypes.MustParseShortChannelID("589390x3312x1"),
+ lntypes.MilliSatoshi(1000),
+ 2500,
+ 40,
+ ),
+ ),
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_012(t *testing.T) {
+ // Please send $30 for coffee beans to the same peer, which supports
+ // features 8, 14 and 99, using secret
+ // 0x1111111111111111111111111111111111111111111111111111111111111111
+
+ assert := assert.New(t)
+
+ expected := "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa"
+
+ pr, _ := NewPaymentRequest(
+ 2_500_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("coffee beans"),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithFeatureBits(99, PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_Decode_Spec_013(t *testing.T) {
+ // Same, but all upper case.
+
+ assert := assert.New(t)
+
+ expected := "LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA"
+
+ pr, _ := NewPaymentRequest(
+ 2_500_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("coffee beans"),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithFeatureBits(99, PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_Decode_Spec_014(t *testing.T) {
+ // Same, but including fields which must be ignored.
+
+ // AFAICT, the encoded bech32 payment request should not be valid because of
+ // this:
+ //
+ // [a reader] MUST fail the payment if any field with fixed `data_length`
+ // (`p`, `h`, `s`, `n`) does not have the correct length (52, 52, 52, 53).
+ t.Skip("not sure why this test exists")
+
+ assert := assert.New(t)
+
+ expected := "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2qrqqqfppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhpnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnp5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnpkqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqz599y53s3ujmcfjp5xrdap68qxymkqphwsexhmhr8wdz5usdzkzrse33chw6dlp3jhuhge9ley7j2ayx36kawe7kmgg8sv5ugdyusdcqzn8z9x"
+
+ pr, _ := NewPaymentRequest(
+ 2_500_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("coffee beans"),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithFeatureBits(99, PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_015(t *testing.T) {
+ // Please send 0.01 BTC with payment metadata 0x01fafaf0
+
+ assert := assert.New(t)
+
+ expected := "lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc"
+
+ paymentMetadata, _ := hex.DecodeString("01fafaf0")
+
+ pr, _ := NewPaymentRequest(
+ 1_000_000_000,
+ WithTimestamp(timestamp),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("payment metadata inside"),
+ WithPaymentMetadata(paymentMetadata),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithFeatureBits(48, PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_EncodeDecode_Spec_016(t *testing.T) {
+ // Public-key recovery with high-S signature
+
+ assert := assert.New(t)
+
+ expected := "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx"
+
+ pr, _ := NewPaymentRequest(
+ 0,
+ WithTimestamp(timestamp),
+ WithPaymentSecret([32]byte(paymentSecretBytes)),
+ WithPaymentHash([32]byte(paymentHashBytes)),
+ WithDescription("Please consider supporting this project"),
+ WithFeatureBits(PaymentSecretRequired, VarOnionOptinRequired),
+ WithNoExpiry(),
+ WithNoMinFinalCLTVExpiryDelta(),
+ )
+
+ encoded, err := pr.EncodeBech32(&TestSigner2{})
+ assert.NoError(err)
+ assert.Equal(expected, encoded)
+
+ decoded, err := DecodePaymentRequest(expected)
+ assert.NoError(err)
+ assert.Equal(pr, decoded)
+}
+
+func TestPaymentRequest_Invalid_Spec_017(t *testing.T) {
+ // Same, but adding invalid unknown feature 100
+
+ assert := assert.New(t)
+
+ encoded := "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q4psqqqqqqqqqqqqqqqqsgqtqyx5vggfcsll4wu246hz02kp85x4katwsk9639we5n5yngc3yhqkm35jnjw4len8vrnqnf5ejh0mzj9n3vz2px97evektfm2l6wqccp3y7372"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, bolt09.ErrUnknownRequiredFeatureBit)
+}
+
+func TestPaymentRequest_Invalid_Spec_018(t *testing.T) {
+ // Bech32 checksum is invalid.
+
+ assert := assert.New(t)
+
+ encoded := "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, bech32.ErrInvalidChecksum)
+}
+
+func TestPaymentRequest_Invalid_Spec_019(t *testing.T) {
+ // Malformed bech32 string (no 1)
+ assert := assert.New(t)
+ encoded := "pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, bech32.ErrInvalidSeparatorIndex)
+}
+
+func TestPaymentRequest_Invalid_Spec_020(t *testing.T) {
+ // Malformed bech32 string (mixed case)
+ assert := assert.New(t)
+ encoded := "LNBC2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, bech32.ErrMixedCase)
+}
+
+func TestPaymentRequest_Invalid_Spec_021(t *testing.T) {
+ // Signature is not recoverable.
+ assert := assert.New(t)
+ encoded := "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, ErrInvalidSignature)
+}
+
+func TestPaymentRequest_Invalid_Spec_022(t *testing.T) {
+ // String is too short.
+ assert := assert.New(t)
+ encoded := "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, bech32.ErrInvalidLength)
+}
+
+func TestPaymentRequest_Invalid_Spec_023(t *testing.T) {
+ // Invalid multiplier
+ assert := assert.New(t)
+ encoded := "lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, ErrInvalidHRP)
+}
+
+func TestPaymentRequest_Invalid_Spec_024(t *testing.T) {
+ // Invalid sub-millisatoshi precision.
+ assert := assert.New(t)
+ encoded := "lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, ErrInvalidHRP)
+}
+
+func TestPaymentRequest_Invalid_Spec_025(t *testing.T) {
+ // Missing required `s` field.
+ assert := assert.New(t)
+ encoded := "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp49qdkj"
+ _, err := DecodePaymentRequest(encoded)
+ assert.ErrorIs(err, ErrInvalidPaymentRequest)
+}
diff --git a/lightning/bolt11/encoding.go b/lightning/bolt11/encoding.go
new file mode 100644
index 0000000..35707e4
--- /dev/null
+++ b/lightning/bolt11/encoding.go
@@ -0,0 +1,691 @@
+package bolt11
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "iter"
+ "regexp"
+ "slices"
+ "strconv"
+ "time"
+
+ "github.com/ekzyis/lntutor/lib/bech32"
+ "github.com/ekzyis/lntutor/lib/bitcoin"
+ "github.com/ekzyis/lntutor/lib/secp256k1"
+ "github.com/ekzyis/lntutor/lightning/bolt09"
+ "github.com/ekzyis/lntutor/lightning/lntypes"
+ "golang.org/x/exp/constraints"
+)
+
+var (
+ errFieldDataNotFound = errors.New("field data not found")
+ errUnknownFieldType = errors.New("unknown field type")
+ ErrInvalidSignature = errors.New("invalid signature")
+ ErrInvalidHRP = errors.New("invalid hrp")
+ ErrInvalidPaymentRequest = errors.New("invalid payment request")
+)
+
+// ======================
+// === encoding stuff ===
+// ======================
+
+// Bolt11Encoder is an interface that represents an encoder for a tagged field
+// in a bolt11 payment request.
+//
+// Most encoders simply encode the data in base32, but some (like fallback
+// addresses) have additional encoding requirements.
+type Bolt11Encoder interface {
+ EncodeBolt11() ([]byte, error)
+}
+
+type BytesBolt11Encoder struct {
+ base32Encoder *bech32.BytesBase32Encoder
+}
+
+type StringBolt11Encoder struct {
+ base32Encoder *bech32.BytesBase32Encoder
+}
+
+type VarUintBolt11Encoder struct {
+ base32Encoder *bech32.VarUintBase32Encoder
+}
+
+type UintBolt11Encoder struct {
+ base32Encoder *bech32.UintBase32Encoder
+}
+
+var _ Bolt11Encoder = (*BytesBolt11Encoder)(nil)
+var _ Bolt11Encoder = (*StringBolt11Encoder)(nil)
+var _ Bolt11Encoder = (*VarUintBolt11Encoder)(nil)
+var _ Bolt11Encoder = (*UintBolt11Encoder)(nil)
+var _ Bolt11Encoder = (*bolt09.FeatureVector)(nil)
+var _ Bolt11Encoder = (*lntypes.Hash)(nil)
+var _ Bolt11Encoder = (*lntypes.RoutingHint)(nil)
+var _ Bolt11Encoder = (bitcoin.Address)(nil)
+
+func (e BytesBolt11Encoder) EncodeBolt11() ([]byte, error) {
+ return e.base32Encoder.EncodeBase32()
+}
+
+func (e StringBolt11Encoder) EncodeBolt11() ([]byte, error) {
+ return e.base32Encoder.EncodeBase32()
+}
+
+func (e VarUintBolt11Encoder) EncodeBolt11() ([]byte, error) {
+ return e.base32Encoder.EncodeBase32()
+}
+
+func (e UintBolt11Encoder) EncodeBolt11() ([]byte, error) {
+ return e.base32Encoder.EncodeBase32()
+}
+
+func NewBytesBolt11Encoder(data []byte) Bolt11Encoder {
+ encoder := bech32.NewBytesBase32Encoder(data)
+ return BytesBolt11Encoder{base32Encoder: &encoder}
+}
+
+func NewStringBolt11Encoder(data string) Bolt11Encoder {
+ encoder := bech32.NewStringBase32Encoder(data)
+ return StringBolt11Encoder{base32Encoder: &encoder}
+}
+
+func NewUintBolt11Encoder(num, bitLen uint) Bolt11Encoder {
+ encoder := bech32.NewUintBase32Encoder(num, bitLen)
+ return UintBolt11Encoder{base32Encoder: &encoder}
+}
+
+func NewVarUintBolt11Encoder(num uint) Bolt11Encoder {
+ encoder := bech32.NewVarUintBase32Encoder(num)
+ return VarUintBolt11Encoder{base32Encoder: &encoder}
+}
+
+// EncodeBech32 returns the bech32 encoded and signed payment request
+func (pr *PaymentRequest) EncodeBech32(signer secp256k1.Signer) (string, error) {
+ err := pr.validate()
+ if err != nil {
+ return "", err
+ }
+
+ var buf bytes.Buffer
+
+ // A bolt11 payment request is in the following format, encoded as bech32:
+ //
+ // - human-readable part:
+ // - network prefix
+ // - amount
+ // - data part:
+ // - timestamp
+ // - zero or more tags
+ // - signature of sha256(hrf||data part)
+ //
+ // see https://github.com/lightning/bolts/blob/master/11-payment-encoding.md
+
+ timestampBase32, err := NewUintBolt11Encoder(uint(pr.Timestamp.Unix()), 35).EncodeBolt11()
+ if err != nil {
+ return "", fmt.Errorf("failed to encode timestamp: %w", err)
+ }
+ buf.Write(timestampBase32)
+
+ err = pr.encodeTaggedFields(&buf)
+ if err != nil {
+ return "", fmt.Errorf("failed to write tagged fields: %w", err)
+ }
+
+ hrp, err := pr.encodeHRP()
+ if err != nil {
+ return "", err
+ }
+
+ err = pr.sign(signer, &buf, hrp)
+ if err != nil {
+ return "", fmt.Errorf("failed to sign payment request: %w", err)
+ }
+
+ encoded, err := bech32.Encode(hrp, buf.Bytes())
+ if err != nil {
+ return "", fmt.Errorf("failed to encode payment request as bech32: %w", err)
+ }
+ return encoded, nil
+}
+
+// encodeHRP returns the human-readable part for the bech32 encoding of the
+// payment request. It contains the network prefix and the amount, if non-zero.
+func (pr *PaymentRequest) encodeHRP() (string, error) {
+ prefix, err := func() (lntypes.NetworkPrefix, error) {
+ return pr.Network.Prefix(), nil
+ }()
+ if err != nil {
+ return "", fmt.Errorf("failed to encode network: %w", err)
+ }
+
+ if pr.Msats == 0 {
+ return string(prefix), nil
+ }
+
+ amt, multiplier, err := func() (lntypes.Bitcoin, lntypes.Multiplier, error) {
+ // Amounts are denominated in bitcoins, not millisatoshis. This means we
+ // first convert millisatoshis to picobitcoins, because that's the
+ // smallest unit we can represent with our multipliers.
+ //
+ // The conversion math is as follows:
+ //
+ // msats / 1e3 = sats
+ // sats / 1e8 = bitcoin
+ // bitcoin * 1e12 = picobitcoin
+ //
+ // => picobitcoins = msats * 1e12 / (1e3 * 1e8) = msats * 10
+ //
+ // This also makes sure that the last decimal is always a zero when
+ // 'pico' is used, since HTLCs are denonimated in millisatoshis.
+ units := lntypes.PicoBitcoin(pr.Msats * 10)
+
+ var multiplier lntypes.Multiplier
+ for _, multiplier = range lntypes.Multipliers {
+ if units%1e3 == 0 {
+ units /= 1e3
+ } else {
+ break
+ }
+ }
+
+ // payment requests are denominated in bitcoins, and the multiplier is
+ // used to represent smaller units of bitcoin
+ return lntypes.Bitcoin(units), multiplier, nil
+ }()
+ if err != nil {
+ return "", fmt.Errorf("failed to encode amount: %w", err)
+ }
+
+ return fmt.Sprintf("%s%d%s", prefix, amt, multiplier), nil
+}
+
+// encodeTaggedFields writes the encoded tagged fields to the buffer.
+// Every tagged field is encoded using the Bolt11Encoder interface.
+func (pr *PaymentRequest) encodeTaggedFields(buf *bytes.Buffer) error {
+ // There can be multiple routing hints, so we initialize the iterator here.
+ // pr.getTaggedFieldEncoder will then always return the next routing hint.
+ var stop func()
+ pr.routingHintNext, stop = iter.Pull(slices.Values(pr.RoutingHints))
+ defer func() { pr.routingHintNext = nil; stop() }()
+
+ for _, fieldType := range pr.taggedFields {
+ encoder, err := pr.getTaggedFieldEncoder(fieldType)
+ if err == errFieldDataNotFound {
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("failed to get tagged field data: 0x%02x: %w", fieldType, err)
+ }
+ err = encodeTaggedField(buf, fieldType, encoder)
+ if err != nil {
+ return fmt.Errorf("failed to write tagged field: 0x%02x: %w", fieldType, err)
+ }
+ }
+
+ return nil
+}
+
+// getTaggedFieldEncoder returns the Bolt11Encoder for the given field type.
+func (pr *PaymentRequest) getTaggedFieldEncoder(fieldType TaggedFieldType) (Bolt11Encoder, error) {
+ switch fieldType {
+ case fieldTypeS:
+ if !pr.PaymentSecret.IsZero() {
+ return &pr.PaymentSecret, nil
+ }
+ return nil, errFieldDataNotFound
+ case fieldTypeP:
+ if !pr.PaymentHash.IsZero() {
+ return &pr.PaymentHash, nil
+ }
+ return nil, errFieldDataNotFound
+ case fieldTypeD:
+ if pr.Description != "" {
+ return NewStringBolt11Encoder(pr.Description), nil
+ }
+ return nil, errFieldDataNotFound
+ case fieldTypeH:
+ if !pr.DescriptionHash.IsZero() {
+ return &pr.DescriptionHash, nil
+ }
+ return nil, errFieldDataNotFound
+ case fieldTypeX:
+ if pr.Expiry != 0 {
+ return NewVarUintBolt11Encoder(uint(pr.Expiry.Seconds())), nil
+ }
+ return nil, errFieldDataNotFound
+ case fieldTypeF:
+ if pr.FallbackAddress != "" {
+ addr, err := bitcoin.DecodeAddress(pr.FallbackAddress)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode fallback address: %w", err)
+ }
+ return addr, nil
+ }
+ return nil, errFieldDataNotFound
+ case fieldType9:
+ return &pr.Features, nil
+ case fieldTypeR:
+ hint, ok := pr.routingHintNext()
+ if !ok {
+ return nil, errFieldDataNotFound
+ }
+ return hint, nil
+ case fieldTypeC:
+ if pr.MinFinalCLTVExpiryDelta != 0 {
+ return NewVarUintBolt11Encoder(uint(pr.MinFinalCLTVExpiryDelta)), nil
+ }
+ return nil, errFieldDataNotFound
+ case fieldTypeM:
+ if len(pr.PaymentMetadata) > 0 {
+ return NewBytesBolt11Encoder(pr.PaymentMetadata), nil
+ }
+ return nil, errFieldDataNotFound
+ }
+ return nil, errUnknownFieldType
+}
+
+// encodeTaggedFields writes the encoded data returned by encoder to the buffer.
+// The field type is used to verify the data length for fields with a fixed data length.
+func encodeTaggedField(buf *bytes.Buffer, fieldType TaggedFieldType, encoder Bolt11Encoder) error {
+ buf.WriteByte(fieldType)
+
+ dataBolt11, err := encoder.EncodeBolt11()
+ if err != nil {
+ return err
+ }
+
+ tf := func() *TaggedField {
+ tf := &TaggedField{FieldType: fieldType, Data: dataBolt11}
+ switch fieldType {
+ case fieldTypeP, fieldTypeS, fieldTypeH:
+ tf.DataLength = 52
+ default:
+ tf.DataLength = uint16(len(dataBolt11))
+ }
+ return tf
+ }()
+
+ if len(tf.Data) != int(tf.DataLength) {
+ return fmt.Errorf("data length does not match: expected %d, got %d", tf.DataLength, len(tf.Data))
+ }
+
+ dataLengthBase32, err := bech32.NewUintBase32Encoder(uint(tf.DataLength), 10).EncodeBase32()
+ if err != nil {
+ return fmt.Errorf("failed to encode data length: %w", err)
+ }
+ buf.Write(dataLengthBase32)
+
+ if _, err := buf.Write(tf.Data); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// ======================
+// === decoding stuff ===
+// ======================
+
+// Bolt11Decoder is an interface that represents a decoder for a tagged field in
+// a bolt11 payment request.
+//
+// A Bolt11Decoder should be a member of a PaymentRequest struct, such that
+// calling DecodeBolt11 will set the field in the payment request.
+type Bolt11Decoder interface {
+ DecodeBolt11([]byte) error
+}
+
+type StringBolt11Decoder struct {
+ s *string
+}
+
+type TimeDurationBolt11Decoder struct {
+ duration *time.Duration
+}
+
+type BytesBolt11Decoder struct {
+ bytes *[]byte
+}
+
+type RoutingHintBolt11Decoder struct {
+ routingHint *lntypes.RoutingHint
+}
+
+type UintBolt11Decoder[T constraints.Unsigned] struct {
+ num *T
+}
+
+var _ Bolt11Decoder = (*StringBolt11Decoder)(nil)
+var _ Bolt11Decoder = (*TimeDurationBolt11Decoder)(nil)
+var _ Bolt11Decoder = (*bitcoin.AddressBolt11Decoder)(nil)
+var _ Bolt11Decoder = (*lntypes.Hash)(nil)
+var _ Bolt11Decoder = (*bolt09.FeatureVector)(nil)
+var _ Bolt11Decoder = (*BytesBolt11Decoder)(nil)
+var _ Bolt11Decoder = (*RoutingHintBolt11Decoder)(nil)
+var _ Bolt11Decoder = (*UintBolt11Decoder[uint8])(nil)
+var _ Bolt11Decoder = (*UintBolt11Decoder[uint16])(nil)
+var _ Bolt11Decoder = (*UintBolt11Decoder[uint32])(nil)
+var _ Bolt11Decoder = (*UintBolt11Decoder[uint64])(nil)
+var _ Bolt11Decoder = (*UintBolt11Decoder[uint])(nil)
+
+func (d StringBolt11Decoder) DecodeBolt11(data []byte) error {
+ decoded, err := bech32.NewBytesBase32Decoder(data).DecodeBase32()
+ if err != nil {
+ return err
+ }
+ *d.s = string(decoded)
+ return nil
+}
+
+func (d TimeDurationBolt11Decoder) DecodeBolt11(data []byte) error {
+ duration, err := bech32.NewUintBase32Decoder(data).DecodeBase32()
+ if err != nil {
+ return err
+ }
+ *d.duration = time.Duration(duration) * time.Second
+ return nil
+}
+
+func (d BytesBolt11Decoder) DecodeBolt11(data []byte) error {
+ decoded, err := bech32.NewBytesBase32Decoder(data).DecodeBase32()
+ if err != nil {
+ return err
+ }
+ *d.bytes = decoded
+ return nil
+}
+
+func (d UintBolt11Decoder[T]) DecodeBolt11(data []byte) error {
+ num, err := bech32.NewUintBase32Decoder(data).DecodeBase32()
+ if err != nil {
+ return err
+ }
+ *d.num = T(num)
+ return nil
+}
+
+func (d RoutingHintBolt11Decoder) DecodeBolt11(data []byte) error {
+ return d.routingHint.DecodeBolt11(data)
+}
+
+func NewStringBolt11Decoder(s *string) Bolt11Decoder {
+ return StringBolt11Decoder{s: s}
+}
+
+func NewTimeDurationBolt11Decoder(duration *time.Duration) Bolt11Decoder {
+ return TimeDurationBolt11Decoder{duration: duration}
+}
+
+func NewBytesBolt11Decoder(bytes *[]byte) Bolt11Decoder {
+ return BytesBolt11Decoder{bytes: bytes}
+}
+
+func NewRoutingHintBolt11Decoder(routingHint *lntypes.RoutingHint) Bolt11Decoder {
+ return RoutingHintBolt11Decoder{routingHint: routingHint}
+}
+
+func NewUintBolt11Decoder[T constraints.Unsigned](num *T) Bolt11Decoder {
+ return UintBolt11Decoder[T]{num: num}
+}
+
+// DecodePaymentRequest decodes the bech32-encoded payment request into a
+// PaymentRequest struct.
+func DecodePaymentRequest(encoded string) (*PaymentRequest, error) {
+ hrp, dataBase32, err := bech32.DecodeNoLimit(encoded)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode payment request: %w", err)
+ }
+
+ // the data part must be the same length as the bech32 string without the
+ // hrp, bech32 separator, and checksum
+ checksumLength := 6
+ if len(dataBase32) != len(encoded)-len(hrp)-1-checksumLength {
+ return nil, fmt.Errorf("unexpected length of decoded bech32 data part: expected %d, got %d", len(encoded)-len(hrp)-1-checksumLength, len(dataBase32))
+ }
+
+ pr := PaymentRequest{}
+
+ err = pr.decodeHumanReadablePart(hrp)
+ if err != nil {
+ return nil, err
+ }
+
+ err = pr.decodeTimestamp(dataBase32[0:7])
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode timestamp: %w", err)
+ }
+
+ // 64 bytes R||S + 1 byte recovery id in base256
+ // => 64 * 8 / 5 = 104 bytes in base32
+ timestampLengthBase32 := 7
+ sigLengthBase32 := 104
+ if len(dataBase32)-sigLengthBase32 <= timestampLengthBase32 {
+ return nil, bech32.ErrInvalidLength
+ }
+ tfBase32 := dataBase32[timestampLengthBase32 : len(dataBase32)-sigLengthBase32]
+
+ err = pr.decodeTaggedFields(tfBase32)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode tagged fields: %w", err)
+ }
+
+ sigBytesBase32 := dataBase32[len(dataBase32)-sigLengthBase32:]
+ sigBytesBase256, err := bech32.NewBytesBase32Decoder(sigBytesBase32).DecodeBase32()
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode signature: %w", err)
+ }
+
+ sig, err := secp256k1.NewCompactECDSASignatureFromBytes(sigBytesBase256)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse signature: %w", err)
+ }
+
+ msg := append([]byte(hrp), tfBase32...)
+ if !sig.Verify(msg) {
+ return nil, ErrInvalidSignature
+ }
+
+ err = pr.validate()
+ if err != nil {
+ return nil, err
+ }
+
+ return &pr, nil
+}
+
+// decodeHumanReadablePart decodes the hrp and sets the network and millisats
+// amount in the payment request.
+func (pr *PaymentRequest) decodeHumanReadablePart(hrp string) error {
+ var (
+ network lntypes.Network
+ amt lntypes.Bitcoin
+ multiplier lntypes.Multiplier
+ err error
+ )
+
+ re := regexp.MustCompile(`^(?P[a-zA-Z]+)(?:(?P\d.*)(?P[munp]))?$`)
+ matches := findNamedMatches(re, hrp)
+
+ if len(matches) != 1 && len(matches) != 3 {
+ // must always match prefix, amount and multiplier are optional but must
+ // match together
+ return fmt.Errorf("%w: invalid format: %s", ErrInvalidHRP, hrp)
+ }
+
+ for key, value := range matches {
+ switch key {
+ case "prefix":
+ network, err = lntypes.DecodeNetworkPrefix(value)
+ if err != nil {
+ return fmt.Errorf("%w: failed to decode network prefix: %w", ErrInvalidHRP, err)
+ }
+ case "amount":
+ numAmt, err := strconv.ParseUint(value, 10, 64)
+ if err != nil {
+ return fmt.Errorf("%w: failed to parse amount: %w", ErrInvalidHRP, err)
+ }
+ amt = lntypes.Bitcoin(numAmt)
+ case "multiplier":
+ multiplier = lntypes.Multiplier(value)
+ }
+ }
+
+ pr.Network = network
+
+ if amt == 0 {
+ return nil
+ }
+
+ if multiplier == lntypes.MultiplierPico && amt%10 != 0 {
+ return fmt.Errorf("%w: invalid sub-millisatoshi amount: %d%s", ErrInvalidHRP, amt, multiplier)
+ }
+
+ var picoBitcoins uint64
+ switch multiplier {
+ case lntypes.MultiplierPico:
+ picoBitcoins = uint64(amt)
+ case lntypes.MultiplierNano:
+ picoBitcoins = uint64(amt) * 1e3
+ case lntypes.MultiplierMicro:
+ picoBitcoins = uint64(amt) * 1e6
+ case lntypes.MultiplierMilli:
+ picoBitcoins = uint64(amt) * 1e9
+ }
+ // see pr.humanReadablePart() for the conversion math
+ pr.Msats = lntypes.MilliSatoshi(picoBitcoins / 10)
+
+ return nil
+}
+
+// decodeTimestamp decodes the timestamp from the byte slice and sets it in the
+// payment request. The byte slice must be in base32, big-endian order and 7
+// bytes long.
+func (pr *PaymentRequest) decodeTimestamp(dataBase32 []byte) error {
+ if len(dataBase32) != 7 {
+ return fmt.Errorf("expected 7 bytes in big-endian order, got %d", len(dataBase32))
+ }
+
+ timestamp, err := bech32.NewUintBase32Decoder(dataBase32).DecodeBase32()
+ if err != nil {
+ return err
+ }
+
+ pr.Timestamp = time.Unix(int64(timestamp), 0)
+ return nil
+}
+
+// decodeTaggedFields decodes the tagged fields from the byte slice and sets
+// them in the payment request. The byte slice must be in base32.
+func (pr *PaymentRequest) decodeTaggedFields(dataBase32 []byte) error {
+ // For decoding, we append a new RoutingHint to the slice and return a
+ // pointer to it each time one is encountered
+ pr.routingHintNext = func() (*lntypes.RoutingHint, bool) {
+ hint := &lntypes.RoutingHint{}
+ pr.RoutingHints = append(pr.RoutingHints, hint)
+ return hint, true
+ }
+ defer func() { pr.routingHintNext = nil }()
+
+ r := bytes.NewReader(dataBase32)
+
+ for {
+ fieldType, err := r.ReadByte()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("failed to read field type: %w", err)
+ }
+
+ tfDataLengthBase32 := make([]byte, 2)
+ _, err = io.ReadFull(r, tfDataLengthBase32)
+ if err != nil {
+ return fmt.Errorf("failed to read data length of 0x%02x: %w", fieldType, err)
+ }
+
+ tfDataLength, err := bech32.NewUintBase32Decoder(tfDataLengthBase32).DecodeBase32()
+ if err != nil {
+ return fmt.Errorf("failed to decode data length of 0x%02x: %w", fieldType, err)
+ }
+
+ tfDataBase32 := make([]byte, tfDataLength)
+ _, err = io.ReadFull(r, tfDataBase32)
+ if err != nil {
+ return fmt.Errorf("failed to read data of 0x%02x: %w", fieldType, err)
+ }
+
+ tfDecoder, err := pr.getTaggedFieldDecoder(fieldType)
+ if err == errUnknownFieldType {
+ // We skip any field we don't know about. The "it's okay to be
+ // odd"-rule only applies to feature bits.
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("failed to get tagged field decoder for 0x%02x: %w", fieldType, err)
+ }
+
+ err = tfDecoder.DecodeBolt11(tfDataBase32)
+ if errors.Is(err, bitcoin.ErrUnknownVersion) {
+ // a reader MUST skip over `f` fields that use an unknown `version`
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("failed to decode data of 0x%02x: %w", fieldType, err)
+ }
+ }
+
+ return nil
+}
+
+// getTaggedFieldDecoder returns the Bolt11Decoder for the given field type and
+// data from the payment request. Calling .DecodeBolt11 on the returned decoder
+// will set the field in the payment request.
+func (pr *PaymentRequest) getTaggedFieldDecoder(fieldType TaggedFieldType) (Bolt11Decoder, error) {
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldType)
+
+ switch fieldType {
+ case fieldTypeP:
+ return &pr.PaymentHash, nil
+ case fieldTypeS:
+ return &pr.PaymentSecret, nil
+ case fieldTypeH:
+ return &pr.DescriptionHash, nil
+ case fieldTypeD:
+ return NewStringBolt11Decoder(&pr.Description), nil
+ case fieldType9:
+ return &pr.Features, nil
+ case fieldTypeX:
+ return NewTimeDurationBolt11Decoder(&pr.Expiry), nil
+ case fieldTypeF:
+ return bitcoin.NewAddressBolt11Decoder(&pr.FallbackAddress, pr.Network), nil
+ case fieldTypeM:
+ return NewBytesBolt11Decoder(&pr.PaymentMetadata), nil
+ case fieldTypeR:
+ hint, ok := pr.routingHintNext()
+ if !ok {
+ return nil, errFieldDataNotFound
+ }
+ return NewRoutingHintBolt11Decoder(hint), nil
+ case fieldTypeC:
+ return NewUintBolt11Decoder(&pr.MinFinalCLTVExpiryDelta), nil
+ }
+
+ return nil, errUnknownFieldType
+}
+
+// findNamedMatches finds the named matches in the regex and returns a map of
+// the named matches to the values. If a named group wasn't found, the map does
+// not contain the key.
+func findNamedMatches(regex *regexp.Regexp, str string) map[string]string {
+ matches := regex.FindStringSubmatch(str)
+ results := map[string]string{}
+ for i, match := range matches {
+ if i == 0 || match == "" {
+ continue
+ }
+ results[regex.SubexpNames()[i]] = match
+ }
+ return results
+}
diff --git a/lightning/bolt11/options.go b/lightning/bolt11/options.go
new file mode 100644
index 0000000..60dbb9f
--- /dev/null
+++ b/lightning/bolt11/options.go
@@ -0,0 +1,174 @@
+package bolt11
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "slices"
+ "time"
+
+ "github.com/ekzyis/lntutor/lightning/bolt09"
+ "github.com/ekzyis/lntutor/lightning/lntypes"
+)
+
+func WithNetwork(network lntypes.Network) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.Network = network
+ }
+}
+
+func WithTimestamp(timestamp time.Time) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.Timestamp = timestamp
+ }
+}
+
+func WithPaymentHash(paymentHash [32]byte) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.PaymentHash = lntypes.Hash(paymentHash)
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeP)
+ }
+}
+
+func WithRandomPaymentHash() func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ var preimage lntypes.Preimage
+ rand.Read(preimage[:])
+ WithPaymentHash(preimage.Hash())(pr)
+ // TODO: how to return preimage to caller?
+ }
+}
+
+func WithPaymentSecret(paymentSecret [32]byte) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.PaymentSecret = lntypes.Hash(paymentSecret)
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeS)
+ }
+}
+
+func WithRandomPaymentSecret() func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ var paymentSecret lntypes.Hash
+ rand.Read(paymentSecret[:])
+ WithPaymentSecret(paymentSecret)(pr)
+ }
+}
+
+func WithDescription(description string) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ descBytes := []byte(description)
+ if len(descBytes) <= MaxDescriptionBytes {
+ pr.Description = description
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeD)
+
+ // clear any existing description hash
+ pr.DescriptionHash = lntypes.Hash{}
+ pr.taggedFields = remove(pr.taggedFields, fieldTypeH)
+ return
+ }
+
+ // description too long, use hash instead
+ pr.DescriptionHash = sha256.Sum256(descBytes)
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeH)
+
+ // clear any existing description
+ pr.Description = ""
+ pr.taggedFields = remove(pr.taggedFields, fieldTypeD)
+ }
+}
+
+func WithDefaultDescription() func(*PaymentRequest) {
+ return WithDescription("lntutor")
+}
+
+func WithDescriptionHash(descriptionHash [32]byte) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.Description = ""
+ pr.taggedFields = remove(pr.taggedFields, fieldTypeD)
+
+ pr.DescriptionHash = lntypes.Hash(descriptionHash)
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeH)
+ }
+}
+
+func WithFeatureBits(featureBits ...FeatureBit) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ // Go does not allow a direct cast between []FeatureBit and
+ // []bolt09.FeatureBit
+ bolt09Bits := make([]bolt09.FeatureBit, len(featureBits))
+ for i, bit := range featureBits {
+ bolt09Bits[i] = bolt09.FeatureBit(bit)
+ }
+ pr.Features = *bolt09.NewFeatureVector(bolt09Bits...)
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldType9)
+ }
+}
+
+func WithExpiry(expiry time.Duration) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.Expiry = expiry
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeX)
+ }
+}
+
+func WithDefaultExpiry() func(*PaymentRequest) {
+ return WithExpiry(time.Hour)
+}
+
+func WithNoExpiry() func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ WithExpiry(0)(pr)
+ pr.taggedFields = remove(pr.taggedFields, fieldTypeX)
+ }
+}
+
+func WithFallbackAddress(fallbackAddress string) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.FallbackAddress = fallbackAddress
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeF)
+ }
+}
+
+func WithRoutingHint(
+ hops ...*lntypes.HopHint,
+) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.RoutingHints = append(pr.RoutingHints, lntypes.NewRoutingHint(hops))
+ pr.taggedFields = append(pr.taggedFields, fieldTypeR)
+ }
+}
+
+func WithMinFinalCLTVExpiryDelta(minFinalCLTVExpiryDelta uint16) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.MinFinalCLTVExpiryDelta = minFinalCLTVExpiryDelta
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeC)
+ }
+}
+
+func WithDefaultMinFinalCLTVExpiryDelta() func(*PaymentRequest) {
+ return WithMinFinalCLTVExpiryDelta(18)
+}
+
+func WithNoMinFinalCLTVExpiryDelta() func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ WithMinFinalCLTVExpiryDelta(0)(pr)
+ pr.taggedFields = remove(pr.taggedFields, fieldTypeC)
+ }
+}
+
+func WithPaymentMetadata(paymentMetadata []byte) func(*PaymentRequest) {
+ return func(pr *PaymentRequest) {
+ pr.PaymentMetadata = paymentMetadata
+ pr.taggedFields = appendOrMoveToEnd(pr.taggedFields, fieldTypeM)
+ }
+}
+
+func appendOrMoveToEnd[S ~[]E, E comparable](slice S, elem E) S {
+ // remove element if it exists
+ slice = slices.DeleteFunc(slice, func(e E) bool { return e == elem })
+ // add element to end
+ return append(slice, elem)
+}
+
+func remove[S ~[]E, E comparable](slice S, elem E) S {
+ return slices.DeleteFunc(slice, func(e E) bool { return e == elem })
+}
diff --git a/lightning/bolt11/types.go b/lightning/bolt11/types.go
new file mode 100644
index 0000000..70325d7
--- /dev/null
+++ b/lightning/bolt11/types.go
@@ -0,0 +1,108 @@
+package bolt11
+
+import (
+ "time"
+
+ "github.com/ekzyis/lntutor/lightning/bolt09"
+ "github.com/ekzyis/lntutor/lightning/lntypes"
+)
+
+type PaymentRequest struct {
+ Network lntypes.Network
+ Msats lntypes.MilliSatoshi
+ Timestamp time.Time
+ Expiry time.Duration
+ PaymentHash lntypes.Hash
+
+ // PaymentSecret makes sure the recipient can tell if the onion payload was
+ // constructed by the sender. If it's not included, the last hop can steal
+ // overpaid amount from the sender by 'probing' with a smaller amount first.
+ // see https://bitcoin.stackexchange.com/a/115738
+ PaymentSecret lntypes.Hash
+
+ Description string
+ DescriptionHash lntypes.Hash
+ Features bolt09.FeatureVector
+ FallbackAddress string
+
+ // Minimum CLTV expiry delta to use for the last HTLC in the route.
+ MinFinalCLTVExpiryDelta uint16
+
+ // taggedFields keeps track of the order the tagged fields were specified in
+ // so we can include them in the same order in the bech32 encoding of the
+ // payment request.
+ taggedFields []TaggedFieldType
+
+ RoutingHints []*lntypes.RoutingHint
+
+ // PaymentMetadata is additional metadata to attach to the payment. This
+ // supports applications where the recipient doesn't keep any context for
+ // the payment.
+ PaymentMetadata []byte
+
+ // routingHintNext returns the next routing hint we should read/write
+ // from/to the bech32 encoded payment request when we encounter another `r`
+ // tagged field. It is initialized before reading/writing the tagged fields.
+ routingHintNext func() (*lntypes.RoutingHint, bool)
+}
+
+type TaggedField struct {
+ FieldType TaggedFieldType // must be encoded as 5 bits
+ DataLength uint16 // must be encoded as 10 bits, big-endian (maximum is 1023)
+ Data []byte // must be encoded as 5 x data_length bits (maximum is 640 bytes)
+}
+
+type TaggedFieldType = byte
+
+const (
+ // fieldTypeP is the field containing the payment hash.
+ fieldTypeP TaggedFieldType = 1
+ // fieldTypeS is the field containing the payment secret.
+ fieldTypeS TaggedFieldType = 16
+ // fieldTypeD is the field containing the description.
+ fieldTypeD TaggedFieldType = 13
+ // fieldTypeH is the field containing the description hash.
+ fieldTypeH TaggedFieldType = 23
+ // fieldTypeX is the field containing the expiry.
+ fieldTypeX TaggedFieldType = 6
+ // fieldType9 is the field containing the feature bits.
+ fieldType9 TaggedFieldType = 5
+ // fieldTypeF is the field containing the fallback address.
+ fieldTypeF TaggedFieldType = 9
+ // fieldTypeR is a repeatable field containing a routing hint with one or
+ // more hops.
+ fieldTypeR TaggedFieldType = 3
+ // fieldTypeC is the field containing the minimum CLTV expiry delta to use
+ // for the last HTLC in the route.
+ fieldTypeC TaggedFieldType = 24
+ // fieldTypeM is the field containing the payment metadata.
+ fieldTypeM TaggedFieldType = 27
+
+ // data_length is limited by 10 bits, so we can only fit 5 x 2^10 bits
+ // or 640 bytes of data in a single field.
+ MaxDescriptionBytes = 639
+)
+
+// bolt09 feature bits that can be set in a bolt11 payment request.
+type FeatureBit uint16
+
+const (
+ // this bit is marked as assumed in bolt09, but for some reason, there's a
+ // test vector with this bit set.
+ VarOnionOptinRequired FeatureBit = FeatureBit(bolt09.VarOnionOptinRequired)
+
+ PaymentSecretRequired FeatureBit = FeatureBit(bolt09.PaymentSecretRequired)
+ PaymentSecretOptional FeatureBit = FeatureBit(bolt09.PaymentSecretOptional)
+
+ BasicMppRequired FeatureBit = FeatureBit(bolt09.BasicMppRequired)
+ BasicMppOptional FeatureBit = FeatureBit(bolt09.BasicMppOptional)
+
+ RouteBlindingRequired FeatureBit = FeatureBit(bolt09.RouteBlindingRequired)
+ RouteBlindingOptional FeatureBit = FeatureBit(bolt09.RouteBlindingOptional)
+
+ AttributionDataRequired FeatureBit = FeatureBit(bolt09.AttributionDataRequired)
+ AttributionDataOptional FeatureBit = FeatureBit(bolt09.AttributionDataOptional)
+
+ PaymentMetadataRequired FeatureBit = FeatureBit(bolt09.PaymentMetadataRequired)
+ PaymentMetadataOptional FeatureBit = FeatureBit(bolt09.PaymentMetadataOptional)
+)
diff --git a/lightning/lntypes/amount.go b/lightning/lntypes/amount.go
new file mode 100644
index 0000000..63510cc
--- /dev/null
+++ b/lightning/lntypes/amount.go
@@ -0,0 +1,18 @@
+package lntypes
+
+type Bitcoin uint64
+
+type PicoBitcoin uint64
+
+type MilliSatoshi uint64
+
+type Multiplier string
+
+const (
+ MultiplierMilli Multiplier = "m"
+ MultiplierMicro Multiplier = "u"
+ MultiplierNano Multiplier = "n"
+ MultiplierPico Multiplier = "p"
+)
+
+var Multipliers = []Multiplier{MultiplierPico, MultiplierNano, MultiplierMicro, MultiplierMilli, ""}
diff --git a/lightning/lntypes/hash.go b/lightning/lntypes/hash.go
new file mode 100644
index 0000000..6d7780b
--- /dev/null
+++ b/lightning/lntypes/hash.go
@@ -0,0 +1,32 @@
+package lntypes
+
+import (
+ "crypto/sha256"
+
+ "github.com/ekzyis/lntutor/lib/bech32"
+)
+
+type Hash [32]byte
+
+func (h *Hash) IsZero() bool {
+ return *h == [32]byte{}
+}
+
+func (h *Hash) EncodeBolt11() ([]byte, error) {
+ return bech32.NewBytesBase32Encoder(h[:]).EncodeBase32()
+}
+
+func (h *Hash) DecodeBolt11(data []byte) error {
+ hash, err := bech32.NewBytesBase32Decoder(data).DecodeBase32()
+ if err != nil {
+ return err
+ }
+ copy(h[:], hash)
+ return nil
+}
+
+type Preimage [32]byte
+
+func (p Preimage) Hash() Hash {
+ return Hash(sha256.Sum256(p[:]))
+}
diff --git a/lightning/lntypes/key.go b/lightning/lntypes/key.go
new file mode 100644
index 0000000..7eb28cf
--- /dev/null
+++ b/lightning/lntypes/key.go
@@ -0,0 +1,66 @@
+package lntypes
+
+import (
+ "encoding/hex"
+ "fmt"
+
+ "github.com/decred/dcrd/dcrec/secp256k1/v4"
+)
+
+type NodePrivateKey struct {
+ secp256k1 secp256k1.PrivateKey
+}
+
+type NodePublicKey struct {
+ secp256k1 secp256k1.PublicKey
+}
+
+func NewNodePrivateKey(secp256k1 *secp256k1.PrivateKey) *NodePrivateKey {
+ return &NodePrivateKey{
+ secp256k1: *secp256k1,
+ }
+}
+
+func (k NodePrivateKey) PubKey() *NodePublicKey {
+ return &NodePublicKey{
+ secp256k1: *k.secp256k1.PubKey(),
+ }
+}
+
+func NewNodePublicKey(secp256k1 *secp256k1.PublicKey) *NodePublicKey {
+ return &NodePublicKey{
+ secp256k1: *secp256k1,
+ }
+}
+
+func ParseNodePublicKeyFromHex(hexStr string) (*NodePublicKey, error) {
+ pubKeyBytes, err := hex.DecodeString(hexStr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode public key as hex: %s: %w", hexStr, err)
+ }
+ pubKey, err := secp256k1.ParsePubKey(pubKeyBytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse public key: %w", err)
+ }
+ return NewNodePublicKey(pubKey), nil
+}
+
+func MustParseNodePublicKeyFromHex(hexStr string) *NodePublicKey {
+ pubKey, err := ParseNodePublicKeyFromHex(hexStr)
+ if err != nil {
+ panic(err)
+ }
+ return pubKey
+}
+
+func (k NodePublicKey) SerializeCompressed() ([]byte, error) {
+ return k.secp256k1.SerializeCompressed(), nil
+}
+
+func ParseNodePublicKeyFromBytes(bytes []byte) (*NodePublicKey, error) {
+ pubKey, err := secp256k1.ParsePubKey(bytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse public key: %w", err)
+ }
+ return NewNodePublicKey(pubKey), nil
+}
diff --git a/lightning/lntypes/network.go b/lightning/lntypes/network.go
new file mode 100644
index 0000000..1b49ac4
--- /dev/null
+++ b/lightning/lntypes/network.go
@@ -0,0 +1,49 @@
+package lntypes
+
+import "errors"
+
+type Network string
+
+const (
+ NetworkMainnet Network = "mainnet"
+ NetworkTestnet Network = "testnet"
+ NetworkRegtest Network = "regtest"
+ NetworkSignet Network = "signet"
+)
+
+type NetworkPrefix string
+
+const (
+ NetworkPrefixMainnet NetworkPrefix = "lnbc"
+ NetworkPrefixTestnet NetworkPrefix = "lntb"
+ NetworkPrefixRegtest NetworkPrefix = "lnbcrt"
+ NetworkPrefixSignet NetworkPrefix = "lntbs"
+)
+
+func (n Network) Prefix() NetworkPrefix {
+ switch n {
+ case NetworkMainnet:
+ return NetworkPrefixMainnet
+ case NetworkTestnet:
+ return NetworkPrefixTestnet
+ case NetworkRegtest:
+ return NetworkPrefixRegtest
+ case NetworkSignet:
+ return NetworkPrefixSignet
+ }
+ return NetworkPrefix("")
+}
+
+func DecodeNetworkPrefix(prefix string) (Network, error) {
+ switch prefix {
+ case string(NetworkPrefixMainnet):
+ return NetworkMainnet, nil
+ case string(NetworkPrefixTestnet):
+ return NetworkTestnet, nil
+ case string(NetworkPrefixRegtest):
+ return NetworkRegtest, nil
+ case string(NetworkPrefixSignet):
+ return NetworkSignet, nil
+ }
+ return Network(""), errors.New("unknown network prefix")
+}
diff --git a/lightning/lntypes/routing.go b/lightning/lntypes/routing.go
new file mode 100644
index 0000000..77c1af4
--- /dev/null
+++ b/lightning/lntypes/routing.go
@@ -0,0 +1,195 @@
+package lntypes
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "github.com/ekzyis/lntutor/lib/bech32"
+)
+
+const (
+ // The length of a hop hint in bytes.
+ hopHintLength = 51
+)
+
+// RoutingHint contains a list of hops that a node can use for pathfinding.
+type RoutingHint struct {
+ HopHints []*HopHint
+}
+
+// HopHint is a single hop in a routing hint.
+type HopHint struct {
+ PubKey *NodePublicKey // 33 bytes
+ ShortChannelID *ShortChannelID // 8 bytes
+ BaseFee MilliSatoshi // 4 bytes
+ FeePPM uint32 // 4 bytes
+ CLTVExpiryDelta uint16 // 2 bytes
+}
+
+type ShortChannelID struct {
+ BlockHeight uint32 // 3 bytes
+ TxIndex uint32 // 3 bytes
+ OutIndex uint16 // 2 bytes
+}
+
+func NewRoutingHint(hopHints []*HopHint) *RoutingHint {
+ return &RoutingHint{HopHints: hopHints}
+}
+
+func (r *RoutingHint) EncodeBolt11() ([]byte, error) {
+ routeHintBase256 := make([]byte, 0, len(r.HopHints)*hopHintLength)
+
+ for _, hopHint := range r.HopHints {
+ hopHintBase256 := make([]byte, hopHintLength)
+
+ pubKeyBytes, err := hopHint.PubKey.SerializeCompressed()
+ if err != nil {
+ return nil, fmt.Errorf("failed to serialize public key: %w", err)
+ }
+ copy(hopHintBase256[:33], pubKeyBytes)
+
+ binary.BigEndian.PutUint64(hopHintBase256[33:41], hopHint.ShortChannelID.ToUint64())
+ binary.BigEndian.PutUint32(hopHintBase256[41:45], uint32(hopHint.BaseFee))
+ binary.BigEndian.PutUint32(hopHintBase256[45:49], uint32(hopHint.FeePPM))
+ binary.BigEndian.PutUint16(hopHintBase256[49:51], uint16(hopHint.CLTVExpiryDelta))
+
+ routeHintBase256 = append(routeHintBase256, hopHintBase256...)
+ }
+
+ return bech32.ConvertBits(routeHintBase256, 8, 5, true)
+}
+
+func (r *RoutingHint) DecodeBolt11(data []byte) error {
+ dataBase256, err := bech32.NewBytesBase32Decoder(data).DecodeBase32()
+ if err != nil {
+ return err
+ }
+
+ if len(dataBase256)%hopHintLength != 0 {
+ return fmt.Errorf("invalid routing hint data length: got %d, expected multiple of %d", len(data), hopHintLength)
+ }
+
+ reader := bytes.NewReader(dataBase256)
+ var hopHints []*HopHint
+
+ for reader.Len() >= hopHintLength {
+ // pubkey
+ pubKeyBytes := make([]byte, 33)
+ if _, err := io.ReadFull(reader, pubKeyBytes); err != nil {
+ return fmt.Errorf("failed to read pubkey in routing hint: %w", err)
+ }
+ pubKey, err := ParseNodePublicKeyFromBytes(pubKeyBytes)
+ if err != nil {
+ return fmt.Errorf("failed to parse pubkey in routing hint: %w", err)
+ }
+
+ // scid
+ scidBytes := make([]byte, 8)
+ if _, err := io.ReadFull(reader, scidBytes); err != nil {
+ return fmt.Errorf("failed to read scid in routing hint: %w", err)
+ }
+ scid := NewShortChannelIDFromUint64(binary.BigEndian.Uint64(scidBytes))
+
+ // base fee
+ baseFeeBytes := make([]byte, 4)
+ if _, err := io.ReadFull(reader, baseFeeBytes); err != nil {
+ return fmt.Errorf("failed to read base fee in routing hint: %w", err)
+ }
+ baseFee := MilliSatoshi(binary.BigEndian.Uint32(baseFeeBytes))
+
+ // fee ppm
+ feePPMBytes := make([]byte, 4)
+ if _, err := io.ReadFull(reader, feePPMBytes); err != nil {
+ return fmt.Errorf("failed to read fee ppm in routing hint: %w", err)
+ }
+ feePPM := binary.BigEndian.Uint32(feePPMBytes)
+
+ // cltv expiry delta
+ cltvExpiryDeltaBytes := make([]byte, 2)
+ if _, err := io.ReadFull(reader, cltvExpiryDeltaBytes); err != nil {
+ return fmt.Errorf("failed to read cltv expiry delta in routing hint: %w", err)
+ }
+ cltvExpiryDelta := binary.BigEndian.Uint16(cltvExpiryDeltaBytes)
+
+ hopHints = append(hopHints, NewHopHint(pubKey, scid, baseFee, feePPM, cltvExpiryDelta))
+ }
+
+ r.HopHints = hopHints
+ return nil
+}
+
+func NewHopHint(
+ pubKey *NodePublicKey,
+ scid *ShortChannelID,
+ baseFee MilliSatoshi,
+ feePPM uint32,
+ cltvExpiryDelta uint16,
+) *HopHint {
+ return &HopHint{
+ PubKey: pubKey,
+ ShortChannelID: scid,
+ BaseFee: baseFee,
+ FeePPM: feePPM,
+ CLTVExpiryDelta: cltvExpiryDelta,
+ }
+}
+
+func NewShortChannelID(
+ blockHeight uint32,
+ txIndex uint32,
+ vout uint16,
+) *ShortChannelID {
+ return &ShortChannelID{
+ BlockHeight: blockHeight,
+ TxIndex: txIndex,
+ OutIndex: vout,
+ }
+}
+
+func ParseShortChannelID(str string) (*ShortChannelID, error) {
+ parts := strings.Split(str, "x")
+ if len(parts) != 3 {
+ return nil, fmt.Errorf("invalid short channel ID format: %s", str)
+ }
+
+ blockHeight, err := strconv.ParseUint(parts[0], 10, 32)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse block height as uint32: %s: %w", parts[0], err)
+ }
+
+ txIndex, err := strconv.ParseUint(parts[1], 10, 32)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse tx index as uint32: %s: %w", parts[1], err)
+ }
+
+ vout, err := strconv.ParseUint(parts[2], 10, 16)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse vout as uint16: %s: %w", parts[2], err)
+ }
+
+ return NewShortChannelID(uint32(blockHeight), uint32(txIndex), uint16(vout)), nil
+}
+
+func MustParseShortChannelID(str string) *ShortChannelID {
+ scid, err := ParseShortChannelID(str)
+ if err != nil {
+ panic(err)
+ }
+ return scid
+}
+
+func (scid *ShortChannelID) ToUint64() uint64 {
+ return (uint64(scid.BlockHeight) << 40) | (uint64(scid.TxIndex) << 16) | uint64(scid.OutIndex)
+}
+
+func NewShortChannelIDFromUint64(num uint64) *ShortChannelID {
+ return &ShortChannelID{
+ BlockHeight: uint32(num>>40) & 0xffffff,
+ TxIndex: uint32(num>>16) & 0xffffff,
+ OutIndex: uint16(num & 0xffff),
+ }
+}
diff --git a/lightning/node.go b/lightning/node.go
new file mode 100644
index 0000000..597d273
--- /dev/null
+++ b/lightning/node.go
@@ -0,0 +1,54 @@
+package lightning
+
+import (
+ "log"
+
+ "github.com/decred/dcrd/dcrec/secp256k1/v4"
+ "github.com/ekzyis/lntutor/lightning/bolt11"
+ "github.com/ekzyis/lntutor/lightning/lntypes"
+)
+
+type Node struct {
+ privateKey *lntypes.NodePrivateKey
+ publicKey *lntypes.NodePublicKey
+ network lntypes.Network
+}
+
+func NewNode(options ...func(*Node)) *Node {
+ node := &Node{}
+
+ for _, option := range options {
+ option(node)
+ }
+
+ if node.privateKey == nil {
+ privateKey, err := secp256k1.GeneratePrivateKey()
+ if err != nil {
+ log.Fatalf("failed to generate private key: %v", err)
+ }
+ node.privateKey = lntypes.NewNodePrivateKey(privateKey)
+ node.publicKey = node.privateKey.PubKey()
+ }
+
+ if node.network == "" {
+ node.network = lntypes.NetworkMainnet
+ }
+
+ return node
+}
+
+func WithPrivateKey(privateKey *lntypes.NodePrivateKey) func(*Node) {
+ return func(node *Node) {
+ node.privateKey = privateKey
+ }
+}
+
+func WithNetwork(network lntypes.Network) func(*Node) {
+ return func(node *Node) {
+ node.network = network
+ }
+}
+
+func (n *Node) CreatePaymentRequest(msats uint64) (*bolt11.PaymentRequest, error) {
+ return bolt11.NewPaymentRequest(msats, bolt11.WithNetwork(n.network))
+}