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