LNURL + phoenixd

This commit is contained in:
ekzyis 2024-12-26 23:46:18 +01:00
parent 1de153f052
commit 0eb37e8e9d
10 changed files with 332 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
zaply

43
env/env.go vendored Normal file
View File

@ -0,0 +1,43 @@
package env
import (
"log"
"os/exec"
"strings"
"github.com/joho/godotenv"
"github.com/namsral/flag"
)
var (
Port int
PhoenixdURL string
PhoenixdLimitedAccessToken string
CommitLongSha string
CommitShortSha string
)
func Load(filenames ...string) error {
if err := godotenv.Load(); err != nil {
return err
}
flag.IntVar(&Port, "PORT", 4444, "Server port")
flag.StringVar(&PhoenixdURL, "PHOENIXD_URL", "", "Phoenixd URL")
flag.StringVar(&PhoenixdLimitedAccessToken, "PHOENIXD_LIMITED_ACCESS_TOKEN", "", "Phoenixd limited access token")
return nil
}
func Parse() {
flag.Parse()
CommitLongSha = execCmd("git", "rev-parse", "HEAD")
CommitShortSha = execCmd("git", "rev-parse", "--short", "HEAD")
}
func execCmd(name string, args ...string) string {
cmd := exec.Command(name, args...)
stdout, err := cmd.Output()
if err != nil {
log.Fatal(err)
}
return strings.TrimSpace(string(stdout))
}

19
go.mod Normal file
View File

@ -0,0 +1,19 @@
module github.com/ekzyis/zaply
go 1.23.4
require (
github.com/joho/godotenv v1.5.1 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/namsral/flag v1.7.4-pre // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.8.0 // indirect
)

29
go.sum Normal file
View File

@ -0,0 +1,29 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs=
github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=

7
lightning/lightning.go Normal file
View File

@ -0,0 +1,7 @@
package lightning
type Bolt11 string
type Lightning interface {
CreateInvoice(msats int64, description string) (Bolt11, error)
}

View File

@ -0,0 +1,98 @@
package phoenixd
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ekzyis/zaply/lightning"
)
type Phoenixd struct {
url *url.URL
accessToken string
limitedAccessToken string
webhookUrl string
}
func NewPhoenixd(opts ...func(*Phoenixd) *Phoenixd) *Phoenixd {
ln := &Phoenixd{}
for _, opt := range opts {
opt(ln)
}
return ln
}
func WithPhoenixdURL(u string) func(*Phoenixd) *Phoenixd {
return func(p *Phoenixd) *Phoenixd {
u, err := url.Parse(u)
if err != nil {
log.Fatal(err)
}
p.url = u
return p
}
}
func WithPhoenixdLimitedAccessToken(limitedAccessToken string) func(*Phoenixd) *Phoenixd {
return func(p *Phoenixd) *Phoenixd {
p.limitedAccessToken = limitedAccessToken
return p
}
}
func WithPhoenixdWebhookUrl(webhookUrl string) func(*Phoenixd) *Phoenixd {
return func(p *Phoenixd) *Phoenixd {
p.webhookUrl = webhookUrl
return p
}
}
func (p *Phoenixd) CreateInvoice(msats int64, description string) (lightning.Bolt11, error) {
values := url.Values{}
values.Add("amountSat", strconv.FormatInt(msats/1000, 10))
values.Add("description", description)
if p.webhookUrl != "" {
values.Add("webhookUrl", p.webhookUrl)
}
endpoint := p.url.JoinPath("createinvoice")
req, err := http.NewRequest("POST", endpoint.String(), strings.NewReader(values.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if p.limitedAccessToken != "" {
req.SetBasicAuth("", p.limitedAccessToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("phoenixd %s: %s", resp.Status, string(body))
}
var response struct {
Serialized string `json:"serialized"`
}
if err := json.Unmarshal(body, &response); err != nil {
return "", err
}
return lightning.Bolt11(response.Serialized), nil
}

74
lnurl/lnurl.go Normal file
View File

@ -0,0 +1,74 @@
package lnurl
import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/ekzyis/zaply/lightning"
"github.com/labstack/echo/v4"
)
var (
MIN_SENDABLE_AMOUNT = 1000 // 1 sat
MAX_SENDABLE_AMOUNT = 100_000_000_000 // 100m sat
MAX_COMMENT_LENGTH = 128
)
func Router(e *echo.Echo, ln lightning.Lightning) {
e.GET("/.well-known/lnurlp/:name", payRequest)
e.GET("/.well-known/lnurlp/:name/pay", pay(ln))
}
func payRequest(c echo.Context) error {
name := c.Param("name")
return c.JSON(
http.StatusOK,
map[string]any{
"callback": fmt.Sprintf("%s/.well-known/lnurlp/%s/pay", c.Request().Host, name),
"minSendable": MIN_SENDABLE_AMOUNT,
"maxSendable": MAX_SENDABLE_AMOUNT,
"metadata": fmt.Sprintf("[[\"text/plain\",\"paying %s\"]]", name),
"tag": "payRequest",
"commentAllowed": MAX_COMMENT_LENGTH,
},
)
}
func pay(ln lightning.Lightning) echo.HandlerFunc {
return func(c echo.Context) error {
qAmount := c.QueryParam("amount")
if qAmount == "" {
return lnurlError(c, http.StatusBadRequest, errors.New("amount required"))
}
msats, err := strconv.ParseInt(qAmount, 10, 64)
if err != nil {
c.Logger().Error(err)
return lnurlError(c, http.StatusBadRequest, errors.New("invalid amount"))
}
if msats < 1000 {
return lnurlError(c, http.StatusBadRequest, errors.New("amount must be at least 1000 msats"))
}
comment := c.QueryParam("comment")
pr, err := ln.CreateInvoice(msats, comment)
if err != nil {
c.Logger().Error(err)
return lnurlError(c, http.StatusInternalServerError, errors.New("failed to create invoice"))
}
return c.JSON(
http.StatusOK,
map[string]any{
"pr": pr,
"routes": []string{},
},
)
}
}
func lnurlError(c echo.Context, code int, err error) error {
return c.JSON(code, map[string]any{"status": "ERROR", "error": err.Error()})
}

21
main.go Normal file
View File

@ -0,0 +1,21 @@
package main
import (
"log"
"github.com/ekzyis/zaply/env"
"github.com/ekzyis/zaply/server"
)
func main() {
if err := env.Load(); err != nil {
log.Fatalf("error loading env: %v", err)
}
env.Parse()
log.Printf("commit: %s", env.CommitShortSha)
log.Printf("phoenixd: %s", env.PhoenixdURL)
s := server.NewServer()
s.Start(":4444")
}

37
server/server.go Normal file
View File

@ -0,0 +1,37 @@
package server
import (
"github.com/ekzyis/zaply/env"
"github.com/ekzyis/zaply/lightning/phoenixd"
"github.com/ekzyis/zaply/lnurl"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type Server struct {
*echo.Echo
}
func NewServer() *Server {
s := &Server{
Echo: echo.New(),
}
s.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "${time_custom} ${method} ${uri} ${status}\n",
CustomTimeFormat: "2006-01-02 15:04:05.00000-0700",
}))
p := phoenixd.NewPhoenixd(
phoenixd.WithPhoenixdURL(env.PhoenixdURL),
phoenixd.WithPhoenixdLimitedAccessToken(env.PhoenixdLimitedAccessToken),
)
lnurl.Router(s.Echo, p)
return s
}
func (s *Server) Start(address string) {
s.Logger.Fatal(s.Echo.Start(address))
}

2
template.env Normal file
View File

@ -0,0 +1,2 @@
PHOENIXD_URL=
PHOENIXD_LIMITED_ACCESS_TOKEN=