lnurl.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  )
   7  
   8  // LNURL-pay + Lightning Address resolution.
   9  //
  10  // Lightning address "user@domain" resolves to
  11  // https://domain/.well-known/lnurlp/user which returns a LNURL-pay response:
  12  //   { callback, minSendable, maxSendable, commentAllowed, ... }
  13  // Then GET callback?amount=<msats>[&comment=..] returns { pr: "lnbc...", ... }.
  14  
  15  // LnurlPayParams describes the first-stage LNURL-pay response.
  16  type LnurlPayParams struct {
  17  	Callback       string
  18  	MinSendable    int64
  19  	MaxSendable    int64
  20  	CommentAllowed int32
  21  	Metadata       string
  22  }
  23  
  24  // lnurlEndpointForAddress converts user@domain to well-known URL.
  25  // For hosts starting with "localhost" or IP-like, uses http; else https.
  26  // Returns empty string on malformed input.
  27  func lnurlEndpointForAddress(addr string) (s string) {
  28  	atIdx := -1
  29  	for i := 0; i < len(addr); i++ {
  30  		if addr[i] == '@' {
  31  			atIdx = i
  32  			break
  33  		}
  34  	}
  35  	if atIdx <= 0 || atIdx == len(addr)-1 {
  36  		return ""
  37  	}
  38  	user := addr[:atIdx]
  39  	host := addr[atIdx+1:]
  40  	for i := 0; i < len(user); i++ {
  41  		c := user[i]
  42  		if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
  43  			c == '.' || c == '_' || c == '-' || c == '+') {
  44  			return ""
  45  		}
  46  	}
  47  	scheme := "https"
  48  	if isLocalHost(host) {
  49  		scheme = "http"
  50  	}
  51  	return scheme | "://" | host | "/.well-known/lnurlp/" | user
  52  }
  53  
  54  func isLocalHost(h string) (ok bool) {
  55  	if h == "localhost" {
  56  		return true
  57  	}
  58  	if len(h) >= 9 && h[:9] == "localhost" {
  59  		return true
  60  	}
  61  	if len(h) >= 4 && h[:4] == "127." {
  62  		return true
  63  	}
  64  	if len(h) >= 8 && h[:8] == "192.168." {
  65  		return true
  66  	}
  67  	if len(h) >= 3 && h[:3] == "10." {
  68  		return true
  69  	}
  70  	return false
  71  }
  72  
  73  // LnurlResolve fetches the first-stage LNURL-pay params.
  74  // cb receives (params, true) on success or (_, false) on failure.
  75  func LnurlResolve(endpoint string, cb func(LnurlPayParams, bool)) {
  76  	dom.FetchText(endpoint, func(body string) {
  77  		if body == "" {
  78  			cb(LnurlPayParams{}, false)
  79  			return
  80  		}
  81  		tag := helpers.JsonGetString(body, "tag")
  82  		if tag != "" && tag != "payRequest" {
  83  			cb(LnurlPayParams{}, false)
  84  			return
  85  		}
  86  		cb(LnurlPayParams{
  87  			Callback:       helpers.JsonGetString(body, "callback"),
  88  			MinSendable:    parseI64(helpers.JsonGetValue(body, "minSendable")),
  89  			MaxSendable:    parseI64(helpers.JsonGetValue(body, "maxSendable")),
  90  			CommentAllowed: int32(parseI64(helpers.JsonGetValue(body, "commentAllowed"))),
  91  			Metadata:       helpers.JsonGetString(body, "metadata"),
  92  		}, true)
  93  	})
  94  }
  95  
  96  // LnurlRequestInvoice calls the callback URL with amount (msats) and optional comment.
  97  // Returns (bolt11, true) on success, or ("", false) on failure.
  98  func LnurlRequestInvoice(callback string, amountMsats int64, comment string, cb func(string, bool)) {
  99  	sep := "?"
 100  	for i := 0; i < len(callback); i++ {
 101  		if callback[i] == '?' {
 102  			sep = "&"
 103  			break
 104  		}
 105  	}
 106  	url := callback | sep | "amount=" | helpers.Itoa(amountMsats)
 107  	if comment != "" {
 108  		url = url | "&comment=" | urlEscape(comment)
 109  	}
 110  	dom.FetchText(url, func(body string) {
 111  		if body == "" {
 112  			cb("", false)
 113  			return
 114  		}
 115  		// Reject error responses: {"status":"ERROR","reason":"..."}.
 116  		status := helpers.JsonGetString(body, "status")
 117  		if status == "ERROR" {
 118  			cb("", false)
 119  			return
 120  		}
 121  		bolt11 := helpers.JsonGetString(body, "pr")
 122  		if bolt11 == "" {
 123  			cb("", false)
 124  			return
 125  		}
 126  		cb(bolt11, true)
 127  	})
 128  }
 129  
 130  // PayLightningAddress resolves, requests invoice, and pays via NWC.
 131  // amountMsats is the amount in millisatoshis (1 sat = 1000 msats).
 132  // cb receives the NWC plaintext response JSON, or empty string on failure.
 133  func PayLightningAddress(nwcConnID, addr string, amountMsats int64, comment, encryption string, cb func(string)) {
 134  	endpoint := lnurlEndpointForAddress(addr)
 135  	if endpoint == "" {
 136  		cb("")
 137  		return
 138  	}
 139  	LnurlResolve(endpoint, func(params LnurlPayParams, ok bool) {
 140  		if !ok || params.Callback == "" {
 141  			cb("")
 142  			return
 143  		}
 144  		if amountMsats < params.MinSendable || amountMsats > params.MaxSendable {
 145  			cb("")
 146  			return
 147  		}
 148  		LnurlRequestInvoice(params.Callback, amountMsats, comment, func(bolt11 string, ok bool) {
 149  			if !ok {
 150  				cb("")
 151  				return
 152  			}
 153  			inner := `{"invoice":` | helpers.JsonString(bolt11) | `}`
 154  			NwcCall(nwcConnID, "pay_invoice", inner, encryption, cb)
 155  		})
 156  	})
 157  }
 158  
 159  // urlEscape percent-encodes unreserved chars per RFC 3986.
 160  func urlEscape(s string) (sv string) {
 161  	buf := []byte{:0:len(s) * 3}
 162  	for i := 0; i < len(s); i++ {
 163  		c := s[i]
 164  		if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
 165  			c == '-' || c == '_' || c == '.' || c == '~' {
 166  			buf = append(buf, c)
 167  		} else {
 168  			buf = append(buf, '%')
 169  			buf = append(buf, hexNibble(int32(c>>4)))
 170  			buf = append(buf, hexNibble(int32(c&0x0f)))
 171  		}
 172  	}
 173  	return string(buf)
 174  }
 175  
 176  func hexNibble(n int32) (b byte) {
 177  	if n < 10 {
 178  		return byte('0' + n)
 179  	}
 180  	return byte('A' + n - 10)
 181  }
 182