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))]