Checkout: Allow NFC/LNURL-W whenever LNURL is available (#4671)

* Checkout: Allow NFC/LNURL-W whenever LNURL is available

With what we have in master right now, we display NFC only for top-up invoices. With these changes, we display NFC in all cases, where LNURL is available.

Note that this hides LNURL from the list of selectable payment methods, it's only available to use the NFC — and explicitely selectable only for the edge case of top-up invoice + non-unified QR (as before).

Rationale: Now that we got NFC tightly integrated, it doesn't make sense to support the NFC experience only for top-up invoices. With this we bring back LNURL for regular invoices as well, but don't make it selectable and use it only for the NFC functionality.

* Fix LNURL condition

* Improve and test NFC/LNURL display condition

Restores what was fixed in #4660.

* Fix and test Lightning-only case

* Add cache busting for locales
This commit is contained in:
d11n 2023-02-22 07:53:14 +01:00 committed by GitHub
parent d542a61f5a
commit d73d0f178f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 86 additions and 39 deletions

View file

@ -23,7 +23,7 @@
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="108.0.5359.7100" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="110.0.5481.7700" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>

View file

@ -62,7 +62,7 @@ namespace BTCPayServer.Tests
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");
var payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
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"));
@ -70,15 +70,17 @@ namespace BTCPayServer.Tests
Assert.Equal(address, copyAddress);
Assert.Equal($"bitcoin:{address.ToUpperInvariant()}", qrValue);
s.Driver.ElementDoesNotExist(By.Id("Lightning_BTC"));
s.Driver.ElementDoesNotExist(By.Id("PayByLNURL"));
// Switch to LNURL
s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Click();
TestUtils.Eventually(() =>
{
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).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"));
s.Driver.FindElement(By.Id("PayByLNURL"));
});
// Default payment method
@ -91,12 +93,13 @@ namespace BTCPayServer.Tests
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");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
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"));
s.Driver.FindElement(By.Id("PayByLNURL"));
// Lightning amount in Sats
Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text);
@ -198,7 +201,7 @@ namespace BTCPayServer.Tests
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");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
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);
@ -209,6 +212,7 @@ namespace BTCPayServer.Tests
Assert.StartsWith("lnbcrt", copyAddressLightning);
Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?amount=", qrValue);
Assert.Contains("&lightning=LNBCRT", qrValue);
s.Driver.FindElement(By.Id("PayByLNURL"));
// BIP21 with LN as default payment method
s.GoToHome();
@ -216,9 +220,10 @@ namespace BTCPayServer.Tests
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");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
Assert.Contains("&lightning=lnbcrt", payUrl);
s.Driver.FindElement(By.Id("PayByLNURL"));
// Ensure LNURL is enabled
s.GoToHome();
@ -233,7 +238,7 @@ namespace BTCPayServer.Tests
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");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
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);
@ -243,6 +248,7 @@ namespace BTCPayServer.Tests
Assert.Equal(address, copyAddressOnchain);
Assert.StartsWith("lnurl", copyAddressLightning);
Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?lightning=LNURL", qrValue);
s.Driver.FindElement(By.Id("PayByLNURL"));
// Expiry message should not show amount for topup invoice
expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));
@ -282,6 +288,26 @@ namespace BTCPayServer.Tests
Assert.True(paymentInfo.Displayed);
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("09:5", paymentInfo.Text);
// Disable LNURL again
s.GoToHome();
s.GoToLightningSettings();
s.Driver.SetCheckbox(By.Id("LNURLEnabled"), false);
s.Driver.FindElement(By.Id("save")).Click();
Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text);
// Test:
// - NFC/LNURL-W available with just Lightning
// - BIP21 works correctly even though Lightning is 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.Id("PayInWallet")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
Assert.Contains("&lightning=lnbcrt", payUrl);
s.Driver.FindElement(By.Id("PayByLNURL"));
}
[Fact(Timeout = TestTimeout)]

View file

@ -652,17 +652,19 @@ namespace BTCPayServer.Controllers
var lnurlId = PaymentMethodId.Parse("BTC_LNURLPAY");
if (paymentMethodId is null)
{
var enabledPaymentIds = store.GetEnabledPaymentIds(_NetworkProvider)
.Where(pmId => storeBlob.CheckoutType == CheckoutType.V1 ||
// Exclude LNURL for Checkout v2 + non-top up invoices
pmId != lnurlId || invoice.IsUnsetTopUp())
.ToArray();
var enabledPaymentIds = store.GetEnabledPaymentIds(_NetworkProvider).ToArray();
// Exclude Lightning if OnChainWithLnInvoiceFallback is active and we have both payment methods
if (storeBlob is { CheckoutType: CheckoutType.V2, OnChainWithLnInvoiceFallback: true } &&
enabledPaymentIds.Contains(btcId) && enabledPaymentIds.Contains(lnId))
if (storeBlob is { CheckoutType: CheckoutType.V2, OnChainWithLnInvoiceFallback: true })
{
enabledPaymentIds = enabledPaymentIds.Where(pmId => pmId != lnId).ToArray();
if (enabledPaymentIds.Contains(btcId) && enabledPaymentIds.Contains(lnId))
{
enabledPaymentIds = enabledPaymentIds.Where(pmId => pmId != lnId).ToArray();
}
if (enabledPaymentIds.Contains(btcId) && enabledPaymentIds.Contains(lnurlId))
{
enabledPaymentIds = enabledPaymentIds.Where(pmId => pmId != lnurlId).ToArray();
}
}
PaymentMethodId? invoicePaymentId = invoice.GetDefaultPaymentMethod();
@ -688,7 +690,7 @@ namespace BTCPayServer.Controllers
if (paymentMethodId is null)
{
paymentMethodId = enabledPaymentIds.FirstOrDefault(e => e.CryptoCode == _NetworkProvider.DefaultNetwork.CryptoCode && e.PaymentType == PaymentTypes.BTCLike) ??
enabledPaymentIds.FirstOrDefault(e => e.CryptoCode == _NetworkProvider.DefaultNetwork.CryptoCode && e.PaymentType == PaymentTypes.LightningLike) ??
enabledPaymentIds.FirstOrDefault(e => e.CryptoCode == _NetworkProvider.DefaultNetwork.CryptoCode && e.PaymentType != PaymentTypes.LNURLPay) ??
enabledPaymentIds.FirstOrDefault();
}
isDefaultPaymentId = true;
@ -703,7 +705,12 @@ namespace BTCPayServer.Controllers
return null;
var paymentMethodTemp = invoice
.GetPaymentMethods()
.FirstOrDefault(c => paymentMethodId.CryptoCode == c.GetId().CryptoCode);
.FirstOrDefault(pm =>
{
var pmId = pm.GetId();
return paymentMethodId.CryptoCode == pmId.CryptoCode &&
((invoice.IsUnsetTopUp() && !storeBlob.OnChainWithLnInvoiceFallback) || pmId != lnurlId);
});
if (paymentMethodTemp == null)
paymentMethodTemp = invoice.GetPaymentMethods().FirstOrDefault();
if (paymentMethodTemp is null)
@ -768,6 +775,7 @@ namespace BTCPayServer.Controllers
BrandColor = storeBlob.BrandColor,
CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType,
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback,
CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)),
BtcAddress = paymentMethodDetails.GetPaymentDestination(),
BtcDue = accounting.Due.ShowMoney(divisibility),
@ -803,9 +811,6 @@ namespace BTCPayServer.Controllers
IsMultiCurrency = invoice.GetPayments(false).Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1,
StoreId = store.Id,
AvailableCryptos = invoice.GetPaymentMethods()
.Where(i => i.Network != null && storeBlob.CheckoutType == CheckoutType.V1 ||
// Exclude LNURL for Checkout v2 + non-top up invoices
i.GetId() != lnurlId || invoice.IsUnsetTopUp())
.Select(kv =>
{
var availableCryptoPaymentMethodId = kv.GetId();
@ -835,20 +840,16 @@ namespace BTCPayServer.Controllers
{
var onchainPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == btcId.ToString());
var lightningPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == lnId.ToString());
var lnurlPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == lnurlId.ToString());
if (onchainPM != null && lightningPM != null)
{
model.AvailableCryptos.Remove(lightningPM);
}
if (onchainPM != null && lnurlPM != null)
{
model.AvailableCryptos.Remove(lnurlPM);
}
}
paymentMethodHandler.PreparePaymentModel(model, dto, storeBlob, paymentMethod);
model.UISettings = paymentMethodHandler.GetCheckoutUISettings();
model.PaymentMethodId = paymentMethodId.ToString();
model.PaymentType = paymentMethodId.PaymentType.ToString();
var expiration = TimeSpan.FromSeconds(model.ExpirationSeconds);
model.TimeLeft = expiration.PrettyPrint();
return model;

View file

@ -32,6 +32,7 @@ namespace BTCPayServer.Models.InvoicingModels
public List<AvailableCrypto> AvailableCryptos { get; set; } = new();
public bool IsModal { get; set; }
public bool IsUnsetTopUp { get; set; }
public bool OnChainWithLnInvoiceFallback { get; set; }
public string CryptoCode { get; set; }
public string InvoiceId { get; set; }
public string BtcAddress { get; set; }

View file

@ -25,7 +25,7 @@
</div>
<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"
<a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100 mt-4" target="_top" id="PayInWallet"
:href="model.invoiceBitcoinUrl" :title="$t(hasPayjoin ? 'BIP21 payment link with PayJoin support' : 'BIP21 payment link')" v-t="'pay_in_wallet'"></a>
@await Component.InvokeAsync("UiExtensionPoint", new {location = "checkout-v2-bitcoin-post-content", model = Model})
</div>

View file

@ -17,7 +17,7 @@
</div>
<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"
<a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100 mt-4" target="_top" id="PayInWallet"
:href="model.invoiceBitcoinUrl" v-t="'pay_in_wallet'"></a>
@await Component.InvokeAsync("UiExtensionPoint", new {location = "checkout-v2-lightning-post-content", model = Model})
</div>

View file

@ -3,12 +3,12 @@
<template id="lnurl-withdraw-template">
<template v-if="display">
<button v-if="isV2" class="btn btn-secondary rounded-pill w-100 mt-4" type="button"
v-on:click="startScan"
v-bind:disabled="scanning || submitting"
v-bind:class="{ 'loading': scanning || submitting, 'text-secondary': !supported }">{{btnText}}</button>
:disabled="scanning || submitting" v-on:click="startScan" :id="btnId"
:class="{ 'loading': scanning || submitting, 'text-secondary': !supported }">{{btnText}}</button>
<bp-loading-button v-else>
<button v-on:click="startScan" class="action-button" style="margin: 0 45px;width:calc(100% - 90px) !important" v-bind:disabled="scanning || submitting"
v-bind:class="{ 'loading': scanning || submitting, 'action-button': supported, 'btn btn-text w-100': !supported }">
<button class="action-button" style="margin: 0 45px;width:calc(100% - 90px) !important"
:disabled="scanning || submitting" v-on:click="startScan" :id="btnId"
:class="{ 'loading': scanning || submitting, 'action-button': supported, 'btn btn-text w-100': !supported }">
<span class="button-text">{{btnText}}</span>
<div class="loader-wrapper">
@await Html.PartialAsync("~/Views/UIInvoice/Checkout-Spinner.cshtml")
@ -26,9 +26,25 @@ Vue.component("lnurl-withdraw-checkout", {
},
computed: {
display: function () {
return (
this.model.paymentMethodId === 'BTC_LNURLPAY' || (
this.model.paymentMethodId === 'BTC' && this.model.invoiceBitcoinUrl.match(/lightning=lnurl/i)));
const {
onChainWithLnInvoiceFallback: isUnified,
paymentMethodId: activePaymentMethodId,
availableCryptos: availablePaymentMethods,
invoiceBitcoinUrl: paymentUrl
} = this.model
const lnurlwAvailable =
// Either we have LN or LNURL available directly
!!availablePaymentMethods.find(pm => ['BTC_LNURLPAY', 'BTC_LightningLike'].includes(pm.paymentMethodId)) ||
// Or the BIP21 payment URL flags Lightning support
!!paymentUrl.match(/lightning=ln/i)
return activePaymentMethodId === 'BTC_LNURLPAY' || (
// Unified QR/BIP21 case
(activePaymentMethodId === 'BTC' && isUnified && lnurlwAvailable) ||
// Lightning with LNURL available
(activePaymentMethodId === 'BTC_LightningLike' && lnurlwAvailable))
},
btnId: function () {
return this.supported ? 'PayByNFC' : 'PayByLNURL'
},
btnText: function () {
if (this.supported) {

View file

@ -11,7 +11,10 @@
ViewData["Title"] = Model.HtmlTitle;
var hasPaymentPlugins = UiExtensions.Any(extension => extension.Location == "checkout-payment-method");
var paymentMethodCount = Model.AvailableCryptos.Count;
// Show LNURL as selectable payment method only for top up invoices + non-BIP21 case
var displayedPaymentMethods = Model.IsUnsetTopUp && !Model.OnChainWithLnInvoiceFallback
? Model.AvailableCryptos
: Model.AvailableCryptos.Where(c => c.PaymentMethodId != "BTC_LNURLPAY").ToList();
}
@functions {
private string PaymentMethodName(PaymentModel.AvailableCrypto pm)
@ -73,12 +76,12 @@
<div id="PaymentDetails" class="payment-details" v-collapsible="displayPaymentDetails">
<payment-details :srv-model="srvModel" :is-active="isActive" class="pb-4"></payment-details>
</div>
@if (paymentMethodCount > 1 || hasPaymentPlugins)
@if (displayedPaymentMethods.Count > 1 || hasPaymentPlugins)
{
<div class="mt-3 mb-2">
<h6 class="text-center mb-3" v-t="'pay_with'"></h6>
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2 pb-2">
@foreach (var crypto in Model.AvailableCryptos)
@foreach (var crypto in displayedPaymentMethods)
{
<a asp-action="Checkout" asp-route-invoiceId="@Model.InvoiceId" asp-route-paymentMethodId="@crypto.PaymentMethodId"
class="btcpay-pill m-0 payment-method"
@ -210,7 +213,7 @@
</div>
</dl>
<script>
const i18nUrl = @Safe.Json($"{Model.RootPath}locales/checkout/{{{{lng}}}}.json");
const i18nUrl = @Safe.Json($"{Model.RootPath}locales/checkout/{{{{lng}}}}.json?v={Env.Version}");
const statusUrl = @Safe.Json(Url.Action("GetStatus", new { invoiceId = Model.InvoiceId }));
const statusWsUrl = @Safe.Json(Url.Action("GetStatusWebSocket", new { invoiceId = Model.InvoiceId }));
const availableLanguages = ['en']; // @Safe.Json(LangService.GetLanguages().Select(language => language.Code));