donation.mx raw
1 package main
2
3 import (
4 "git.smesh.lol/musiquay/web/common/helpers"
5 "git.smesh.lol/musiquay/web/common/jsbridge/dom"
6 "git.smesh.lol/musiquay/web/common/jsbridge/signer"
7 "git.smesh.lol/musiquay/web/common/jsbridge/subtle"
8 "git.smesh.lol/musiquay/web/common/jsbridge/ws"
9 "git.smesh.lol/nostr/pkg/core"
10 )
11
12 // Hardcoded read-only NWC URI (make_invoice + lookup_invoice permissions only).
13 const donationNWCURI = "nostr+walletconnect://7a6b3021e354883c524723a14e2571b2d937dee18935bc00679d26fe593f9d8d?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=774c76cf2150e76d59e2a897fd4de681faf9f9b08d52ba3999d7e8368607b6bc&lud16=davidv@getalby.com"
14
15 type donationNwc struct {
16 WalletPubkey string // 64 hex
17 RelayURL string
18 Secret string // 64 hex (client secret)
19 }
20
21 var (
22 )
23
24 // parseDonationURI parses the hardcoded URI; takes the first relay.
25 func parseDonationURI(uri string) (v2 donationNwc, ok bool) {
26 var d donationNwc
27 prefix := "nostr+walletconnect://"
28 if len(uri) < len(prefix) || uri[:len(prefix)] != prefix {
29 return d, false
30 }
31 rest := uri[len(prefix):]
32 qIdx := -1
33 for i := 0; i < len(rest); i++ {
34 if rest[i] == '?' {
35 qIdx = i
36 break
37 }
38 }
39 if qIdx < 0 {
40 return d, false
41 }
42 pk := rest[:qIdx]
43 if len(pk) != 64 {
44 return d, false
45 }
46 d.WalletPubkey = pk
47 q := rest[qIdx+1:]
48 start := 0
49 for i := 0; i <= len(q); i++ {
50 if i == len(q) || q[i] == '&' {
51 kv := q[start:i]
52 eqIdx := -1
53 for j := 0; j < len(kv); j++ {
54 if kv[j] == '=' {
55 eqIdx = j
56 break
57 }
58 }
59 if eqIdx > 0 {
60 k := kv[:eqIdx]
61 v := donURLDecode(kv[eqIdx+1:])
62 switch k {
63 case "relay":
64 if d.RelayURL == "" {
65 d.RelayURL = v
66 }
67 case "secret":
68 d.Secret = v
69 }
70 }
71 start = i + 1
72 }
73 }
74 if d.RelayURL == "" || len(d.Secret) != 64 {
75 return d, false
76 }
77 return d, true
78 }
79
80 func donURLDecode(s string) (sv string) {
81 buf := []byte{:0:len(s)}
82 for i := 0; i < len(s); i++ {
83 c := s[i]
84 if c == '%' && i+2 < len(s) {
85 hi := donHexDigit(s[i+1])
86 lo := donHexDigit(s[i+2])
87 if hi >= 0 && lo >= 0 {
88 buf = push(buf, byte(hi<<4|lo))
89 i += 2
90 continue
91 }
92 }
93 buf = push(buf, c)
94 }
95 return string(buf)
96 }
97
98 func donHexDigit(c byte) (n int32) {
99 if c >= '0' && c <= '9' {
100 return int32(c - '0')
101 }
102 if c >= 'a' && c <= 'f' {
103 return int32(c-'a') + 10
104 }
105 if c >= 'A' && c <= 'F' {
106 return int32(c-'A') + 10
107 }
108 return -1
109 }
110
111 // donationNwcCall runs one NWC call over a one-shot WS connection using the
112 // hardcoded URI. method is "make_invoice" or "lookup_invoice".
113 // innerParams is a JSON object string. cb receives decrypted response JSON.
114 func donationNwcCall(method, innerParams string, cb func(string)) {
115 d, ok := parseDonationURI(donationNWCURI)
116 if !ok {
117 cb("")
118 return
119 }
120 // ECDH with the donation secret key via signer worker.
121 signer.EcdhWithSecret(d.Secret, d.WalletPubkey, func(sharedHex string) {
122 if sharedHex == "" {
123 cb("")
124 return
125 }
126 shared := helpers.HexDecode(sharedHex)
127 if len(shared) != 32 {
128 cb("")
129 return
130 }
131 req := "{\"method\":" | helpers.JsonString(method) | ",\"params\":" | innerParams | "}"
132 iv := []byte{:16}
133 subtle.RandomBytes(iv)
134 ct := subtle.AESCBCEncrypt(shared, iv, []byte(req))
135 if len(ct) == 0 {
136 cb("")
137 return
138 }
139 content := helpers.Base64Encode(ct) | "?iv=" | helpers.Base64Encode(iv)
140 now := dom.NowSeconds()
141 ev := &nostr.Event{
142 Kind: 23194,
143 CreatedAt: now,
144 Tags: [][]string{
145 []string{"p", d.WalletPubkey},
146 []string{"encryption", "nip04"},
147 },
148 Content: content,
149 }
150 evJSON := ev.ToJSON()
151 // Sign with the donation secret key via signer worker.
152 signer.SignWithSecret(d.Secret, evJSON, func(signedJSON string) {
153 if signedJSON == "" {
154 cb("")
155 return
156 }
157 signedEv := nostr.ParseEvent(signedJSON)
158 if signedEv == nil {
159 cb("")
160 return
161 }
162 donFinishCall(d, shared, signedEv, cb)
163 })
164 })
165 }
166
167 func donFinishCall(d donationNwc, shared []byte, ev *nostr.Event, cb func(string)) {
168 reqID := ev.ID
169 evJSON := ev.ToJSON()
170 clientPubHex := ev.PubKey
171
172 appSt.donNextSub++
173 subID := "don-" | helpers.Itoa(int64(appSt.donNextSub))
174 var sock ws.Conn
175 timedOut := false
176 appSt.donSubs[subID] = func(plain string) {
177 if timedOut {
178 return
179 }
180 ws.Send(sock, `["CLOSE",` | helpers.JsonString(subID) | `]`)
181 ws.Close(sock)
182 cb(plain)
183 }
184 sock = ws.Dial(d.RelayURL,
185 func(_ int32, msg string) { donOnMessage(shared, helpers.HexDecode(d.WalletPubkey), msg) },
186 func(_ int32) {
187 reqMsg := `["REQ",` | helpers.JsonString(subID) |
188 `,{"kinds":[23195],"#e":[` | helpers.JsonString(reqID) |
189 `],"#p":[` | helpers.JsonString(clientPubHex) | `],"limit":1}]`
190 ws.Send(sock, reqMsg)
191 ws.Send(sock, `["EVENT",` | evJSON | `]`)
192 },
193 func(_ int32, code int32, reason string) {
194 if cbFn, ok2 := appSt.donSubs[subID]; ok2 {
195 delete(appSt.donSubs, subID)
196 if !timedOut {
197 cbFn("")
198 }
199 }
200 },
201 func(_ int32) {},
202 )
203 dom.SetTimeout(func() {
204 if cbFn, ok2 := appSt.donSubs[subID]; ok2 {
205 timedOut = true
206 delete(appSt.donSubs, subID)
207 ws.Close(sock)
208 cbFn("")
209 }
210 }, 45000)
211 }
212
213 // donationMakeInvoice issues make_invoice on the hardcoded NWC and returns
214 // the bolt11 invoice via cb (empty string on failure).
215 // amountSats is the invoice amount in sats; desc is the memo.
216 func donationMakeInvoice(amountSats int64, desc string, cb func(string, string)) {
217 msats := amountSats * 1000
218 inner := "{\"amount\":" | helpers.Itoa(msats) |
219 ",\"description\":" | helpers.JsonString(desc) | "}"
220 donationNwcCall("make_invoice", inner, func(plain string) {
221 if plain == "" {
222 cb("", "")
223 return
224 }
225 errVal := helpers.JsonGetValue(plain, "error")
226 if errVal != "" && errVal != "null" {
227 dom.ConsoleLog("[donation] wallet error: " | errVal)
228 cb("", "")
229 return
230 }
231 result := helpers.JsonGetValue(plain, "result")
232 if result == "" {
233 cb("", "")
234 return
235 }
236 inv := helpers.JsonGetString(result, "invoice")
237 ph := helpers.JsonGetString(result, "payment_hash")
238 cb(inv, ph)
239 })
240 }
241
242 // donationPollSettled calls lookup_invoice periodically and fires cb(true)
243 // when settled_at > 0 (or state=="settled"). Stops when stop returns true.
244 func donationPollSettled(paymentHash string, stop func() bool, cb func(bool)) {
245 if paymentHash == "" {
246 cb(false)
247 return
248 }
249 inner := "{\"payment_hash\":" | helpers.JsonString(paymentHash) | "}"
250 donationNwcCall("lookup_invoice", inner, func(plain string) {
251 if stop() {
252 return
253 }
254 settled := false
255 if plain != "" {
256 result := helpers.JsonGetValue(plain, "result")
257 if result != "" {
258 sa := helpers.JsonGetValue(result, "settled_at")
259 st := helpers.JsonGetString(result, "state")
260 if (sa != "" && sa != "null" && sa != "0") || st == "settled" {
261 settled = true
262 }
263 }
264 }
265 if settled {
266 cb(true)
267 return
268 }
269 dom.SetTimeout(func() {
270 if stop() {
271 return
272 }
273 donationPollSettled(paymentHash, stop, cb)
274 }, 3000)
275 })
276 }
277
278 // donOnMessage handles relay frames during a donation invoice request.
279 // shared is the NIP-04 shared secret; peerPub is the wallet pubkey.
280 func donOnMessage(shared, peerPub []byte, msg string) {
281 if len(msg) < 10 || msg[0] != '[' {
282 return
283 }
284 i := 1
285 if msg[i] != '"' {
286 return
287 }
288 i++
289 tStart := i
290 for i < len(msg) && msg[i] != '"' {
291 i++
292 }
293 if i >= len(msg) {
294 return
295 }
296 tag := msg[tStart:i]
297 i++
298 if msg[i] != ',' {
299 return
300 }
301 i++
302 if tag != "EVENT" {
303 return
304 }
305 subID, evJSON, ok := parseEventMsg(msg[i:])
306 if !ok {
307 return
308 }
309 cbFn, ok := appSt.donSubs[subID]
310 if !ok {
311 return
312 }
313 ct := helpers.JsonGetString(evJSON, "content")
314 // NIP-04 decrypt.
315 ivIdx := -1
316 for j := 0; j < len(ct)-3; j++ {
317 if ct[j] == '?' && ct[j+1] == 'i' && ct[j+2] == 'v' && ct[j+3] == '=' {
318 ivIdx = j
319 break
320 }
321 }
322 if ivIdx < 0 {
323 delete(appSt.donSubs, subID)
324 cbFn("")
325 return
326 }
327 ctBytes := helpers.Base64Decode(ct[:ivIdx])
328 ivBytes := helpers.Base64Decode(ct[ivIdx+4:])
329 if ctBytes == nil || ivBytes == nil {
330 delete(appSt.donSubs, subID)
331 cbFn("")
332 return
333 }
334 pt := subtle.AESCBCDecrypt(shared, ivBytes, ctBytes)
335 delete(appSt.donSubs, subID)
336 cbFn(string(pt))
337 }
338
339 // --- UI ---
340
341 func showDonationModal() {
342 scrim := dom.CreateElement("div")
343 dom.SetStyle(scrim, "position", "fixed")
344 dom.SetStyle(scrim, "inset", "0")
345 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
346 dom.SetStyle(scrim, "display", "flex")
347 dom.SetStyle(scrim, "alignItems", "center")
348 dom.SetStyle(scrim, "justifyContent", "center")
349 dom.SetStyle(scrim, "zIndex", "9999")
350 dom.SetStyle(scrim, "cursor", "pointer")
351
352 card := dom.CreateElement("div")
353 dom.SetStyle(card, "position", "relative")
354 dom.SetStyle(card, "background", "var(--bg, #1a1a1a)")
355 dom.SetStyle(card, "color", "var(--fg)")
356 dom.SetStyle(card, "borderRadius", "16px")
357 dom.SetStyle(card, "padding", "24px")
358 dom.SetStyle(card, "display", "flex")
359 dom.SetStyle(card, "flexDirection", "column")
360 dom.SetStyle(card, "alignItems", "center")
361 dom.SetStyle(card, "minWidth", "320px")
362 dom.SetStyle(card, "maxWidth", "92vw")
363 dom.SetStyle(card, "cursor", "default")
364 dom.SetAttribute(card, "onclick", "event.stopPropagation()")
365 dom.AppendChild(scrim, card)
366
367 // Confetti layer (covers whole viewport; 17 pieces). Activated by adding class "run".
368 confetti := dom.CreateElement("div")
369 dom.SetAttribute(confetti, "class", "confetti")
370 dom.SetInnerHTML(confetti, "<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>")
371 dom.AppendChild(scrim, confetti)
372
373 title := dom.CreateElement("h3")
374 dom.SetTextContent(title, "Donate via Lightning")
375 dom.SetStyle(title, "margin", "0 0 12px 0")
376 dom.AppendChild(card, title)
377
378 amountRow := dom.CreateElement("div")
379 dom.SetStyle(amountRow, "display", "flex")
380 dom.SetStyle(amountRow, "gap", "8px")
381 dom.SetStyle(amountRow, "marginBottom", "8px")
382 dom.SetStyle(amountRow, "width", "100%")
383
384 amtInput := dom.CreateElement("input")
385 dom.SetAttribute(amtInput, "type", "number")
386 dom.SetAttribute(amtInput, "min", "1")
387 dom.SetAttribute(amtInput, "class", "signer-input")
388 dom.SetAttribute(amtInput, "placeholder", "sats")
389 dom.SetProperty(amtInput, "value", "1000")
390 dom.SetStyle(amtInput, "flex", "1")
391 dom.AppendChild(amountRow, amtInput)
392 dom.AppendChild(card, amountRow)
393
394 memoInput := dom.CreateElement("input")
395 dom.SetAttribute(memoInput, "type", "text")
396 dom.SetAttribute(memoInput, "class", "signer-input")
397 dom.SetAttribute(memoInput, "placeholder", "memo (optional)")
398 dom.SetProperty(memoInput, "value", "musiquay donation")
399 dom.SetStyle(memoInput, "width", "100%")
400 dom.SetStyle(memoInput, "marginBottom", "12px")
401 dom.AppendChild(card, memoInput)
402
403 status := dom.CreateElement("p")
404 dom.SetStyle(status, "fontSize", "12px")
405 dom.SetStyle(status, "color", "var(--muted)")
406 dom.SetStyle(status, "margin", "0 0 8px 0")
407 dom.SetStyle(status, "minHeight", "1em")
408 dom.AppendChild(card, status)
409
410 qrBox := dom.CreateElement("div")
411 dom.SetStyle(qrBox, "background", "#ffffff")
412 dom.SetStyle(qrBox, "padding", "12px")
413 dom.SetStyle(qrBox, "borderRadius", "8px")
414 dom.SetStyle(qrBox, "display", "none")
415 dom.SetStyle(qrBox, "cursor", "pointer")
416 dom.AppendChild(card, qrBox)
417
418 toast := dom.CreateElement("p")
419 dom.SetStyle(toast, "fontSize", "12px")
420 dom.SetStyle(toast, "color", "var(--accent)")
421 dom.SetStyle(toast, "margin", "8px 0 0 0")
422 dom.SetStyle(toast, "minHeight", "1em")
423 dom.AppendChild(card, toast)
424
425 genBtn := dom.CreateElement("button")
426 dom.SetAttribute(genBtn, "class", "signer-btn")
427 dom.SetStyle(genBtn, "marginTop", "8px")
428 dom.SetTextContent(genBtn, "Generate invoice")
429 dom.AppendChild(card, genBtn)
430
431 dom.AppendChild(dom.Body(), scrim)
432
433 // paid gates the polling loop; closed-over by stop closure. Also set when
434 // modal is dismissed so we stop polling if the user closes early.
435 paid := false
436 closed := false
437 dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
438 closed = true
439 dom.RemoveChild(dom.Body(), scrim)
440 }))
441
442 dom.AddEventListener(genBtn, "click", dom.RegisterCallback(func() {
443 amtStr := dom.GetProperty(amtInput, "value")
444 amt := parseI64(amtStr)
445 if amt <= 0 {
446 dom.SetTextContent(status, "Enter a positive amount.")
447 return
448 }
449 memo := dom.GetProperty(memoInput, "value")
450 dom.SetTextContent(status, "Requesting invoice...")
451 dom.SetAttribute(genBtn, "disabled", "true")
452 dom.SetStyle(qrBox, "display", "none")
453 dom.SetTextContent(toast, "")
454 donationMakeInvoice(amt, memo, func(invoice, paymentHash string) {
455 dom.SetAttribute(genBtn, "disabled", "")
456 if invoice == "" {
457 dom.SetTextContent(status, "Failed to get invoice.")
458 return
459 }
460 dom.SetTextContent(status, helpers.Itoa(amt) | " sats - click QR to copy")
461 svg := qrSVG(invoice, 4)
462 if svg == "" {
463 dom.SetTextContent(status, "Invoice too long for QR.")
464 return
465 }
466 dom.SetInnerHTML(qrBox, svg)
467 dom.SetStyle(qrBox, "display", "block")
468 inv := invoice
469 dom.AddEventListener(qrBox, "click", dom.RegisterCallback(func() {
470 dom.WriteClipboard(inv, func(ok bool) {
471 if ok {
472 dom.SetTextContent(toast, "copied to clipboard")
473 } else {
474 dom.SetTextContent(toast, "copy failed")
475 }
476 dom.SetTimeout(func() {
477 dom.SetTextContent(toast, "")
478 }, 1800)
479 })
480 }))
481 // Start polling lookup_invoice until settled or modal closed.
482 donationPollSettled(paymentHash,
483 func() bool { return closed || paid },
484 func(settled bool) {
485 if !settled || closed || paid {
486 return
487 }
488 paid = true
489 // Trigger confetti animation.
490 dom.SetAttribute(confetti, "class", "confetti run")
491 // Swap modal content to thanks, 2x larger.
492 dom.SetStyle(card, "minWidth", "640px")
493 dom.SetStyle(card, "padding", "48px")
494 dom.SetTextContent(title, "Thanks!")
495 dom.SetStyle(title, "fontSize", "48px")
496 dom.SetStyle(title, "margin", "0 0 24px 0")
497 dom.SetStyle(amountRow, "display", "none")
498 dom.SetStyle(memoInput, "display", "none")
499 dom.SetStyle(qrBox, "display", "none")
500 dom.SetStyle(genBtn, "display", "none")
501 dom.SetStyle(status, "fontSize", "24px")
502 dom.SetStyle(status, "color", "var(--fg)")
503 dom.SetTextContent(status, "Payment received - " | helpers.Itoa(amt) | " sats")
504 dom.SetTextContent(toast, "")
505 },
506 )
507 })
508 }))
509 }
510