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/nostr" ) // NIP-57 zaps. Accumulation of kind 9735 receipts is in main.mx // handleActionEvent; this file handles the click-to-zap flow and the // bolt11 amount parser that feeds the accumulator. // parseBolt11Msat extracts the amount in millisats from a BOLT11 invoice. // BOLT11 hrp format: "ln" + currency + [amount][multiplier]. // The separator is the LAST '1' in the invoice (bech32). Returns 0 if no // amount is encoded or parsing fails. func parseBolt11Msat(inv string) (n int64) { sep := -1 for i := len(inv) - 1; i >= 0; i-- { if inv[i] == '1' { sep = i break } } if sep < 4 { return 0 } hrp := inv[:sep] if len(hrp) < 4 || hrp[0] != 'l' || hrp[1] != 'n' { return 0 } // Skip currency letters after "ln". i := 2 for i < len(hrp) && hrp[i] >= 'a' && hrp[i] <= 'z' { // An 'm', 'u', 'n', 'p' is only the multiplier if it's the LAST // char of hrp and there were digits before it. Otherwise it's // currency. Stop when we hit a digit. if hrp[i] >= '0' && hrp[i] <= '9' { break } i++ } // Now parse digits. digStart := i for i < len(hrp) && hrp[i] >= '0' && hrp[i] <= '9' { i++ } if i == digStart { return 0 } var amt int64 for j := digStart; j < i; j++ { amt = amt*10 + int64(hrp[j]-'0') } mult := byte(0) if i < len(hrp) { mult = hrp[i] } // BTC base: 1 BTC = 10^11 msat. // no mult: amt * 10^11 msat // m: 10^-3 BTC * amt = amt * 10^8 msat // u: amt * 10^5 msat // n: amt * 10^2 msat // p: amt * 0.1 msat (integer-safe only when amt is multiple of 10) switch mult { case 0: return amt * 100000000000 case 'm': return amt * 100000000 case 'u': return amt * 100000 case 'n': return amt * 100 case 'p': return amt / 10 } return 0 } // showZapModal opens the zap flow for a note event. If the user has an NWC // connection configured, it offers amount input + pay via NWC. Otherwise it // shows the author's lightning address as a QR code the user can scan with // an external wallet. func showZapModal(ev *nostr.Event) { lud16 := "" if content, ok := profileContentCache[ev.PubKey]; ok { lud16 = helpers.JsonGetString(content, "lud16") if lud16 == "" { lud16 = helpers.JsonGetString(content, "lud06") } } if lud16 == "" { // Trigger a profile fetch so the next click works. fetchAuthorProfile(ev.PubKey) } 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) title := dom.CreateElement("h3") dom.SetTextContent(title, "Zap") dom.SetStyle(title, "margin", "0 0 12px 0") dom.AppendChild(card, title) 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) dom.AppendChild(dom.Body(), scrim) dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() { dom.RemoveChild(dom.Body(), scrim) })) if lud16 == "" { dom.SetTextContent(status, "Author has no Lightning address (lud16).") return } if len(nwcConns) == 0 { zapRenderLud16QR(card, status, lud16) return } zapRenderNwcPay(card, status, ev, lud16) } // zapRenderLud16QR populates the card with a QR of the author's lightning // address as a lightning: URI. Click to copy. func zapRenderLud16QR(card, status dom.Element, lud16 string) { dom.SetTextContent(status, "Scan to zap " | lud16) uri := "lightning:" | lud16 qrBox := dom.CreateElement("div") dom.SetStyle(qrBox, "background", "#ffffff") dom.SetStyle(qrBox, "padding", "12px") dom.SetStyle(qrBox, "borderRadius", "8px") dom.SetStyle(qrBox, "cursor", "pointer") svg := qrSVG(uri, 5) if svg == "" { dom.SetTextContent(status, "Failed to render QR.") return } dom.SetInnerHTML(qrBox, svg) 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) capURI := uri dom.AddEventListener(qrBox, "click", dom.RegisterCallback(func() { dom.WriteClipboard(capURI, func(ok bool) { if ok { dom.SetTextContent(toast, "copied to clipboard") } else { dom.SetTextContent(toast, "copy failed") } dom.SetTimeout(func() { dom.SetTextContent(toast, "") }, 1800) }) })) } // zapRenderNwcPay adds amount input + pay button. On pay: build kind 9734 // zap request, call LNURL callback with it, then NwcCall pay_invoice. func zapRenderNwcPay(card, status dom.Element, ev *nostr.Event, lud16 string) { 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", "100") dom.SetStyle(amtInput, "width", "100%") dom.SetStyle(amtInput, "marginBottom", "8px") dom.AppendChild(card, amtInput) commentInput := dom.CreateElement("input") dom.SetAttribute(commentInput, "type", "text") dom.SetAttribute(commentInput, "class", "signer-input") dom.SetAttribute(commentInput, "placeholder", "comment (optional)") dom.SetStyle(commentInput, "width", "100%") dom.SetStyle(commentInput, "marginBottom", "12px") dom.AppendChild(card, commentInput) payBtn := dom.CreateElement("button") dom.SetAttribute(payBtn, "class", "signer-btn") dom.SetTextContent(payBtn, "Zap " | lud16) dom.AppendChild(card, payBtn) dom.SetTextContent(status, "") connID := nwcConns[0].ID targetEv := ev dom.AddEventListener(payBtn, "click", dom.RegisterCallback(func() { amtStr := dom.GetProperty(amtInput, "value") amtSats := parseI64(amtStr) if amtSats <= 0 { dom.SetTextContent(status, "Enter a positive amount.") return } comment := dom.GetProperty(commentInput, "value") amtMsats := amtSats * 1000 dom.SetAttribute(payBtn, "disabled", "true") dom.SetTextContent(status, "Resolving...") endpoint := lnurlEndpointForAddress(lud16) if endpoint == "" { dom.SetTextContent(status, "Invalid lightning address.") dom.SetAttribute(payBtn, "disabled", "") return } LnurlResolve(endpoint, func(params LnurlPayParams, ok bool) { if !ok || params.Callback == "" { dom.SetTextContent(status, "LNURL resolve failed.") dom.SetAttribute(payBtn, "disabled", "") return } if amtMsats < params.MinSendable || amtMsats > params.MaxSendable { dom.SetTextContent(status, "Amount out of range.") dom.SetAttribute(payBtn, "disabled", "") return } buildZapRequest(targetEv, amtMsats, comment, func(zapReqJSON string) { if zapReqJSON == "" { dom.SetTextContent(status, "Could not sign zap request.") dom.SetAttribute(payBtn, "disabled", "") return } dom.SetTextContent(status, "Requesting invoice...") sep := "?" for i := 0; i < len(params.Callback); i++ { if params.Callback[i] == '?' { sep = "&" break } } url := params.Callback | sep | "amount=" | helpers.Itoa(amtMsats) | "&nostr=" | urlEscape(zapReqJSON) if comment != "" { url = url | "&comment=" | urlEscape(comment) } dom.FetchText(url, func(body string) { if body == "" { dom.SetTextContent(status, "Invoice fetch failed.") dom.SetAttribute(payBtn, "disabled", "") return } if helpers.JsonGetString(body, "status") == "ERROR" { dom.SetTextContent(status, "LNURL error.") dom.SetAttribute(payBtn, "disabled", "") return } bolt11 := helpers.JsonGetString(body, "pr") if bolt11 == "" { dom.SetTextContent(status, "No invoice returned.") dom.SetAttribute(payBtn, "disabled", "") return } dom.SetTextContent(status, "Paying...") inner := `{"invoice":` | helpers.JsonString(bolt11) | `}` NwcCall(connID, "pay_invoice", inner, "", func(resp string) { if resp == "" { dom.SetTextContent(status, "Payment failed.") dom.SetAttribute(payBtn, "disabled", "") return } errCode := helpers.JsonGetString(resp, "error") if errCode != "" { dom.SetTextContent(status, "Payment rejected: " | errCode) dom.SetAttribute(payBtn, "disabled", "") return } dom.SetTextContent(status, "Zap sent - " | helpers.Itoa(amtSats) | " sats") dom.SetAttribute(payBtn, "disabled", "") }) }) }) }) })) } // buildZapRequest assembles a kind 9734 zap request, signs it, and returns // the serialized JSON via cb. Returns "" on signer failure. func buildZapRequest(ev *nostr.Event, amtMsats int64, comment string, cb func(string)) { if !signer.HasSigner() || pubhex == "" { cb("") return } // Relays tag: include up to 5 of the user's configured relays. relaysTag := `["relays"` n := 0 for _, u := range relayURLs { if n >= 5 { break } relaysTag = relaysTag | "," | helpers.JsonString(u) n++ } if n == 0 { relaysTag = relaysTag | `,"wss://relay.damus.io"` } relaysTag = relaysTag | "]" tags := "[" | relaysTag | `,["amount",` | helpers.JsonString(helpers.Itoa(amtMsats)) | `]` | `,["p",` | helpers.JsonString(ev.PubKey) | `]` | `,["e",` | helpers.JsonString(ev.ID) | `]` | "]" content := "" if comment != "" { content = comment } unsigned := `{"kind":9734,"content":` | helpers.JsonString(content) | `,"tags":` | tags | `,"created_at":` | helpers.Itoa(dom.NowSeconds()) | `,"pubkey":"` | pubhex | `"}` signer.SignEvent(unsigned, func(signed string) { cb(signed) }) }