Lightning address support (#2804)

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
This commit is contained in:
Andrew Camilleri
2021-10-29 11:01:16 +02:00
committed by GitHub
parent 25f84d000b
commit fc8a5ff95f
7 changed files with 595 additions and 86 deletions

View File

@@ -3,6 +3,8 @@ using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Security;
using System.Security.Authentication;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -241,13 +243,15 @@ namespace BTCPayServer.Tests
s.RegisterNewUser(isAdmin: true);
s.Driver.Navigate().GoToUrl(s.Link("/server/services"));
Assert.Contains("server/services/ssh", s.Driver.PageSource);
using (var client = await s.Server.PayTester.GetService<Configuration.BTCPayServerOptions>().SSHSettings.ConnectAsync())
using (var client = await s.Server.PayTester.GetService<Configuration.BTCPayServerOptions>().SSHSettings
.ConnectAsync())
{
var result = await client.RunBash("echo hello");
Assert.Equal(string.Empty, result.Error);
Assert.Equal("hello\n", result.Output);
Assert.Equal(0, result.ExitStatus);
}
s.Driver.Navigate().GoToUrl(s.Link("/server/services/ssh"));
s.Driver.AssertNoError();
s.Driver.FindElement(By.Id("SSHKeyFileContent")).Clear();
@@ -259,7 +263,8 @@ namespace BTCPayServer.Tests
// Browser replace \n to \r\n, so it is hard to compare exactly what we want
Assert.Contains("tes't", text);
Assert.Contains("test2", text);
Assert.True(s.Driver.PageSource.Contains("authorized_keys has been updated", StringComparison.OrdinalIgnoreCase));
Assert.True(s.Driver.PageSource.Contains("authorized_keys has been updated",
StringComparison.OrdinalIgnoreCase));
s.Driver.FindElement(By.Id("SSHKeyFileContent")).Clear();
s.Driver.FindElement(By.Id("submit")).Click();
@@ -295,6 +300,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("button[value=\"ResetPassword\"]")).Submit();
s.FindAlertMessage();
}
CanSetupEmailCore(s);
s.CreateNewStore();
s.GoToUrl($"stores/{s.StoreId}/emails");
@@ -320,6 +326,7 @@ namespace BTCPayServer.Tests
s.Driver.Navigate().GoToUrl(s.Link("/server/services/dynamic-dns/pouet.hello.com/delete"));
s.Driver.FindElement(By.Id("ConfirmContinue")).Click();
}
s.Driver.FindElement(By.Id("AddDynamicDNS")).Click();
s.Driver.AssertNoError();
// We will just cheat for test purposes by only querying the server
@@ -375,13 +382,15 @@ namespace BTCPayServer.Tests
s.GoToStore(storeId);
Assert.Contains(storeName, s.Driver.PageSource);
Assert.True(s.Driver.PageSource.Contains(onchainHint), "Wallet hint should be present at this point");
Assert.True(s.Driver.PageSource.Contains(offchainHint), "Lightning hint should be present at this point");
Assert.True(s.Driver.PageSource.Contains(offchainHint),
"Lightning hint should be present at this point");
// setup onchain wallet
s.GoToStore(storeId);
s.AddDerivationScheme();
s.Driver.AssertNoError();
Assert.False(s.Driver.PageSource.Contains(onchainHint), "Wallet hint not dismissed on derivation scheme add");
Assert.False(s.Driver.PageSource.Contains(onchainHint),
"Wallet hint not dismissed on derivation scheme add");
// setup offchain wallet
s.GoToStore(storeId);
@@ -389,7 +398,8 @@ namespace BTCPayServer.Tests
s.Driver.AssertNoError();
var successAlert = s.FindAlertMessage();
Assert.Contains("BTC Lightning node updated.", successAlert.Text);
Assert.False(s.Driver.PageSource.Contains(offchainHint), "Lightning hint should be dismissed at this point");
Assert.False(s.Driver.PageSource.Contains(offchainHint),
"Lightning hint should be dismissed at this point");
var storeUrl = s.Driver.Url;
s.ClickOnAllSideMenus();
@@ -482,12 +492,9 @@ namespace BTCPayServer.Tests
var client = new NBitpayClient.Bitpay(new Key(), s.ServerUri);
await client.AuthorizeClient(new NBitpayClient.PairingCode(pairingCode));
await client.CreateInvoiceAsync(new NBitpayClient.Invoice()
{
Price = 0.000000012m,
Currency = "USD",
FullNotifications = true
}, NBitpayClient.Facade.Merchant);
await client.CreateInvoiceAsync(
new NBitpayClient.Invoice() { Price = 0.000000012m, Currency = "USD", FullNotifications = true },
NBitpayClient.Facade.Merchant);
client = new NBitpayClient.Bitpay(new Key(), s.ServerUri);
@@ -495,12 +502,9 @@ namespace BTCPayServer.Tests
s.Driver.Navigate().GoToUrl(code.CreateLink(s.ServerUri));
s.Driver.FindElement(By.Id("ApprovePairing")).Click();
await client.CreateInvoiceAsync(new NBitpayClient.Invoice()
{
Price = 0.000000012m,
Currency = "USD",
FullNotifications = true
}, NBitpayClient.Facade.Merchant);
await client.CreateInvoiceAsync(
new NBitpayClient.Invoice() { Price = 0.000000012m, Currency = "USD", FullNotifications = true },
NBitpayClient.Facade.Merchant);
s.Driver.Navigate().GoToUrl(s.Link("/api-tokens"));
s.Driver.FindElement(By.Id("RequestPairing")).Click();
@@ -571,7 +575,8 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("TargetAmount")).SendKeys("700");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
s.Driver.FindElement(By.Id("ViewApp")).Click();
Assert.Equal("currently active!", s.Driver.FindElement(By.CssSelector("[data-test='time-state']")).Text);
Assert.Equal("currently active!",
s.Driver.FindElement(By.CssSelector("[data-test='time-state']")).Text);
}
}
@@ -594,7 +599,8 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Name("ViewAppButton")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
Assert.Equal("Amount due", s.Driver.FindElement(By.CssSelector("[data-test='amount-due-title']")).Text);
Assert.Equal("Pay Invoice", s.Driver.FindElement(By.CssSelector("[data-test='pay-button']")).Text.Trim());
Assert.Equal("Pay Invoice",
s.Driver.FindElement(By.CssSelector("[data-test='pay-button']")).Text.Trim());
// expire
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
@@ -611,7 +617,8 @@ namespace BTCPayServer.Tests
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.Navigate().Refresh();
s.Driver.AssertElementNotFound(By.CssSelector("[data-test='status']"));
Assert.Equal("Pay Invoice", s.Driver.FindElement(By.CssSelector("[data-test='pay-button']")).Text.Trim());
Assert.Equal("Pay Invoice",
s.Driver.FindElement(By.CssSelector("[data-test='pay-button']")).Text.Trim());
}
}
@@ -628,15 +635,18 @@ namespace BTCPayServer.Tests
s.GoToWallet(walletId, WalletsNavPages.Receive);
s.Driver.FindElement(By.Id("generateButton")).Click();
var addressStr = s.Driver.FindElement(By.Id("address")).GetProperty("value");
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
var address = BitcoinAddress.Create(addressStr,
((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
await s.Server.ExplorerNode.GenerateAsync(1);
for (int i = 0; i < 6; i++)
{
await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m));
}
var targetTx = await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.2m));
var tx = await s.Server.ExplorerNode.GetRawTransactionAsync(targetTx);
var spentOutpoint = new OutPoint(targetTx, tx.Outputs.FindIndex(txout => txout.Value == Money.Coins(1.2m)));
var spentOutpoint = new OutPoint(targetTx,
tx.Outputs.FindIndex(txout => txout.Value == Money.Coins(1.2m)));
await TestUtils.EventuallyAsync(async () =>
{
var store = await s.Server.PayTester.StoreRepository.FindStore(storeId);
@@ -653,7 +663,8 @@ namespace BTCPayServer.Tests
s.GoToWallet(walletId);
s.Driver.WaitForAndClick(By.Id("toggleInputSelection"));
s.Driver.WaitForElement(By.Id(spentOutpoint.ToString()));
Assert.Equal("true", s.Driver.FindElement(By.Name("InputSelection")).GetAttribute("value").ToLowerInvariant());
Assert.Equal("true",
s.Driver.FindElement(By.Name("InputSelection")).GetAttribute("value").ToLowerInvariant());
var el = s.Driver.FindElement(By.Id(spentOutpoint.ToString()));
s.Driver.FindElement(By.Id(spentOutpoint.ToString())).Click();
var inputSelectionSelect = s.Driver.FindElement(By.Name("SelectedInputs"));
@@ -723,6 +734,7 @@ namespace BTCPayServer.Tests
// Fix as needed.
Assert.Contains($"value=\"{value}\"", s.Driver.PageSource);
}
// This one should be checked
Assert.Contains($"value=\"InvoiceProcessing\" checked", s.Driver.PageSource);
Assert.Contains($"value=\"InvoiceCreated\" checked", s.Driver.PageSource);
@@ -741,7 +753,8 @@ namespace BTCPayServer.Tests
var headers = request.Request.Headers;
var actualSig = headers["BTCPay-Sig"].First();
var bytes = await request.Request.Body.ReadBytesAsync((int)headers.ContentLength.Value);
var expectedSig = $"sha256={Encoders.Hex.EncodeData(new HMACSHA256(Encoding.UTF8.GetBytes("HelloWorld")).ComputeHash(bytes))}";
var expectedSig =
$"sha256={Encoders.Hex.EncodeData(new HMACSHA256(Encoding.UTF8.GetBytes("HelloWorld")).ComputeHash(bytes))}";
Assert.Equal(expectedSig, actualSig);
request.Response.StatusCode = 200;
server.Done();
@@ -799,7 +812,8 @@ namespace BTCPayServer.Tests
foreach (var isHotwallet in new[] { false, true })
{
var (storeName, storeId) = s.CreateNewStore();
s.GenerateWallet(privkeys: isHotwallet, seed: "melody lizard phrase voice unique car opinion merge degree evil swift cargo");
s.GenerateWallet(privkeys: isHotwallet,
seed: "melody lizard phrase voice unique car opinion merge degree evil swift cargo");
s.GoToWallet(s.WalletId, WalletsNavPages.Settings);
if (isHotwallet)
Assert.Contains("View seed", s.Driver.PageSource);
@@ -847,7 +861,8 @@ namespace BTCPayServer.Tests
var sess = await s.Server.ExplorerClient.CreateWebsocketNotificationSessionAsync();
await sess.ListenAllTrackedSourceAsync();
var nextEvent = sess.NextEventAsync();
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(receiveAddr, Network.RegTest), Money.Parse("0.1"));
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(receiveAddr, Network.RegTest),
Money.Parse("0.1"));
await nextEvent;
await Task.Delay(200);
s.Driver.Navigate().Refresh();
@@ -870,7 +885,8 @@ namespace BTCPayServer.Tests
var address = invoice.EntityToDTO().Addresses["BTC"];
//wallet should have been imported to bitcoin core wallet in watch only mode.
var result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
var result =
await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
Assert.True(result.IsWatchOnly);
s.GoToStore(storeId);
var mnemonic = s.GenerateWallet("BTC", "", true, true);
@@ -880,10 +896,12 @@ namespace BTCPayServer.Tests
invoiceId = s.CreateInvoice(storeName);
invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId);
address = invoice.EntityToDTO().Addresses["BTC"];
result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
result = await s.Server.ExplorerNode.GetAddressInfoAsync(
BitcoinAddress.Create(address, Network.RegTest));
//spendable from bitcoin core wallet!
Assert.False(result.IsWatchOnly);
var tx = s.Server.ExplorerNode.SendToAddress(BitcoinAddress.Create(address, Network.RegTest), Money.Coins(3.0m));
var tx = s.Server.ExplorerNode.SendToAddress(BitcoinAddress.Create(address, Network.RegTest),
Money.Coins(3.0m));
await s.Server.ExplorerNode.GenerateAsync(1);
s.Driver.FindElement(By.Id("Wallets")).Click();
@@ -893,8 +911,10 @@ namespace BTCPayServer.Tests
// Make sure wallet info is correct
s.Driver.FindElement(By.Id("WalletSettings")).Click();
Assert.Contains(mnemonic.DeriveExtKey().GetPublicKey().GetHDFingerPrint().ToString(), s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).GetAttribute("value"));
Assert.Contains("m/84'/1'/0'", s.Driver.FindElement(By.Id("AccountKeys_0__AccountKeyPath")).GetAttribute("value"));
Assert.Contains(mnemonic.DeriveExtKey().GetPublicKey().GetHDFingerPrint().ToString(),
s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).GetAttribute("value"));
Assert.Contains("m/84'/1'/0'",
s.Driver.FindElement(By.Id("AccountKeys_0__AccountKeyPath")).GetAttribute("value"));
// Make sure we can rescan, because we are admin!
s.Driver.FindElement(By.Id("WalletRescan")).Click();
@@ -946,8 +966,10 @@ namespace BTCPayServer.Tests
s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept();
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info);
Assert.Equal(parsedBip21.Amount.ToString(false), s.Driver.FindElement(By.Id("Outputs_0__Amount")).GetAttribute("value"));
Assert.Equal(parsedBip21.Address.ToString(), s.Driver.FindElement(By.Id("Outputs_0__DestinationAddress")).GetAttribute("value"));
Assert.Equal(parsedBip21.Amount.ToString(false),
s.Driver.FindElement(By.Id("Outputs_0__Amount")).GetAttribute("value"));
Assert.Equal(parsedBip21.Address.ToString(),
s.Driver.FindElement(By.Id("Outputs_0__DestinationAddress")).GetAttribute("value"));
s.GoToWallet(new WalletId(storeId, "BTC"), WalletsNavPages.Settings);
var walletUrl = s.Driver.Url;
@@ -955,9 +977,11 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("button[value=view-seed]")).Click();
// Seed backup page
var recoveryPhrase = s.Driver.FindElements(By.Id("RecoveryPhrase")).First().GetAttribute("data-mnemonic");
var recoveryPhrase = s.Driver.FindElements(By.Id("RecoveryPhrase")).First()
.GetAttribute("data-mnemonic");
Assert.Equal(mnemonic.ToString(), recoveryPhrase);
Assert.Contains("The recovery phrase will also be stored on the server as a hot wallet.", s.Driver.PageSource);
Assert.Contains("The recovery phrase will also be stored on the server as a hot wallet.",
s.Driver.PageSource);
// No confirmation, just a link to return to the wallet
Assert.Empty(s.Driver.FindElements(By.Id("confirm")));
@@ -974,12 +998,15 @@ namespace BTCPayServer.Tests
await s.StartAsync();
s.RegisterNewUser(true);
var (_, storeId) = s.CreateNewStore();
var mnemonic = s.GenerateWallet("BTC", "click chunk owner kingdom faint steak safe evidence bicycle repeat bulb wheel");
var mnemonic = s.GenerateWallet("BTC",
"click chunk owner kingdom faint steak safe evidence bicycle repeat bulb wheel");
// Make sure wallet info is correct
s.GoToWallet(new WalletId(storeId, "BTC"), WalletsNavPages.Settings);
Assert.Contains(mnemonic.DeriveExtKey().GetPublicKey().GetHDFingerPrint().ToString(), s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).GetAttribute("value"));
Assert.Contains( "m/84'/1'/0'", s.Driver.FindElement(By.Id("AccountKeys_0__AccountKeyPath")).GetAttribute("value"));
Assert.Contains(mnemonic.DeriveExtKey().GetPublicKey().GetHDFingerPrint().ToString(),
s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).GetAttribute("value"));
Assert.Contains("m/84'/1'/0'",
s.Driver.FindElement(By.Id("AccountKeys_0__AccountKeyPath")).GetAttribute("value"));
}
}
@@ -1001,7 +1028,8 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("PP1");
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0");;
s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0");
;
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
@@ -1168,7 +1196,9 @@ namespace BTCPayServer.Tests
TimeSpan.FromHours(1), CancellationToken.None)).BOLT11;
s.Driver.FindElement(By.Id("Destination")).SendKeys(bolt);
s.Driver.FindElement(By.Id("SelectedPaymentMethod")).Click();
s.Driver.FindElement(By.CssSelector($"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike )}]")).Click();
s.Driver.FindElement(By.CssSelector(
$"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike)}]"))
.Click();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter);
//we do not allow short-life bolts.
@@ -1181,7 +1211,9 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Destination")).Clear();
s.Driver.FindElement(By.Id("Destination")).SendKeys(bolt);
s.Driver.FindElement(By.Id("SelectedPaymentMethod")).Click();
s.Driver.FindElement(By.CssSelector($"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike )}]")).Click();
s.Driver.FindElement(By.CssSelector(
$"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike)}]"))
.Click();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter);
s.FindAlertMessage();
@@ -1199,10 +1231,14 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("#pay-invoices-form")).Submit();
//lightning config in tests is very unstable so we can just go ahead and handle it as both
s.FindAlertMessage(new []{StatusMessageModel.StatusSeverity.Error, StatusMessageModel.StatusSeverity.Success});
s.FindAlertMessage(new[]
{
StatusMessageModel.StatusSeverity.Error, StatusMessageModel.StatusSeverity.Success
});
s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
s.Driver.FindElement(By.Id($"{new PaymentMethodId("BTC", PaymentTypes.LightningLike)}-view")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.Completed}-view")).Click();
if (!s.Driver.PageSource.Contains(bolt))
{
@@ -1295,13 +1331,15 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("copy-tab")).Click();
var lnurl = s.Driver.FindElement(By.CssSelector("input.checkoutTextbox")).GetAttribute("value");
var parsed = LNURL.LNURL.Parse(lnurl, out var tag);
var fetchedReuqest = Assert.IsType<LNURL.LNURLPayRequest>(await LNURL.LNURL.FetchInformation(parsed, new HttpClient()));
var fetchedReuqest =
Assert.IsType<LNURL.LNURLPayRequest>(await LNURL.LNURL.FetchInformation(parsed, new HttpClient()));
Assert.Equal(1m, fetchedReuqest.MinSendable.ToDecimal(LightMoneyUnit.Satoshi));
Assert.NotEqual(1m, fetchedReuqest.MaxSendable.ToDecimal(LightMoneyUnit.Satoshi));
var lnurlResponse = await fetchedReuqest.SendRequest(new LightMoney(0.000001m, LightMoneyUnit.BTC),
network, new HttpClient());
Assert.Equal(new LightMoney(0.000001m, LightMoneyUnit.BTC), lnurlResponse.GetPaymentRequest(network).MinimumAmount);
Assert.Equal(new LightMoney(0.000001m, LightMoneyUnit.BTC),
lnurlResponse.GetPaymentRequest(network).MinimumAmount);
var lnurlResponse2 = await fetchedReuqest.SendRequest(new LightMoney(0.000002m, LightMoneyUnit.BTC),
network, new HttpClient());
@@ -1349,11 +1387,13 @@ namespace BTCPayServer.Tests
network, new HttpClient());
lnurlResponse2 = await fetchedReuqest.SendRequest(new LightMoney(0.0000001m, LightMoneyUnit.BTC),
network, new HttpClient());
// Invoice amounts do no change so the payment request is not regenerated
//invoice amounts do no change so the paymnet request is not regenerated
Assert.Equal(lnurlResponse.Pr, lnurlResponse2.Pr);
await s.Server.CustomerLightningD.Pay(lnurlResponse.Pr);
Assert.Equal(new LightMoney(0.0000001m, LightMoneyUnit.BTC), lnurlResponse2.GetPaymentRequest(network).MinimumAmount);
Assert.Equal(new LightMoney(0.0000001m, LightMoneyUnit.BTC),
lnurlResponse2.GetPaymentRequest(network).MinimumAmount);
s.GoToStore(s.StoreId);
s.Driver.FindElement(By.Id($"Modify-Lightning{cryptoCode}")).Click();
@@ -1376,7 +1416,7 @@ namespace BTCPayServer.Tests
s.GoToStore(s.StoreId);
s.Driver.FindElement(By.Id($"Modify-Lightning{cryptoCode}")).Click();
s.Driver.SetCheckbox(By.Id("LNURLBech32Mode"), false);
s.Driver.SetCheckbox(By.Id("LNURLStandardInvoiceEnabled"), true);
s.Driver.SetCheckbox(By.Id("LNURLStandardInvoiceEnabled"), false);
s.Driver.SetCheckbox(By.Id("DisableBolt11PaymentMethod"), true);
s.Driver.FindElement(By.Id("save")).Click();
Assert.Contains($"{cryptoCode} Lightning settings successfully updated", s.FindAlertMessage().Text);
@@ -1384,7 +1424,7 @@ namespace BTCPayServer.Tests
// Ensure the toggles are set correctly
s.Driver.FindElement(By.Id($"Modify-Lightning{cryptoCode}")).Click();
Assert.True(s.Driver.FindElement(By.Id("DisableBolt11PaymentMethod")).Selected);
Assert.True(s.Driver.FindElement(By.Id("LNURLStandardInvoiceEnabled")).Selected);
Assert.False(s.Driver.FindElement(By.Id("LNURLStandardInvoiceEnabled")).Selected);
Assert.False(s.Driver.FindElement(By.Id("LNURLBech32Mode")).Selected);
s.CreateInvoice(store.storeName, 0.0000001m, cryptoCode,"",null, expectedSeverity: StatusMessageModel.StatusSeverity.Error);
@@ -1416,7 +1456,8 @@ namespace BTCPayServer.Tests
Assert.Equal(new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike),PaymentMethodId.Parse(Assert.Single(s.Driver.FindElement(By.Id("PaymentMethods")).FindElements(By.TagName("option"))).GetAttribute("value")));
s.Driver.FindElement(By.Id("Name")).SendKeys("PP1");
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("0.0000001");;
s.Driver.FindElement(By.Id("Amount")).SendKeys("0.0000001");
;
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.FindElement(By.Id("Destination")).SendKeys(lnurl);
@@ -1450,6 +1491,87 @@ namespace BTCPayServer.Tests
});
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
public async Task CanUseLNAddress()
{
using var s = SeleniumTester.Create();
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
s.RegisterNewUser(true);
//ln address tests
var store = s.CreateNewStore();
s.GoToStore(s.StoreId, StoreNavPages.Integrations);
//ensure ln address is not available as lnurl is not configured
s.Driver.FindElement(By.Id("lightning-address-option"))
.FindElement(By.ClassName("btcpay-status--disabled"));
s.GoToStore(s.StoreId, StoreNavPages.PaymentMethods);
s.AddLightningNode("BTC", LightningConnectionType.LndREST, false);
s.Driver.FindElement(By.Id($"Modify-LightningBTC")).Click();
s.Driver.SetCheckbox(By.Id("LNURLEnabled"), true);
s.Driver.WaitForAndClick(By.Id("save"));
Assert.Contains($"BTC Lightning settings successfully updated", s.FindAlertMessage().Text);
s.GoToStore(s.StoreId, StoreNavPages.Integrations);
s.Driver.FindElement(By.Id("lightning-address-option"))
.FindElement(By.Id("lightning-address-setup-link")).Click();
s.Driver.ToggleCollapse("AddAddress");
var lnaddress1 = Guid.NewGuid().ToString();
s.Driver.FindElement(By.Id("Add_Username")).SendKeys(lnaddress1);
s.Driver.FindElement(By.CssSelector("button[value='add']")).Click();
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.Driver.ToggleCollapse("AddAddress");
s.Driver.FindElement(By.Id("Add_Username")).SendKeys(lnaddress1);
s.Driver.FindElement(By.CssSelector("button[value='add']")).Click();
s.Driver.FindElement(By.ClassName("text-danger"));
s.Driver.FindElement(By.Id("Add_Username")).Clear();
var lnaddress2 = "EUR" + Guid.NewGuid().ToString();
s.Driver.FindElement(By.Id("Add_Username")).SendKeys(lnaddress2);
s.Driver.ToggleCollapse("AdvancedSettings");
s.Driver.FindElement(By.Id("Add_CurrencyCode")).SendKeys("EUR");
s.Driver.FindElement(By.Id("Add_Min")).SendKeys("2");
s.Driver.FindElement(By.Id("Add_Max")).SendKeys("10");
s.Driver.FindElement(By.CssSelector("button[value='add']")).Click();
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
var addresses = s.Driver.FindElements(By.ClassName("lightning-address-value"));
Assert.Equal(2, addresses.Count);
foreach (IWebElement webElement in addresses)
{
var value = webElement.GetAttribute("value");
//cannot test this directly as https is not supported on our e2e tests
// var request = await LNURL.LNURL.FetchPayRequestViaInternetIdentifier(value, new HttpClient());
var lnurl = new Uri(LNURL.LNURL.ExtractUriFromInternetIdentifier(value).ToString()
.Replace("https", "http"));
var request =(LNURL.LNURLPayRequest) await LNURL.LNURL.FetchInformation(lnurl, new HttpClient());
switch (value)
{
case { } v when v.StartsWith(lnaddress2):
Assert.Equal(2, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi));
Assert.Equal(10, request.MaxSendable.ToDecimal(LightMoneyUnit.Satoshi));
break;
case { } v when v.StartsWith(lnaddress1):
Assert.Equal(1, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi));
Assert.Equal(6.12m, request.MaxSendable.ToDecimal(LightMoneyUnit.BTC));
break;
}
}
}
private static void CanBrowseContent(SeleniumTester s)
{
s.Driver.FindElement(By.ClassName("delivery-content")).Click();

View File

@@ -332,6 +332,8 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<LightningLikePaymentHandler>());
services.AddSingleton<LNURLPayPaymentHandler>();
services.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<LNURLPayPaymentHandler>());
services.AddSingleton<IUIExtension>(new UIExtension("LNURL/LightningAddressOption",
"store-integrations-list"));
services.AddSingleton<IHostedService, LightningListener>();
services.AddSingleton<PaymentMethodHandlerDictionary>();

View File

@@ -21,11 +21,13 @@ using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using LNURL;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin;
using NBitcoin.Crypto;
using Newtonsoft.Json;
@@ -103,7 +105,7 @@ namespace BTCPayServer
return NotFound();
}
ViewPointOfSaleViewModel.Item[] items = { };
ViewPointOfSaleViewModel.Item[] items = null;
string currencyCode = null;
switch (app.AppType)
{
@@ -133,6 +135,62 @@ namespace BTCPayServer
() => (null, new List<string> { AppService.GetAppInternalTag(appId) }, item.Price.Value, true));
}
public class EditLightningAddressVM
{
public class EditLightningAddressItem : LightningAddressSettings.LightningAddressItem
{
[Required]
[RegularExpression("[a-zA-Z0-9-_]+")]
public string Username { get; set; }
}
public EditLightningAddressItem Add { get; set; }
public List<EditLightningAddressItem> Items { get; set; } = new List<EditLightningAddressItem>();
}
public class LightningAddressSettings
{
public class LightningAddressItem
{
public string StoreId { get; set; }
[Display(Name = "Invoice currency")]
public string CurrencyCode { get; set; }
public string CryptoCode { get; set; }
[Display(Name = "Min sats")]
[Range(1, double.PositiveInfinity)]
public decimal? Min { get; set; }
[Display(Name = "Max sats")]
[Range(1, double.PositiveInfinity)]
public decimal? Max { get; set; }
}
public ConcurrentDictionary<string, LightningAddressItem> Items { get; set; } =
new ConcurrentDictionary<string, LightningAddressItem>();
public ConcurrentDictionary<string, string[]> StoreToItemMap { get; set; } =
new ConcurrentDictionary<string, string[]>();
public override string ToString()
{
return null;
}
}
[HttpGet("~/.well-known/lnurlp/{username}")]
public async Task<IActionResult> ResolveLightningAddress(string username)
{
var lightningAddressSettings = await _settingsRepository.GetSettingAsync<LightningAddressSettings>() ??
new LightningAddressSettings();
if (!lightningAddressSettings.Items.TryGetValue(username.ToLowerInvariant(), out var item))
{
return NotFound();
}
return await GetLNURL(item.CryptoCode, item.StoreId, item.CurrencyCode, item.Min, item.Max,
() => (username, null, null, true));
}
[HttpGet("pay")]
public async Task<IActionResult> GetLNURL(string cryptoCode, string storeId, string currencyCode = null,
@@ -140,7 +198,6 @@ namespace BTCPayServer
Func<(string username, List<string> additionalTags, decimal? invoiceAmount, bool? anyoneCanInvoice)>
internalDetails = null)
{
currencyCode ??= cryptoCode;
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network is null || !network.SupportLightning)
{
@@ -153,6 +210,7 @@ namespace BTCPayServer
return NotFound();
}
currencyCode ??= store.GetStoreBlob().DefaultCurrency ?? cryptoCode;
var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay);
var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike);
var methods = store.GetSupportedPaymentMethods(_btcPayNetworkProvider);
@@ -178,6 +236,7 @@ namespace BTCPayServer
return NotFound();
}
var lnAddress = username is null ? null : $"{username}@{Request.Host.ToString()}";
List<string[]> lnurlMetadata = new List<string[]>();
var i = await _invoiceController.CreateInvoiceCoreRaw(
@@ -200,7 +259,21 @@ namespace BTCPayServer
max = min;
}
if (!string.IsNullOrEmpty(username))
{
var pm = i.GetPaymentMethod(pmi);
var paymentMethodDetails = (LNURLPayPaymentMethodDetails)pm.GetPaymentMethodDetails();
paymentMethodDetails.ConsumedLightningAddress = lnAddress;
pm.SetPaymentMethodDetails(paymentMethodDetails);
await _invoiceRepository.UpdateInvoicePaymentMethod(i.Id, pm);
}
lnurlMetadata.Add(new[] { "text/plain", i.Id });
if (!string.IsNullOrEmpty(username))
{
lnurlMetadata.Add(new[] { "text/identifier", lnAddress });
}
return Ok(new LNURLPayRequest
{
Tag = "payRequest",
@@ -258,6 +331,10 @@ namespace BTCPayServer
List<string[]> lnurlMetadata = new List<string[]>();
lnurlMetadata.Add(new[] { "text/plain", i.Id });
if (!string.IsNullOrEmpty(paymentMethodDetails.ConsumedLightningAddress))
{
lnurlMetadata.Add(new[] { "text/identifier", paymentMethodDetails.ConsumedLightningAddress });
}
var metadata = JsonConvert.SerializeObject(lnurlMetadata);
if (amount.HasValue && (amount < min || amount > max))
@@ -303,7 +380,7 @@ namespace BTCPayServer
});
}
}
catch (Exception e)
catch (Exception)
{
return BadRequest(new LNUrlStatusResponse
{
@@ -323,6 +400,7 @@ namespace BTCPayServer
lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails);
await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod);
_eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoiceId,
paymentMethodDetails, pmi));
return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse
@@ -365,5 +443,124 @@ namespace BTCPayServer
Status = "ERROR", Reason = "Invoice not in a valid payable state"
});
}
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("~/stores/{storeId}/integrations/lightning-address")]
public async Task<IActionResult> EditLightningAddress(string storeId)
{
if (ControllerContext.HttpContext.GetStoreData().GetEnabledPaymentIds(_btcPayNetworkProvider).All(id => id.PaymentType != LNURLPayPaymentType.Instance))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Message = "LNURL is required for lightning addresses but has not yet been enabled.",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction("PaymentMethods", "Stores", new { storeId });
}
var lightningAddressSettings = await _settingsRepository.GetSettingAsync<LightningAddressSettings>() ??
new LightningAddressSettings();
if (lightningAddressSettings.StoreToItemMap.TryGetValue(storeId, out var addresses))
{
return View(new EditLightningAddressVM
{
Items = addresses.Select(s => new EditLightningAddressVM.EditLightningAddressItem
{
Max = lightningAddressSettings.Items[s].Max,
Min = lightningAddressSettings.Items[s].Min,
CurrencyCode = lightningAddressSettings.Items[s].CurrencyCode,
CryptoCode = lightningAddressSettings.Items[s].CryptoCode,
StoreId = lightningAddressSettings.Items[s].StoreId,
Username = s,
}).ToList()
});
}
return View(new EditLightningAddressVM
{
Items = new List<EditLightningAddressVM.EditLightningAddressItem>()
});
}
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("~/stores/{storeId}/integrations/lightning-address")]
public async Task<IActionResult> EditLightningAddress(string storeId, [FromForm] EditLightningAddressVM vm,
string command, [FromServices] CurrencyNameTable currencyNameTable)
{
if (command == "add")
{
if (!string.IsNullOrEmpty(vm.Add.CurrencyCode) && currencyNameTable.GetCurrencyData(vm.Add.CurrencyCode, false) is null)
{
vm.AddModelError(addressVm => addressVm.Add.CurrencyCode, "Currency is invalid", this);
}
if (!ModelState.IsValid)
{
return View(vm);
}
var lightningAddressSettings = await _settingsRepository.GetSettingAsync<LightningAddressSettings>() ??
new LightningAddressSettings();
if (lightningAddressSettings.Items.ContainsKey(vm.Add.Username.ToLowerInvariant()))
{
vm.AddModelError(addressVm => addressVm.Add.Username, "Username is already taken", this);
}
if (!ModelState.IsValid)
{
return View(vm);
}
if (lightningAddressSettings.StoreToItemMap.TryGetValue(storeId, out var ids))
{
ids = ids.Concat(new[] { vm.Add.Username.ToLowerInvariant() }).ToArray();
}
else
{
ids = new[] { vm.Add.Username.ToLowerInvariant() };
}
lightningAddressSettings.StoreToItemMap.AddOrReplace(storeId, ids);
vm.Add.StoreId = storeId;
vm.Add.CryptoCode = ControllerContext.HttpContext.GetStoreData()
.GetEnabledPaymentIds(_btcPayNetworkProvider)
.OrderBy(id => id.CryptoCode == "BTC")
.First()
.CryptoCode;
lightningAddressSettings.Items.TryAdd(vm.Add.Username.ToLowerInvariant(), vm.Add);
await _settingsRepository.UpdateSetting(lightningAddressSettings);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Lightning address added successfully."
});
return RedirectToAction("EditLightningAddress");
}
if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase))
{
var lightningAddressSettings = await _settingsRepository.GetSettingAsync<LightningAddressSettings>() ??
new LightningAddressSettings();
var index = int.Parse(
command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
CultureInfo.InvariantCulture);
if (lightningAddressSettings.StoreToItemMap.TryGetValue(storeId, out var addresses))
{
var addressToRemove = addresses[index];
addresses = addresses.Where(s => s != addressToRemove).ToArray();
lightningAddressSettings.StoreToItemMap.AddOrReplace(storeId, addresses);
lightningAddressSettings.Items.TryRemove(addressToRemove, out _);
await _settingsRepository.UpdateSetting(lightningAddressSettings);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = $"Lightning address {addressToRemove} removed successfully."
});
}
}
return RedirectToAction("EditLightningAddress");
}
}
}

View File

@@ -15,6 +15,7 @@ namespace BTCPayServer.Payments
public bool Bech32Mode { get; set; }
public string ProvidedComment { get; set; }
public string ConsumedLightningAddress { get; set; }
public override PaymentType GetPaymentType()
{
@@ -23,7 +24,7 @@ namespace BTCPayServer.Payments
public override string GetAdditionalDataPartialName()
{
if (string.IsNullOrEmpty(ProvidedComment))
if (string.IsNullOrEmpty(ProvidedComment) && string.IsNullOrEmpty(ConsumedLightningAddress))
{
return null;
}

View File

@@ -0,0 +1,144 @@
@using BTCPayServer.Views.Stores
@using BTCPayServer.Abstractions.Extensions
@model LNURLController.EditLightningAddressVM
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["NavPartialName"] = "../Stores/_Nav";
ViewData.SetActivePageAndTitle(StoreNavPages.Integrations, "Lightning Address Setup", Context.GetStoreData().StoreName);
}
@section PageHeadContent {
<style>
.settings-holder span:not(:last-child):after{
content: " / ";
}
</style>
}
@section PageFootContent {
<script>
delegate('click', '.remove', event => {
event.preventDefault()
const { name, value } = event.target
const confirmButton = document.getElementById('ConfirmContinue')
confirmButton.setAttribute('name', name)
confirmButton.setAttribute('value', value)
})
</script>
}
<div class="d-sm-flex align-items-center justify-content-between mb-2">
<h2 class="mb-3 mb-sm-0">@ViewData["PageTitle"]</h2>
<a data-bs-toggle="collapse" data-bs-target="#AddAddress" class="btn btn-primary" role="button">
<span class="fa fa-plus"></span>
Add Address
</a>
</div>
<form asp-action="EditLightningAddress" method="post">
@{
var showAddForm = !ViewContext.ViewData.ModelState.IsValid || !string.IsNullOrEmpty(Model.Add?.Username) || Model.Add?.Max != null || Model.Add?.Min != null || !string.IsNullOrEmpty(Model.Add?.CurrencyCode);
var showAdvancedOptions = !string.IsNullOrEmpty(Model.Add?.CurrencyCode) || Model.Add?.Min != null || Model.Add?.Max != null;
}
<div class="row collapse @(showAddForm ? "show": "")" id="AddAddress">
<div class="form-group pt-3">
<label asp-for="Add.Username" class="form-label"></label>
<div class="input-group">
<input asp-for="Add.Username" class="form-control"/>
<span class="input-group-text" >@@@Context.Request.Host.ToUriComponent()@Context.Request.PathBase</span>
</div>
<span asp-validation-for="Add.Username" class="text-danger"></span>
</div>
<a class="mb-3" role="button" data-bs-toggle="collapse" data-bs-target="#AdvancedSettings">Advanced settings</a>
<div id="AdvancedSettings" class="collapse @(showAdvancedOptions ? "show" : "")">
<div class="row">
<div class="col-12 col-sm-auto">
<div class="form-group">
<label asp-for="Add.CurrencyCode" class="form-label"></label>
<input asp-for="Add.CurrencyCode" class="form-control" style="max-width:16ch;"/>
<span asp-validation-for="Add.CurrencyCode" class="text-danger"></span>
</div>
</div>
<div class="col-12 col-sm-auto">
<div class="form-group">
<label asp-for="Add.Min" class="form-label"></label>
<input asp-for="Add.Min" class="form-control" type="number" min="1" style="max-width:16ch;"/>
<span asp-validation-for="Add.Min" class="text-danger"></span>
</div>
</div>
<div class="col-12 col-sm-auto">
<div class="form-group">
<label asp-for="Add.Max" class="form-label"></label>
<input asp-for="Add.Max" class="form-control" type="number" min="1" max="@int.MaxValue" style="max-width:16ch;"/>
<span asp-validation-for="Add.Max" class="text-danger"></span>
</div>
</div>
</div>
</div>
<div class="form-group">
<button type="submit" name="command" value="add" class="btn btn-primary">Save</button>
</div>
</div>
@if (Model.Items.Any())
{
<table class="table table-hover">
<thead>
<tr>
<th>Address</th>
<th>Settings</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@for (var index = 0; index < Model.Items.Count; index++)
{
<input asp-for="Items[index].CurrencyCode" type="hidden"/>
<input asp-for="Items[index].Min" type="hidden"/>
<input asp-for="Items[index].Max" type="hidden"/>
<input asp-for="Items[index].Username" type="hidden"/>
var address = $"{Model.Items[index].Username}@{Context.Request.Host.ToUriComponent()}{Context.Request.PathBase}";
<tr>
<td>
<div class="input-group" data-clipboard="@address">
<input type="text" class="form-control copy-cursor lightning-address-value" readonly="readonly" value="@address"/>
<button type="button" class="btn btn-outline-secondary" data-clipboard-confirm>Copy</button>
</div>
</td>
<td class="settings-holder align-middle">
@if (Model.Items[index].Min.HasValue)
{
<span>@Safe.Raw($"{Model.Items[index].Min} min sats")</span>
}
@if (Model.Items[index].Max.HasValue)
{
<span> @Safe.Raw($"{Model.Items[index].Max} max sats")</span>
}
@if (!string.IsNullOrEmpty(Model.Items[index].CurrencyCode))
{
<span> @Safe.Raw($"tracked in {Model.Items[index].CurrencyCode}")</span>
}
</td>
<td class="text-end">
<button type="submit" title="Remove" name="command" value="@($"remove:{index}")"
class="btn btn-link px-0 remove" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The Lightning Address <strong>@address</strong> will be removed." data-confirm-input="REMOVE">
Remove
</button>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-secondary mt-3">
There are no Lightning Addresses yet.
</p>
}
</form>
<partial name="_Confirm" model="@(new ConfirmModel("Remove Lightning Address", "This Lightning Address will be removed.", "Remove"))" />

View File

@@ -10,3 +10,13 @@
</td>
</tr>
}
@if (!string.IsNullOrEmpty(Model.ConsumedLightningAddress))
{
<tr>
<td colspan="100% bg-tile">
Lightning address used: @Model.ConsumedLightningAddress
</td>
</tr>
}

View File

@@ -0,0 +1,33 @@
@using BTCPayServer.Payments.Lightning
@inject BTCPayNetworkProvider BTCPayNetworkProvider
@{
var store = Context.GetStoreData();
var possible = store.GetSupportedPaymentMethods(BTCPayNetworkProvider).OfType<LNURLPaySupportedPaymentMethod>().Any(type => type.CryptoCode == "BTC");
}
<li class="list-group-item bg-tile" id="lightning-address-option">
<div class="d-flex align-items-center">
<span class="d-flex flex-wrap flex-fill flex-column flex-sm-row">
<strong class="me-3">
Lightning Address
<a href="https://lightningaddress.com/" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o" title="More information..."></span>
</a>
</strong>
</span>
<span class="d-flex align-items-center fw-semibold">
@if (possible)
{
<a id="lightning-address-setup-link" class="btn btn-primary btn-sm ms-4 px-3 py-1 fw-semibold" asp-controller="LNURL" asp-action="EditLightningAddress" asp-route-storeId="@Context.GetRouteValue("storeId")">
Setup
</a>
}
else
{
<span class="d-flex align-items-center text-danger">
<span class="me-2 btcpay-status btcpay-status--disabled"></span>
You need LNURL configured first.
</span>
}
</span>
</div>
</li>