zap.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 "git.smesh.lol/smesh/web/common/jsbridge/signer"
7 "git.smesh.lol/smesh/web/common/nostr"
8 )
9
10 // NIP-57 zaps. Accumulation of kind 9735 receipts is in main.mx
11 // handleActionEvent; this file handles the click-to-zap flow and the
12 // bolt11 amount parser that feeds the accumulator.
13
14 // parseBolt11Msat extracts the amount in millisats from a BOLT11 invoice.
15 // BOLT11 hrp format: "ln" + currency + [amount][multiplier].
16 // The separator is the LAST '1' in the invoice (bech32). Returns 0 if no
17 // amount is encoded or parsing fails.
18 func parseBolt11Msat(inv string) (n int64) {
19 sep := -1
20 for i := len(inv) - 1; i >= 0; i-- {
21 if inv[i] == '1' {
22 sep = i
23 break
24 }
25 }
26 if sep < 4 {
27 return 0
28 }
29 hrp := inv[:sep]
30 if len(hrp) < 4 || hrp[0] != 'l' || hrp[1] != 'n' {
31 return 0
32 }
33 // Skip currency letters after "ln".
34 i := 2
35 for i < len(hrp) && hrp[i] >= 'a' && hrp[i] <= 'z' {
36 // An 'm', 'u', 'n', 'p' is only the multiplier if it's the LAST
37 // char of hrp and there were digits before it. Otherwise it's
38 // currency. Stop when we hit a digit.
39 if hrp[i] >= '0' && hrp[i] <= '9' {
40 break
41 }
42 i++
43 }
44 // Now parse digits.
45 digStart := i
46 for i < len(hrp) && hrp[i] >= '0' && hrp[i] <= '9' {
47 i++
48 }
49 if i == digStart {
50 return 0
51 }
52 var amt int64
53 for j := digStart; j < i; j++ {
54 amt = amt*10 + int64(hrp[j]-'0')
55 }
56 mult := byte(0)
57 if i < len(hrp) {
58 mult = hrp[i]
59 }
60 // BTC base: 1 BTC = 10^11 msat.
61 // no mult: amt * 10^11 msat
62 // m: 10^-3 BTC * amt = amt * 10^8 msat
63 // u: amt * 10^5 msat
64 // n: amt * 10^2 msat
65 // p: amt * 0.1 msat (integer-safe only when amt is multiple of 10)
66 switch mult {
67 case 0:
68 return amt * 100000000000
69 case 'm':
70 return amt * 100000000
71 case 'u':
72 return amt * 100000
73 case 'n':
74 return amt * 100
75 case 'p':
76 return amt / 10
77 }
78 return 0
79 }
80
81 // showZapModal opens the zap flow for a note event. If the user has an NWC
82 // connection configured, it offers amount input + pay via NWC. Otherwise it
83 // shows the author's lightning address as a QR code the user can scan with
84 // an external wallet.
85 func showZapModal(ev *nostr.Event) {
86 lud16 := ""
87 if content, ok := profileContentCache[ev.PubKey]; ok {
88 lud16 = helpers.JsonGetString(content, "lud16")
89 if lud16 == "" {
90 lud16 = helpers.JsonGetString(content, "lud06")
91 }
92 }
93 if lud16 == "" {
94 // Trigger a profile fetch so the next click works.
95 fetchAuthorProfile(ev.PubKey)
96 }
97
98 scrim := dom.CreateElement("div")
99 dom.SetStyle(scrim, "position", "fixed")
100 dom.SetStyle(scrim, "inset", "0")
101 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
102 dom.SetStyle(scrim, "display", "flex")
103 dom.SetStyle(scrim, "alignItems", "center")
104 dom.SetStyle(scrim, "justifyContent", "center")
105 dom.SetStyle(scrim, "zIndex", "9999")
106 dom.SetStyle(scrim, "cursor", "pointer")
107
108 card := dom.CreateElement("div")
109 dom.SetStyle(card, "position", "relative")
110 dom.SetStyle(card, "background", "var(--bg, #1a1a1a)")
111 dom.SetStyle(card, "color", "var(--fg)")
112 dom.SetStyle(card, "borderRadius", "16px")
113 dom.SetStyle(card, "padding", "24px")
114 dom.SetStyle(card, "display", "flex")
115 dom.SetStyle(card, "flexDirection", "column")
116 dom.SetStyle(card, "alignItems", "center")
117 dom.SetStyle(card, "minWidth", "320px")
118 dom.SetStyle(card, "maxWidth", "92vw")
119 dom.SetStyle(card, "cursor", "default")
120 dom.SetAttribute(card, "onclick", "event.stopPropagation()")
121 dom.AppendChild(scrim, card)
122
123 title := dom.CreateElement("h3")
124 dom.SetTextContent(title, "Zap")
125 dom.SetStyle(title, "margin", "0 0 12px 0")
126 dom.AppendChild(card, title)
127
128 status := dom.CreateElement("p")
129 dom.SetStyle(status, "fontSize", "12px")
130 dom.SetStyle(status, "color", "var(--muted)")
131 dom.SetStyle(status, "margin", "0 0 8px 0")
132 dom.SetStyle(status, "minHeight", "1em")
133 dom.AppendChild(card, status)
134
135 dom.AppendChild(dom.Body(), scrim)
136 dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
137 dom.RemoveChild(dom.Body(), scrim)
138 }))
139
140 if lud16 == "" {
141 dom.SetTextContent(status, "Author has no Lightning address (lud16).")
142 return
143 }
144
145 if len(nwcConns) == 0 {
146 zapRenderLud16QR(card, status, lud16)
147 return
148 }
149 zapRenderNwcPay(card, status, ev, lud16)
150 }
151
152 // zapRenderLud16QR populates the card with a QR of the author's lightning
153 // address as a lightning: URI. Click to copy.
154 func zapRenderLud16QR(card, status dom.Element, lud16 string) {
155 dom.SetTextContent(status, "Scan to zap " | lud16)
156 uri := "lightning:" | lud16
157 qrBox := dom.CreateElement("div")
158 dom.SetStyle(qrBox, "background", "#ffffff")
159 dom.SetStyle(qrBox, "padding", "12px")
160 dom.SetStyle(qrBox, "borderRadius", "8px")
161 dom.SetStyle(qrBox, "cursor", "pointer")
162 svg := qrSVG(uri, 5)
163 if svg == "" {
164 dom.SetTextContent(status, "Failed to render QR.")
165 return
166 }
167 dom.SetInnerHTML(qrBox, svg)
168 dom.AppendChild(card, qrBox)
169 toast := dom.CreateElement("p")
170 dom.SetStyle(toast, "fontSize", "12px")
171 dom.SetStyle(toast, "color", "var(--accent)")
172 dom.SetStyle(toast, "margin", "8px 0 0 0")
173 dom.SetStyle(toast, "minHeight", "1em")
174 dom.AppendChild(card, toast)
175 capURI := uri
176 dom.AddEventListener(qrBox, "click", dom.RegisterCallback(func() {
177 dom.WriteClipboard(capURI, func(ok bool) {
178 if ok {
179 dom.SetTextContent(toast, "copied to clipboard")
180 } else {
181 dom.SetTextContent(toast, "copy failed")
182 }
183 dom.SetTimeout(func() {
184 dom.SetTextContent(toast, "")
185 }, 1800)
186 })
187 }))
188 }
189
190 // zapRenderNwcPay adds amount input + pay button. On pay: build kind 9734
191 // zap request, call LNURL callback with it, then NwcCall pay_invoice.
192 func zapRenderNwcPay(card, status dom.Element, ev *nostr.Event, lud16 string) {
193 amtInput := dom.CreateElement("input")
194 dom.SetAttribute(amtInput, "type", "number")
195 dom.SetAttribute(amtInput, "min", "1")
196 dom.SetAttribute(amtInput, "class", "signer-input")
197 dom.SetAttribute(amtInput, "placeholder", "sats")
198 dom.SetProperty(amtInput, "value", "100")
199 dom.SetStyle(amtInput, "width", "100%")
200 dom.SetStyle(amtInput, "marginBottom", "8px")
201 dom.AppendChild(card, amtInput)
202
203 commentInput := dom.CreateElement("input")
204 dom.SetAttribute(commentInput, "type", "text")
205 dom.SetAttribute(commentInput, "class", "signer-input")
206 dom.SetAttribute(commentInput, "placeholder", "comment (optional)")
207 dom.SetStyle(commentInput, "width", "100%")
208 dom.SetStyle(commentInput, "marginBottom", "12px")
209 dom.AppendChild(card, commentInput)
210
211 payBtn := dom.CreateElement("button")
212 dom.SetAttribute(payBtn, "class", "signer-btn")
213 dom.SetTextContent(payBtn, "Zap " | lud16)
214 dom.AppendChild(card, payBtn)
215
216 dom.SetTextContent(status, "")
217
218 connID := nwcConns[0].ID
219 targetEv := ev
220 dom.AddEventListener(payBtn, "click", dom.RegisterCallback(func() {
221 amtStr := dom.GetProperty(amtInput, "value")
222 amtSats := parseI64(amtStr)
223 if amtSats <= 0 {
224 dom.SetTextContent(status, "Enter a positive amount.")
225 return
226 }
227 comment := dom.GetProperty(commentInput, "value")
228 amtMsats := amtSats * 1000
229 dom.SetAttribute(payBtn, "disabled", "true")
230 dom.SetTextContent(status, "Resolving...")
231
232 endpoint := lnurlEndpointForAddress(lud16)
233 if endpoint == "" {
234 dom.SetTextContent(status, "Invalid lightning address.")
235 dom.SetAttribute(payBtn, "disabled", "")
236 return
237 }
238 LnurlResolve(endpoint, func(params LnurlPayParams, ok bool) {
239 if !ok || params.Callback == "" {
240 dom.SetTextContent(status, "LNURL resolve failed.")
241 dom.SetAttribute(payBtn, "disabled", "")
242 return
243 }
244 if amtMsats < params.MinSendable || amtMsats > params.MaxSendable {
245 dom.SetTextContent(status, "Amount out of range.")
246 dom.SetAttribute(payBtn, "disabled", "")
247 return
248 }
249 buildZapRequest(targetEv, amtMsats, comment, func(zapReqJSON string) {
250 if zapReqJSON == "" {
251 dom.SetTextContent(status, "Could not sign zap request.")
252 dom.SetAttribute(payBtn, "disabled", "")
253 return
254 }
255 dom.SetTextContent(status, "Requesting invoice...")
256 sep := "?"
257 for i := 0; i < len(params.Callback); i++ {
258 if params.Callback[i] == '?' {
259 sep = "&"
260 break
261 }
262 }
263 url := params.Callback | sep |
264 "amount=" | helpers.Itoa(amtMsats) |
265 "&nostr=" | urlEscape(zapReqJSON)
266 if comment != "" {
267 url = url | "&comment=" | urlEscape(comment)
268 }
269 dom.FetchText(url, func(body string) {
270 if body == "" {
271 dom.SetTextContent(status, "Invoice fetch failed.")
272 dom.SetAttribute(payBtn, "disabled", "")
273 return
274 }
275 if helpers.JsonGetString(body, "status") == "ERROR" {
276 dom.SetTextContent(status, "LNURL error.")
277 dom.SetAttribute(payBtn, "disabled", "")
278 return
279 }
280 bolt11 := helpers.JsonGetString(body, "pr")
281 if bolt11 == "" {
282 dom.SetTextContent(status, "No invoice returned.")
283 dom.SetAttribute(payBtn, "disabled", "")
284 return
285 }
286 dom.SetTextContent(status, "Paying...")
287 inner := `{"invoice":` | helpers.JsonString(bolt11) | `}`
288 NwcCall(connID, "pay_invoice", inner, "", func(resp string) {
289 if resp == "" {
290 dom.SetTextContent(status, "Payment failed.")
291 dom.SetAttribute(payBtn, "disabled", "")
292 return
293 }
294 errCode := helpers.JsonGetString(resp, "error")
295 if errCode != "" {
296 dom.SetTextContent(status, "Payment rejected: " | errCode)
297 dom.SetAttribute(payBtn, "disabled", "")
298 return
299 }
300 dom.SetTextContent(status, "Zap sent - " | helpers.Itoa(amtSats) | " sats")
301 dom.SetAttribute(payBtn, "disabled", "")
302 })
303 })
304 })
305 })
306 }))
307 }
308
309 // buildZapRequest assembles a kind 9734 zap request, signs it, and returns
310 // the serialized JSON via cb. Returns "" on signer failure.
311 func buildZapRequest(ev *nostr.Event, amtMsats int64, comment string, cb func(string)) {
312 if !signer.HasSigner() || pubhex == "" {
313 cb("")
314 return
315 }
316 // Relays tag: include up to 5 of the user's configured relays.
317 relaysTag := `["relays"`
318 n := 0
319 for _, u := range relayURLs {
320 if n >= 5 {
321 break
322 }
323 relaysTag = relaysTag | "," | helpers.JsonString(u)
324 n++
325 }
326 if n == 0 {
327 relaysTag = relaysTag | `,"wss://relay.damus.io"`
328 }
329 relaysTag = relaysTag | "]"
330
331 tags := "[" | relaysTag |
332 `,["amount",` | helpers.JsonString(helpers.Itoa(amtMsats)) | `]` |
333 `,["p",` | helpers.JsonString(ev.PubKey) | `]` |
334 `,["e",` | helpers.JsonString(ev.ID) | `]` |
335 "]"
336 content := ""
337 if comment != "" {
338 content = comment
339 }
340 unsigned := `{"kind":9734,"content":` | helpers.JsonString(content) |
341 `,"tags":` | tags |
342 `,"created_at":` | helpers.Itoa(dom.NowSeconds()) |
343 `,"pubkey":"` | pubhex | `"}`
344 signer.SignEvent(unsigned, func(signed string) {
345 cb(signed)
346 })
347 }
348