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