lnurl.mx raw
1 package main
2
3 import (
4 "git.smesh.lol/smesh/web/common/helpers"
5 "git.smesh.lol/smesh/web/common/jsbridge/dom"
6 )
7
8 // LNURL-pay + Lightning Address resolution.
9 //
10 // Lightning address "user@domain" resolves to
11 // https://domain/.well-known/lnurlp/user which returns a LNURL-pay response:
12 // { callback, minSendable, maxSendable, commentAllowed, ... }
13 // Then GET callback?amount=<msats>[&comment=..] returns { pr: "lnbc...", ... }.
14
15 // LnurlPayParams describes the first-stage LNURL-pay response.
16 type LnurlPayParams struct {
17 Callback string
18 MinSendable int64
19 MaxSendable int64
20 CommentAllowed int32
21 Metadata string
22 }
23
24 // lnurlEndpointForAddress converts user@domain to well-known URL.
25 // For hosts starting with "localhost" or IP-like, uses http; else https.
26 // Returns empty string on malformed input.
27 func lnurlEndpointForAddress(addr string) (s string) {
28 atIdx := -1
29 for i := 0; i < len(addr); i++ {
30 if addr[i] == '@' {
31 atIdx = i
32 break
33 }
34 }
35 if atIdx <= 0 || atIdx == len(addr)-1 {
36 return ""
37 }
38 user := addr[:atIdx]
39 host := addr[atIdx+1:]
40 for i := 0; i < len(user); i++ {
41 c := user[i]
42 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
43 c == '.' || c == '_' || c == '-' || c == '+') {
44 return ""
45 }
46 }
47 scheme := "https"
48 if isLocalHost(host) {
49 scheme = "http"
50 }
51 return scheme | "://" | host | "/.well-known/lnurlp/" | user
52 }
53
54 func isLocalHost(h string) (ok bool) {
55 if h == "localhost" {
56 return true
57 }
58 if len(h) >= 9 && h[:9] == "localhost" {
59 return true
60 }
61 if len(h) >= 4 && h[:4] == "127." {
62 return true
63 }
64 if len(h) >= 8 && h[:8] == "192.168." {
65 return true
66 }
67 if len(h) >= 3 && h[:3] == "10." {
68 return true
69 }
70 return false
71 }
72
73 // LnurlResolve fetches the first-stage LNURL-pay params.
74 // cb receives (params, true) on success or (_, false) on failure.
75 func LnurlResolve(endpoint string, cb func(LnurlPayParams, bool)) {
76 dom.FetchText(endpoint, func(body string) {
77 if body == "" {
78 cb(LnurlPayParams{}, false)
79 return
80 }
81 tag := helpers.JsonGetString(body, "tag")
82 if tag != "" && tag != "payRequest" {
83 cb(LnurlPayParams{}, false)
84 return
85 }
86 cb(LnurlPayParams{
87 Callback: helpers.JsonGetString(body, "callback"),
88 MinSendable: parseI64(helpers.JsonGetValue(body, "minSendable")),
89 MaxSendable: parseI64(helpers.JsonGetValue(body, "maxSendable")),
90 CommentAllowed: int32(parseI64(helpers.JsonGetValue(body, "commentAllowed"))),
91 Metadata: helpers.JsonGetString(body, "metadata"),
92 }, true)
93 })
94 }
95
96 // LnurlRequestInvoice calls the callback URL with amount (msats) and optional comment.
97 // Returns (bolt11, true) on success, or ("", false) on failure.
98 func LnurlRequestInvoice(callback string, amountMsats int64, comment string, cb func(string, bool)) {
99 sep := "?"
100 for i := 0; i < len(callback); i++ {
101 if callback[i] == '?' {
102 sep = "&"
103 break
104 }
105 }
106 url := callback | sep | "amount=" | helpers.Itoa(amountMsats)
107 if comment != "" {
108 url = url | "&comment=" | urlEscape(comment)
109 }
110 dom.FetchText(url, func(body string) {
111 if body == "" {
112 cb("", false)
113 return
114 }
115 // Reject error responses: {"status":"ERROR","reason":"..."}.
116 status := helpers.JsonGetString(body, "status")
117 if status == "ERROR" {
118 cb("", false)
119 return
120 }
121 bolt11 := helpers.JsonGetString(body, "pr")
122 if bolt11 == "" {
123 cb("", false)
124 return
125 }
126 cb(bolt11, true)
127 })
128 }
129
130 // PayLightningAddress resolves, requests invoice, and pays via NWC.
131 // amountMsats is the amount in millisatoshis (1 sat = 1000 msats).
132 // cb receives the NWC plaintext response JSON, or empty string on failure.
133 func PayLightningAddress(nwcConnID, addr string, amountMsats int64, comment, encryption string, cb func(string)) {
134 endpoint := lnurlEndpointForAddress(addr)
135 if endpoint == "" {
136 cb("")
137 return
138 }
139 LnurlResolve(endpoint, func(params LnurlPayParams, ok bool) {
140 if !ok || params.Callback == "" {
141 cb("")
142 return
143 }
144 if amountMsats < params.MinSendable || amountMsats > params.MaxSendable {
145 cb("")
146 return
147 }
148 LnurlRequestInvoice(params.Callback, amountMsats, comment, func(bolt11 string, ok bool) {
149 if !ok {
150 cb("")
151 return
152 }
153 inner := `{"invoice":` | helpers.JsonString(bolt11) | `}`
154 NwcCall(nwcConnID, "pay_invoice", inner, encryption, cb)
155 })
156 })
157 }
158
159 // urlEscape percent-encodes unreserved chars per RFC 3986.
160 func urlEscape(s string) (sv string) {
161 buf := []byte{:0:len(s) * 3}
162 for i := 0; i < len(s); i++ {
163 c := s[i]
164 if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
165 c == '-' || c == '_' || c == '.' || c == '~' {
166 buf = append(buf, c)
167 } else {
168 buf = append(buf, '%')
169 buf = append(buf, hexNibble(int32(c>>4)))
170 buf = append(buf, hexNibble(int32(c&0x0f)))
171 }
172 }
173 return string(buf)
174 }
175
176 func hexNibble(n int32) (b byte) {
177 if n < 10 {
178 return byte('0' + n)
179 }
180 return byte('A' + n - 10)
181 }
182