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