BIP21 + LNURL fix (#4616)

In case of the unified invoice, the LNURL wasn't correct — with this change we are simply reusing th one that was issued on invoice creation instead of generating it anew on the fly.

Also fixes missing uppercasing for the QR code in case of non-unified QR.
And removes the `lightning:` scheme from the LNURL that's displayed to the user (unifies it with what we do for Onchain and Lightning)
This commit is contained in:
d11n 2023-02-10 03:23:48 +01:00 committed by GitHub
parent 0d1bab45a0
commit 753ffd401b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 87 additions and 30 deletions

View file

@ -33,7 +33,8 @@ namespace BTCPayServer.Tests
s.CreateNewStore();
s.EnableCheckoutV2();
s.AddLightningNode();
s.AddDerivationScheme();
// Use non-legacy derivation scheme
s.AddDerivationScheme("BTC", "tpubDD79XF4pzhmPSJ9AyUay9YbXAeD1c6nkUqC32pnKARJH6Ja5hGUfGc76V82ahXpsKqN6UcSGXMkzR34aZq4W23C6DAdZFaVrzWqzj24F8BC");
// Configure store url
var storeUrl = "https://satoshisteaks.com/";
@ -59,9 +60,16 @@ namespace BTCPayServer.Tests
Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count);
Assert.Contains("Bitcoin", s.Driver.FindElement(By.CssSelector(".payment-method.active")).Text);
Assert.Contains("LNURL", s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Text);
var qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
var address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
var payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
var copyAddress = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value");
Assert.Equal($"bitcoin:{address}", payUrl);
Assert.StartsWith("bcrt", s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value"));
Assert.DoesNotContain("lightning=", payUrl);
Assert.Equal(address, copyAddress);
Assert.Equal($"bitcoin:{address.ToUpperInvariant()}", qrValue);
s.Driver.ElementDoesNotExist(By.Id("Lightning_BTC"));
// Switch to LNURL
s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Click();
@ -69,18 +77,26 @@ namespace BTCPayServer.Tests
{
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("lightning:lnurl", payUrl);
Assert.StartsWith("lnurl", s.Driver.WaitForElement(By.Id("Lightning_BTC")).GetAttribute("value"));
s.Driver.ElementDoesNotExist(By.Id("Address_BTC"));
});
// Default payment method
s.GoToHome();
invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike");
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count);
Assert.Contains("Lightning", s.Driver.WaitForElement(By.CssSelector(".payment-method.active")).Text);
Assert.Contains("Bitcoin", s.Driver.WaitForElement(By.CssSelector(".payment-method")).Text);
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("lightning:lnbcrt", payUrl);
copyAddress = s.Driver.FindElement(By.Id("Lightning_BTC_LightningLike")).GetAttribute("value");
Assert.Equal($"lightning:{address}", payUrl);
Assert.Equal(address, copyAddress);
Assert.Equal($"lightning:{address.ToUpperInvariant()}", qrValue);
s.Driver.ElementDoesNotExist(By.Id("Address_BTC"));
// Lightning amount in Sats
Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text);
@ -90,6 +106,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("save")).Click();
Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text);
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Contains("Sats", s.Driver.FindElement(By.Id("AmountDue")).Text);
// Expire
@ -114,6 +131,7 @@ namespace BTCPayServer.Tests
s.GoToHome();
invoiceId = s.CreateInvoice();
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
// Details
s.Driver.ToggleCollapse("PaymentDetails");
@ -126,7 +144,7 @@ namespace BTCPayServer.Tests
// Pay partial amount
await Task.Delay(200);
var address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
var amountFraction = "0.00001";
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
Money.Parse(amountFraction));
@ -176,28 +194,55 @@ namespace BTCPayServer.Tests
invoiceId = s.CreateInvoice();
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
var copyAddressOnchain = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value");
var copyAddressLightning = s.Driver.FindElement(By.Id("Lightning_BTC")).GetAttribute("value");
Assert.StartsWith($"bitcoin:{address}?amount=", payUrl);
Assert.Contains("?amount=", payUrl);
Assert.Contains("&lightning=", payUrl);
Assert.StartsWith("bcrt", copyAddressOnchain);
Assert.Equal(address, copyAddressOnchain);
Assert.StartsWith("lnbcrt", copyAddressLightning);
Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?amount=", qrValue);
Assert.Contains("&lightning=LNBCRT", qrValue);
// BIP21 with LN as default payment method
s.GoToHome();
invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike");
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
Assert.Contains("&lightning=lnbcrt", payUrl);
// BIP21 with topup invoice
// Ensure LNURL is enabled
s.GoToHome();
s.GoToLightningSettings();
Assert.True(s.Driver.FindElement(By.Id("LNURLEnabled")).Selected);
Assert.True(s.Driver.FindElement(By.Id("LNURLStandardInvoiceEnabled")).Selected);
// BIP21 with topup invoice
invoiceId = s.CreateInvoice(amount: null);
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
Assert.DoesNotContain("&lightning=lnurl", payUrl);
copyAddressOnchain = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value");
copyAddressLightning = s.Driver.FindElement(By.Id("Lightning_BTC")).GetAttribute("value");
Assert.StartsWith($"bitcoin:{address}", payUrl);
Assert.Contains("?lightning=lnurl", payUrl);
Assert.DoesNotContain("amount=", payUrl);
Assert.StartsWith("bcrt", copyAddressOnchain);
Assert.Equal(address, copyAddressOnchain);
Assert.StartsWith("lnurl", copyAddressLightning);
Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?lightning=LNURL", qrValue);
// Expiry message should not show amount for topup invoice
expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));
@ -223,6 +268,7 @@ namespace BTCPayServer.Tests
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
paymentInfo = s.Driver.FindElement(By.Id("PaymentInfo"));
Assert.False(paymentInfo.Displayed);
Assert.DoesNotContain("This invoice will expire in", paymentInfo.Text);

View file

@ -1613,10 +1613,10 @@ namespace BTCPayServer.Tests
var paymentMethodUnified = Assert.IsType<PaymentModel>(
Assert.IsType<ViewResult>(res).Model
);
Assert.StartsWith("bitcoin:", paymentMethodUnified.InvoiceBitcoinUrl);
Assert.StartsWith("bitcoin:", paymentMethodUnified.InvoiceBitcoinUrlQR);
Assert.Contains("&lightning=", paymentMethodUnified.InvoiceBitcoinUrl);
Assert.Contains("&lightning=", paymentMethodUnified.InvoiceBitcoinUrlQR);
Assert.StartsWith("bitcoin:bcrt", paymentMethodUnified.InvoiceBitcoinUrl);
Assert.StartsWith("bitcoin:BCRT", paymentMethodUnified.InvoiceBitcoinUrlQR);
Assert.Contains("&lightning=lnbcrt", paymentMethodUnified.InvoiceBitcoinUrl);
Assert.Contains("&lightning=LNBCRT", paymentMethodUnified.InvoiceBitcoinUrlQR);
// Check correct casing: Addresses in payment URI need to be …
// - lowercase in link version

View file

@ -655,7 +655,7 @@ namespace BTCPayServer.Controllers
var enabledPaymentIds = store.GetEnabledPaymentIds(_NetworkProvider)
.Where(pmId => storeBlob.CheckoutType == CheckoutType.V1 ||
// Exclude LNURL for Checkout v2 + non-top up invoices
(pmId.PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp()))
pmId != lnurlId || invoice.IsUnsetTopUp())
.ToArray();
// Exclude Lightning if OnChainWithLnInvoiceFallback is active and we have both payment methods
@ -805,7 +805,7 @@ namespace BTCPayServer.Controllers
AvailableCryptos = invoice.GetPaymentMethods()
.Where(i => i.Network != null && storeBlob.CheckoutType == CheckoutType.V1 ||
// Exclude LNURL for Checkout v2 + non-top up invoices
i.GetId().PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp())
i.GetId() != lnurlId || invoice.IsUnsetTopUp())
.Select(kv =>
{
var availableCryptoPaymentMethodId = kv.GetId();

View file

@ -10,7 +10,6 @@ using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Http;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer.Models;
@ -44,7 +43,6 @@ namespace BTCPayServer.Payments.Bitcoin
network => Encoders.ASCII.EncodeData(
network.NBitcoinNetwork.GetBech32Encoder(Bech32Type.WITNESS_PUBKEY_ADDRESS, false)
.HumanReadablePart));
}
class Prepare
@ -58,10 +56,11 @@ namespace BTCPayServer.Payments.Bitcoin
StoreBlob storeBlob, IPaymentMethod paymentMethod)
{
var paymentMethodId = paymentMethod.GetId();
var paymentMethodDetails = (BitcoinLikeOnChainPaymentMethod)paymentMethod.GetPaymentMethodDetails();
var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
var network = _networkProvider.GetNetwork<BTCPayNetwork>(model.CryptoCode);
model.ShowRecommendedFee = storeBlob.ShowRecommendedFee;
model.FeeRate = ((BitcoinLikeOnChainPaymentMethod)paymentMethod.GetPaymentMethodDetails()).GetFeeRate();
model.FeeRate = paymentMethodDetails.GetFeeRate();
model.PaymentMethodName = GetPaymentMethodName(network);
string lightningFallback = null;
@ -75,11 +74,20 @@ namespace BTCPayServer.Payments.Bitcoin
}
else
{
var lnurl = invoiceResponse.CryptoInfo.FirstOrDefault(a =>
var lnurlInfo = invoiceResponse.CryptoInfo.FirstOrDefault(a =>
a.GetpaymentMethodId() == new PaymentMethodId(model.CryptoCode, PaymentTypes.LNURLPay));
if (lnurl is not null)
if (lnurlInfo is not null)
{
lightningFallback = LNURL.LNURL.EncodeUri(new Uri(lnurl.Url), "payRequest", true).ToString();
lightningFallback = lnurlInfo.PaymentUrls?.AdditionalData["LNURLP"].ToObject<string>();
// This seems to be an edge case in the Selenium tests, in which the LNURLP isn't populated.
// I have come across it only in the tests and this is supposed to make them happy.
if (string.IsNullOrEmpty(lightningFallback))
{
var serverUrl = new Uri(lnurlInfo.Url[..lnurlInfo.Url.IndexOf("/i/", StringComparison.InvariantCultureIgnoreCase)]);
var uri = new Uri($"{serverUrl}{network.CryptoCode}/lnurl/pay/i/{invoiceResponse.Id}");
lightningFallback = LNURL.LNURL.EncodeUri(uri, "payRequest", true).ToString();
}
}
}
if (!string.IsNullOrEmpty(lightningFallback))

View file

@ -41,6 +41,8 @@ namespace BTCPayServer.Payments.Lightning
public override PaymentType PaymentType => PaymentTypes.LightningLike;
private const string UriScheme = "lightning:";
public IOptions<LightningNetworkOptions> Options { get; }
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
@ -107,12 +109,13 @@ namespace BTCPayServer.Payments.Lightning
StoreBlob storeBlob, IPaymentMethod paymentMethod)
{
var paymentMethodId = paymentMethod.GetId();
var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
var network = _networkProvider.GetNetwork<BTCPayNetwork>(model.CryptoCode);
var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
var lnurl = cryptoInfo.PaymentUrls?.AdditionalData["LNURLP"].ToObject<string>();
model.PaymentMethodName = GetPaymentMethodName(network);
model.InvoiceBitcoinUrl = cryptoInfo.PaymentUrls?.AdditionalData["LNURLP"].ToObject<string>();
model.InvoiceBitcoinUrlQR = model.InvoiceBitcoinUrl;
model.BtcAddress = model.InvoiceBitcoinUrl;
model.BtcAddress = lnurl?.Replace(UriScheme, "");
model.InvoiceBitcoinUrl = lnurl;
model.InvoiceBitcoinUrlQR = lnurl?.ToUpperInvariant().Replace(UriScheme.ToUpperInvariant(), UriScheme);
model.PeerInfo = ((LNURLPayPaymentMethodDetails)paymentMethod.GetPaymentMethodDetails()).NodeInfo;
if (storeBlob.LightningAmountInSatoshi && model.CryptoCode == "BTC")
{

View file

@ -4,7 +4,7 @@
<template id="bitcoin-method-checkout-template">
@await Component.InvokeAsync("UiExtensionPoint", new {location = "checkout-v2-bitcoin-pre-content", model = Model})
<div class="payment-box">
<div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId">
<div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-qr-value="model.invoiceBitcoinUrlQR" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId">
<div>
<qrcode :value="model.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
</div>

View file

@ -3,7 +3,7 @@
<template id="lightning-method-checkout-template">
<div class="payment-box">
@await Component.InvokeAsync("UiExtensionPoint" , new { location="checkout-v2-lightning-pre-content", model = Model})
<div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId">
<div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-qr-value="model.invoiceBitcoinUrlQR" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId">
<div>
<qrcode :value="model.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
</div>
@ -12,10 +12,10 @@
</div>
<div v-if="model.btcAddress" class="input-group mt-3">
<div class="form-floating">
<input id="Address_@Model.PaymentMethodId" class="form-control-plaintext" readonly="readonly" :value="model.btcAddress">
<label for="Address_@Model.PaymentMethodId" v-t="'lightning'"></label>
<input id="Lightning_@Model.PaymentMethodId" class="form-control-plaintext" readonly="readonly" :value="model.btcAddress">
<label for="Lightning_@Model.PaymentMethodId" v-t="'lightning'"></label>
</div>
<button type="button" class="btn btn-link" data-clipboard-target="#Address_@Model.PaymentMethodId" :data-clipboard-confirm="$t('copy_confirm')" v-t="'copy'"></button>
<button type="button" class="btn btn-link" data-clipboard-target="#Lightning_@Model.PaymentMethodId" :data-clipboard-confirm="$t('copy_confirm')" v-t="'copy'"></button>
</div>
<a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100 mt-4" target="_top"
:href="model.invoiceBitcoinUrl" v-t="'pay_in_wallet'"></a>