using System.Text; using System.Text.RegularExpressions; using Newtonsoft.Json; using Nostr.Client.Json; using Nostr.Client.Messages; using Nostr.Client.Utils; public enum LNURLErrorCode { ServiceUnavailable = 1, InvalidLNURL = 2, } public class LNURLError : Exception { public LNURLErrorCode Code { get; private set; } public LNURLError(LNURLErrorCode code, string message) : base(message) { Code = code; } } public class Lnurl { private readonly HttpClient _client; public Lnurl(HttpClient client) { _client = client; } public static Uri? ParseLnUrl(string lnurl) { var emailRegex = new Regex( "^(([^<>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$"); lnurl = lnurl.ToLower().Trim(); if (lnurl.StartsWith("lnurlp:")) { return new Uri(lnurl.Replace("lnurlp://", "https://")); } if (lnurl.StartsWith("lnurl")) { Bech32.Decode(lnurl, out var hrp, out var decoded); var decodedString = Encoding.UTF8.GetString(decoded!); if (!decodedString.StartsWith("http")) { return default; } return new Uri(decodedString); } if (emailRegex.IsMatch(lnurl)) { var parts = lnurl.Split('@'); return new Uri($"https://{parts[1]}/.well-known/lnurlp/{parts[0]}"); } if (lnurl.StartsWith("https:")) { return new Uri(lnurl); } return default; } public async Task LoadAsync(string lnurl) { var url = ParseLnUrl(lnurl); using var httpClient = new HttpClient(); var response = await httpClient.GetAsync(url); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject(json)!; } return default; } public async Task GetInvoiceAsync(LNURLService service, int amount, string? comment = null, NostrEvent? zap = null) { if (service == null || string.IsNullOrWhiteSpace(service.Callback)) { throw new LNURLError(LNURLErrorCode.InvalidLNURL, "No callback url"); } var query = new Dictionary { ["amount"] = (amount * 1000).ToString() }; if (!string.IsNullOrWhiteSpace(comment) && service.CommentAllowed.HasValue) { query["comment"] = comment; } if (!string.IsNullOrWhiteSpace(service.NostrPubkey) && zap != null) { query["nostr"] = JsonConvert.SerializeObject(zap, NostrSerializer.Settings); } var builder = new UriBuilder(service.Callback) { Query = string.Join('&', query.Select(a => $"{a.Key}={Uri.EscapeDataString(a.Value)}")) }; try { using var httpClient = new HttpClient(); var response = await httpClient.GetAsync(builder.Uri); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject(json); if (data?.Status == "ERROR") { throw new Exception(data.Reason); } return data!; } throw new LNURLError(LNURLErrorCode.ServiceUnavailable, $"Failed to fetch invoice ({response.ReasonPhrase})"); } catch (Exception e) { throw new LNURLError(LNURLErrorCode.ServiceUnavailable, "Failed to load callback"); } } } public class LNURLService { [JsonProperty("tag")] public string? Tag { get; init; } [JsonProperty("nostrPubkey")] public string? NostrPubkey { get; init; } [JsonProperty("minSendable")] public int? MinSendable { get; init; } [JsonProperty("maxSnedable")] public int? MaxSendable { get; init; } [JsonProperty("metadata")] public string Metadata { get; init; } = null!; [JsonProperty("callback")] public string Callback { get; init; } = null!; [JsonProperty("commentAllowed")] public int? CommentAllowed { get; init; } } public class LNURLStatus { [JsonProperty("status")] public string Status { get; set; } = null!; [JsonProperty("reason")] public string? Reason { get; set; } } public class LNURLInvoice : LNURLStatus { [JsonProperty("pr")] public string Pr { get; set; } = null!; [JsonProperty("successAction")] public LNURLSuccessAction? SuccessAction { get; set; } } public class LNURLSuccessAction { [JsonProperty("description")] public string? Description { get; set; } [JsonProperty("url")] public string? Url { get; set; } }