Add LUD-21 (LNURL-pay Verify) support (#7250)
What changed, and why it matters
This commit adds a new public feature to BTCPay Server called LUD-21, which lets anyone check whether a Lightning Network payment has settled by knowing its payment hash. The feature is enabled by default. The code went through several review rounds that fixed information-leakage issues, race conditions, and input-validation problems. The final version appears reasonably hardened, but because it intentionally exposes invoice settlement status and the payment preimage to unauthenticated callers, it carries a real privacy and operational risk if a merchant does not realize it is on by default.
Operators should review whether they need LUD-21 and consider disabling it if exposing settlement/preimage data to unauthenticated third parties is undesirable. The project should add rate limiting to /lnurlp/verify/{paymentHash} promptly, because the removal of rate limiting was left as a follow-up. A security note in release notes explaining the new default-on public endpoint would help merchants make an informed choice.
Security signals we found
New unauthenticated GET endpoint exposes invoice settlement status and Lightning preimage for a known payment hash
Feature is enabled by default (LUD21Enabled = true) in LNURL payment method config
Payment hash is indexed in AddressInvoices and exposed through callback verify URL
Multiple review iterations addressed information disclosure (uniform "Not found" responses), race conditions (UPSERT instead of check-then-insert), and input validation (64-hex guard)
Cross-store isolation is enforced by looking up the invoice first and then validating the store's LUD-21 setting
Preimage is returned only when invoice status is Settled or Processing
Rate limiting was added and then explicitly removed, to be discussed in a follow-up
Evidence from the diff
The change implements the LNURL-pay LUD-21 verify endpoint. It adds GET /lnurlp/verify/{paymentHash}, returns {status, settled, preimage, pr} for a matching invoice, and includes a verify URL in LNURL-pay callback responses when LUD21Enabled is true (default true). Payment hashes are indexed via AddressInvoices using INSERT … ON CONFLICT DO NOTHING. The endpoint validates that paymentHash is exactly 64 hex characters, normalizes to lowercase, looks up the invoice through AddressInvoices, confirms the store has LUD-21 enabled, and verifies the hash matches the prompt details before returning the preimage only for settled/processing invoices. Review feedback removed the username from the route, standardized error reasons to “Not found”, removed antiforgery token exemption, and added cross-store isolation by resolving the store from the invoice.
Changed components
BTCPayServer/Controllers/UILNURLController.csBTCPayServer/Services/Invoices/InvoiceRepository.csBTCPayServer/Payments/LNURLPay/LNURLPaymentMethodConfig.csBTCPayServer/Controllers/UIStoresController.LightningLike.csBTCPayServer/Views/UIStores/LightningSettings.cshtmlBTCPayServer/Models/StoreViewModels/LightningSettingsViewModel.csBTCPayServer/wwwroot/swagger/v1/swagger.template.stores-payment-methods.lnurl.jsonBTCPayServer.Tests/LightningTests.csInspect captured patch +233 / −12
diff --git a/BTCPayServer.Tests/LightningTests.cs b/BTCPayServer.Tests/LightningTests.cs
index 7c4d5b8..d0a6a28 100644
--- a/BTCPayServer.Tests/LightningTests.cs
+++ b/BTCPayServer.Tests/LightningTests.cs
@@ -955,4 +955,105 @@ public class LightningTests(ITestOutputHelper testOutputHelper) : UnitTestBase(t
Assert.Null(conf["useBech32Scheme"]); // default stripped
#pragma warning restore CS0618 // Type or member is obsolete
}
+
+ [Fact(Timeout = 60 * 20 * 1000)]
+ [Trait("Integration", "Integration")]
+ [Trait("Lightning", "Lightning")]
+ public async Task CanUseLUD21VerifyEndpoint()
+ {
+ using var tester = CreateServerTester();
+ tester.ActivateLightning();
+ await tester.StartAsync();
+ await tester.EnsureChannelsSetup();
+ var user = tester.NewAccount();
+ await user.GrantAccessAsync(true);
+ var client = await user.CreateClient(Policies.Unrestricted);
+
+ // Enable LNURL and Lightning
+ var methods = await client.GetStorePaymentMethods(user.StoreId);
+ await user.RegisterLightningNodeAsync("BTC", false);
+
+ // Set up a Lightning Address
+ var username = Guid.NewGuid().ToString("n").Substring(0, 8);
+ await client.AddOrUpdateStoreLightningAddress(user.StoreId, username,
+ new LightningAddressData());
+
+ // Verify endpoint returns 404 for unknown payment hash
+ var fakeHash = "0000000000000000000000000000000000000000000000000000000000000000";
+ var response = await tester.PayTester.HttpClient.GetAsync(
+ $"/lnurlp/verify/{fakeHash}");
+ Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
+
+ // Create an invoice via LNURL-pay flow to get a real payment hash.
+ // The Lightning Address resolver lives at /.well-known/lnurlp/{username}
+ // (LUD-16). The bare /lnurlp/{username} path has no route.
+ var lnurlResponse = await tester.PayTester.HttpClient.GetAsync(
+ $"/.well-known/lnurlp/{username}");
+ Assert.Equal(System.Net.HttpStatusCode.OK, lnurlResponse.StatusCode);
+ var lnurlPayRequest = JObject.Parse(await lnurlResponse.Content.ReadAsStringAsync());
+ var callback = lnurlPayRequest["callback"]?.ToString();
+ Assert.NotNull(callback);
+
+ // Make the callback with minimum amount to create a Lightning invoice
+ var minSendable = lnurlPayRequest["minSendable"]?.Value<long>() ?? 1000;
+ var callbackUri = new Uri(callback);
+ var callbackPath = callbackUri.PathAndQuery;
+ var separator = callbackPath.Contains('?') ? "&" : "?";
+ var callbackResponse = await tester.PayTester.HttpClient.GetAsync(
+ $"{callbackPath}{separator}amount={minSendable}");
+ Assert.Equal(System.Net.HttpStatusCode.OK, callbackResponse.StatusCode);
+ var callbackResult = JObject.Parse(await callbackResponse.Content.ReadAsStringAsync());
+
+ // Check if verify URL is present in callback response
+ var verifyUrl = callbackResult["verify"]?.ToString();
+ Assert.NotNull(verifyUrl);
+
+ // Extract payment hash from verify URL
+ var verifyUri = new Uri(verifyUrl);
+ var verifyPath = verifyUri.PathAndQuery;
+
+ // Call verify endpoint - invoice should exist but not be settled yet
+ var verifyResponse = await tester.PayTester.HttpClient.GetAsync(verifyPath);
+ Assert.Equal(System.Net.HttpStatusCode.OK, verifyResponse.StatusCode);
+ var verifyResult = JObject.Parse(await verifyResponse.Content.ReadAsStringAsync());
+ Assert.Equal("OK", verifyResult["status"]?.ToString());
+ Assert.False(verifyResult["settled"]?.Value<bool>());
+ var preimageToken = verifyResult["preimage"];
+ Assert.True(preimageToken is null || preimageToken.Type == JTokenType.Null,
+ "Expected preimage to be null or absent for an unsettled invoice");
+ Assert.NotNull(verifyResult["pr"]?.ToString());
+
+ // Repeat the callback with the SAME amount. Exercises the idempotent flush
+ // branch in UpdatePrompt(trackedDestinations): the AddressInvoices row for
+ // the payment hash already exists, so the flush loop should take the
+ // "existing is not null" path without throwing or duplicating rows, and
+ // the verify endpoint should still resolve the hash afterward.
+ var callbackResponse2 = await tester.PayTester.HttpClient.GetAsync(
+ $"{callbackPath}{separator}amount={minSendable}");
+ Assert.Equal(System.Net.HttpStatusCode.OK, callbackResponse2.StatusCode);
+ var verifyResponse2 = await tester.PayTester.HttpClient.GetAsync(verifyPath);
+ Assert.Equal(System.Net.HttpStatusCode.OK, verifyResponse2.StatusCode);
+
+ // Repeat the callback with a DIFFERENT amount. UpdatePrompt should persist
+ // the new prompt blob AND index the new payment hash in AddressInvoices so
+ // the new verify URL resolves.
+ var newAmount = minSendable * 2;
+ var callbackResponse3 = await tester.PayTester.HttpClient.GetAsync(
+ $"{callbackPath}{separator}amount={newAmount}");
+ Assert.Equal(System.Net.HttpStatusCode.OK, callbackResponse3.StatusCode);
+ var callbackResult3 = JObject.Parse(await callbackResponse3.Content.ReadAsStringAsync());
+ var verifyUrl3 = callbackResult3["verify"]?.ToString();
+ Assert.NotNull(verifyUrl3);
+ var verifyPath3 = new Uri(verifyUrl3).PathAndQuery;
+ var verifyResponse3 = await tester.PayTester.HttpClient.GetAsync(verifyPath3);
+ Assert.Equal(System.Net.HttpStatusCode.OK, verifyResponse3.StatusCode);
+
+ // Malformed paymentHash (non-hex / wrong length) must be rejected at the
+ // 64-hex guard before any DB lookup, returning Reason "Not found".
+ var malformedResponse = await tester.PayTester.HttpClient.GetAsync(
+ $"/lnurlp/verify/not-a-hex-hash");
+ Assert.Equal(System.Net.HttpStatusCode.NotFound, malformedResponse.StatusCode);
+ var malformedBody = JObject.Parse(await malformedResponse.Content.ReadAsStringAsync());
+ Assert.Equal("Not found", malformedBody["reason"]?.ToString());
+ }
}
diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs
index 85a48d1..5269783 100644
--- a/BTCPayServer/Controllers/UILNURLController.cs
+++ b/BTCPayServer/Controllers/UILNURLController.cs
@@ -461,6 +461,63 @@ namespace BTCPayServer
return Ok(lnurlRequest);
}
+ /// <summary>
+ /// LUD-21: Verify payment status for an LNURL payment.
+ /// Returns settlement status and preimage for a given payment hash.
+ /// </summary>
+ [HttpGet("~/lnurlp/verify/{paymentHash}")]
+ [EnableCors(CorsPolicies.All)]
+ public async Task<IActionResult> LnurlPayVerify(string paymentHash)
+ {
+ if (string.IsNullOrEmpty(paymentHash))
+ return NotFound();
+
+ // A Lightning payment hash is exactly 32 bytes / 64 hex chars.
+ // Reject anything else up front to avoid pointless DB lookups on garbage input.
+ if (paymentHash.Length != 64 || !paymentHash.All(Uri.IsHexDigit))
+ return NotFound(new LNUrlStatusResponse { Status = "ERROR", Reason = "Not found" });
+
+ paymentHash = paymentHash.ToLowerInvariant();
+
+ var cryptoCode = "BTC";
+ var pmi = PaymentTypes.LNURL.GetPaymentMethodId(cryptoCode);
+
+ // Find invoice by payment hash via AddressInvoices index
+ var invoice = await _invoiceRepository.GetInvoiceFromAddress(pmi, paymentHash);
+ if (invoice is null)
+ return NotFound(new LNUrlStatusResponse { Status = "ERROR", Reason = "Not found" });
+
+ var store = await _storeRepository.FindStore(invoice.StoreId);
+ if (store is null)
+ return NotFound(new LNUrlStatusResponse { Status = "ERROR", Reason = "Not found" });
+
+ var lnUrlPmi = GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod);
+ if (lnUrlPmi is null || !lnUrlMethod.LUD21Enabled)
+ return NotFound(new LNUrlStatusResponse { Status = "ERROR", Reason = "Not found" });
+
+ var prompt = invoice.GetPaymentPrompt(pmi);
+ if (prompt is null)
+ return NotFound(new LNUrlStatusResponse { Status = "ERROR", Reason = "Not found" });
+
+ var handler = (LNURLPayPaymentHandler)_handlers[pmi];
+ var details = handler.ParsePaymentPromptDetails(prompt.Details);
+
+ if (!string.Equals(details.PaymentHash?.ToString(), paymentHash, StringComparison.Ordinal))
+ return NotFound(new LNUrlStatusResponse { Status = "ERROR", Reason = "Not found" });
+
+ var settled = invoice.Status == InvoiceStatus.Settled ||
+ invoice.Status == InvoiceStatus.Processing;
+ var preimage = settled ? details.Preimage?.ToString() : null;
+
+ return Ok(new
+ {
+ status = "OK",
+ settled,
+ preimage,
+ pr = prompt.Destination
+ });
+ }
+
[HttpGet("pay/lnaddress/{username}")]
[EnableCors(CorsPolicies.All)]
[IgnoreAntiforgeryToken]
@@ -808,17 +865,34 @@ namespace BTCPayServer
if (updatePaymentMethod)
{
- await _invoiceRepository.UpdatePrompt(invoiceId, lightningPaymentMethod);
+ // Index payment hash for LUD-21 verify lookup via AddressInvoices,
+ // flushed atomically with the prompt update.
+ var trackedDestinations = lnurlSupportedPaymentMethod.LUD21Enabled && promptDetails.PaymentHash is not null
+ ? new[] { promptDetails.PaymentHash.ToString().ToLowerInvariant() }
+ : null;
+ await _invoiceRepository.UpdatePrompt(invoiceId, lightningPaymentMethod, trackedDestinations);
_eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoiceId, promptDetails, pmi));
}
- return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse
+ var callbackResponse = JObject.FromObject(new LNURLPayRequest.LNURLPayRequestCallbackResponse
{
Disposable = true,
Routes = Array.Empty<string>(),
Pr = lightningPaymentMethod.Destination,
SuccessAction = successAction
});
+
+ // LUD-21: Add verify URL if enabled
+ if (lnurlSupportedPaymentMethod.LUD21Enabled &&
+ promptDetails.PaymentHash is not null)
+ {
+ callbackResponse["verify"] = _linkGenerator.GetUriByAction(
+ nameof(LnurlPayVerify), "UILNURL",
+ new { paymentHash = promptDetails.PaymentHash.ToString().ToLowerInvariant() },
+ Request.Scheme, Request.Host, Request.PathBase);
+ }
+
+ return Ok(callbackResponse);
}
return BadRequest(new LNUrlStatusResponse
diff --git a/BTCPayServer/Controllers/UIStoresController.LightningLike.cs b/BTCPayServer/Controllers/UIStoresController.LightningLike.cs
index 87eb77b..d1d49ab 100644
--- a/BTCPayServer/Controllers/UIStoresController.LightningLike.cs
+++ b/BTCPayServer/Controllers/UIStoresController.LightningLike.cs
@@ -185,7 +185,8 @@ public partial class UIStoresController
store.SetPaymentMethodConfig(_handlers[lnurl], new LNURLPaymentMethodConfig
{
UseBech32Scheme = true,
- LUD12Enabled = false
+ LUD12Enabled = false,
+ LUD21Enabled = true
});
await _storeRepo.UpdateStore(store);
@@ -257,6 +258,7 @@ public partial class UIStoresController
vm.LNURLEnabled = !store.GetStoreBlob().GetExcludedPaymentMethods().Match(lnurlId);
vm.LNURLBech32Mode = lnurl.UseBech32Scheme;
vm.LUD12Enabled = lnurl.LUD12Enabled;
+ vm.LUD21Enabled = lnurl.LUD21Enabled;
}
return View(vm);
@@ -300,7 +302,8 @@ public partial class UIStoresController
var lnurl = GetConfig<LNURLPaymentMethodConfig>(lnurlId, store);
if (lnurl is null || (
lnurl.UseBech32Scheme != vm.LNURLBech32Mode ||
- lnurl.LUD12Enabled != vm.LUD12Enabled))
+ lnurl.LUD12Enabled != vm.LUD12Enabled ||
+ lnurl.LUD21Enabled != vm.LUD21Enabled))
{
needUpdate = true;
}
@@ -308,7 +311,8 @@ public partial class UIStoresController
store.SetPaymentMethodConfig(_handlers[lnurlId], new LNURLPaymentMethodConfig
{
UseBech32Scheme = vm.LNURLBech32Mode,
- LUD12Enabled = vm.LUD12Enabled
+ LUD12Enabled = vm.LUD12Enabled,
+ LUD21Enabled = vm.LUD21Enabled
});
if (store.SetStoreBlob(blob))
diff --git a/BTCPayServer/Models/StoreViewModels/LightningSettingsViewModel.cs b/BTCPayServer/Models/StoreViewModels/LightningSettingsViewModel.cs
index e9701a0..9d8b3ab 100644
--- a/BTCPayServer/Models/StoreViewModels/LightningSettingsViewModel.cs
+++ b/BTCPayServer/Models/StoreViewModels/LightningSettingsViewModel.cs
@@ -26,5 +26,8 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Allow payee to pass a comment")]
public bool LUD12Enabled { get; set; }
+
+ [Display(Name = "Enable LNURL-pay verify endpoint (LUD-21)")]
+ public bool LUD21Enabled { get; set; }
}
}
diff --git a/BTCPayServer/Payments/LNURLPay/LNURLPaymentMethodConfig.cs b/BTCPayServer/Payments/LNURLPay/LNURLPaymentMethodConfig.cs
index c7ca220..375ff5c 100644
--- a/BTCPayServer/Payments/LNURLPay/LNURLPaymentMethodConfig.cs
+++ b/BTCPayServer/Payments/LNURLPay/LNURLPaymentMethodConfig.cs
@@ -11,5 +11,12 @@ namespace BTCPayServer.Payments.Lightning
[JsonProperty("lud12Enabled")]
public bool LUD12Enabled { get; set; } = true;
+ /// <summary>
+ /// LUD-21: LNURL-pay verify endpoint. Allows external services to verify
+ /// Lightning payment settlement without authentication.
+ /// </summary>
+ [JsonProperty("lud21Enabled")]
+ public bool LUD21Enabled { get; set; } = true;
+
}
}
diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
index b2432bb..5b168ba 100644
--- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs
+++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
@@ -77,6 +77,18 @@ namespace BTCPayServer.Services.Invoices
return row is null ? null : ToEntity(row);
}
+ public async Task AddAddressInvoice(string invoiceId, PaymentMethodId paymentMethodId, string address)
+ {
+ await using var context = _applicationDbContextFactory.CreateContext();
+ await UpsertAddressInvoice(context, invoiceId, paymentMethodId.ToString(), address);
+ }
+
+ private static async Task UpsertAddressInvoice(ApplicationDbContext context, string invoiceId, string paymentMethodId, string address)
+ {
+ await context.Database.ExecuteSqlInterpolatedAsync(
+ $"""INSERT INTO "AddressInvoices" ("Address", "PaymentMethodId", "InvoiceDataId") VALUES ({address}, {paymentMethodId}, {invoiceId}) ON CONFLICT ("Address", "PaymentMethodId") DO NOTHING""");
+ }
+
/// <summary>
/// Returns all invoices which either:
/// * Have the <paramref name="paymentMethodId"/> activated and are pending
@@ -330,7 +342,7 @@ retry:
}
}
}
- public async Task UpdatePrompt(string invoiceId, PaymentPrompt prompt)
+ public async Task UpdatePrompt(string invoiceId, PaymentPrompt prompt, IEnumerable<string> trackedDestinations = null)
{
retry:
using (var context = _applicationDbContextFactory.CreateContext())
@@ -346,7 +358,20 @@ retry:
return;
invoiceEntity.SetPaymentPrompt(prompt.PaymentMethodId, prompt);
invoice.SetBlob(invoiceEntity);
+ // Persist the blob update first, on its own SaveChanges.
+ // A DbUpdateConcurrencyException here must propagate so the
+ // outer catch can retry; a unique-key violation on
+ // AddressInvoices must not be able to roll this back.
await context.SaveChangesAsync();
+
+ if (trackedDestinations is not null)
+ {
+ var pmi = prompt.PaymentMethodId.ToString();
+ foreach (var tracked in trackedDestinations)
+ {
+ await UpsertAddressInvoice(context, invoiceId, pmi, tracked);
+ }
+ }
}
catch (DbUpdateConcurrencyException)
{
@@ -367,14 +392,10 @@ retry:
var existing = invoiceEntity.GetPaymentPrompt(prompt.PaymentMethodId);
if (existing.Destination != prompt.Destination && prompt.Activated && prompt.Destination is not null)
{
+ var pmi = paymentPromptContext.PaymentMethodId.ToString();
foreach (var tracked in paymentPromptContext.TrackedDestinations)
{
- await context.AddressInvoices.AddAsync(new AddressInvoiceData()
- {
- InvoiceDataId = invoiceId,
- Address = tracked,
- PaymentMethodId = paymentPromptContext.PaymentMethodId.ToString()
- });
+ await UpsertAddressInvoice(context, invoiceId, pmi, tracked);
}
AddToTextSearch(context, invoice, prompt.Destination);
}
diff --git a/BTCPayServer/Views/UIStores/LightningSettings.cshtml b/BTCPayServer/Views/UIStores/LightningSettings.cshtml
index a397135..083c646 100644
--- a/BTCPayServer/Views/UIStores/LightningSettings.cshtml
+++ b/BTCPayServer/Views/UIStores/LightningSettings.cshtml
@@ -105,6 +105,13 @@
<label asp-for="LUD12Enabled" class="form-check-label"></label>
</div>
</div>
+ <div class="form-group mb-3">
+ <div class="d-flex align-items-center">
+ <input type="checkbox" asp-for="LUD21Enabled" class="btcpay-toggle me-3" />
+ <label asp-for="LUD21Enabled" class="form-check-label"></label>
+ </div>
+ <div class="form-text" text-translate="true">Allows external services to verify Lightning payment settlement via LNURL-pay without authentication</div>
+ </div>
</div>
</div>
</div>
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-payment-methods.lnurl.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-payment-methods.lnurl.json
index 54c4b05..689f6ca 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-payment-methods.lnurl.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-payment-methods.lnurl.json
@@ -12,6 +12,10 @@
"lud12Enabled": {
"type": "boolean",
"description": "Allow comments to be passed on via lnurl."
+ },
+ "lud21Enabled": {
+ "type": "boolean",
+ "description": "Whether to include a [LUD-21](https://github.com/lnurl/luds/blob/luds/21.md) verify URL in LNURL-pay callback responses, enabling external services to verify payment settlement without authentication."
}
}
}
Why this scored 41/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.