package main import ( "git.smesh.lol/smesh/web/common/helpers" "git.smesh.lol/smesh/web/common/jsbridge/dom" ) // LNURL-pay + Lightning Address resolution. // // Lightning address "user@domain" resolves to // https://domain/.well-known/lnurlp/user which returns a LNURL-pay response: // { callback, minSendable, maxSendable, commentAllowed, ... } // Then GET callback?amount=[&comment=..] returns { pr: "lnbc...", ... }. // LnurlPayParams describes the first-stage LNURL-pay response. type LnurlPayParams struct { Callback string MinSendable int64 MaxSendable int64 CommentAllowed int32 Metadata string } // lnurlEndpointForAddress converts user@domain to well-known URL. // For hosts starting with "localhost" or IP-like, uses http; else https. // Returns empty string on malformed input. func lnurlEndpointForAddress(addr string) (s string) { atIdx := -1 for i := 0; i < len(addr); i++ { if addr[i] == '@' { atIdx = i break } } if atIdx <= 0 || atIdx == len(addr)-1 { return "" } user := addr[:atIdx] host := addr[atIdx+1:] for i := 0; i < len(user); i++ { c := user[i] if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' || c == '+') { return "" } } scheme := "https" if isLocalHost(host) { scheme = "http" } return scheme | "://" | host | "/.well-known/lnurlp/" | user } func isLocalHost(h string) (ok bool) { if h == "localhost" { return true } if len(h) >= 9 && h[:9] == "localhost" { return true } if len(h) >= 4 && h[:4] == "127." { return true } if len(h) >= 8 && h[:8] == "192.168." { return true } if len(h) >= 3 && h[:3] == "10." { return true } return false } // LnurlResolve fetches the first-stage LNURL-pay params. // cb receives (params, true) on success or (_, false) on failure. func LnurlResolve(endpoint string, cb func(LnurlPayParams, bool)) { dom.FetchText(endpoint, func(body string) { if body == "" { cb(LnurlPayParams{}, false) return } tag := helpers.JsonGetString(body, "tag") if tag != "" && tag != "payRequest" { cb(LnurlPayParams{}, false) return } cb(LnurlPayParams{ Callback: helpers.JsonGetString(body, "callback"), MinSendable: parseI64(helpers.JsonGetValue(body, "minSendable")), MaxSendable: parseI64(helpers.JsonGetValue(body, "maxSendable")), CommentAllowed: int32(parseI64(helpers.JsonGetValue(body, "commentAllowed"))), Metadata: helpers.JsonGetString(body, "metadata"), }, true) }) } // LnurlRequestInvoice calls the callback URL with amount (msats) and optional comment. // Returns (bolt11, true) on success, or ("", false) on failure. func LnurlRequestInvoice(callback string, amountMsats int64, comment string, cb func(string, bool)) { sep := "?" for i := 0; i < len(callback); i++ { if callback[i] == '?' { sep = "&" break } } url := callback | sep | "amount=" | helpers.Itoa(amountMsats) if comment != "" { url = url | "&comment=" | urlEscape(comment) } dom.FetchText(url, func(body string) { if body == "" { cb("", false) return } // Reject error responses: {"status":"ERROR","reason":"..."}. status := helpers.JsonGetString(body, "status") if status == "ERROR" { cb("", false) return } bolt11 := helpers.JsonGetString(body, "pr") if bolt11 == "" { cb("", false) return } cb(bolt11, true) }) } // PayLightningAddress resolves, requests invoice, and pays via NWC. // amountMsats is the amount in millisatoshis (1 sat = 1000 msats). // cb receives the NWC plaintext response JSON, or empty string on failure. func PayLightningAddress(nwcConnID, addr string, amountMsats int64, comment, encryption string, cb func(string)) { endpoint := lnurlEndpointForAddress(addr) if endpoint == "" { cb("") return } LnurlResolve(endpoint, func(params LnurlPayParams, ok bool) { if !ok || params.Callback == "" { cb("") return } if amountMsats < params.MinSendable || amountMsats > params.MaxSendable { cb("") return } LnurlRequestInvoice(params.Callback, amountMsats, comment, func(bolt11 string, ok bool) { if !ok { cb("") return } inner := `{"invoice":` | helpers.JsonString(bolt11) | `}` NwcCall(nwcConnID, "pay_invoice", inner, encryption, cb) }) }) } // urlEscape percent-encodes unreserved chars per RFC 3986. func urlEscape(s string) (sv string) { buf := []byte{:0:len(s) * 3} for i := 0; i < len(s); i++ { c := s[i] if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' { buf = append(buf, c) } else { buf = append(buf, '%') buf = append(buf, hexNibble(int32(c>>4))) buf = append(buf, hexNibble(int32(c&0x0f))) } } return string(buf) } func hexNibble(n int32) (b byte) { if n < 10 { return byte('0' + n) } return byte('A' + n - 10) }