diff --git a/LNURL.Core/HttpLNURLCommunicator.cs b/LNURL.Core/HttpLNURLCommunicator.cs new file mode 100644 index 0000000..fe67015 --- /dev/null +++ b/LNURL.Core/HttpLNURLCommunicator.cs @@ -0,0 +1,31 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace LNURL; + +/// +/// An that performs LNURL requests over HTTP(S) +/// using an . +/// +public class HttpLNURLCommunicator : ILNURLCommunicator +{ + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance with the specified . + /// + /// The HTTP client to use for requests. If null, a new instance is created. + public HttpLNURLCommunicator(HttpClient httpClient = null) + { + _httpClient = httpClient ?? new HttpClient(); + } + + /// + public async Task SendRequest(Uri lnurl, CancellationToken cancellationToken = default) + { + var response = await _httpClient.GetAsync(lnurl, cancellationToken); + return await response.Content.ReadAsStringAsync(cancellationToken); + } +} diff --git a/LNURL.Core/ILNURLCommunicator.cs b/LNURL.Core/ILNURLCommunicator.cs new file mode 100644 index 0000000..9006b2c --- /dev/null +++ b/LNURL.Core/ILNURLCommunicator.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace LNURL; + +/// +/// Abstracts the transport layer for LNURL protocol communication, +/// enabling LNURL flows over HTTP, Nostr, or other transports. +/// +public interface ILNURLCommunicator +{ + /// + /// Sends a request to the given LNURL endpoint and returns the raw JSON response. + /// + /// The endpoint URI (may be an HTTP URL, nostr: URI, etc.). + /// A token to cancel the asynchronous operation. + /// The raw JSON response string from the endpoint. + Task SendRequest(Uri lnurl, CancellationToken cancellationToken = default); +} diff --git a/LNURL.Core/LNAuthRequest.cs b/LNURL.Core/LNAuthRequest.cs index 6e0663b..8433b20 100644 --- a/LNURL.Core/LNAuthRequest.cs +++ b/LNURL.Core/LNAuthRequest.cs @@ -65,15 +65,22 @@ public enum LNAuthRequestAction /// /// Sends the signed challenge and public key to the LNURL-auth service to complete authentication. /// - public async Task SendChallenge(ECDSASignature sig, PubKey key, HttpClient httpClient, CancellationToken cancellationToken = default) + public Task SendChallenge(ECDSASignature sig, PubKey key, HttpClient httpClient, CancellationToken cancellationToken = default) + { + return SendChallenge(sig, key, new HttpLNURLCommunicator(httpClient), cancellationToken); + } + + /// + /// Sends the signed challenge using a custom transport. + /// + public async Task SendChallenge(ECDSASignature sig, PubKey key, ILNURLCommunicator communicator, CancellationToken cancellationToken = default) { var url = LNUrl; var uriBuilder = new UriBuilder(url); LNURL.AppendPayloadToQuery(uriBuilder, "sig", Encoders.Hex.EncodeData(sig.ToDER())); LNURL.AppendPayloadToQuery(uriBuilder, "key", key.ToHex()); url = new Uri(uriBuilder.ToString()); - var response = await httpClient.GetAsync(url, cancellationToken); - var content = await response.Content.ReadAsStringAsync(cancellationToken); + var content = await communicator.SendRequest(url, cancellationToken); return System.Text.Json.JsonSerializer.Deserialize(content, LNURLJsonOptions.Default); } @@ -87,6 +94,15 @@ public Task SendChallenge(Key key, HttpClient httpClient, C return SendChallenge(sig, key.PubKey, httpClient, cancellationToken); } + /// + /// Signs the challenge with the given key and sends the result using a custom transport. + /// + public Task SendChallenge(Key key, ILNURLCommunicator communicator, CancellationToken cancellationToken = default) + { + var sig = SignChallenge(key); + return SendChallenge(sig, key.PubKey, communicator, cancellationToken); + } + /// /// Signs this request's challenge with the given private key. /// diff --git a/LNURL.Core/LNURL.cs b/LNURL.Core/LNURL.cs index 3dd09f2..34d68a3 100644 --- a/LNURL.Core/LNURL.cs +++ b/LNURL.Core/LNURL.cs @@ -72,7 +72,8 @@ public static Uri Parse(string lnurl, out string tag) Bech32Engine.Decode(lnurl, out _, out var data); var result = new Uri(Encoding.UTF8.GetString(data)); - if (!result.IsOnion() && !result.Scheme.Equals("https") && !result.IsLocalNetwork()) + if (!result.IsOnion() && !result.Scheme.Equals("https") && !result.IsLocalNetwork() && + result.Scheme != "nostr") throw new FormatException("LNURL provided is not secure."); var query = result.ParseQueryString(); @@ -83,7 +84,9 @@ public static Uri Parse(string lnurl, out string tag) if (Uri.TryCreate(lnurl, UriKind.Absolute, out var lud17Uri) && SchemeTagMapping.TryGetValue(lud17Uri.Scheme.ToLowerInvariant(), out tag)) return new Uri(lud17Uri.ToString() - .Replace(lud17Uri.Scheme + ":", lud17Uri.IsOnion() ? "http:" : "https:")); + .Replace(lud17Uri.Scheme + ":", + (lud17Uri.Host.StartsWith("nprofile1") || lud17Uri.Host.StartsWith("naddr1")) ? "nostr:" : + lud17Uri.IsOnion() ? "http:" : "https:")); throw new FormatException("LNURL uses bech32 and 'lnurl' as the hrp (LUD1) or an lnurl LUD17 scheme. "); } @@ -98,8 +101,10 @@ public static Uri Parse(string lnurl, out string tag) /// public static string EncodeBech32(Uri serviceUrl) { - if (serviceUrl.Scheme != "https" && !serviceUrl.IsOnion() && !serviceUrl.IsLocalNetwork()) - throw new ArgumentException("serviceUrl must be an onion service OR https based OR on the local network", + if (serviceUrl.Scheme != "https" && !serviceUrl.IsOnion() && !serviceUrl.IsLocalNetwork() && + serviceUrl.Scheme != "nostr") + throw new ArgumentException( + "serviceUrl must be an onion service OR https based OR on the local network OR a Nostr NIP-21 URI", nameof(serviceUrl)); return Bech32Engine.Encode("lnurl", Encoding.UTF8.GetBytes(serviceUrl.ToString())); @@ -114,8 +119,10 @@ public static string EncodeBech32(Uri serviceUrl) /// A in the chosen encoding format. public static Uri EncodeUri(Uri serviceUrl, string tag, bool bech32) { - if (serviceUrl.Scheme != "https" && !serviceUrl.IsOnion() && !serviceUrl.IsLocalNetwork()) - throw new ArgumentException("serviceUrl must be an onion service OR https based OR on the local network", + if (serviceUrl.Scheme != "https" && !serviceUrl.IsOnion() && !serviceUrl.IsLocalNetwork() && + serviceUrl.Scheme != "nostr") + throw new ArgumentException( + "serviceUrl must be an onion service OR https based OR on the local network OR a Nostr NIP-21 URI", nameof(serviceUrl)); if (string.IsNullOrEmpty(tag)) tag = serviceUrl.ParseQueryString().Get("tag"); if (tag == "login") LNAuthRequest.EnsureValidUrl(serviceUrl); @@ -221,6 +228,21 @@ public static Task FetchInformation(Uri lnUrl, string tag, HttpClient ht /// Supports fast withdraw (LUD-03 query-string parameters) and LNURL-auth (LUD-04) inline parsing. /// public static async Task FetchInformation(Uri lnUrl, string tag, HttpClient httpClient, CancellationToken cancellationToken) + { + return await FetchInformation(lnUrl, tag, new HttpLNURLCommunicator(httpClient), cancellationToken); + } + + /// + /// Fetches LNURL endpoint information using a custom transport. + /// This enables LNURL flows over Nostr or other non-HTTP transports. + /// + public static Task FetchInformation(Uri lnUrl, string tag, ILNURLCommunicator communicator) + { + return FetchInformation(lnUrl, tag, communicator, default); + } + + /// + public static async Task FetchInformation(Uri lnUrl, string tag, ILNURLCommunicator communicator, CancellationToken cancellationToken) { try { @@ -231,22 +253,20 @@ public static async Task FetchInformation(Uri lnUrl, string tag, HttpCli // ignored } - if (tag is null) tag = lnUrl.ParseQueryString().Get("tag"); + tag ??= lnUrl.ParseQueryString().Get("tag"); NameValueCollection queryString; - HttpResponseMessage response; string k1; switch (tag) { case null: - response = await httpClient.GetAsync(lnUrl, cancellationToken); - var content = await response.Content.ReadAsStringAsync(cancellationToken); + var content = await communicator.SendRequest(lnUrl, cancellationToken); using (var doc = JsonDocument.Parse(content)) { if (doc.RootElement.TryGetProperty("tag", out var tagToken)) { tag = tagToken.GetString(); - return DeserializeByTag(content, tag); + return DeserializeByTag(content, tag, lnUrl); } } @@ -257,12 +277,11 @@ public static async Task FetchInformation(Uri lnUrl, string tag, HttpCli var minWithdrawable = queryString.Get("minWithdrawable"); var maxWithdrawable = queryString.Get("maxWithdrawable"); var defaultDescription = queryString.Get("defaultDescription"); - var callback = queryString.Get("callback"); - if (k1 is null || minWithdrawable is null || maxWithdrawable is null || callback is null) + var callback = queryString.Get("callback") ?? lnUrl.ToString(); + if (k1 is null || minWithdrawable is null || maxWithdrawable is null) { - response = await httpClient.GetAsync(lnUrl, cancellationToken); - var withdrawContent = await response.Content.ReadAsStringAsync(cancellationToken); - return DeserializeByTag(withdrawContent, tag); + var withdrawContent = await communicator.SendRequest(lnUrl, cancellationToken); + return DeserializeByTag(withdrawContent, tag, lnUrl); } return new LNURLWithdrawRequest @@ -290,23 +309,33 @@ public static async Task FetchInformation(Uri lnUrl, string tag, HttpCli }; default: - response = await httpClient.GetAsync(lnUrl, cancellationToken); - var defaultContent = await response.Content.ReadAsStringAsync(cancellationToken); - return DeserializeByTag(defaultContent, tag); + var defaultContent = await communicator.SendRequest(lnUrl, cancellationToken); + return DeserializeByTag(defaultContent, tag, lnUrl); } } - private static object DeserializeByTag(string json, string tag) + private static object DeserializeByTag(string json, string tag, Uri lnUrl = null) { if (LNUrlStatusResponse.IsErrorResponse(json, out var errorResponse)) return errorResponse; - return tag switch + switch (tag) { - "channelRequest" => JsonSerializer.Deserialize(json, LNURLJsonOptions.Default), - "hostedChannelRequest" => JsonSerializer.Deserialize(json, LNURLJsonOptions.Default), - "withdrawRequest" => JsonSerializer.Deserialize(json, LNURLJsonOptions.Default), - "payRequest" => JsonSerializer.Deserialize(json, LNURLJsonOptions.Default), - _ => JsonDocument.Parse(json) - }; + case "channelRequest": + var channelRequest = JsonSerializer.Deserialize(json, LNURLJsonOptions.Default); + channelRequest.Callback ??= lnUrl; + return channelRequest; + case "hostedChannelRequest": + return JsonSerializer.Deserialize(json, LNURLJsonOptions.Default); + case "withdrawRequest": + var withdrawRequest = JsonSerializer.Deserialize(json, LNURLJsonOptions.Default); + withdrawRequest.Callback ??= lnUrl; + return withdrawRequest; + case "payRequest": + var payRequest = JsonSerializer.Deserialize(json, LNURLJsonOptions.Default); + payRequest.Callback ??= lnUrl; + return payRequest; + default: + return JsonDocument.Parse(json); + } } } diff --git a/LNURL.Core/LNURLChannelRequest.cs b/LNURL.Core/LNURLChannelRequest.cs index fd7c983..cb5bd07 100644 --- a/LNURL.Core/LNURLChannelRequest.cs +++ b/LNURL.Core/LNURLChannelRequest.cs @@ -49,7 +49,16 @@ public class LNURLChannelRequest /// /// Sends a channel open request to the service callback. /// - public async Task SendRequest(PubKey ourId, bool privateChannel, HttpClient httpClient, + public Task SendRequest(PubKey ourId, bool privateChannel, HttpClient httpClient, + CancellationToken cancellationToken = default) + { + return SendRequest(ourId, privateChannel, new HttpLNURLCommunicator(httpClient), cancellationToken); + } + + /// + /// Sends a channel open request using a custom transport. + /// + public async Task SendRequest(PubKey ourId, bool privateChannel, ILNURLCommunicator communicator, CancellationToken cancellationToken = default) { var url = Callback; @@ -59,15 +68,22 @@ public async Task SendRequest(PubKey ourId, bool privateChannel, HttpClient http LNURL.AppendPayloadToQuery(uriBuilder, "private", privateChannel ? "1" : "0"); url = new Uri(uriBuilder.ToString()); - var response = await httpClient.GetAsync(url, cancellationToken); - var content = await response.Content.ReadAsStringAsync(cancellationToken); + var content = await communicator.SendRequest(url, cancellationToken); if (LNUrlStatusResponse.IsErrorResponse(content, out var error)) throw new LNUrlException(error.Reason); } /// /// Sends a cancellation request for this channel request to the service callback. /// - public async Task CancelRequest(PubKey ourId, HttpClient httpClient, CancellationToken cancellationToken = default) + public Task CancelRequest(PubKey ourId, HttpClient httpClient, CancellationToken cancellationToken = default) + { + return CancelRequest(ourId, new HttpLNURLCommunicator(httpClient), cancellationToken); + } + + /// + /// Sends a cancellation request using a custom transport. + /// + public async Task CancelRequest(PubKey ourId, ILNURLCommunicator communicator, CancellationToken cancellationToken = default) { var url = Callback; var uriBuilder = new UriBuilder(url); @@ -76,8 +92,7 @@ public async Task CancelRequest(PubKey ourId, HttpClient httpClient, Cancellatio LNURL.AppendPayloadToQuery(uriBuilder, "cancel", "1"); url = new Uri(uriBuilder.ToString()); - var response = await httpClient.GetAsync(url, cancellationToken); - var content = await response.Content.ReadAsStringAsync(cancellationToken); + var content = await communicator.SendRequest(url, cancellationToken); if (LNUrlStatusResponse.IsErrorResponse(content, out var error)) throw new LNUrlException(error.Reason); } } diff --git a/LNURL.Core/LNURLPayRequest.cs b/LNURL.Core/LNURLPayRequest.cs index 4ed9af8..aa12fa9 100644 --- a/LNURL.Core/LNURLPayRequest.cs +++ b/LNURL.Core/LNURLPayRequest.cs @@ -31,7 +31,7 @@ public class LNURLPayRequest /// /// Gets or sets the callback URL to which the wallet sends the payment amount to receive a BOLT11 invoice. /// - [JsonProperty("callback")] + [JsonProperty("callback", NullValueHandling = NullValueHandling.Ignore)] [JsonConverter(typeof(UriJsonConverter))] [STJ.JsonPropertyName("callback")] public Uri Callback { get; set; } @@ -148,8 +148,20 @@ public static bool VerifyPayerData(LUD18PayerData payerFields, LUD18PayerDataRes /// Sends the second step of the LNURL-pay flow (LUD-06) by calling the service callback with the /// chosen amount, optional comment (LUD-12), and optional payer data (LUD-18). /// + public Task SendRequest(LightMoney amount, Network network, + HttpClient httpClient, string comment = null, LUD18PayerDataResponse payerData = null, + CancellationToken cancellationToken = default) + { + return SendRequest(amount, network, new HttpLNURLCommunicator(httpClient), comment, payerData, + cancellationToken); + } + + /// + /// Sends the second step of the LNURL-pay flow (LUD-06) using a custom transport. + /// public async Task SendRequest(LightMoney amount, Network network, - HttpClient httpClient, string comment = null, LUD18PayerDataResponse payerData = null, CancellationToken cancellationToken = default) + ILNURLCommunicator communicator, string comment = null, LUD18PayerDataResponse payerData = null, + CancellationToken cancellationToken = default) { var url = Callback; var uriBuilder = new UriBuilder(url); @@ -161,8 +173,7 @@ public async Task SendRequest(LightMoney amount HttpUtility.UrlEncode(System.Text.Json.JsonSerializer.Serialize(payerData, LNURLJsonOptions.Default))); url = new Uri(uriBuilder.ToString()); - var response = await httpClient.GetAsync(url, cancellationToken); - var content = await response.Content.ReadAsStringAsync(cancellationToken); + var content = await communicator.SendRequest(url, cancellationToken); if (LNUrlStatusResponse.IsErrorResponse(content, out var error)) throw new LNUrlException(error.Reason); var result = System.Text.Json.JsonSerializer.Deserialize(content, LNURLJsonOptions.Default); diff --git a/LNURL.Core/LNURLWithdrawRequest.cs b/LNURL.Core/LNURLWithdrawRequest.cs index 2bf3ee3..37a1c03 100644 --- a/LNURL.Core/LNURLWithdrawRequest.cs +++ b/LNURL.Core/LNURLWithdrawRequest.cs @@ -107,7 +107,16 @@ public Task SendRequest(string bolt11, HttpClient httpClien /// Sends a withdrawal request to the service callback with the specified BOLT11 invoice, /// optional PIN, and optional balance notification URL. /// - public async Task SendRequest(string bolt11, HttpClient httpClient, string pin = null, + public Task SendRequest(string bolt11, HttpClient httpClient, string pin = null, + Uri balanceNotify = null, CancellationToken cancellationToken = default) + { + return SendRequest(bolt11, new HttpLNURLCommunicator(httpClient), pin, balanceNotify, cancellationToken); + } + + /// + /// Sends a withdrawal request using a custom transport. + /// + public async Task SendRequest(string bolt11, ILNURLCommunicator communicator, string pin = null, Uri balanceNotify = null, CancellationToken cancellationToken = default) { var url = Callback; @@ -118,8 +127,7 @@ public async Task SendRequest(string bolt11, HttpClient htt if (pin != null) LNURL.AppendPayloadToQuery(uriBuilder, "pin", pin); url = new Uri(uriBuilder.ToString()); - var response = await httpClient.GetAsync(url, cancellationToken); - var content = await response.Content.ReadAsStringAsync(cancellationToken); + var content = await communicator.SendRequest(url, cancellationToken); return System.Text.Json.JsonSerializer.Deserialize(content, LNURLJsonOptions.Default); } diff --git a/LNURL.Tests/UnitTest1.cs b/LNURL.Tests/UnitTest1.cs index f829444..39aa379 100644 --- a/LNURL.Tests/UnitTest1.cs +++ b/LNURL.Tests/UnitTest1.cs @@ -1,14 +1,19 @@ using System; +using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using BTCPayServer.Lightning; using NBitcoin; using NBitcoin.Altcoins.Elements; using NBitcoin.Crypto; using NBitcoin.DataEncoders; +using NBitcoin.Secp256k1; using Newtonsoft.Json; +using NNostr.Client; +using NNostr.Client.Protocols; using Xunit; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -1085,5 +1090,292 @@ public void CreateOptionsReturnsNewInstance() } #endregion + + #region Nostr / ILNURLCommunicator Tests + + [Fact] + public void CanParseNostrUri() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var pubKey = key.CreateXOnlyPubKey(); + var nprofile = new NIP19.NosteProfileNote + { + PubKey = pubKey.ToHex(), + Relays = new[] { "wss://r.x.com" } + }; + var nprofileStr = nprofile.ToNIP19(); + var nostrUri = new Uri($"nostr:{nprofileStr}"); + + // Should round-trip through bech32 encoding + var bech32 = LNURL.EncodeUri(nostrUri, "payRequest", true); + var parsed = LNURL.Parse(bech32.ToString(), out var tag); + Assert.Equal(nostrUri, parsed); + } + + [Fact] + public void CanEncodeBech32NostrUri() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var nprofile = new NIP19.NosteProfileNote + { + PubKey = key.CreateXOnlyPubKey().ToHex(), + Relays = new[] { "wss://relay.example.com" } + }; + var nostrUri = new Uri($"nostr:{nprofile.ToNIP19()}"); + + var bech32 = LNURL.EncodeBech32(nostrUri); + Assert.StartsWith("lnurl1", bech32); + + // Decode back + var decoded = LNURL.Parse(bech32, out _); + Assert.Equal("nostr", decoded.Scheme); + } + + [Fact] + public void CanEncodeUriNostrScheme() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var nprofile = new NIP19.NosteProfileNote + { + PubKey = key.CreateXOnlyPubKey().ToHex(), + Relays = new[] { "wss://relay.example.com" } + }; + var nostrUri = new Uri($"nostr:{nprofile.ToNIP19()}"); + + // bech32 mode + var lnurl = LNURL.EncodeUri(nostrUri, "payRequest", true); + Assert.StartsWith("lightning:", lnurl.ToString()); + + // LUD-17 mode — should detect nprofile host and use nostr: scheme + var lud17 = LNURL.EncodeUri(nostrUri, "payRequest", false); + Assert.Equal("nostr", LNURL.Parse(lnurl.ToString(), out var tag).Scheme); + } + + [Fact] + public void LNURLNostrHelperGeneratesEndpoint() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var relays = new[] { new Uri("wss://relay1.example.com"), new Uri("wss://relay2.example.com") }; + + var helper = new LNURLNostrHelper(key, relays, + _ => Task.FromResult("{}")); + + var endpoint = helper.Endpoint; + Assert.Equal("nostr", endpoint.Scheme); + Assert.StartsWith("nprofile1", endpoint.Host); + + // Should produce a valid LNURL + var lnurl = helper.GetLNURL("payRequest"); + Assert.NotNull(lnurl); + } + + [Fact] + public void LNURLNostrHelperHasCorrectFilter() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var helper = new LNURLNostrHelper(key, new[] { new Uri("wss://relay.example.com") }, + _ => Task.FromResult("{}")); + + var filter = helper.Filter; + Assert.Contains(key.CreateXOnlyPubKey().ToHex(), filter.ReferencedPublicKeys); + } + + [Fact] + public async Task CallbackFallbackToLnUrl() + { + // Simulate a payRequest response with no callback field + var payRequestJson = JsonSerializer.Serialize(new + { + tag = "payRequest", + minSendable = 1000, + maxSendable = 10000000, + metadata = "[[\"text/plain\",\"test\"]]" + }); + + var lnUrl = new Uri("https://example.com/lnurl-pay"); + var communicator = new FakeCommunicator(payRequestJson); + + var result = await LNURL.FetchInformation(lnUrl, "payRequest", communicator, CancellationToken.None); + var payRequest = Assert.IsType(result); + + // Callback should fall back to the original lnUrl + Assert.Equal(lnUrl, payRequest.Callback); + } + + [Fact] + public async Task CallbackFallbackToLnUrlForWithdraw() + { + var withdrawJson = JsonSerializer.Serialize(new + { + tag = "withdrawRequest", + k1 = "abc123", + minWithdrawable = 1000, + maxWithdrawable = 50000, + defaultDescription = "test" + }); + + var lnUrl = new Uri("https://example.com/lnurl-withdraw"); + var communicator = new FakeCommunicator(withdrawJson); + + var result = await LNURL.FetchInformation(lnUrl, "withdrawRequest", communicator, CancellationToken.None); + var withdrawRequest = Assert.IsType(result); + + Assert.Equal(lnUrl, withdrawRequest.Callback); + } + + [Fact] + public async Task FetchInformationWithCommunicator() + { + var payJson = JsonSerializer.Serialize(new + { + tag = "payRequest", + callback = "https://example.com/callback", + minSendable = 1000, + maxSendable = 100000000, + metadata = "[[\"text/plain\",\"test service\"]]" + }); + + var communicator = new FakeCommunicator(payJson); + var result = await LNURL.FetchInformation( + new Uri("https://example.com/lnurl-pay"), null, communicator, CancellationToken.None); + + var payRequest = Assert.IsType(result); + Assert.Equal(new Uri("https://example.com/callback"), payRequest.Callback); + Assert.Equal(LightMoney.MilliSatoshis(1000), payRequest.MinSendable); + } + + [Fact] + public void CanParseNaddrNostrUri() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var pubKey = key.CreateXOnlyPubKey(); + var naddr = new NIP19.NostrAddressNote + { + Kind = (uint)NostrLNURLCommunicator.LnurlParameterEventKind, + Author = pubKey.ToHex(), + Identifier = Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes("payRequest")), + Relays = new[] { "wss://relay.example.com" } + }; + var naddrStr = naddr.ToNIP19(); + Assert.StartsWith("naddr1", naddrStr); + + var nostrUri = new UriBuilder("nostr", naddrStr).Uri; + + // bech32 round-trip + var bech32 = LNURL.EncodeUri(nostrUri, "payRequest", true); + var parsed = LNURL.Parse(bech32.ToString(), out var tag); + Assert.Equal("nostr", parsed.Scheme); + Assert.Contains("naddr1", parsed.ToString()); + } + + [Fact] + public void CanEncodeLud17NaddrUri() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var naddr = new NIP19.NostrAddressNote + { + Kind = (uint)NostrLNURLCommunicator.LnurlParameterEventKind, + Author = key.CreateXOnlyPubKey().ToHex(), + Identifier = Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes("payRequest")), + Relays = new[] { "wss://relay.example.com" } + }; + var nostrUri = new UriBuilder("nostr", naddr.ToNIP19()).Uri; + + // LUD-17 mode should produce lnurlp: scheme that parses back to nostr: + var lud17 = LNURL.EncodeUri(nostrUri, "payRequest", false); + var parsed = LNURL.Parse(lud17.ToString(), out var tag); + Assert.Equal("nostr", parsed.Scheme); + } + + [Fact] + public void LNURLNostrHelperGeneratesNaddrEndpoint() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var relays = new[] { new Uri("wss://relay1.example.com") }; + + var helper = new LNURLNostrHelper(key, relays, + _ => Task.FromResult("{}")); + + var naddrEndpoint = helper.GetNaddrEndpoint("payRequest"); + Assert.Equal("nostr", naddrEndpoint.Scheme); + Assert.StartsWith("naddr1", naddrEndpoint.Host.ToLowerInvariant()); + + // Should produce a valid LNURL + var lnurl = helper.GetNaddrLNURL("payRequest", true); + Assert.StartsWith("lightning:lnurl1", lnurl.ToString()); + } + + [Fact] + public async Task LNURLNostrHelperCreatesParameterEvent() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var relays = new[] { new Uri("wss://relay.example.com") }; + + var helper = new LNURLNostrHelper(key, relays, + _ => Task.FromResult("{}")); + + var paramsJson = JsonSerializer.Serialize(new + { + tag = "payRequest", + minSendable = 1000, + maxSendable = 100000000, + metadata = "[[\"text/plain\",\"test\"]]" + }); + + var evt = await helper.CreateParameterEvent(paramsJson, "payRequest"); + + Assert.Equal(NostrLNURLCommunicator.LnurlParameterEventKind, evt.Kind); + Assert.Equal(key.CreateXOnlyPubKey().ToHex(), evt.PublicKey); + Assert.Equal(paramsJson, evt.Content); + Assert.Contains(evt.Tags, t => t.TagIdentifier == "d" && t.Data.Contains("payRequest")); + Assert.NotNull(evt.Id); + Assert.NotNull(evt.Signature); + } + + [Fact] + public async Task FetchInformationWithNaddrCommunicator() + { + var key = NostrExtensions.ParseKey(RandomUtils.GetBytes(32)); + var relays = new[] { new Uri("wss://relay.example.com") }; + var helper = new LNURLNostrHelper(key, relays, + _ => Task.FromResult("{}")); + + var naddrUri = helper.GetNaddrEndpoint("payRequest"); + + var payJson = JsonSerializer.Serialize(new + { + tag = "payRequest", + minSendable = 1000, + maxSendable = 100000000, + metadata = "[[\"text/plain\",\"naddr test\"]]" + }); + + var communicator = new FakeCommunicator(payJson); + var result = await LNURL.FetchInformation(naddrUri, "payRequest", communicator, CancellationToken.None); + + var payRequest = Assert.IsType(result); + Assert.Equal(LightMoney.MilliSatoshis(1000), payRequest.MinSendable); + // Callback should fall back to the naddr URI + Assert.Equal(naddrUri, payRequest.Callback); + } + + /// + /// A test double for that returns a fixed JSON response. + /// + private class FakeCommunicator : ILNURLCommunicator + { + private readonly string _response; + public Uri LastRequestedUri { get; private set; } + + public FakeCommunicator(string response) => _response = response; + + public Task SendRequest(Uri lnurl, CancellationToken cancellationToken = default) + { + LastRequestedUri = lnurl; + return Task.FromResult(_response); + } + } + + #endregion } } diff --git a/LNURL/LNURL.csproj b/LNURL/LNURL.csproj index b47a284..c63eab0 100644 --- a/LNURL/LNURL.csproj +++ b/LNURL/LNURL.csproj @@ -23,6 +23,7 @@ + diff --git a/LNURL/LNURLCompositeCommunicator.cs b/LNURL/LNURLCompositeCommunicator.cs new file mode 100644 index 0000000..eab9be8 --- /dev/null +++ b/LNURL/LNURLCompositeCommunicator.cs @@ -0,0 +1,36 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using NNostr.Client; + +namespace LNURL; + +/// +/// An that routes requests by URI scheme: +/// nostr: URIs are handled via Nostr relays, all others via HTTP. +/// +public class LNURLCompositeCommunicator : ILNURLCommunicator +{ + private readonly HttpLNURLCommunicator _httpCommunicator; + private readonly NostrLNURLCommunicator _nostrCommunicator; + + /// + /// Initializes a new composite communicator. + /// + /// The HTTP client for HTTP-based LNURL requests. If null, a new instance is created. + /// An optional Nostr client for relay-based LNURL requests. + public LNURLCompositeCommunicator(HttpClient httpClient = null, NostrClient nostrClient = null) + { + _httpCommunicator = new HttpLNURLCommunicator(httpClient); + _nostrCommunicator = new NostrLNURLCommunicator(nostrClient); + } + + /// + public Task SendRequest(Uri lnurl, CancellationToken cancellationToken = default) + { + return lnurl.Scheme == "nostr" + ? _nostrCommunicator.SendRequest(lnurl, cancellationToken) + : _httpCommunicator.SendRequest(lnurl, cancellationToken); + } +} diff --git a/LNURL/LNURLNostrHelper.cs b/LNURL/LNURLNostrHelper.cs new file mode 100644 index 0000000..90985a4 --- /dev/null +++ b/LNURL/LNURLNostrHelper.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Web; +using NBitcoin.Secp256k1; +using NNostr.Client; +using NNostr.Client.Protocols; + +namespace LNURL; + +/// +/// Server-side helper for merchants to expose LNURL endpoints over Nostr relays. +/// Handles incoming NIP-17 (Gift Wrap / NIP-59) LNURL requests and produces wrapped responses. +/// +public class LNURLNostrHelper +{ + private readonly ECPrivKey _key; + private ECXOnlyPubKey PubKey => _key.CreateXOnlyPubKey(); + private readonly Uri[] _relays; + private readonly Func> _handleData; + private readonly IEnumerable> _queryParams; + + /// + /// Gets the nostr: endpoint URI (nprofile-based) for this helper. + /// + public Uri Endpoint + { + get + { + var pubKey = PubKey; + string nip19; + if (_relays?.Any() is not true) + { + nip19 = pubKey.ToNIP19(); + } + else + { + nip19 = new NIP19.NosteProfileNote + { + PubKey = pubKey.ToHex(), + Relays = _relays.Select(r => r.ToString()).ToArray() + }.ToNIP19(); + } + + var uriBuilder = new UriBuilder("nostr", nip19); + if (_queryParams?.Any() is true) + { + foreach (var param in _queryParams) + { + LNURL.AppendPayloadToQuery(uriBuilder, param.Key, param.Value); + } + } + + return uriBuilder.Uri; + } + } + + /// + /// Gets the Nostr subscription filter for listening to incoming LNURL requests + /// addressed to this helper. Listens for Kind 1059 (Gift Wrap) events. + /// + public NostrSubscriptionFilter Filter => new() + { + ReferencedPublicKeys = new[] { PubKey.ToHex() }, + Kinds = new[] { 1059 } + }; + + /// + /// Gets an LNURL-encoded URI for this helper's Nostr endpoint. + /// + /// The LNURL tag (e.g. "payRequest", "withdrawRequest"). + /// If true, returns a bech32-encoded LNURL; otherwise a LUD-17 scheme URI. + public Uri GetLNURL(string tag, bool bech32 = false) => LNURL.EncodeUri(Endpoint, tag, bech32); + + /// + /// Initializes a new LNURL Nostr helper for a merchant. + /// + /// The merchant's Nostr private key. + /// Nostr relay URIs where the merchant listens for requests. + /// + /// Callback that processes incoming LNURL query parameters and returns a raw JSON response string. + /// + /// Optional additional query parameters to include in the endpoint URI. + public LNURLNostrHelper(ECPrivKey key, Uri[] relays, + Func> handleData, + IEnumerable> queryParams = null) + { + _key = key; + _relays = relays; + _handleData = handleData; + _queryParams = queryParams; + } + + /// + /// Creates a Kind 31120 parameterized replaceable event containing LNURL parameters. + /// Publish this event to your relays so wallets using naddr can fetch it directly. + /// + /// The LNURL parameters JSON (same format as the HTTP response). + /// The LNURL tag (e.g. "payRequest") — used as the d tag value. + /// A signed Kind 31120 event ready to publish. + public async Task CreateParameterEvent(string parametersJson, string tag) + { + var evt = new NostrEvent + { + Kind = NostrLNURLCommunicator.LnurlParameterEventKind, + PublicKey = PubKey.ToHex(), + Content = parametersJson, + CreatedAt = DateTimeOffset.UtcNow, + Tags = new List + { + new() { TagIdentifier = "d", Data = new List { tag } } + } + }; + await evt.ComputeIdAndSignAsync(_key); + return evt; + } + + /// + /// Gets an naddr-based nostr: URI for this helper, pointing to a Kind 31120 + /// parameterized replaceable event with the given tag as the d value. + /// + /// The LNURL tag (e.g. "payRequest"). + public Uri GetNaddrEndpoint(string tag) + { + var naddr = new NIP19.NostrAddressNote + { + Kind = (uint)NostrLNURLCommunicator.LnurlParameterEventKind, + Author = PubKey.ToHex(), + Identifier = Convert.ToHexString(Encoding.UTF8.GetBytes(tag)), + Relays = _relays?.Select(r => r.ToString()).ToArray() ?? Array.Empty() + }; + return new UriBuilder("nostr", naddr.ToNIP19()).Uri; + } + + /// + /// Gets an LNURL-encoded URI using naddr addressing. + /// + /// The LNURL tag (e.g. "payRequest"). + /// If true, returns a bech32-encoded LNURL; otherwise a LUD-17 scheme URI. + public Uri GetNaddrLNURL(string tag, bool bech32 = false) => LNURL.EncodeUri(GetNaddrEndpoint(tag), tag, bech32); + + /// + /// Handles an incoming NIP-17 Gift Wrap event containing an LNURL request. + /// Unwraps the NIP-59 layers, processes the request via the callback, + /// and returns a Gift Wrapped response event to publish. + /// + /// The incoming Kind 1059 (Gift Wrap) Nostr event. + /// A Gift Wrapped response event to publish, or null if the event cannot be processed. + public async Task HandleIncomingRequest(NostrEvent nostrEvent) + { + NostrEvent innerEvent; + try + { + innerEvent = await NIP17.Open(nostrEvent, _key); + } + catch + { + return null; + } + + var senderPubKey = NostrExtensions.ParsePubKey(innerEvent.PublicKey); + var content = innerEvent.Content; + + var values = HttpUtility.ParseQueryString(content ?? string.Empty); + var response = await _handleData(values); + + // Create a Kind 14 DM response + var responseDm = new NostrEvent + { + Content = response, + PublicKey = PubKey.ToHex(), + Kind = 14, + CreatedAt = DateTimeOffset.Now, + Tags = new List + { + new() { TagIdentifier = "p", Data = new List { innerEvent.PublicKey } } + } + }; + + // Wrap using NIP-17 (Seal + Gift Wrap) + return await NIP17.Create(responseDm, _key, senderPubKey, responseDm.Tags.ToArray()); + } +} diff --git a/LNURL/NostrLNURLCommunicator.cs b/LNURL/NostrLNURLCommunicator.cs new file mode 100644 index 0000000..3d1c35e --- /dev/null +++ b/LNURL/NostrLNURLCommunicator.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using NBitcoin; +using NBitcoin.Secp256k1; +using NNostr.Client; +using NNostr.Client.Protocols; + +namespace LNURL; + +/// +/// An that performs LNURL requests over Nostr relays. +/// Supports both naddr (direct fetch of Kind 31120 replaceable events) and +/// nprofile (NIP-17 private DM exchange) addressing modes. +/// +public class NostrLNURLCommunicator : ILNURLCommunicator +{ + private readonly NostrClient _nostrClient; + + /// + /// Initializes a new instance with an existing . + /// + public NostrLNURLCommunicator(NostrClient nostrClient) + { + _nostrClient = nostrClient; + } + + /// + /// Initializes a new instance that connects to the specified relay URI. + /// + public NostrLNURLCommunicator(Uri relayUri) + { + _nostrClient = new NostrClient(relayUri); + } + + /// + /// The Nostr event kind for LNURL parameter events (parameterized replaceable). + /// + public const int LnurlParameterEventKind = 31120; + + /// + public async Task SendRequest(Uri lnurl, CancellationToken cancellationToken = default) + { + var note = lnurl.Host.FromNIP19Note(); + switch (note) + { + case NIP19.NostrAddressNote addressNote: + { + var client = _nostrClient ?? + new NostrClient(new Uri(addressNote.Relays.First())); + return await FetchReplaceable(client, addressNote, cancellationToken); + } + case NIP19.NosteProfileNote profileNote: + { + var client = _nostrClient ?? + new NostrClient(new Uri(profileNote.Relays.First())); + return await SendViaNip17(client, profileNote, lnurl.Query, cancellationToken); + } + default: + throw new NotSupportedException( + "The nostr: URI must contain an naddr or nprofile."); + } + } + + private static async Task FetchReplaceable(NostrClient nostrClient, + NIP19.NostrAddressNote addressNote, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + + await nostrClient.CreateSubscription("lnurl-params", + new[] + { + new NostrSubscriptionFilter + { + Kinds = new[] { (int)addressNote.Kind }, + Authors = new[] { addressNote.Author }, + ExtensionData = new Dictionary + { + ["#d"] = JsonSerializer.SerializeToElement(new[] { Encoding.UTF8.GetString(Convert.FromHexString(addressNote.Identifier)) }) + } + } + }, cancellationToken); + + nostrClient.EventsReceived += (_, args) => + { + foreach (var evt in args.events) + { + tcs.TrySetResult(evt.Content); + } + }; + + nostrClient.EoseReceived += (_, _) => + { + tcs.TrySetException( + new LNUrlException("No LNURL parameter event found on relay.")); + }; + + await nostrClient.ConnectAndWaitUntilConnected(cancellationToken, cancellationToken); + _ = nostrClient.ListenForMessages(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(15)); + cts.Token.Register(() => tcs.TrySetCanceled(cts.Token)); + + return await tcs.Task; + } + + private static async Task SendViaNip17(NostrClient nostrClient, + NIP19.NosteProfileNote nostrProfileNote, string content, CancellationToken cancellationToken) + { + var tmpKey = ECPrivKey.Create(RandomUtils.GetBytes(32)); + var tmpPubKey = tmpKey.CreateXOnlyPubKey(); + var recipientPubKey = NostrExtensions.ParsePubKey(nostrProfileNote.PubKey); + + var dm = new NostrEvent + { + Content = content ?? string.Empty, + Kind = 14, + PublicKey = tmpPubKey.ToHex(), + CreatedAt = DateTimeOffset.Now, + Tags = new List + { + new() { TagIdentifier = "p", Data = new List { nostrProfileNote.PubKey } } + } + }; + + var giftWrap = await NIP17.Create(dm, tmpKey, recipientPubKey, dm.Tags.ToArray()); + + var tcs = new TaskCompletionSource(); + + await nostrClient.CreateSubscription("lnurl-response", + new[] + { + new NostrSubscriptionFilter + { + ReferencedPublicKeys = new[] { tmpPubKey.ToHex() }, + Kinds = new[] { 1059 } + } + }, cancellationToken); + + nostrClient.EventsReceived += async (_, args) => + { + foreach (var evt in args.events) + { + try + { + var innerEvent = await NIP17.Open(evt, tmpKey); + tcs.TrySetResult(innerEvent.Content); + } + catch + { + } + } + }; + + await nostrClient.ConnectAndWaitUntilConnected(cancellationToken, cancellationToken); + _ = nostrClient.ListenForMessages(); + + await nostrClient.PublishEvent(giftWrap, cancellationToken); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + cts.Token.Register(() => tcs.TrySetCanceled(cts.Token)); + + return await tcs.Task; + } +} diff --git a/LNURL/TypeForwarders.cs b/LNURL/TypeForwarders.cs index b9a0860..ab5138b 100644 --- a/LNURL/TypeForwarders.cs +++ b/LNURL/TypeForwarders.cs @@ -15,6 +15,8 @@ [assembly: TypeForwardedTo(typeof(LNUrlException))] [assembly: TypeForwardedTo(typeof(Extensions))] [assembly: TypeForwardedTo(typeof(BoltCardHelper))] +[assembly: TypeForwardedTo(typeof(ILNURLCommunicator))] +[assembly: TypeForwardedTo(typeof(HttpLNURLCommunicator))] // JsonConverters (Newtonsoft) [assembly: TypeForwardedTo(typeof(UriJsonConverter))]