package main import ( "git.smesh.lol/smesh/web/common/helpers" "git.smesh.lol/smesh/web/common/jsbridge/dom" "git.smesh.lol/smesh/web/common/jsbridge/signer" "git.smesh.lol/smesh/web/common/jsbridge/subtle" "git.smesh.lol/smesh/web/common/jsbridge/ws" "git.smesh.lol/smesh/web/common/nostr" ) // Hardcoded read-only NWC URI (make_invoice + lookup_invoice permissions only). const donationNWCURI = "nostr+walletconnect://7a6b3021e354883c524723a14e2571b2d937dee18935bc00679d26fe593f9d8d?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=774c76cf2150e76d59e2a897fd4de681faf9f9b08d52ba3999d7e8368607b6bc&lud16=davidv@getalby.com" type donationNwc struct { WalletPubkey string // 64 hex RelayURL string Secret string // 64 hex (client secret) } var ( donSubs = map[string]func(string){} // subID -> callback(plain JSON or "") donNextSub int32 ) // parseDonationURI parses the hardcoded URI; takes the first relay. func parseDonationURI(uri string) (donationNwc, bool) { var d donationNwc prefix := "nostr+walletconnect://" if len(uri) < len(prefix) || uri[:len(prefix)] != prefix { return d, false } rest := uri[len(prefix):] qIdx := -1 for i := 0; i < len(rest); i++ { if rest[i] == '?' { qIdx = i break } } if qIdx < 0 { return d, false } pk := rest[:qIdx] if len(pk) != 64 { return d, false } d.WalletPubkey = pk q := rest[qIdx+1:] start := 0 for i := 0; i <= len(q); i++ { if i == len(q) || q[i] == '&' { kv := q[start:i] eqIdx := -1 for j := 0; j < len(kv); j++ { if kv[j] == '=' { eqIdx = j break } } if eqIdx > 0 { k := kv[:eqIdx] v := donURLDecode(kv[eqIdx+1:]) switch k { case "relay": if d.RelayURL == "" { d.RelayURL = v } case "secret": d.Secret = v } } start = i + 1 } } if d.RelayURL == "" || len(d.Secret) != 64 { return d, false } return d, true } func donURLDecode(s string) (sv string) { buf := []byte{:0:len(s)} for i := 0; i < len(s); i++ { c := s[i] if c == '%' && i+2 < len(s) { hi := donHexDigit(s[i+1]) lo := donHexDigit(s[i+2]) if hi >= 0 && lo >= 0 { buf = append(buf, byte(hi<<4|lo)) i += 2 continue } } buf = append(buf, c) } return string(buf) } func donHexDigit(c byte) (n int32) { if c >= '0' && c <= '9' { return int32(c - '0') } if c >= 'a' && c <= 'f' { return int32(c-'a') + 10 } if c >= 'A' && c <= 'F' { return int32(c-'A') + 10 } return -1 } // donationNwcCall runs one NWC call over a one-shot WS connection using the // hardcoded URI. method is "make_invoice" or "lookup_invoice". // innerParams is a JSON object string. cb receives decrypted response JSON. func donationNwcCall(method, innerParams string, cb func(string)) { d, ok := parseDonationURI(donationNWCURI) if !ok { cb("") return } // ECDH with the donation secret key via signer worker. signer.EcdhWithSecret(d.Secret, d.WalletPubkey, func(sharedHex string) { if sharedHex == "" { cb("") return } shared := helpers.HexDecode(sharedHex) if len(shared) != 32 { cb("") return } req := "{\"method\":" | helpers.JsonString(method) | ",\"params\":" | innerParams | "}" iv := []byte{:16} subtle.RandomBytes(iv) ct := subtle.AESCBCEncrypt(shared, iv, []byte(req)) if len(ct) == 0 { cb("") return } content := helpers.Base64Encode(ct) | "?iv=" | helpers.Base64Encode(iv) now := dom.NowSeconds() ev := &nostr.Event{ Kind: 23194, CreatedAt: now, Tags: nostr.Tags{ nostr.Tag{"p", d.WalletPubkey}, nostr.Tag{"encryption", "nip04"}, }, Content: content, } evJSON := ev.ToJSON() // Sign with the donation secret key via signer worker. signer.SignWithSecret(d.Secret, evJSON, func(signedJSON string) { if signedJSON == "" { cb("") return } signedEv := nostr.ParseEvent(signedJSON) if signedEv == nil { cb("") return } donFinishCall(d, shared, signedEv, cb) }) }) } func donFinishCall(d donationNwc, shared []byte, ev *nostr.Event, cb func(string)) { reqID := ev.ID evJSON := ev.ToJSON() clientPubHex := ev.PubKey donNextSub++ subID := "don-" | helpers.Itoa(int64(donNextSub)) var sock ws.Conn timedOut := false donSubs[subID] = func(plain string) { if timedOut { return } ws.Send(sock, `["CLOSE",` | helpers.JsonString(subID) | `]`) ws.Close(sock) cb(plain) } sock = ws.Dial(d.RelayURL, func(_ int32, msg string) { donOnMessage(shared, helpers.HexDecode(d.WalletPubkey), msg) }, func(_ int32) { reqMsg := `["REQ",` | helpers.JsonString(subID) | `,{"kinds":[23195],"#e":[` | helpers.JsonString(reqID) | `],"#p":[` | helpers.JsonString(clientPubHex) | `],"limit":1}]` ws.Send(sock, reqMsg) ws.Send(sock, `["EVENT",` | evJSON | `]`) }, func(_ int32, code int32, reason string) { if cbFn, ok := donSubs[subID]; ok { delete(donSubs, subID) if !timedOut { cbFn("") } } }, func(_ int32) {}, ) dom.SetTimeout(func() { if cbFn, ok := donSubs[subID]; ok { timedOut = true delete(donSubs, subID) ws.Close(sock) cbFn("") } }, 45000) } // donationMakeInvoice issues make_invoice on the hardcoded NWC and returns // the bolt11 invoice via cb (empty string on failure). // amountSats is the invoice amount in sats; desc is the memo. func donationMakeInvoice(amountSats int64, desc string, cb func(string, string)) { msats := amountSats * 1000 inner := "{\"amount\":" | helpers.Itoa(msats) | ",\"description\":" | helpers.JsonString(desc) | "}" donationNwcCall("make_invoice", inner, func(plain string) { if plain == "" { cb("", "") return } errVal := helpers.JsonGetValue(plain, "error") if errVal != "" && errVal != "null" { dom.ConsoleLog("[donation] wallet error: " | errVal) cb("", "") return } result := helpers.JsonGetValue(plain, "result") if result == "" { cb("", "") return } inv := helpers.JsonGetString(result, "invoice") ph := helpers.JsonGetString(result, "payment_hash") cb(inv, ph) }) } // donationPollSettled calls lookup_invoice periodically and fires cb(true) // when settled_at > 0 (or state=="settled"). Stops when stop returns true. func donationPollSettled(paymentHash string, stop func() bool, cb func(bool)) { if paymentHash == "" { cb(false) return } inner := "{\"payment_hash\":" | helpers.JsonString(paymentHash) | "}" donationNwcCall("lookup_invoice", inner, func(plain string) { if stop() { return } settled := false if plain != "" { result := helpers.JsonGetValue(plain, "result") if result != "" { sa := helpers.JsonGetValue(result, "settled_at") st := helpers.JsonGetString(result, "state") if (sa != "" && sa != "null" && sa != "0") || st == "settled" { settled = true } } } if settled { cb(true) return } dom.SetTimeout(func() { if stop() { return } donationPollSettled(paymentHash, stop, cb) }, 3000) }) } // donOnMessage handles relay frames during a donation invoice request. // shared is the NIP-04 shared secret; peerPub is the wallet pubkey. func donOnMessage(shared, peerPub []byte, msg string) { if len(msg) < 10 || msg[0] != '[' { return } i := 1 if msg[i] != '"' { return } i++ tStart := i for i < len(msg) && msg[i] != '"' { i++ } if i >= len(msg) { return } tag := msg[tStart:i] i++ if msg[i] != ',' { return } i++ if tag != "EVENT" { return } subID, evJSON, ok := parseEventMsg(msg[i:]) if !ok { return } cbFn, ok := donSubs[subID] if !ok { return } ct := helpers.JsonGetString(evJSON, "content") // NIP-04 decrypt. ivIdx := -1 for j := 0; j < len(ct)-3; j++ { if ct[j] == '?' && ct[j+1] == 'i' && ct[j+2] == 'v' && ct[j+3] == '=' { ivIdx = j break } } if ivIdx < 0 { delete(donSubs, subID) cbFn("") return } ctBytes := helpers.Base64Decode(ct[:ivIdx]) ivBytes := helpers.Base64Decode(ct[ivIdx+4:]) if ctBytes == nil || ivBytes == nil { delete(donSubs, subID) cbFn("") return } pt := subtle.AESCBCDecrypt(shared, ivBytes, ctBytes) delete(donSubs, subID) cbFn(string(pt)) } // --- UI --- func showDonationModal() { scrim := dom.CreateElement("div") dom.SetStyle(scrim, "position", "fixed") dom.SetStyle(scrim, "inset", "0") dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)") dom.SetStyle(scrim, "display", "flex") dom.SetStyle(scrim, "alignItems", "center") dom.SetStyle(scrim, "justifyContent", "center") dom.SetStyle(scrim, "zIndex", "9999") dom.SetStyle(scrim, "cursor", "pointer") card := dom.CreateElement("div") dom.SetStyle(card, "position", "relative") dom.SetStyle(card, "background", "var(--bg, #1a1a1a)") dom.SetStyle(card, "color", "var(--fg)") dom.SetStyle(card, "borderRadius", "16px") dom.SetStyle(card, "padding", "24px") dom.SetStyle(card, "display", "flex") dom.SetStyle(card, "flexDirection", "column") dom.SetStyle(card, "alignItems", "center") dom.SetStyle(card, "minWidth", "320px") dom.SetStyle(card, "maxWidth", "92vw") dom.SetStyle(card, "cursor", "default") dom.SetAttribute(card, "onclick", "event.stopPropagation()") dom.AppendChild(scrim, card) // Confetti layer (covers whole viewport; 17 pieces). Activated by adding class "run". confetti := dom.CreateElement("div") dom.SetAttribute(confetti, "class", "confetti") dom.SetInnerHTML(confetti, "") dom.AppendChild(scrim, confetti) title := dom.CreateElement("h3") dom.SetTextContent(title, "Donate via Lightning") dom.SetStyle(title, "margin", "0 0 12px 0") dom.AppendChild(card, title) amountRow := dom.CreateElement("div") dom.SetStyle(amountRow, "display", "flex") dom.SetStyle(amountRow, "gap", "8px") dom.SetStyle(amountRow, "marginBottom", "8px") dom.SetStyle(amountRow, "width", "100%") amtInput := dom.CreateElement("input") dom.SetAttribute(amtInput, "type", "number") dom.SetAttribute(amtInput, "min", "1") dom.SetAttribute(amtInput, "class", "signer-input") dom.SetAttribute(amtInput, "placeholder", "sats") dom.SetProperty(amtInput, "value", "1000") dom.SetStyle(amtInput, "flex", "1") dom.AppendChild(amountRow, amtInput) dom.AppendChild(card, amountRow) memoInput := dom.CreateElement("input") dom.SetAttribute(memoInput, "type", "text") dom.SetAttribute(memoInput, "class", "signer-input") dom.SetAttribute(memoInput, "placeholder", "memo (optional)") dom.SetProperty(memoInput, "value", "smesh donation") dom.SetStyle(memoInput, "width", "100%") dom.SetStyle(memoInput, "marginBottom", "12px") dom.AppendChild(card, memoInput) status := dom.CreateElement("p") dom.SetStyle(status, "fontSize", "12px") dom.SetStyle(status, "color", "var(--muted)") dom.SetStyle(status, "margin", "0 0 8px 0") dom.SetStyle(status, "minHeight", "1em") dom.AppendChild(card, status) qrBox := dom.CreateElement("div") dom.SetStyle(qrBox, "background", "#ffffff") dom.SetStyle(qrBox, "padding", "12px") dom.SetStyle(qrBox, "borderRadius", "8px") dom.SetStyle(qrBox, "display", "none") dom.SetStyle(qrBox, "cursor", "pointer") dom.AppendChild(card, qrBox) toast := dom.CreateElement("p") dom.SetStyle(toast, "fontSize", "12px") dom.SetStyle(toast, "color", "var(--accent)") dom.SetStyle(toast, "margin", "8px 0 0 0") dom.SetStyle(toast, "minHeight", "1em") dom.AppendChild(card, toast) genBtn := dom.CreateElement("button") dom.SetAttribute(genBtn, "class", "signer-btn") dom.SetStyle(genBtn, "marginTop", "8px") dom.SetTextContent(genBtn, "Generate invoice") dom.AppendChild(card, genBtn) dom.AppendChild(dom.Body(), scrim) // paid gates the polling loop; closed-over by stop closure. Also set when // modal is dismissed so we stop polling if the user closes early. paid := false closed := false dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() { closed = true dom.RemoveChild(dom.Body(), scrim) })) dom.AddEventListener(genBtn, "click", dom.RegisterCallback(func() { amtStr := dom.GetProperty(amtInput, "value") amt := parseI64(amtStr) if amt <= 0 { dom.SetTextContent(status, "Enter a positive amount.") return } memo := dom.GetProperty(memoInput, "value") dom.SetTextContent(status, "Requesting invoice...") dom.SetAttribute(genBtn, "disabled", "true") dom.SetStyle(qrBox, "display", "none") dom.SetTextContent(toast, "") donationMakeInvoice(amt, memo, func(invoice, paymentHash string) { dom.SetAttribute(genBtn, "disabled", "") if invoice == "" { dom.SetTextContent(status, "Failed to get invoice.") return } dom.SetTextContent(status, helpers.Itoa(amt) | " sats - click QR to copy") svg := qrSVG(invoice, 4) if svg == "" { dom.SetTextContent(status, "Invoice too long for QR.") return } dom.SetInnerHTML(qrBox, svg) dom.SetStyle(qrBox, "display", "block") inv := invoice dom.AddEventListener(qrBox, "click", dom.RegisterCallback(func() { dom.WriteClipboard(inv, func(ok bool) { if ok { dom.SetTextContent(toast, "copied to clipboard") } else { dom.SetTextContent(toast, "copy failed") } dom.SetTimeout(func() { dom.SetTextContent(toast, "") }, 1800) }) })) // Start polling lookup_invoice until settled or modal closed. donationPollSettled(paymentHash, func() bool { return closed || paid }, func(settled bool) { if !settled || closed || paid { return } paid = true // Trigger confetti animation. dom.SetAttribute(confetti, "class", "confetti run") // Swap modal content to thanks, 2x larger. dom.SetStyle(card, "minWidth", "640px") dom.SetStyle(card, "padding", "48px") dom.SetTextContent(title, "Thanks!") dom.SetStyle(title, "fontSize", "48px") dom.SetStyle(title, "margin", "0 0 24px 0") dom.SetStyle(amountRow, "display", "none") dom.SetStyle(memoInput, "display", "none") dom.SetStyle(qrBox, "display", "none") dom.SetStyle(genBtn, "display", "none") dom.SetStyle(status, "fontSize", "24px") dom.SetStyle(status, "color", "var(--fg)") dom.SetTextContent(status, "Payment received - " | helpers.Itoa(amt) | " sats") dom.SetTextContent(toast, "") }, ) }) })) }