Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions LNURL.Core/HttpLNURLCommunicator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace LNURL;

/// <summary>
/// An <see cref="ILNURLCommunicator"/> that performs LNURL requests over HTTP(S)
/// using an <see cref="HttpClient"/>.
/// </summary>
public class HttpLNURLCommunicator : ILNURLCommunicator
{
private readonly HttpClient _httpClient;

/// <summary>
/// Initializes a new instance with the specified <see cref="HttpClient"/>.
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests. If <c>null</c>, a new instance is created.</param>
public HttpLNURLCommunicator(HttpClient httpClient = null)
{
_httpClient = httpClient ?? new HttpClient();
}

/// <inheritdoc />
public async Task<string> SendRequest(Uri lnurl, CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync(lnurl, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}
20 changes: 20 additions & 0 deletions LNURL.Core/ILNURLCommunicator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace LNURL;

/// <summary>
/// Abstracts the transport layer for LNURL protocol communication,
/// enabling LNURL flows over HTTP, Nostr, or other transports.
/// </summary>
public interface ILNURLCommunicator
{
/// <summary>
/// Sends a request to the given LNURL endpoint and returns the raw JSON response.
/// </summary>
/// <param name="lnurl">The endpoint URI (may be an HTTP URL, <c>nostr:</c> URI, etc.).</param>
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
/// <returns>The raw JSON response string from the endpoint.</returns>
Task<string> SendRequest(Uri lnurl, CancellationToken cancellationToken = default);
}
22 changes: 19 additions & 3 deletions LNURL.Core/LNAuthRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,22 @@ public enum LNAuthRequestAction
/// <summary>
/// Sends the signed challenge and public key to the LNURL-auth service to complete authentication.
/// </summary>
public async Task<LNUrlStatusResponse> SendChallenge(ECDSASignature sig, PubKey key, HttpClient httpClient, CancellationToken cancellationToken = default)
public Task<LNUrlStatusResponse> SendChallenge(ECDSASignature sig, PubKey key, HttpClient httpClient, CancellationToken cancellationToken = default)
{
return SendChallenge(sig, key, new HttpLNURLCommunicator(httpClient), cancellationToken);
}

/// <summary>
/// Sends the signed challenge using a custom <see cref="ILNURLCommunicator"/> transport.
/// </summary>
public async Task<LNUrlStatusResponse> 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<LNUrlStatusResponse>(content, LNURLJsonOptions.Default);
}
Expand All @@ -87,6 +94,15 @@ public Task<LNUrlStatusResponse> SendChallenge(Key key, HttpClient httpClient, C
return SendChallenge(sig, key.PubKey, httpClient, cancellationToken);
}

/// <summary>
/// Signs the <see cref="K1"/> challenge with the given key and sends the result using a custom transport.
/// </summary>
public Task<LNUrlStatusResponse> SendChallenge(Key key, ILNURLCommunicator communicator, CancellationToken cancellationToken = default)
{
var sig = SignChallenge(key);
return SendChallenge(sig, key.PubKey, communicator, cancellationToken);
}

/// <summary>
/// Signs this request's <see cref="K1"/> challenge with the given private key.
/// </summary>
Expand Down
83 changes: 56 additions & 27 deletions LNURL.Core/LNURL.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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. ");
}
Expand All @@ -98,8 +101,10 @@ public static Uri Parse(string lnurl, out string tag)
/// </exception>
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()));
Expand All @@ -114,8 +119,10 @@ public static string EncodeBech32(Uri serviceUrl)
/// <returns>A <see cref="Uri"/> in the chosen encoding format.</returns>
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);
Expand Down Expand Up @@ -221,6 +228,21 @@ public static Task<object> FetchInformation(Uri lnUrl, string tag, HttpClient ht
/// Supports fast withdraw (LUD-03 query-string parameters) and LNURL-auth (LUD-04) inline parsing.
/// </summary>
public static async Task<object> FetchInformation(Uri lnUrl, string tag, HttpClient httpClient, CancellationToken cancellationToken)
{
return await FetchInformation(lnUrl, tag, new HttpLNURLCommunicator(httpClient), cancellationToken);
}

/// <summary>
/// Fetches LNURL endpoint information using a custom <see cref="ILNURLCommunicator"/> transport.
/// This enables LNURL flows over Nostr or other non-HTTP transports.
/// </summary>
public static Task<object> FetchInformation(Uri lnUrl, string tag, ILNURLCommunicator communicator)
{
return FetchInformation(lnUrl, tag, communicator, default);
}

/// <inheritdoc cref="FetchInformation(Uri, string, ILNURLCommunicator)"/>
public static async Task<object> FetchInformation(Uri lnUrl, string tag, ILNURLCommunicator communicator, CancellationToken cancellationToken)
{
try
{
Expand All @@ -231,22 +253,20 @@ public static async Task<object> 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);
}
}

Expand All @@ -257,12 +277,11 @@ public static async Task<object> 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
Expand Down Expand Up @@ -290,23 +309,33 @@ public static async Task<object> 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<LNURLChannelRequest>(json, LNURLJsonOptions.Default),
"hostedChannelRequest" => JsonSerializer.Deserialize<LNURLHostedChannelRequest>(json, LNURLJsonOptions.Default),
"withdrawRequest" => JsonSerializer.Deserialize<LNURLWithdrawRequest>(json, LNURLJsonOptions.Default),
"payRequest" => JsonSerializer.Deserialize<LNURLPayRequest>(json, LNURLJsonOptions.Default),
_ => JsonDocument.Parse(json)
};
case "channelRequest":
var channelRequest = JsonSerializer.Deserialize<LNURLChannelRequest>(json, LNURLJsonOptions.Default);
channelRequest.Callback ??= lnUrl;
return channelRequest;
case "hostedChannelRequest":
return JsonSerializer.Deserialize<LNURLHostedChannelRequest>(json, LNURLJsonOptions.Default);
case "withdrawRequest":
var withdrawRequest = JsonSerializer.Deserialize<LNURLWithdrawRequest>(json, LNURLJsonOptions.Default);
withdrawRequest.Callback ??= lnUrl;
return withdrawRequest;
case "payRequest":
var payRequest = JsonSerializer.Deserialize<LNURLPayRequest>(json, LNURLJsonOptions.Default);
payRequest.Callback ??= lnUrl;
return payRequest;
default:
return JsonDocument.Parse(json);
}
}
}
27 changes: 21 additions & 6 deletions LNURL.Core/LNURLChannelRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ public class LNURLChannelRequest
/// <summary>
/// Sends a channel open request to the service callback.
/// </summary>
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);
}

/// <summary>
/// Sends a channel open request using a custom <see cref="ILNURLCommunicator"/> transport.
/// </summary>
public async Task SendRequest(PubKey ourId, bool privateChannel, ILNURLCommunicator communicator,
CancellationToken cancellationToken = default)
{
var url = Callback;
Expand All @@ -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);
}

/// <summary>
/// Sends a cancellation request for this channel request to the service callback.
/// </summary>
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);
}

/// <summary>
/// Sends a cancellation request using a custom <see cref="ILNURLCommunicator"/> transport.
/// </summary>
public async Task CancelRequest(PubKey ourId, ILNURLCommunicator communicator, CancellationToken cancellationToken = default)
{
var url = Callback;
var uriBuilder = new UriBuilder(url);
Expand All @@ -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);
}
}
19 changes: 15 additions & 4 deletions LNURL.Core/LNURLPayRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
/// <summary>
/// Gets or sets the callback URL to which the wallet sends the payment amount to receive a BOLT11 invoice.
/// </summary>
[JsonProperty("callback")]
[JsonProperty("callback", NullValueHandling = NullValueHandling.Ignore)]
[JsonConverter(typeof(UriJsonConverter))]
[STJ.JsonPropertyName("callback")]
public Uri Callback { get; set; }
Expand Down Expand Up @@ -105,7 +105,7 @@
/// </summary>
[JsonProperty("nostrPubkey", NullValueHandling = NullValueHandling.Ignore)]
[STJ.JsonPropertyName("nostrPubkey")]
public string? NostrPubkey { get; set; }

Check warning on line 108 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

/// <summary>
/// Gets or sets whether this service supports Nostr zap receipts (NIP-57).
Expand Down Expand Up @@ -148,8 +148,20 @@
/// 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).
/// </summary>
public Task<LNURLPayRequestCallbackResponse> 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);
}

/// <summary>
/// Sends the second step of the LNURL-pay flow (LUD-06) using a custom <see cref="ILNURLCommunicator"/> transport.
/// </summary>
public async Task<LNURLPayRequestCallbackResponse> 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);
Expand All @@ -161,8 +173,7 @@
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<LNURLPayRequestCallbackResponse>(content, LNURLJsonOptions.Default);
Expand Down Expand Up @@ -192,19 +203,19 @@
{
[JsonProperty("name", DefaultValueHandling = DefaultValueHandling.Ignore)]
[STJ.JsonPropertyName("name")]
public PayerDataField Name { get; set; }

Check warning on line 206 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'LNURLPayRequest.LUD18PayerData.Name'

[JsonProperty("pubkey", DefaultValueHandling = DefaultValueHandling.Ignore)]
[STJ.JsonPropertyName("pubkey")]
public PayerDataField Pubkey { get; set; }

Check warning on line 210 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'LNURLPayRequest.LUD18PayerData.Pubkey'

[JsonProperty("email", DefaultValueHandling = DefaultValueHandling.Ignore)]
[STJ.JsonPropertyName("email")]
public PayerDataField Email { get; set; }

Check warning on line 214 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'LNURLPayRequest.LUD18PayerData.Email'

[JsonProperty("auth", DefaultValueHandling = DefaultValueHandling.Ignore)]
[STJ.JsonPropertyName("auth")]
public AuthPayerDataField Auth { get; set; }

Check warning on line 218 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'LNURLPayRequest.LUD18PayerData.Auth'
}

/// <summary>
Expand All @@ -214,16 +225,16 @@
{
[JsonProperty("name", DefaultValueHandling = DefaultValueHandling.Ignore)]
[STJ.JsonPropertyName("name")]
public string Name { get; set; }

Check warning on line 228 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'LNURLPayRequest.LUD18PayerDataResponse.Name'

[JsonProperty("pubkey", DefaultValueHandling = DefaultValueHandling.Ignore)]
[JsonConverter(typeof(PubKeyJsonConverter))]
[STJ.JsonPropertyName("pubkey")]
public PubKey Pubkey { get; set; }

Check warning on line 233 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'LNURLPayRequest.LUD18PayerDataResponse.Pubkey'

[JsonProperty("email", DefaultValueHandling = DefaultValueHandling.Ignore)]
[STJ.JsonPropertyName("email")]
public string Email { get; set; }

Check warning on line 237 in LNURL.Core/LNURLPayRequest.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'LNURLPayRequest.LUD18PayerDataResponse.Email'

[JsonProperty("auth", DefaultValueHandling = DefaultValueHandling.Ignore)]
[STJ.JsonPropertyName("auth")]
Expand Down
14 changes: 11 additions & 3 deletions LNURL.Core/LNURLWithdrawRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,16 @@ public Task<LNUrlStatusResponse> 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.
/// </summary>
public async Task<LNUrlStatusResponse> SendRequest(string bolt11, HttpClient httpClient, string pin = null,
public Task<LNUrlStatusResponse> SendRequest(string bolt11, HttpClient httpClient, string pin = null,
Uri balanceNotify = null, CancellationToken cancellationToken = default)
{
return SendRequest(bolt11, new HttpLNURLCommunicator(httpClient), pin, balanceNotify, cancellationToken);
}

/// <summary>
/// Sends a withdrawal request using a custom <see cref="ILNURLCommunicator"/> transport.
/// </summary>
public async Task<LNUrlStatusResponse> SendRequest(string bolt11, ILNURLCommunicator communicator, string pin = null,
Uri balanceNotify = null, CancellationToken cancellationToken = default)
{
var url = Callback;
Expand All @@ -118,8 +127,7 @@ public async Task<LNUrlStatusResponse> 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<LNUrlStatusResponse>(content, LNURLJsonOptions.Default);
}
Expand Down
Loading
Loading