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, "")
},
)
})
}))
}