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