diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1896cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +zaply diff --git a/env/env.go b/env/env.go new file mode 100644 index 0000000..803fc33 --- /dev/null +++ b/env/env.go @@ -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)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7132571 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..48ba603 --- /dev/null +++ b/go.sum @@ -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= diff --git a/lightning/lightning.go b/lightning/lightning.go new file mode 100644 index 0000000..a6fc31e --- /dev/null +++ b/lightning/lightning.go @@ -0,0 +1,7 @@ +package lightning + +type Bolt11 string + +type Lightning interface { + CreateInvoice(msats int64, description string) (Bolt11, error) +} diff --git a/lightning/phoenixd/phoenixd.go b/lightning/phoenixd/phoenixd.go new file mode 100644 index 0000000..7f0ca99 --- /dev/null +++ b/lightning/phoenixd/phoenixd.go @@ -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 +} diff --git a/lnurl/lnurl.go b/lnurl/lnurl.go new file mode 100644 index 0000000..9485f63 --- /dev/null +++ b/lnurl/lnurl.go @@ -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()}) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b224ebd --- /dev/null +++ b/main.go @@ -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") +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..aa7c94e --- /dev/null +++ b/server/server.go @@ -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)) +} diff --git a/template.env b/template.env new file mode 100644 index 0000000..0816ee3 --- /dev/null +++ b/template.env @@ -0,0 +1,2 @@ +PHOENIXD_URL= +PHOENIXD_LIMITED_ACCESS_TOKEN=