diff --git a/BTCPayServer.Tests/EclairTester.cs b/BTCPayServer.Tests/EclairTester.cs index 04e651040..3c886f764 100644 --- a/BTCPayServer.Tests/EclairTester.cs +++ b/BTCPayServer.Tests/EclairTester.cs @@ -34,5 +34,6 @@ namespace BTCPayServer.Tests { return GetNodeInfoAsync().GetAwaiter().GetResult(); } + } } diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 8ee29fa06..bb1922506 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -65,7 +65,7 @@ namespace BTCPayServer.Tests PayTester.HostName = GetEnvironment("TESTS_HOSTNAME", "127.0.0.1"); PayTester.Start(); - var btc = NetworkProvider.GetNetwork("BTC").NBitcoinNetwork; + var btc = NetworkProvider.GetNetwork("BTC").NBitcoinNetwork; CustomerEclair = new EclairTester(this, "TEST_ECLAIR", "http://eclair-cli:gpwefwmmewci@127.0.0.1:30992/", "eclair", btc); MerchantCharge = new ChargeTester(this, "TEST_CHARGE", "http://api-token:foiewnccewuify@127.0.0.1:54938/", "lightning-charged", btc); } @@ -89,13 +89,35 @@ namespace BTCPayServer.Tests var channels = CustomerEclair.RPC.ChannelsAsync(); var info = await merchantInfo; - var connect = CustomerEclair.RPC.ConnectAsync(new NodeInfo(info.Id, MerchantCharge.P2PHost, info.Port)); + var clightning = new NodeInfo(info.Id, MerchantCharge.P2PHost, info.Port); + var connect = CustomerEclair.RPC.ConnectAsync(clightning); await Task.WhenAll(blockCount, customer, channels, connect); // Mine until segwit is activated if (blockCount.Result <= 432) { ExplorerNode.Generate(433 - blockCount.Result); } + + if (channels.Result.Length == 0) + { + await CustomerEclair.RPC.OpenAsync(clightning, Money.Satoshis(16777215)); + while ((await CustomerEclair.RPC.ChannelsAsync())[0].State != "NORMAL") + { + ExplorerNode.Generate(1); + } + } + } + + public void SendLightningPayment(Invoice invoice) + { + SendLightningPaymentAsync(invoice).GetAwaiter().GetResult(); + } + + public async Task SendLightningPaymentAsync(Invoice invoice) + { + var bolt11 = invoice.CryptoInfo.Where(o => o.PaymentUrls.BOLT11 != null).First().PaymentUrls.BOLT11; + bolt11 = bolt11.Replace("lightning:", "", StringComparison.OrdinalIgnoreCase); + await CustomerEclair.RPC.SendAsync(bolt11); } public EclairTester MerchantEclair { get; set; } diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 2079474e9..1763687e1 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -81,7 +81,7 @@ namespace BTCPayServer.Tests DerivationSchemeFormat = "BTCPay", DerivationScheme = DerivationScheme.ToString(), Confirmation = true - }, "Save"); + }); } public DerivationStrategyBase DerivationScheme { get; set; } @@ -111,5 +111,20 @@ namespace BTCPayServer.Tests { get; set; } + + public void RegisterLightningNode(string cryptoCode) + { + RegisterLightningNodeAsync(cryptoCode).GetAwaiter().GetResult(); + } + + public async Task RegisterLightningNodeAsync(string cryptoCode) + { + var storeController = parent.PayTester.GetController(UserId); + await storeController.AddLightningNode(StoreId, new LightningNodeViewModel() + { + CryptoCurrency = "BTC", + Url = parent.MerchantCharge.Client.Uri.AbsoluteUri + }, "save"); + } } } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 304cbfdfc..45a4b5b32 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -30,6 +30,7 @@ using System.Globalization; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.HostedServices; +using BTCPayServer.Payments.Lightning; namespace BTCPayServer.Tests { @@ -181,7 +182,7 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid); Assert.Equal(Money.Coins(1.0m), accounting.Paid); Assert.Equal(Money.Coins(5.2m), accounting.TotalDue); - Assert.Equal(2, accounting.TxCount); + Assert.Equal(2, accounting.TxRequired); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null); accounting = paymentMethod.Calculate(); @@ -199,7 +200,7 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid); Assert.Equal(Money.Coins(1.5m), accounting.Paid); Assert.Equal(Money.Coins(5.2m + 0.01m / 2), accounting.TotalDue); // The fee for LTC added - Assert.Equal(2, accounting.TxCount); + Assert.Equal(2, accounting.TxRequired); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null); accounting = paymentMethod.Calculate(); @@ -207,7 +208,7 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid); Assert.Equal(Money.Coins(3.0m), accounting.Paid); Assert.Equal(Money.Coins(10.01m + 0.1m * 2 + 0.01m), accounting.TotalDue); - Assert.Equal(2, accounting.TxCount); + Assert.Equal(2, accounting.TxRequired); var remaining = Money.Coins(4.2m - 0.5m + 0.01m / 2); entity.Payments.Add(new PaymentEntity() { CryptoCode = "BTC", Output = new TxOut(remaining, new Key()), Accounted = true }); @@ -219,7 +220,7 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(1.5m) + remaining, accounting.Paid); Assert.Equal(Money.Coins(5.2m + 0.01m / 2), accounting.TotalDue); Assert.Equal(accounting.Paid, accounting.TotalDue); - Assert.Equal(2, accounting.TxCount); + Assert.Equal(2, accounting.TxRequired); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null); accounting = paymentMethod.Calculate(); @@ -228,7 +229,7 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(3.0m) + remaining * 2, accounting.Paid); // Paying 2 BTC fee, LTC fee removed because fully paid Assert.Equal(Money.Coins(10.01m + 0.1m * 2 + 0.1m * 2 /* + 0.01m no need to pay this fee anymore */), accounting.TotalDue); - Assert.Equal(1, accounting.TxCount); + Assert.Equal(1, accounting.TxRequired); Assert.Equal(accounting.Paid, accounting.TotalDue); #pragma warning restore CS0618 } @@ -287,6 +288,41 @@ namespace BTCPayServer.Tests { var light = LightMoney.MilliSatoshis(1); Assert.Equal("0.00000000001", light.ToString()); + + light = LightMoney.MilliSatoshis(200000); + Assert.Equal(200m, light.ToDecimal(LightMoneyUnit.Satoshi)); + Assert.Equal(0.00000001m * 200m, light.ToDecimal(LightMoneyUnit.BTC)); + } + + [Fact] + public void CanSetLightningServer() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + var storeController = tester.PayTester.GetController(user.UserId); + Assert.IsType(storeController.UpdateStore(user.StoreId).GetAwaiter().GetResult()); + Assert.IsType(storeController.AddLightningNode(user.StoreId).GetAwaiter().GetResult()); + + var testResult = storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel() + { + CryptoCurrency = "BTC", + Url = tester.MerchantCharge.Client.Uri.AbsoluteUri + }, "test").GetAwaiter().GetResult(); + Assert.DoesNotContain("Error", ((LightningNodeViewModel)Assert.IsType(testResult).Model).StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.True(storeController.ModelState.IsValid); + + Assert.IsType(storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel() + { + CryptoCurrency = "BTC", + Url = tester.MerchantCharge.Client.Uri.AbsoluteUri + }, "save").GetAwaiter().GetResult()); + + var storeVm = Assert.IsType(Assert.IsType(storeController.UpdateStore(user.StoreId).GetAwaiter().GetResult()).Model); + Assert.Single(storeVm.LightningNodes); + } } [Fact] @@ -297,7 +333,29 @@ namespace BTCPayServer.Tests { tester.Start(); tester.PrepareLightning(); + var user = tester.NewAccount(); + user.GrantAccess(); + user.RegisterLightningNode("BTC"); + user.RegisterDerivationScheme("BTC"); + var invoice = user.BitPay.CreateInvoice(new Invoice() + { + Price = 0.01, + Currency = "USD", + PosData = "posData", + OrderId = "orderId", + ItemDesc = "Some description" + }); + + + tester.SendLightningPayment(invoice); + + Eventually(() => + { + var localInvoice = user.BitPay.GetInvoice(invoice.Id); + Assert.Equal("complete", localInvoice.Status); + Assert.Equal("False", localInvoice.ExceptionStatus.ToString()); + }); } } @@ -674,7 +732,7 @@ namespace BTCPayServer.Tests }, Facade.Merchant); var repo = tester.PayTester.GetService(); var ctx = tester.PayTester.GetService().CreateContext(); - + Assert.Equal(0, invoice.CryptoInfo[0].TxCount); Eventually(() => { var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery() @@ -730,6 +788,7 @@ namespace BTCPayServer.Tests Assert.Equal(firstPayment, localInvoice.BtcPaid); txFee = localInvoice.BtcDue - invoice.BtcDue; Assert.Equal("paidPartial", localInvoice.ExceptionStatus.ToString()); + Assert.Equal(1, localInvoice.CryptoInfo[0].TxCount); Assert.NotEqual(localInvoice.BitcoinAddress, invoice.BitcoinAddress); //New address Assert.True(IsMapped(invoice, ctx)); Assert.True(IsMapped(localInvoice, ctx)); @@ -749,6 +808,7 @@ namespace BTCPayServer.Tests { var localInvoice = user.BitPay.GetInvoice(invoice.Id, Facade.Merchant); Assert.Equal("paid", localInvoice.Status); + Assert.Equal(2, localInvoice.CryptoInfo[0].TxCount); Assert.Equal(firstPayment + secondPayment, localInvoice.BtcPaid); Assert.Equal(Money.Zero, localInvoice.BtcDue); Assert.Equal(localInvoice.BitcoinAddress, invoiceAddress.ToString()); //no new address generated @@ -835,7 +895,7 @@ namespace BTCPayServer.Tests private void Eventually(Action act) { - CancellationTokenSource cts = new CancellationTokenSource(20000); + CancellationTokenSource cts = new CancellationTokenSource(200000); while (true) { try diff --git a/BTCPayServer/BTCPayNetwork.cs b/BTCPayServer/BTCPayNetwork.cs index 7462f7a00..5cf350259 100644 --- a/BTCPayServer/BTCPayNetwork.cs +++ b/BTCPayServer/BTCPayNetwork.cs @@ -62,11 +62,13 @@ namespace BTCPayServer } public string CryptoImagePath { get; set; } + public string LightningImagePath { get; set; } public NBXplorer.NBXplorerNetwork NBXplorerNetwork { get; set; } public BTCPayDefaultSettings DefaultSettings { get; set; } public KeyPath CoinType { get; internal set; } public int MaxTrackedConfirmation { get; internal set; } = 6; + public string CLightningNetworkName { get; internal set; } public override string ToString() { diff --git a/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs b/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs index 89fd2444a..fe8f127e6 100644 --- a/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs +++ b/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs @@ -20,14 +20,18 @@ namespace BTCPayServer Add(new BTCPayNetwork() { CryptoCode = nbxplorerNetwork.CryptoCode, - BlockExplorerLink = NBXplorerNetworkProvider.ChainType == ChainType.Main? "https://www.smartbit.com.au/tx/{0}" : "https://testnet.smartbit.com.au/tx/{0}", + BlockExplorerLink = NBXplorerNetworkProvider.ChainType == ChainType.Main ? "https://www.smartbit.com.au/tx/{0}" : "https://testnet.smartbit.com.au/tx/{0}", NBitcoinNetwork = nbxplorerNetwork.NBitcoinNetwork, NBXplorerNetwork = nbxplorerNetwork, UriScheme = "bitcoin", DefaultRateProvider = btcRate, CryptoImagePath = "imlegacy/bitcoin-symbol.svg", + LightningImagePath = "imlegacy/bitcoin-symbol.svg", DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NBXplorerNetworkProvider.ChainType), - CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("0'") : new KeyPath("1'") + CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("0'") : new KeyPath("1'"), + CLightningNetworkName = ChainType == ChainType.Main ? "bitcoin" : + ChainType == ChainType.Test ? "testnet" : + ChainType == ChainType.Regtest ? "regtest" : null }); } } diff --git a/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs b/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs index db99984b8..98c7aff8d 100644 --- a/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs +++ b/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs @@ -25,8 +25,11 @@ namespace BTCPayServer UriScheme = "litecoin", DefaultRateProvider = ltcRate, CryptoImagePath = "imlegacy/litecoin-symbol.svg", + LightningImagePath = "imlegacy/litecoin-symbol.svg", DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NBXplorerNetworkProvider.ChainType), - CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("2'") : new KeyPath("3'") + CoinType = NBXplorerNetworkProvider.ChainType == ChainType.Main ? new KeyPath("2'") : new KeyPath("3'"), + CLightningNetworkName = ChainType == ChainType.Main ? "litecoin" : + ChainType == ChainType.Test ? "litecoin-testnet" : null }); } } diff --git a/BTCPayServer/BTCPayNetworkProvider.cs b/BTCPayServer/BTCPayNetworkProvider.cs index 09b78e3b5..e36feee6e 100644 --- a/BTCPayServer/BTCPayNetworkProvider.cs +++ b/BTCPayServer/BTCPayNetworkProvider.cs @@ -25,13 +25,40 @@ namespace BTCPayServer } } + BTCPayNetworkProvider(BTCPayNetworkProvider filtered, string[] cryptoCodes) + { + ChainType = filtered.ChainType; + _NBXplorerNetworkProvider = new NBXplorerNetworkProvider(filtered.ChainType); + _Networks = new Dictionary(); + cryptoCodes = cryptoCodes.Select(c => c.ToUpperInvariant()).ToArray(); + foreach (var network in filtered._Networks) + { + if(cryptoCodes.Contains(network.Key)) + { + _Networks.Add(network.Key, network.Value); + } + } + } + + public ChainType ChainType { get; set; } public BTCPayNetworkProvider(ChainType chainType) { _NBXplorerNetworkProvider = new NBXplorerNetworkProvider(chainType); + ChainType = chainType; InitBitcoin(); InitLitecoin(); } + /// + /// Keep only the specified crypto + /// + /// Crypto to support + /// + public BTCPayNetworkProvider Filter(string[] cryptoCodes) + { + return new BTCPayNetworkProvider(this, cryptoCodes); + } + [Obsolete("To use only for legacy stuff")] public BTCPayNetwork BTC { @@ -43,7 +70,7 @@ namespace BTCPayServer public void Add(BTCPayNetwork network) { - _Networks.Add(network.CryptoCode, network); + _Networks.Add(network.CryptoCode.ToUpperInvariant(), network); } public IEnumerable GetAll() @@ -51,6 +78,11 @@ namespace BTCPayServer return _Networks.Values.ToArray(); } + public bool Support(string cryptoCode) + { + return _Networks.ContainsKey(cryptoCode.ToUpperInvariant()); + } + public BTCPayNetwork GetNetwork(string cryptoCode) { _Networks.TryGetValue(cryptoCode.ToUpperInvariant(), out BTCPayNetwork network); diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index bf62c22df..7d0777b99 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -26,7 +26,7 @@ - + diff --git a/BTCPayServer/Configuration/BTCPayServerOptions.cs b/BTCPayServer/Configuration/BTCPayServerOptions.cs index d7f9aafb4..4d8ad31ab 100644 --- a/BTCPayServer/Configuration/BTCPayServerOptions.cs +++ b/BTCPayServer/Configuration/BTCPayServerOptions.cs @@ -58,28 +58,30 @@ namespace BTCPayServer.Configuration var supportedChains = conf.GetOrDefault("chains", "btc") .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(t => t.ToUpperInvariant()); - var validChains = new List(); - foreach (var net in new BTCPayNetworkProvider(ChainType).GetAll()) + NetworkProvider = new BTCPayNetworkProvider(ChainType).Filter(supportedChains.ToArray()); + foreach (var chain in supportedChains) { - if (supportedChains.Contains(net.CryptoCode)) - { - validChains.Add(net.CryptoCode); - NBXplorerConnectionSetting setting = new NBXplorerConnectionSetting(); - setting.CryptoCode = net.CryptoCode; - setting.ExplorerUri = conf.GetOrDefault($"{net.CryptoCode}.explorer.url", net.NBXplorerNetwork.DefaultSettings.DefaultUrl); - setting.CookieFile = conf.GetOrDefault($"{net.CryptoCode}.explorer.cookiefile", net.NBXplorerNetwork.DefaultSettings.DefaultCookieFile); - NBXplorerConnectionSettings.Add(setting); - } + if (NetworkProvider.GetNetwork(chain) == null) + throw new ConfigException($"Invalid chains \"{chain}\""); + } + + var validChains = new List(); + foreach (var net in NetworkProvider.GetAll()) + { + NBXplorerConnectionSetting setting = new NBXplorerConnectionSetting(); + setting.CryptoCode = net.CryptoCode; + setting.ExplorerUri = conf.GetOrDefault($"{net.CryptoCode}.explorer.url", net.NBXplorerNetwork.DefaultSettings.DefaultUrl); + setting.CookieFile = conf.GetOrDefault($"{net.CryptoCode}.explorer.cookiefile", net.NBXplorerNetwork.DefaultSettings.DefaultCookieFile); + NBXplorerConnectionSettings.Add(setting); } - var invalidChains = String.Join(',', supportedChains.Where(s => !validChains.Contains(s)).ToArray()); - if(!string.IsNullOrEmpty(invalidChains)) - throw new ConfigException($"Invalid chains {invalidChains}"); Logs.Configuration.LogInformation("Supported chains: " + String.Join(',', supportedChains.ToArray())); + PostgresConnectionString = conf.GetOrDefault("postgres", null); ExternalUrl = conf.GetOrDefault("externalurl", null); } + public BTCPayNetworkProvider NetworkProvider { get; set; } public string PostgresConnectionString { get; diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index e8665bfa9..6d8c559ea 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -198,24 +198,29 @@ namespace BTCPayServer.Controllers Rate = FormatCurrency(paymentMethod), MerchantRefLink = invoice.RedirectURL ?? "/", StoreName = store.StoreName, - InvoiceBitcoinUrl = cryptoInfo.PaymentUrls.BIP21, - TxCount = accounting.TxCount, + InvoiceBitcoinUrl = paymentMethodId.PaymentType == PaymentTypes.BTCLike ? cryptoInfo.PaymentUrls.BIP21 : + paymentMethodId.PaymentType == PaymentTypes.LightningLike ? cryptoInfo.PaymentUrls.BOLT11 : + throw new NotSupportedException(), + InvoiceBitcoinUrlQR = paymentMethodId.PaymentType == PaymentTypes.BTCLike ? cryptoInfo.PaymentUrls.BIP21 : + paymentMethodId.PaymentType == PaymentTypes.LightningLike ? cryptoInfo.PaymentUrls.BOLT11.ToUpperInvariant() : + throw new NotSupportedException(), + TxCount = accounting.TxRequired, BtcPaid = accounting.Paid.ToString(), Status = invoice.Status, - CryptoImage = "/" + Url.Content(network.CryptoImagePath), - NetworkFeeDescription = $"{accounting.TxCount} transaction{(accounting.TxCount > 1 ? "s" : "")} x {paymentMethodDetails.GetTxFee()} {network.CryptoCode}", + CryptoImage = "/" + GetImage(paymentMethodId, network), + NetworkFeeDescription = $"{accounting.TxRequired} transaction{(accounting.TxRequired > 1 ? "s" : "")} x {paymentMethodDetails.GetTxFee()} {network.CryptoCode}", AvailableCryptos = invoice.GetPaymentMethods(_NetworkProvider) .Where(i => i.Network != null) .Select(kv=> new PaymentModel.AvailableCrypto() { PaymentMethodId = kv.GetId().ToString(), - CryptoImage = "/" + kv.Network.CryptoImagePath, + CryptoImage = "/" + GetImage(kv.GetId(), kv.Network), Link = Url.Action(nameof(Checkout), new { invoiceId = invoiceId, paymentMethodId = kv.GetId().ToString() }) }).Where(c => c.CryptoImage != "/") .ToList() }; - var isMultiCurrency = invoice.GetPayments().Select(p=>p.GetCryptoCode()).Concat(new[] { network.CryptoCode }).Distinct().Count() > 1; + var isMultiCurrency = invoice.GetPayments().Select(p=>p.GetpaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1; if (isMultiCurrency) model.NetworkFeeDescription = $"{accounting.NetworkFee} {network.CryptoCode}"; @@ -224,6 +229,11 @@ namespace BTCPayServer.Controllers return model; } + private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network) + { + return (paymentMethodId.PaymentType == PaymentTypes.BTCLike ? Url.Content(network.CryptoImagePath) : Url.Content(network.LightningImagePath)); + } + private string FormatCurrency(PaymentMethod paymentMethod) { string currency = paymentMethod.ParentEntity.ProductInformation.Currency; diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index ebe7b2322..d9a4324d2 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -138,6 +138,7 @@ namespace BTCPayServer.Controllers { var rate = await storeBlob.ApplyRateRules(o.Network, _RateProviders.GetRateProvider(o.Network, false)).GetRateAsync(invoice.Currency); PaymentMethod paymentMethod = new PaymentMethod(); + paymentMethod.ParentEntity = entity; paymentMethod.SetId(o.SupportedPaymentMethod.PaymentId); paymentMethod.Rate = rate; var paymentDetails = await o.Handler.CreatePaymentMethodDetails(o.SupportedPaymentMethod, paymentMethod, o.Network); @@ -162,7 +163,7 @@ namespace BTCPayServer.Controllers #pragma warning disable CS0618 // Legacy Bitpay clients expect information for BTC information, even if the store do not support it var legacyBTCisSet = paymentMethods.Any(p => p.GetId().IsBTCOnChain); - if (!legacyBTCisSet) + if (!legacyBTCisSet && _NetworkProvider.BTC != null) { var btc = _NetworkProvider.BTC; var feeProvider = ((IFeeProviderFactory)_ServiceProvider.GetService(typeof(IFeeProviderFactory))).CreateFeeProvider(btc); diff --git a/BTCPayServer/Controllers/StoresController.BTCLike.cs b/BTCPayServer/Controllers/StoresController.BTCLike.cs new file mode 100644 index 000000000..3c3f16e42 --- /dev/null +++ b/BTCPayServer/Controllers/StoresController.BTCLike.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Services; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBXplorer.DerivationStrategy; +using Newtonsoft.Json; + +namespace BTCPayServer.Controllers +{ + public partial class StoresController + { + [HttpGet] + [Route("{storeId}/derivations")] + public async Task AddDerivationScheme(string storeId, string selectedScheme = null) + { + selectedScheme = selectedScheme ?? "BTC"; + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + DerivationSchemeViewModel vm = new DerivationSchemeViewModel(); + vm.ServerUrl = GetStoreUrl(storeId); + vm.SetCryptoCurrencies(_ExplorerProvider, selectedScheme); + return View(vm); + } + + [HttpPost] + [Route("{storeId}/derivations")] + public async Task AddDerivationScheme(string storeId, DerivationSchemeViewModel vm) + { + vm.ServerUrl = GetStoreUrl(storeId); + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + + var network = vm.CryptoCurrency == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCurrency); + vm.SetCryptoCurrencies(_ExplorerProvider, vm.CryptoCurrency); + if (network == null) + { + ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network"); + return View(vm); + } + var wallet = _WalletProvider.GetWallet(network); + if (wallet == null) + { + ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network"); + return View(vm); + } + + PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); + DerivationStrategy strategy = null; + try + { + if (!string.IsNullOrEmpty(vm.DerivationScheme)) + { + strategy = ParseDerivationStrategy(vm.DerivationScheme, vm.DerivationSchemeFormat, network); + vm.DerivationScheme = strategy.ToString(); + } + store.SetSupportedPaymentMethod(paymentMethodId, strategy); + } + catch + { + ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme"); + vm.Confirmation = false; + return View(vm); + } + + + if (vm.Confirmation) + { + try + { + if (strategy != null) + await wallet.TrackAsync(strategy.DerivationStrategyBase); + store.SetSupportedPaymentMethod(paymentMethodId, strategy); + } + catch + { + ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme"); + return View(vm); + } + + await _Repo.UpdateStore(store); + StatusMessage = $"Derivation scheme for {network.CryptoCode} has been modified."; + return RedirectToAction(nameof(UpdateStore), new { storeId = storeId }); + } + else + { + if (!string.IsNullOrEmpty(vm.DerivationScheme)) + { + var line = strategy.DerivationStrategyBase.GetLineFor(DerivationFeature.Deposit); + + for (int i = 0; i < 10; i++) + { + var address = line.Derive((uint)i); + vm.AddressSamples.Add((DerivationStrategyBase.GetKeyPath(DerivationFeature.Deposit).Derive((uint)i).ToString(), address.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork).ToString())); + } + } + vm.Confirmation = true; + return View(vm); + } + } + + + + public class GetInfoResult + { + public int RecommendedSatoshiPerByte { get; set; } + public double Balance { get; set; } + } + + public class SendToAddressResult + { + public string TransactionId { get; set; } + } + + [HttpGet] + [Route("{storeId}/ws/ledger")] + public async Task LedgerConnection( + string storeId, + string command, + // getinfo + string cryptoCode = null, + // sendtoaddress + string destination = null, string amount = null, string feeRate = null, string substractFees = null + ) + { + if (!HttpContext.WebSockets.IsWebSocketRequest) + return NotFound(); + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + + var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + + var hw = new HardwareWalletService(webSocket); + object result = null; + try + { + BTCPayNetwork network = null; + if (cryptoCode != null) + { + network = _NetworkProvider.GetNetwork(cryptoCode); + if (network == null) + throw new FormatException("Invalid value for crypto code"); + } + + BitcoinAddress destinationAddress = null; + if (destination != null) + { + try + { + destinationAddress = BitcoinAddress.Create(destination); + } + catch { } + if (destinationAddress == null) + throw new FormatException("Invalid value for destination"); + } + + FeeRate feeRateValue = null; + if (feeRate != null) + { + try + { + feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate, CultureInfo.InvariantCulture)), 1); + } + catch { } + if (feeRateValue == null || feeRateValue.FeePerK <= Money.Zero) + throw new FormatException("Invalid value for fee rate"); + } + + Money amountBTC = null; + if (amount != null) + { + try + { + amountBTC = Money.Parse(amount); + } + catch { } + if (amountBTC == null || amountBTC <= Money.Zero) + throw new FormatException("Invalid value for amount"); + } + + bool subsctractFeesValue = false; + if (substractFees != null) + { + try + { + subsctractFeesValue = bool.Parse(substractFees); + } + catch { throw new FormatException("Invalid value for substract fees"); } + } + if (command == "test") + { + result = await hw.Test(); + } + if (command == "getxpub") + { + result = await hw.GetExtPubKey(network); + } + if (command == "getinfo") + { + var strategy = GetDirectDerivationStrategy(store, network); + var strategyBase = GetDerivationStrategy(store, network); + if (strategy == null || !await hw.SupportDerivation(network, strategy)) + { + throw new Exception($"This store is not configured to use this ledger"); + } + + var feeProvider = _FeeRateProvider.CreateFeeProvider(network); + var recommendedFees = feeProvider.GetFeeRateAsync(); + var balance = _WalletProvider.GetWallet(network).GetBalance(strategyBase); + result = new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi }; + } + + if (command == "sendtoaddress") + { + var strategy = GetDirectDerivationStrategy(store, network); + var strategyBase = GetDerivationStrategy(store, network); + var wallet = _WalletProvider.GetWallet(network); + var change = wallet.GetChangeAddressAsync(strategyBase); + var unspentCoins = await wallet.GetUnspentCoins(strategyBase); + var changeAddress = await change; + var transaction = await hw.SendToAddress(strategy, unspentCoins, network, + new[] { (destinationAddress as IDestination, amountBTC, subsctractFeesValue) }, + feeRateValue, + changeAddress.Item1, + changeAddress.Item2); + try + { + var broadcastResult = await wallet.BroadcastTransactionsAsync(new List() { transaction }); + if (!broadcastResult[0].Success) + { + throw new Exception($"RPC Error while broadcasting: {broadcastResult[0].RPCCode} {broadcastResult[0].RPCCodeMessage} {broadcastResult[0].RPCMessage}"); + } + } + catch (Exception ex) + { + throw new Exception("Error while broadcasting: " + ex.Message); + } + wallet.InvalidateCache(strategyBase); + result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() }; + } + } + catch (OperationCanceledException) + { result = new LedgerTestResult() { Success = false, Error = "Timeout" }; } + catch (Exception ex) + { result = new LedgerTestResult() { Success = false, Error = ex.Message }; } + + try + { + if (result != null) + { + UTF8Encoding UTF8NOBOM = new UTF8Encoding(false); + var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _MvcJsonOptions.SerializerSettings)); + await webSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token); + } + } + catch { } + finally + { + await webSocket.CloseSocket(); + } + + return new EmptyResult(); + } + + private DirectDerivationStrategy GetDirectDerivationStrategy(StoreData store, BTCPayNetwork network) + { + var strategy = GetDerivationStrategy(store, network); + var directStrategy = strategy as DirectDerivationStrategy; + if (directStrategy == null) + directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy; + if (!directStrategy.Segwit) + return null; + return directStrategy; + } + + private DerivationStrategyBase GetDerivationStrategy(StoreData store, BTCPayNetwork network) + { + var strategy = store + .GetSupportedPaymentMethods(_NetworkProvider) + .OfType() + .FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork); + if (strategy == null) + { + throw new Exception($"Derivation strategy for {network.CryptoCode} is not set"); + } + + return strategy.DerivationStrategyBase; + } + } +} diff --git a/BTCPayServer/Controllers/StoresController.LightningLike.cs b/BTCPayServer/Controllers/StoresController.LightningLike.cs new file mode 100644 index 000000000..ee22dd8b6 --- /dev/null +++ b/BTCPayServer/Controllers/StoresController.LightningLike.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning.CLightning; +using Microsoft.AspNetCore.Mvc; +using BTCPayServer.Payments.Lightning; + +namespace BTCPayServer.Controllers +{ + public partial class StoresController + { + [HttpGet] + [Route("{storeId}/lightning")] + public async Task AddLightningNode(string storeId, string selectedCrypto = null) + { + selectedCrypto = selectedCrypto ?? "BTC"; + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + LightningNodeViewModel vm = new LightningNodeViewModel(); + vm.SetCryptoCurrencies(_NetworkProvider, selectedCrypto); + return View(vm); + } + + [HttpPost] + [Route("{storeId}/lightning")] + public async Task AddLightningNode(string storeId, LightningNodeViewModel vm, string command) + { + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + var network = vm.CryptoCurrency == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCurrency); + vm.SetCryptoCurrencies(_NetworkProvider, vm.CryptoCurrency); + if (network == null || network.CLightningNetworkName == null) + { + ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network"); + return View(vm); + } + + PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.LightningLike); + Payments.Lightning.LightningSupportedPaymentMethod paymentMethod = null; + if (!string.IsNullOrEmpty(vm.Url)) + { + Uri uri; + if (!Uri.TryCreate(vm.Url, UriKind.Absolute, out uri)) + { + ModelState.AddModelError(nameof(vm.Url), "Invalid URL"); + return View(vm); + } + if (_NetworkProvider.ChainType == NBXplorer.ChainType.Main) + { + if (uri.Scheme != "https") + { + ModelState.AddModelError(nameof(vm.Url), "The url must be HTTPS"); + return View(vm); + } + } + if (string.IsNullOrEmpty(uri.UserInfo) || uri.UserInfo.Split(':').Length != 2) + { + ModelState.AddModelError(nameof(vm.Url), "The url is missing user and password"); + return View(vm); + } + + paymentMethod = new Payments.Lightning.LightningSupportedPaymentMethod() + { + CryptoCode = paymentMethodId.CryptoCode + }; + paymentMethod.SetLightningChargeUrl(uri); + } + if (command == "save") + { + store.SetSupportedPaymentMethod(paymentMethodId, paymentMethod); + await _Repo.UpdateStore(store); + StatusMessage = $"Lightning node modified ({network.CryptoCode})"; + return RedirectToAction(nameof(UpdateStore), new { storeId = storeId }); + } + else // if(command == "test") + { + if (paymentMethod == null) + { + ModelState.AddModelError(nameof(vm.Url), "Missing url parameter"); + return View(vm); + } + var handler = (LightningLikePaymentHandler)_ServiceProvider.GetRequiredService>(); + try + { + await handler.Test(paymentMethod, network); + } + catch (Exception ex) + { + vm.StatusMessage = $"Error: {ex.Message}"; + return View(vm); + } + vm.StatusMessage = "Connection to the lightning node succeed"; + return View(vm); + } + } + } +} diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index ae771e7de..342234008 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -2,13 +2,9 @@ using BTCPayServer.Data; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; -using BTCPayServer.Payments; using BTCPayServer.Services; -using BTCPayServer.Services.Fees; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; -using LedgerWallet; -using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; @@ -17,16 +13,11 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Options; using NBitcoin; using NBitcoin.DataEncoders; -using NBitpayClient; using NBXplorer.DerivationStrategy; -using Newtonsoft.Json; using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; -using System.Net.WebSockets; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -36,9 +27,10 @@ namespace BTCPayServer.Controllers [Authorize(AuthenticationSchemes = "Identity.Application")] [Authorize(Policy = "CanAccessStore")] [AutoValidateAntiforgeryToken] - public class StoresController : Controller + public partial class StoresController : Controller { public StoresController( + IServiceProvider serviceProvider, IOptions mvcJsonOptions, StoreRepository repo, TokenRepository tokenRepo, @@ -60,7 +52,9 @@ namespace BTCPayServer.Controllers _ExplorerProvider = explorerProvider; _MvcJsonOptions = mvcJsonOptions.Value; _FeeRateProvider = feeRateProvider; + _ServiceProvider = serviceProvider; } + IServiceProvider _ServiceProvider; BTCPayNetworkProvider _NetworkProvider; private ExplorerClientProvider _ExplorerProvider; private MvcJsonOptions _MvcJsonOptions; @@ -122,193 +116,6 @@ namespace BTCPayServer.Controllers return HttpContext.Request.GetAbsoluteRoot() + "/stores/" + storeId + "/"; } - public class GetInfoResult - { - public int RecommendedSatoshiPerByte { get; set; } - public double Balance { get; set; } - } - - public class SendToAddressResult - { - public string TransactionId { get; set; } - } - - [HttpGet] - [Route("{storeId}/ws/ledger")] - public async Task LedgerConnection( - string storeId, - string command, - // getinfo - string cryptoCode = null, - // sendtoaddress - string destination = null, string amount = null, string feeRate = null, string substractFees = null - ) - { - if (!HttpContext.WebSockets.IsWebSocketRequest) - return NotFound(); - var store = await _Repo.FindStore(storeId, GetUserId()); - if (store == null) - return NotFound(); - - var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); - - var hw = new HardwareWalletService(webSocket); - object result = null; - try - { - BTCPayNetwork network = null; - if (cryptoCode != null) - { - network = _NetworkProvider.GetNetwork(cryptoCode); - if (network == null) - throw new FormatException("Invalid value for crypto code"); - } - - BitcoinAddress destinationAddress = null; - if (destination != null) - { - try - { - destinationAddress = BitcoinAddress.Create(destination); - } - catch { } - if (destinationAddress == null) - throw new FormatException("Invalid value for destination"); - } - - FeeRate feeRateValue = null; - if (feeRate != null) - { - try - { - feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate, CultureInfo.InvariantCulture)), 1); - } - catch { } - if (feeRateValue == null || feeRateValue.FeePerK <= Money.Zero) - throw new FormatException("Invalid value for fee rate"); - } - - Money amountBTC = null; - if (amount != null) - { - try - { - amountBTC = Money.Parse(amount); - } - catch { } - if (amountBTC == null || amountBTC <= Money.Zero) - throw new FormatException("Invalid value for amount"); - } - - bool subsctractFeesValue = false; - if (substractFees != null) - { - try - { - subsctractFeesValue = bool.Parse(substractFees); - } - catch { throw new FormatException("Invalid value for substract fees"); } - } - if (command == "test") - { - result = await hw.Test(); - } - if (command == "getxpub") - { - result = await hw.GetExtPubKey(network); - } - if (command == "getinfo") - { - var strategy = GetDirectDerivationStrategy(store, network); - var strategyBase = GetDerivationStrategy(store, network); - if (strategy == null || !await hw.SupportDerivation(network, strategy)) - { - throw new Exception($"This store is not configured to use this ledger"); - } - - var feeProvider = _FeeRateProvider.CreateFeeProvider(network); - var recommendedFees = feeProvider.GetFeeRateAsync(); - var balance = _WalletProvider.GetWallet(network).GetBalance(strategyBase); - result = new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi }; - } - - if (command == "sendtoaddress") - { - var strategy = GetDirectDerivationStrategy(store, network); - var strategyBase = GetDerivationStrategy(store, network); - var wallet = _WalletProvider.GetWallet(network); - var change = wallet.GetChangeAddressAsync(strategyBase); - var unspentCoins = await wallet.GetUnspentCoins(strategyBase); - var changeAddress = await change; - var transaction = await hw.SendToAddress(strategy, unspentCoins, network, - new[] { (destinationAddress as IDestination, amountBTC, subsctractFeesValue) }, - feeRateValue, - changeAddress.Item1, - changeAddress.Item2); - try - { - var broadcastResult = await wallet.BroadcastTransactionsAsync(new List() { transaction }); - if (!broadcastResult[0].Success) - { - throw new Exception($"RPC Error while broadcasting: {broadcastResult[0].RPCCode} {broadcastResult[0].RPCCodeMessage} {broadcastResult[0].RPCMessage}"); - } - } - catch (Exception ex) - { - throw new Exception("Error while broadcasting: " + ex.Message); - } - wallet.InvalidateCache(strategyBase); - result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() }; - } - } - catch (OperationCanceledException) - { result = new LedgerTestResult() { Success = false, Error = "Timeout" }; } - catch (Exception ex) - { result = new LedgerTestResult() { Success = false, Error = ex.Message }; } - - try - { - if (result != null) - { - UTF8Encoding UTF8NOBOM = new UTF8Encoding(false); - var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _MvcJsonOptions.SerializerSettings)); - await webSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token); - } - } - catch { } - finally - { - await webSocket.CloseSocket(); - } - - return new EmptyResult(); - } - - private DirectDerivationStrategy GetDirectDerivationStrategy(StoreData store, BTCPayNetwork network) - { - var strategy = GetDerivationStrategy(store, network); - var directStrategy = strategy as DirectDerivationStrategy; - if (directStrategy == null) - directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy; - if (!directStrategy.Segwit) - return null; - return directStrategy; - } - - private DerivationStrategyBase GetDerivationStrategy(StoreData store, BTCPayNetwork network) - { - var strategy = store - .GetSupportedPaymentMethods(_NetworkProvider) - .OfType() - .FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork); - if (strategy == null) - { - throw new Exception($"Derivation strategy for {network.CryptoCode} is not set"); - } - - return strategy.DerivationStrategyBase; - } - [HttpGet] public async Task ListStores() { @@ -398,7 +205,7 @@ namespace BTCPayServer.Controllers vm.StoreWebsite = store.StoreWebsite; vm.NetworkFee = !storeBlob.NetworkFeeDisabled; vm.SpeedPolicy = store.SpeedPolicy; - AddDerivationSchemes(store, vm); + AddPaymentMethods(store, vm); vm.StatusMessage = StatusMessage; vm.MonitoringExpiration = storeBlob.MonitoringExpiration; vm.InvoiceExpiration = storeBlob.InvoiceExpiration; @@ -407,114 +214,28 @@ namespace BTCPayServer.Controllers return View(vm); } - private void AddDerivationSchemes(StoreData store, StoreViewModel vm) + private void AddPaymentMethods(StoreData store, StoreViewModel vm) { - var strategies = store + foreach(var strategy in store .GetSupportedPaymentMethods(_NetworkProvider) - .OfType() - .ToDictionary(s => s.Network.CryptoCode); - foreach (var explorerProvider in _ExplorerProvider.GetAll()) + .OfType()) { - if (strategies.TryGetValue(explorerProvider.Item1.CryptoCode, out DerivationStrategy strat)) + vm.DerivationSchemes.Add(new StoreViewModel.DerivationScheme() { - vm.DerivationSchemes.Add(new StoreViewModel.DerivationScheme() - { - Crypto = explorerProvider.Item1.CryptoCode, - Value = strat.DerivationStrategyBase.ToString() - }); - } - } - } - - [HttpGet] - [Route("{storeId}/derivations")] - public async Task AddDerivationScheme(string storeId, string selectedScheme = null) - { - selectedScheme = selectedScheme ?? "BTC"; - var store = await _Repo.FindStore(storeId, GetUserId()); - if (store == null) - return NotFound(); - DerivationSchemeViewModel vm = new DerivationSchemeViewModel(); - vm.ServerUrl = GetStoreUrl(storeId); - vm.SetCryptoCurrencies(_ExplorerProvider, selectedScheme); - return View(vm); - } - - [HttpPost] - [Route("{storeId}/derivations")] - public async Task AddDerivationScheme(string storeId, DerivationSchemeViewModel vm, string selectedScheme = null) - { - selectedScheme = selectedScheme ?? "BTC"; - vm.ServerUrl = GetStoreUrl(storeId); - var store = await _Repo.FindStore(storeId, GetUserId()); - if (store == null) - return NotFound(); - - var network = vm.CryptoCurrency == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCurrency); - vm.SetCryptoCurrencies(_ExplorerProvider, selectedScheme); - if (network == null) - { - ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network"); - return View(vm); - } - var wallet = _WalletProvider.GetWallet(network); - if (wallet == null) - { - ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network"); - return View(vm); + Crypto = strategy.PaymentId.CryptoCode, + Value = strategy.DerivationStrategyBase.ToString() + }); } - PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); - DerivationStrategy strategy = null; - try + foreach(var lightning in store + .GetSupportedPaymentMethods(_NetworkProvider) + .OfType()) { - if (!string.IsNullOrEmpty(vm.DerivationScheme)) + vm.LightningNodes.Add(new StoreViewModel.LightningNode() { - strategy = ParseDerivationStrategy(vm.DerivationScheme, vm.DerivationSchemeFormat, network); - vm.DerivationScheme = strategy.ToString(); - } - store.SetSupportedPaymentMethod(paymentMethodId, strategy); - } - catch - { - ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme"); - vm.Confirmation = false; - return View(vm); - } - - - if (vm.Confirmation) - { - try - { - if (strategy != null) - await wallet.TrackAsync(strategy.DerivationStrategyBase); - store.SetSupportedPaymentMethod(paymentMethodId, strategy); - } - catch - { - ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid Derivation Scheme"); - return View(vm); - } - - await _Repo.UpdateStore(store); - StatusMessage = $"Derivation scheme for {network.CryptoCode} has been modified."; - return RedirectToAction(nameof(UpdateStore), new { storeId = storeId }); - } - else - { - if (!string.IsNullOrEmpty(vm.DerivationScheme)) - { - var line = strategy.DerivationStrategyBase.GetLineFor(DerivationFeature.Deposit); - - for (int i = 0; i < 10; i++) - { - var address = line.Derive((uint)i); - vm.AddressSamples.Add((DerivationStrategyBase.GetKeyPath(DerivationFeature.Deposit).Derive((uint)i).ToString(), address.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork).ToString())); - } - } - vm.Confirmation = true; - return View(vm); + CryptoCode = lightning.CryptoCode, + Address = lightning.GetLightningChargeUrl(false).AbsoluteUri + }); } } @@ -531,7 +252,7 @@ namespace BTCPayServer.Controllers var store = await _Repo.FindStore(storeId, GetUserId()); if (store == null) return NotFound(); - AddDerivationSchemes(store, model); + AddPaymentMethods(store, model); bool needUpdate = false; if (store.SpeedPolicy != model.SpeedPolicy) diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index b9f85c464..8c6bcc01d 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -129,7 +129,7 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(o => { var opts = o.GetRequiredService(); - return new BTCPayNetworkProvider(opts.ChainType); + return opts.NetworkProvider; }); services.TryAddSingleton(); @@ -143,9 +143,12 @@ namespace BTCPayServer.Hosting }); services.AddSingleton, Payments.Bitcoin.BitcoinLikePaymentHandler>(); + services.AddSingleton(); + + services.AddSingleton, Payments.Lightning.LightningLikePaymentHandler>(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/JsonConverters/LightMoneyJsonConverter.cs b/BTCPayServer/JsonConverters/LightMoneyJsonConverter.cs new file mode 100644 index 000000000..805a18c50 --- /dev/null +++ b/BTCPayServer/JsonConverters/LightMoneyJsonConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using System.Reflection; +using BTCPayServer.Payments.Lightning; +using NBitcoin.JsonConverters; +using System.Globalization; + +namespace BTCPayServer.JsonConverters +{ + public class LightMoneyJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return typeof(LightMoneyJsonConverter).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + try + { + return reader.TokenType == JsonToken.Null ? null : + reader.TokenType == JsonToken.Integer ? new LightMoney((long)reader.Value) : + reader.TokenType == JsonToken.String ? new LightMoney(long.Parse((string)reader.Value, CultureInfo.InvariantCulture)) + : null; + } + catch (InvalidCastException) + { + throw new JsonObjectException("Money amount should be in millisatoshi", reader); + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(((LightMoney)value).MilliSatoshi); + } + } +} diff --git a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs index 0d71c1a24..964871636 100644 --- a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs +++ b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs @@ -32,6 +32,7 @@ namespace BTCPayServer.Models.InvoicingModels public string Rate { get; set; } public string OrderAmount { get; set; } public string InvoiceBitcoinUrl { get; set; } + public string InvoiceBitcoinUrlQR { get; set; } public int TxCount { get; set; } public string BtcPaid { get; set; } public string StoreEmail { get; set; } diff --git a/BTCPayServer/Models/StoreViewModels/LightningNodeViewModel.cs b/BTCPayServer/Models/StoreViewModels/LightningNodeViewModel.cs new file mode 100644 index 000000000..266e737f7 --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/LightningNodeViewModel.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class LightningNodeViewModel + { + class Format + { + public string Name { get; set; } + public string Value { get; set; } + } + [Display(Name = "Lightning charge url")] + public string Url + { + get; + set; + } + + [Display(Name = "Crypto currency")] + public string CryptoCurrency + { + get; + set; + } + public SelectList CryptoCurrencies { get; set; } + public string StatusMessage { get; set; } + + public void SetCryptoCurrencies(BTCPayNetworkProvider networkProvider, string selectedScheme) + { + var choices = networkProvider.GetAll() + .Where(n => n.CLightningNetworkName != null) + .Select(o => new Format() { Name = o.CryptoCode, Value = o.CryptoCode }).ToArray(); + var chosen = choices.FirstOrDefault(f => f.Name == selectedScheme) ?? choices.FirstOrDefault(); + CryptoCurrencies = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen); + CryptoCurrency = chosen.Name; + } + } +} diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index 1e47744af..012132b55 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -103,6 +103,16 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Default crypto currency on checkout")] public string DefaultCryptoCurrency { get; set; } + public class LightningNode + { + public string CryptoCode { get; set; } + public string Address { get; set; } + } + public List LightningNodes + { + get; set; + } = new List(); + public void SetCryptoCurrencies(ExplorerClientProvider explorerProvider, string defaultCrypto) { var choices = explorerProvider.GetAll().Select(o => new Format() { Name = o.Item1.CryptoCode, Value = o.Item1.CryptoCode }).ToArray(); diff --git a/BTCPayServer/MultiValueDictionary.cs b/BTCPayServer/MultiValueDictionary.cs new file mode 100644 index 000000000..63ad41c06 --- /dev/null +++ b/BTCPayServer/MultiValueDictionary.cs @@ -0,0 +1,1114 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// From https://github.com/dotnet/corefxlab/blob/master/src/System.Collections.Generic.MultiValueDictionary/System/Collections/Generic/MultiValueDictionary.cs +using System; +using System.Collections; +using System.Collections.Generic; + +namespace BTCPayServer +{ + /// + /// A MultiValueDictionary can be viewed as a that allows multiple + /// values for any given unique key. While the MultiValueDictionary API is + /// mostly the same as that of a regular , there is a distinction + /// in that getting the value for a key returns a of values + /// rather than a single value associated with that key. Additionally, + /// there is functionality to allow adding or removing more than a single + /// value at once. + /// + /// The MultiValueDictionary can also be viewed as a IReadOnlyDictionary<TKey,IReadOnlyCollection<TValue>t> + /// where the is abstracted from the view of the programmer. + /// + /// For a read-only MultiValueDictionary. + /// + /// The type of the key. + /// The type of the value. + public class MultiValueDictionary : + IReadOnlyDictionary> + { + #region Variables + /*====================================================================== + ** Variables + ======================================================================*/ + + /// + /// The private dictionary that this class effectively wraps around + /// + private Dictionary dictionary; + + /// + /// The function to construct a new + /// + /// + private Func> NewCollectionFactory = () => new List(); + + /// + /// The current version of this MultiValueDictionary used to determine MultiValueDictionary modification + /// during enumeration + /// + private int version; + + #endregion + + #region Constructors + /*====================================================================== + ** Constructors + ======================================================================*/ + + /// + /// Initializes a new instance of the + /// class that is empty, has the default initial capacity, and uses the default + /// for . + /// + public MultiValueDictionary() + { + dictionary = new Dictionary(); + } + + /// + /// Initializes a new instance of the class that is + /// empty, has the specified initial capacity, and uses the default + /// for . + /// + /// Initial number of keys that the will allocate space for + /// capacity must be >= 0 + public MultiValueDictionary(int capacity) + { + if (capacity < 0) + throw new ArgumentOutOfRangeException("capacity", "Properties.Resources.ArgumentOutOfRange_NeedNonNegNum"); + dictionary = new Dictionary(capacity); + } + + /// + /// Initializes a new instance of the class + /// that is empty, has the default initial capacity, and uses the + /// specified . + /// + /// Specified comparer to use for the s + /// If is set to null, then the default for is used. + public MultiValueDictionary(IEqualityComparer comparer) + { + dictionary = new Dictionary(comparer); + } + + /// + /// Initializes a new instance of the class + /// that is empty, has the specified initial capacity, and uses the + /// specified . + /// + /// Initial number of keys that the will allocate space for + /// Specified comparer to use for the s + /// Capacity must be >= 0 + /// If is set to null, then the default for is used. + public MultiValueDictionary(int capacity, IEqualityComparer comparer) + { + if (capacity < 0) + throw new ArgumentOutOfRangeException("capacity", "Properties.Resources.ArgumentOutOfRange_NeedNonNegNum"); + dictionary = new Dictionary(capacity, comparer); + } + + /// + /// Initializes a new instance of the class that contains + /// elements copied from the specified IEnumerable<KeyValuePair<TKey, IReadOnlyCollection<TValue>>> and uses the + /// default for the type. + /// + /// IEnumerable to copy elements into this from + /// enumerable must be non-null + public MultiValueDictionary(IEnumerable>> enumerable) + : this(enumerable, null) + { + } + + /// + /// Initializes a new instance of the class that contains + /// elements copied from the specified IEnumerable<KeyValuePair<TKey, IReadOnlyCollection<TValue>>> and uses the + /// specified . + /// + /// IEnumerable to copy elements into this from + /// Specified comparer to use for the s + /// enumerable must be non-null + /// If is set to null, then the default for is used. + public MultiValueDictionary(IEnumerable>> enumerable, IEqualityComparer comparer) + { + if (enumerable == null) + throw new ArgumentNullException("enumerable"); + + dictionary = new Dictionary(comparer); + foreach (var pair in enumerable) + AddRange(pair.Key, pair.Value); + } + + #endregion + + #region Static Factories + /*====================================================================== + ** Static Factories + ======================================================================*/ + + /// + /// Creates a new new instance of the + /// class that is empty, has the default initial capacity, and uses the default + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// A new with the specified + /// parameters. + /// must not have + /// IsReadOnly set to true by default. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create() + where TValueCollection : ICollection, new() + { + if (new TValueCollection().IsReadOnly) + throw new InvalidOperationException("Properties.Resources.Create_TValueCollectionReadOnly"); + + var multiValueDictionary = new MultiValueDictionary(); + multiValueDictionary.NewCollectionFactory = () => new TValueCollection(); + return multiValueDictionary; + } + + /// + /// Creates a new new instance of the + /// class that is empty, has the specified initial capacity, and uses the default + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// Initial number of keys that the will allocate space for + /// A new with the specified + /// parameters. + /// Capacity must be >= 0 + /// must not have + /// IsReadOnly set to true by default. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(int capacity) + where TValueCollection : ICollection, new() + { + if (capacity < 0) + throw new ArgumentOutOfRangeException("capacity", "Properties.Resources.ArgumentOutOfRange_NeedNonNegNum"); + if (new TValueCollection().IsReadOnly) + throw new InvalidOperationException("Properties.Resources.Create_TValueCollectionReadOnly"); + + var multiValueDictionary = new MultiValueDictionary(capacity); + multiValueDictionary.NewCollectionFactory = () => new TValueCollection(); + return multiValueDictionary; + } + + /// + /// Creates a new new instance of the + /// class that is empty, has the default initial capacity, and uses the specified + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// Specified comparer to use for the s + /// must not have + /// IsReadOnly set to true by default. + /// A new with the specified + /// parameters. + /// If is set to null, then the default for is used. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(IEqualityComparer comparer) + where TValueCollection : ICollection, new() + { + if (new TValueCollection().IsReadOnly) + throw new InvalidOperationException("Properties.Resources.Create_TValueCollectionReadOnly"); + + var multiValueDictionary = new MultiValueDictionary(comparer); + multiValueDictionary.NewCollectionFactory = () => new TValueCollection(); + return multiValueDictionary; + } + + /// + /// Creates a new new instance of the + /// class that is empty, has the specified initial capacity, and uses the specified + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// Initial number of keys that the will allocate space for + /// Specified comparer to use for the s + /// A new with the specified + /// parameters. + /// must not have + /// IsReadOnly set to true by default. + /// Capacity must be >= 0 + /// If is set to null, then the default for is used. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(int capacity, IEqualityComparer comparer) + where TValueCollection : ICollection, new() + { + if (capacity < 0) + throw new ArgumentOutOfRangeException("capacity", "Properties.Resources.ArgumentOutOfRange_NeedNonNegNum"); + if (new TValueCollection().IsReadOnly) + throw new InvalidOperationException("Properties.Resources.Create_TValueCollectionReadOnly"); + + var multiValueDictionary = new MultiValueDictionary(capacity, comparer); + multiValueDictionary.NewCollectionFactory = () => new TValueCollection(); + return multiValueDictionary; + } + + /// + /// Initializes a new instance of the class that contains + /// elements copied from the specified IEnumerable<KeyValuePair<TKey, IReadOnlyCollection<TValue>>> + /// and uses the default for the type. + /// The internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// IEnumerable to copy elements into this from + /// A new with the specified + /// parameters. + /// must not have + /// IsReadOnly set to true by default. + /// enumerable must be non-null + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(IEnumerable>> enumerable) + where TValueCollection : ICollection, new() + { + if (enumerable == null) + throw new ArgumentNullException("enumerable"); + if (new TValueCollection().IsReadOnly) + throw new InvalidOperationException("Properties.Resources.Create_TValueCollectionReadOnly"); + + var multiValueDictionary = new MultiValueDictionary(); + multiValueDictionary.NewCollectionFactory = () => new TValueCollection(); + foreach (var pair in enumerable) + multiValueDictionary.AddRange(pair.Key, pair.Value); + return multiValueDictionary; + } + + /// + /// Initializes a new instance of the class that contains + /// elements copied from the specified IEnumerable<KeyValuePair<TKey, IReadOnlyCollection<TValue>>> + /// and uses the specified for the type. + /// The internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// IEnumerable to copy elements into this from + /// Specified comparer to use for the s + /// A new with the specified + /// parameters. + /// must not have + /// IsReadOnly set to true by default. + /// enumerable must be non-null + /// If is set to null, then the default for is used. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(IEnumerable>> enumerable, IEqualityComparer comparer) + where TValueCollection : ICollection, new() + { + if (enumerable == null) + throw new ArgumentNullException("enumerable"); + if (new TValueCollection().IsReadOnly) + throw new InvalidOperationException("Properties.Resources.Create_TValueCollectionReadOnly"); + + var multiValueDictionary = new MultiValueDictionary(comparer); + multiValueDictionary.NewCollectionFactory = () => new TValueCollection(); + foreach (var pair in enumerable) + multiValueDictionary.AddRange(pair.Key, pair.Value); + return multiValueDictionary; + } + + #endregion + + #region Static Factories with Func parameters + /*====================================================================== + ** Static Factories with Func parameters + ======================================================================*/ + + /// + /// Creates a new new instance of the + /// class that is empty, has the default initial capacity, and uses the default + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// A function to create a new to use + /// in the internal dictionary store of this . + /// A new with the specified + /// parameters. + /// must create collections with + /// IsReadOnly set to true by default. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(Func collectionFactory) + where TValueCollection : ICollection + { + if (collectionFactory().IsReadOnly) + throw new InvalidOperationException(("Properties.Resources.Create_TValueCollectionReadOnly")); + + var multiValueDictionary = new MultiValueDictionary(); + multiValueDictionary.NewCollectionFactory = (Func>)(Delegate)collectionFactory; + return multiValueDictionary; + } + + /// + /// Creates a new new instance of the + /// class that is empty, has the specified initial capacity, and uses the default + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// Initial number of keys that the will allocate space for + /// A function to create a new to use + /// in the internal dictionary store of this . + /// A new with the specified + /// parameters. + /// Capacity must be >= 0 + /// must create collections with + /// IsReadOnly set to true by default. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(int capacity, Func collectionFactory) + where TValueCollection : ICollection + { + if (capacity < 0) + throw new ArgumentOutOfRangeException("capacity", "Properties.Resources.ArgumentOutOfRange_NeedNonNegNum"); + if (collectionFactory().IsReadOnly) + throw new InvalidOperationException(("Properties.Resources.Create_TValueCollectionReadOnly")); + + var multiValueDictionary = new MultiValueDictionary(capacity); + multiValueDictionary.NewCollectionFactory = (Func>)(Delegate)collectionFactory; + return multiValueDictionary; + } + + /// + /// Creates a new new instance of the + /// class that is empty, has the default initial capacity, and uses the specified + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// Specified comparer to use for the s + /// A function to create a new to use + /// in the internal dictionary store of this . + /// must create collections with + /// IsReadOnly set to true by default. + /// A new with the specified + /// parameters. + /// If is set to null, then the default for is used. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(IEqualityComparer comparer, Func collectionFactory) + where TValueCollection : ICollection + { + if (collectionFactory().IsReadOnly) + throw new InvalidOperationException(("Properties.Resources.Create_TValueCollectionReadOnly")); + + var multiValueDictionary = new MultiValueDictionary(comparer); + multiValueDictionary.NewCollectionFactory = (Func>)(Delegate)collectionFactory; + return multiValueDictionary; + } + + /// + /// Creates a new new instance of the + /// class that is empty, has the specified initial capacity, and uses the specified + /// for . The + /// internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// Initial number of keys that the will allocate space for + /// Specified comparer to use for the s + /// A function to create a new to use + /// in the internal dictionary store of this . + /// A new with the specified + /// parameters. + /// must create collections with + /// IsReadOnly set to true by default. + /// Capacity must be >= 0 + /// If is set to null, then the default for is used. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(int capacity, IEqualityComparer comparer, Func collectionFactory) + where TValueCollection : ICollection + { + if (capacity < 0) + throw new ArgumentOutOfRangeException("capacity", "Properties.Resources.ArgumentOutOfRange_NeedNonNegNum"); + if (collectionFactory().IsReadOnly) + throw new InvalidOperationException(("Properties.Resources.Create_TValueCollectionReadOnly")); + + var multiValueDictionary = new MultiValueDictionary(capacity, comparer); + multiValueDictionary.NewCollectionFactory = (Func>)(Delegate)collectionFactory; + return multiValueDictionary; + } + + /// + /// Initializes a new instance of the class that contains + /// elements copied from the specified IEnumerable<KeyValuePair<TKey, IReadOnlyCollection<TValue>>> + /// and uses the default for the type. + /// The internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// IEnumerable to copy elements into this from + /// A function to create a new to use + /// in the internal dictionary store of this . + /// A new with the specified + /// parameters. + /// must create collections with + /// IsReadOnly set to true by default. + /// enumerable must be non-null + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(IEnumerable>> enumerable, Func collectionFactory) + where TValueCollection : ICollection + { + if (enumerable == null) + throw new ArgumentNullException("enumerable"); + if (collectionFactory().IsReadOnly) + throw new InvalidOperationException(("Properties.Resources.Create_TValueCollectionReadOnly")); + + var multiValueDictionary = new MultiValueDictionary(); + multiValueDictionary.NewCollectionFactory = (Func>)(Delegate)collectionFactory; + foreach (var pair in enumerable) + multiValueDictionary.AddRange(pair.Key, pair.Value); + return multiValueDictionary; + } + + /// + /// Initializes a new instance of the class that contains + /// elements copied from the specified IEnumerable<KeyValuePair<TKey, IReadOnlyCollection<TValue>>> + /// and uses the specified for the type. + /// The internal dictionary will use instances of the + /// class as its collection type. + /// + /// + /// The collection type that this + /// will contain in its internal dictionary. + /// + /// IEnumerable to copy elements into this from + /// Specified comparer to use for the s + /// A function to create a new to use + /// in the internal dictionary store of this . + /// A new with the specified + /// parameters. + /// must create collections with + /// IsReadOnly set to true by default. + /// enumerable must be non-null + /// If is set to null, then the default for is used. + /// + /// Note that must implement + /// in addition to being constructable through new(). The collection returned from the constructor + /// must also not have IsReadOnly set to True by default. + /// + public static MultiValueDictionary Create(IEnumerable>> enumerable, IEqualityComparer comparer, Func collectionFactory) + where TValueCollection : ICollection + { + if (enumerable == null) + throw new ArgumentNullException("enumerable"); + if (collectionFactory().IsReadOnly) + throw new InvalidOperationException(("Properties.Resources.Create_TValueCollectionReadOnly")); + + var multiValueDictionary = new MultiValueDictionary(comparer); + multiValueDictionary.NewCollectionFactory = (Func>)(Delegate)collectionFactory; + foreach (var pair in enumerable) + multiValueDictionary.AddRange(pair.Key, pair.Value); + return multiValueDictionary; + } + + #endregion + + #region Concrete Methods + /*====================================================================== + ** Concrete Methods + ======================================================================*/ + + /// + /// Adds the specified and to the . + /// + /// The of the element to add. + /// The of the element to add. + /// is null. + /// + /// Unlike the Add for , the Add will not + /// throw any exceptions. If the given is already in the , + /// then will be added to associated with + /// + /// + /// A call to this Add method will always invalidate any currently running enumeration regardless + /// of whether the Add method actually modified the . + /// + public void Add(TKey key, TValue value) + { + if (key == null) + throw new ArgumentNullException("key"); + InnerCollectionView collection; + if (!dictionary.TryGetValue(key, out collection)) + { + collection = new InnerCollectionView(key, NewCollectionFactory()); + dictionary.Add(key, collection); + } + collection.AddValue(value); + version++; + } + + /// + /// Adds a number of key-value pairs to this , where + /// the key for each value is , and the value for a pair + /// is an element from + /// + /// The of all entries to add + /// An of values to add + /// and must be non-null + /// + /// A call to this AddRange method will always invalidate any currently running enumeration regardless + /// of whether the AddRange method actually modified the . + /// + public void AddRange(TKey key, IEnumerable values) + { + if (key == null) + throw new ArgumentNullException("key"); + if (values == null) + throw new ArgumentNullException("values"); + + InnerCollectionView collection; + if (!dictionary.TryGetValue(key, out collection)) + { + collection = new InnerCollectionView(key, NewCollectionFactory()); + dictionary.Add(key, collection); + } + foreach (TValue value in values) + { + collection.AddValue(value); + } + version++; + } + + /// + /// Removes every associated with the given + /// from the . + /// + /// The of the elements to remove + /// true if the removal was successful; otherwise false + /// is null. + public bool Remove(TKey key) + { + if (key == null) + throw new ArgumentNullException("key"); + + InnerCollectionView collection; + if (dictionary.TryGetValue(key, out collection) && dictionary.Remove(key)) + { + version++; + return true; + } + return false; + } + + /// + /// Removes the first instance (if any) of the given - + /// pair from this . + /// + /// The of the element to remove + /// The of the element to remove + /// must be non-null + /// true if the removal was successful; otherwise false + /// + /// If the being removed is the last one associated with its , then that + /// will be removed from the and its + /// associated will be freed as if a call to + /// had been made. + /// + public bool Remove(TKey key, TValue value) + { + if (key == null) + throw new ArgumentNullException("key"); + + InnerCollectionView collection; + if (dictionary.TryGetValue(key, out collection) && collection.RemoveValue(value)) + { + if (collection.Count == 0) + dictionary.Remove(key); + version++; + return true; + } + return false; + } + + /// + /// Determines if the given - + /// pair exists within this . + /// + /// The of the element. + /// The of the element. + /// true if found; otherwise false + /// must be non-null + public bool Contains(TKey key, TValue value) + { + if (key == null) + throw new ArgumentNullException("key"); + + InnerCollectionView collection; + return (dictionary.TryGetValue(key, out collection) && collection.Contains(value)); + } + + /// + /// Determines if the given exists within this . + /// + /// A to search the for + /// true if the contains the ; otherwise false + public bool ContainsValue(TValue value) + { + foreach (InnerCollectionView sublist in dictionary.Values) + if (sublist.Contains(value)) + return true; + return false; + } + + /// + /// Removes every and from this + /// . + /// + public void Clear() + { + dictionary.Clear(); + version++; + } + + #endregion + + #region Members implemented from IReadOnlyDictionary> + /*====================================================================== + ** Members implemented from IReadOnlyDictionary> + ======================================================================*/ + + /// + /// Determines if the given exists within this and has + /// at least one associated with it. + /// + /// The to search the for + /// true if the contains the requested ; + /// otherwise false. + /// must be non-null + public bool ContainsKey(TKey key) + { + if (key == null) + throw new ArgumentNullException("key"); + // Since modification to the MultiValueDictionary is only allowed through its own API, we + // can ensure that if a collection is in the internal dictionary then it must have at least one + // associated TValue, or else it would have been removed whenever its final TValue was removed. + return dictionary.ContainsKey(key); + } + + /// + /// Gets each in this that + /// has one or more associated . + /// + /// + /// An containing each + /// in this that has one or more associated + /// . + /// + public IEnumerable Keys + { + get + { + return dictionary.Keys; + } + } + + /// + /// Attempts to get the associated with the given + /// and place it into . + /// + /// The of the element to retrieve + /// + /// When this method returns, contains the associated with the specified + /// if it is found; otherwise contains the default value of . + /// + /// + /// true if the contains an element with the specified + /// ; otherwise, false. + /// + /// must be non-null + public bool TryGetValue(TKey key, out IReadOnlyCollection value) + { + if (key == null) + throw new ArgumentNullException("key"); + + InnerCollectionView collection; + var success = dictionary.TryGetValue(key, out collection); + value = collection; + return success; + } + + /// + /// Gets an enumerable of from this , + /// where each is the collection of every associated + /// with a present in the . + /// + /// An IEnumerable of each in this + /// + public IEnumerable> Values + { + get + { + return dictionary.Values; + } + } + + /// + /// Get every associated with the given . If + /// is not found in this , will + /// throw a . + /// + /// The of the elements to retrieve. + /// must be non-null + /// does not have any associated + /// s in this . + /// + /// An containing every + /// associated with . + /// + /// + /// Note that the returned will change alongside any changes + /// to the + /// + public IReadOnlyCollection this[TKey key] + { + get + { + if (key == null) + throw new ArgumentNullException("key"); + + InnerCollectionView collection; + if (dictionary.TryGetValue(key, out collection)) + return collection; + else + throw new KeyNotFoundException(); + } + } + + /// + /// Returns the number of s with one or more associated + /// in this . + /// + /// The number of s in this . + public int Count + { + get + { + return dictionary.Count; + } + } + + /// + /// Get an Enumerator over the - + /// pairs in this . + /// + /// an Enumerator over the - + /// pairs in this . + public IEnumerator>> GetEnumerator() + { + return new Enumerator(this); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new Enumerator(this); + } + + #endregion + + /// + /// The Enumerator class for a + /// that iterates over - + /// pairs. + /// + private class Enumerator : + IEnumerator>> + { + private MultiValueDictionary multiValueDictionary; + private int version; + private KeyValuePair> current; + private Dictionary.Enumerator enumerator; + private enum EnumerationState { BeforeFirst, During, AfterLast }; + private EnumerationState state; + + /// + /// Constructor for the enumerator + /// + /// A MultiValueDictionary to iterate over + internal Enumerator(MultiValueDictionary multiValueDictionary) + { + this.multiValueDictionary = multiValueDictionary; + this.version = multiValueDictionary.version; + this.current = default(KeyValuePair>); + this.enumerator = multiValueDictionary.dictionary.GetEnumerator(); + this.state = EnumerationState.BeforeFirst; + ; + } + + public KeyValuePair> Current + { + get + { + return current; + } + } + + object IEnumerator.Current + { + get + { + switch (state) + { + case EnumerationState.BeforeFirst: + throw new InvalidOperationException(("Properties.Resources.InvalidOperation_EnumNotStarted")); + case EnumerationState.AfterLast: + throw new InvalidOperationException(("Properties.Resources.InvalidOperation_EnumEnded")); + default: + return current; + } + } + } + + /// + /// Advances the enumerator to the next element of the collection. + /// + /// + /// true if the enumerator was successfully advanced to the next element; false if the enumerator has passed the end of the collection. + /// + /// The collection was modified after the enumerator was created. + public bool MoveNext() + { + if (version != multiValueDictionary.version) + { + throw new InvalidOperationException("Properties.Resources.InvalidOperation_EnumFailedVersion"); + } + else if (enumerator.MoveNext()) + { + current = new KeyValuePair>(enumerator.Current.Key, enumerator.Current.Value); + state = EnumerationState.During; + return true; + } + else + { + current = default(KeyValuePair>); + state = EnumerationState.AfterLast; + return false; + } + } + + /// + /// Sets the enumerator to its initial position, which is before the first element in the collection. + /// + /// The collection was modified after the enumerator was created. + public void Reset() + { + if (version != multiValueDictionary.version) + throw new InvalidOperationException("Properties.Resources.InvalidOperation_EnumFailedVersion"); + enumerator.Dispose(); + enumerator = multiValueDictionary.dictionary.GetEnumerator(); + current = default(KeyValuePair>); + state = EnumerationState.BeforeFirst; + } + + /// + /// Frees resources associated with this Enumerator + /// + public void Dispose() + { + enumerator.Dispose(); + } + } + + /// + /// An inner class that functions as a view of an ICollection within a MultiValueDictionary + /// + private class InnerCollectionView : + ICollection, + IReadOnlyCollection + { + private TKey key; + private ICollection collection; + + #region Private Concrete API + /*====================================================================== + ** Private Concrete API + ======================================================================*/ + + public InnerCollectionView(TKey key, ICollection collection) + { + this.key = key; + this.collection = collection; + } + + public void AddValue(TValue item) + { + collection.Add(item); + } + + public bool RemoveValue(TValue item) + { + return collection.Remove(item); + } + + #endregion + + #region Shared API + /*====================================================================== + ** Shared API + ======================================================================*/ + + public bool Contains(TValue item) + { + return collection.Contains(item); + } + + public void CopyTo(TValue[] array, int arrayIndex) + { + if (array == null) + throw new ArgumentNullException("array"); + if (arrayIndex < 0) + throw new ArgumentOutOfRangeException("arrayIndex", "Properties.Resources.ArgumentOutOfRange_NeedNonNegNum"); + if (arrayIndex > array.Length) + throw new ArgumentOutOfRangeException("arrayIndex", "Properties.Resources.ArgumentOutOfRange_Index"); + if (array.Length - arrayIndex < collection.Count) + throw new ArgumentException("Properties.Resources.CopyTo_ArgumentsTooSmall", "arrayIndex"); + + collection.CopyTo(array, arrayIndex); + } + + public int Count + { + get + { + return collection.Count; + } + } + + public bool IsReadOnly + { + get + { + return true; + } + } + + public IEnumerator GetEnumerator() + { + return collection.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public TKey Key + { + get + { + return key; + } + } + + #endregion + + #region Public-Facing API + /*====================================================================== + ** Public-Facing API + ======================================================================*/ + + void ICollection.Add(TValue item) + { + throw new NotSupportedException("Properties.Resources.ReadOnly_Modification"); + } + + void ICollection.Clear() + { + throw new NotSupportedException("Properties.Resources.ReadOnly_Modification"); + } + + bool ICollection.Remove(TValue item) + { + throw new NotSupportedException("Properties.Resources.ReadOnly_Modification"); + } + + #endregion + } + } +} diff --git a/BTCPayServer/Payments/Lightning/CLightning/ChargeClient.cs b/BTCPayServer/Payments/Lightning/CLightning/ChargeClient.cs index c3f6fa8c8..262ad4f92 100644 --- a/BTCPayServer/Payments/Lightning/CLightning/ChargeClient.cs +++ b/BTCPayServer/Payments/Lightning/CLightning/ChargeClient.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.WebSockets; using System.Text; +using System.Threading; using System.Threading.Tasks; using NBitcoin; +using NBXplorer; using Newtonsoft.Json; namespace BTCPayServer.Payments.Lightning.CLightning @@ -40,29 +44,69 @@ namespace BTCPayServer.Payments.Lightning.CLightning Credentials = new NetworkCredential(userInfo[0], userInfo[1]); } + public async Task CreateInvoiceAsync(CreateInvoiceRequest request, CancellationToken cancellation = default(CancellationToken)) + { + var message = CreateMessage(HttpMethod.Post, "invoice"); + Dictionary parameters = new Dictionary(); + parameters.Add("msatoshi", request.Amont.MilliSatoshi.ToString(CultureInfo.InvariantCulture)); + parameters.Add("expiry", ((int)request.Expiry.TotalSeconds).ToString(CultureInfo.InvariantCulture)); + message.Content = new FormUrlEncodedContent(parameters); + var result = await _Client.SendAsync(message, cancellation); + result.EnsureSuccessStatusCode(); + var content = await result.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(content); + } + + public async Task Listen(CancellationToken cancellation = default(CancellationToken)) + { + var socket = new ClientWebSocket(); + socket.Options.SetRequestHeader("Authorization", $"Basic {GetBase64Creds()}"); + var uri = new UriBuilder(Uri) { UserName = null, Password = null }.Uri.AbsoluteUri; + if (!uri.EndsWith('/')) + uri += "/"; + uri += "ws"; + uri = ToWebsocketUri(uri); + await socket.ConnectAsync(new Uri(uri), cancellation); + return new ChargeSession(socket); + } + + private static string ToWebsocketUri(string uri) + { + if (uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + uri = uri.Replace("https://", "wss://", StringComparison.OrdinalIgnoreCase); + if (uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + uri = uri.Replace("http://", "ws://", StringComparison.OrdinalIgnoreCase); + return uri; + } + public NetworkCredential Credentials { get; set; } public GetInfoResponse GetInfo() { return GetInfoAsync().GetAwaiter().GetResult(); } - public async Task GetInfoAsync() + public async Task GetInfoAsync(CancellationToken cancellation = default(CancellationToken)) { - var request = Get("info"); - var message = await _Client.SendAsync(request); + var request = CreateMessage(HttpMethod.Get, "info"); + var message = await _Client.SendAsync(request, cancellation); message.EnsureSuccessStatusCode(); var content = await message.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject(content); } - private HttpRequestMessage Get(string path) + private HttpRequestMessage CreateMessage(HttpMethod method, string path) { var uri = GetFullUri(path); - var request = new HttpRequestMessage(HttpMethod.Get, uri); - request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{Credentials.UserName}:{Credentials.Password}"))); + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", GetBase64Creds()); return request; } + private string GetBase64Creds() + { + return Convert.ToBase64String(Encoding.ASCII.GetBytes($"{Credentials.UserName}:{Credentials.Password}")); + } + private Uri GetFullUri(string partialUrl) { var uri = _Uri.AbsoluteUri; diff --git a/BTCPayServer/Payments/Lightning/CLightning/ChargeSession.cs b/BTCPayServer/Payments/Lightning/CLightning/ChargeSession.cs new file mode 100644 index 000000000..596724054 --- /dev/null +++ b/BTCPayServer/Payments/Lightning/CLightning/ChargeSession.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NBXplorer; +using Newtonsoft.Json; + +namespace BTCPayServer.Payments.Lightning.CLightning +{ + public class ChargeInvoiceNotification + { + public string Id { get; set; } + + [JsonProperty("msatoshi")] + [JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))] + public LightMoney MilliSatoshi { get; set; } + [JsonProperty("paid_at")] + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? PaidAt { get; set; } + [JsonProperty("expires_at")] + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? ExpiresAt { get; set; } + public string Status { get; set; } + + [JsonProperty("payreq")] + public string PaymentRequest { get; set; } + } + public class ChargeSession : IDisposable + { + private ClientWebSocket socket; + + const int ORIGINAL_BUFFER_SIZE = 1024 * 5; + const int MAX_BUFFER_SIZE = 1024 * 1024 * 5; + public ChargeSession(ClientWebSocket socket) + { + this.socket = socket; + var buffer = new byte[ORIGINAL_BUFFER_SIZE]; + _Buffer = new ArraySegment(buffer, 0, buffer.Length); + } + + ArraySegment _Buffer; + public async Task NextEvent(CancellationToken cancellation = default(CancellationToken)) + { + var buffer = _Buffer; + var array = _Buffer.Array; + var originalSize = _Buffer.Array.Length; + var newSize = _Buffer.Array.Length; + while (true) + { + var message = await socket.ReceiveAsync(buffer, cancellation); + if (message.MessageType == WebSocketMessageType.Close) + { + await CloseSocketAndThrow(WebSocketCloseStatus.NormalClosure, "Close message received from the peer", cancellation); + break; + } + if (message.MessageType != WebSocketMessageType.Text) + { + await CloseSocketAndThrow(WebSocketCloseStatus.InvalidMessageType, "Only Text is supported", cancellation); + break; + } + if (message.EndOfMessage) + { + buffer = new ArraySegment(array, 0, buffer.Offset + message.Count); + try + { + var o = ParseMessage(buffer); + if (newSize != originalSize) + { + Array.Resize(ref array, originalSize); + } + return o; + } + catch (Exception ex) + { + await CloseSocketAndThrow(WebSocketCloseStatus.InvalidPayloadData, $"Invalid payload: {ex.Message}", cancellation); + } + } + else + { + if (buffer.Count - message.Count <= 0) + { + newSize *= 2; + if (newSize > MAX_BUFFER_SIZE) + await CloseSocketAndThrow(WebSocketCloseStatus.MessageTooBig, "Message is too big", cancellation); + Array.Resize(ref array, newSize); + buffer = new ArraySegment(array, buffer.Offset, newSize - buffer.Offset); + } + + buffer = buffer.Slice(message.Count, buffer.Count - message.Count); + } + } + throw new InvalidOperationException("Should never happen"); + } + + UTF8Encoding UTF8 = new UTF8Encoding(false, true); + private ChargeInvoiceNotification ParseMessage(ArraySegment buffer) + { + var str = UTF8.GetString(buffer.Array, 0, buffer.Count); + return JsonConvert.DeserializeObject(str, new JsonSerializerSettings()); + } + + private async Task CloseSocketAndThrow(WebSocketCloseStatus status, string description, CancellationToken cancellation) + { + var array = _Buffer.Array; + if (array.Length != ORIGINAL_BUFFER_SIZE) + Array.Resize(ref array, ORIGINAL_BUFFER_SIZE); + await socket.CloseSocket(status, description, cancellation); + throw new WebSocketException($"The socket has been closed ({status}: {description})"); + } + + public async void Dispose() + { + await this.socket.CloseSocket(); + } + + public async Task DisposeAsync() + { + await this.socket.CloseSocket(); + } + } +} diff --git a/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceRequest.cs b/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceRequest.cs new file mode 100644 index 000000000..7d8b1eec8 --- /dev/null +++ b/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceRequest.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Payments.Lightning.CLightning +{ + public class CreateInvoiceRequest + { + public LightMoney Amont { get; set; } + public TimeSpan Expiry { get; set; } + } +} diff --git a/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceResponse.cs b/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceResponse.cs new file mode 100644 index 000000000..2b96fff8f --- /dev/null +++ b/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceResponse.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Payments.Lightning.CLightning +{ + public class CreateInvoiceResponse + { + public string PayReq { get; set; } + public string Id { get; set; } + } +} diff --git a/BTCPayServer/Payments/Lightning/ChargeListener.cs b/BTCPayServer/Payments/Lightning/ChargeListener.cs new file mode 100644 index 000000000..df8ad7e9c --- /dev/null +++ b/BTCPayServer/Payments/Lightning/ChargeListener.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Events; +using BTCPayServer.Payments.Lightning.CLightning; +using BTCPayServer.Services.Invoices; +using Microsoft.Extensions.Hosting; +using NBXplorer; + +namespace BTCPayServer.Payments.Lightning +{ + public class ChargeListener : IHostedService + { + EventAggregator _Aggregator; + InvoiceRepository _InvoiceRepository; + BTCPayNetworkProvider _NetworkProvider; + public ChargeListener(EventAggregator aggregator, + InvoiceRepository invoiceRepository, + BTCPayNetworkProvider networkProvider) + { + _Aggregator = aggregator; + _InvoiceRepository = invoiceRepository; + _NetworkProvider = networkProvider; + } + + CompositeDisposable leases = new CompositeDisposable(); + public Task StartAsync(CancellationToken cancellationToken) + { + leases.Add(_Aggregator.Subscribe(async inv => + { + if (inv.Name == "invoice_created") + { + var invoice = await _InvoiceRepository.GetInvoice(null, inv.InvoiceId); + await Task.WhenAll(invoice.GetPaymentMethods(_NetworkProvider) + .Where(c => c.GetId().PaymentType == PaymentTypes.LightningLike) + .Select(s => Listen(invoice, s, _NetworkProvider.GetNetwork(s.GetId().CryptoCode)))).ConfigureAwait(false); + } + })); + return Task.CompletedTask; + } + + //MultiValueDictionary + private async Task Listen(InvoiceEntity invoice, PaymentMethod paymentMethod, BTCPayNetwork network) + { + var lightningMethod = paymentMethod.GetPaymentMethodDetails() as LightningLikePaymentMethodDetails; + if (lightningMethod == null) + return; + var lightningSupportedMethod = invoice.GetSupportedPaymentMethod(_NetworkProvider) + .FirstOrDefault(c => c.CryptoCode == network.CryptoCode); + if (lightningSupportedMethod == null) + return; + + var charge = new ChargeClient(lightningSupportedMethod.GetLightningChargeUrl(true), network.NBitcoinNetwork); + var session = await charge.Listen(); + while (true) + { + var notification = await session.NextEvent(); + if (notification.Id == lightningMethod.InvoiceId && + notification.PaymentRequest == lightningMethod.BOLT11) + { + if (notification.Status == "paid" && notification.PaidAt.HasValue) + { + await _InvoiceRepository.AddPayment(invoice.Id, notification.PaidAt.Value, new LightningLikePaymentData() + { + BOLT11 = notification.PaymentRequest, + Amount = notification.MilliSatoshi + }, network.CryptoCode, accounted: true); + _Aggregator.Publish(new InvoiceEvent(invoice.Id, 1002, "invoice_receivedPayment")); + break; + } + if(notification.Status == "expired") + { + break; + } + } + } + } + + + public Task StopAsync(CancellationToken cancellationToken) + { + leases.Dispose(); + return Task.CompletedTask; + } + } +} diff --git a/BTCPayServer/Payments/Lightning/Eclair/EclairRPCClient.cs b/BTCPayServer/Payments/Lightning/Eclair/EclairRPCClient.cs index 66d0da9f0..771788b12 100644 --- a/BTCPayServer/Payments/Lightning/Eclair/EclairRPCClient.cs +++ b/BTCPayServer/Payments/Lightning/Eclair/EclairRPCClient.cs @@ -11,6 +11,16 @@ using NBitcoin.RPC; namespace BTCPayServer.Payments.Lightning.Eclair { + public class SendResponse + { + public string PaymentHash { get; set; } + } + public class ChannelInfo + { + public string NodeId { get; set; } + public string ChannelId { get; set; } + public string State { get; set; } + } public class EclairRPCClient { public EclairRPCClient(Uri address, Network network) @@ -112,14 +122,14 @@ namespace BTCPayServer.Payments.Lightning.Eclair return await SendCommandAsync(new RPCRequest("allchannels", Array.Empty())).ConfigureAwait(false); } - public string[] Channels() + public ChannelInfo[] Channels() { return ChannelsAsync().GetAwaiter().GetResult(); } - public async Task ChannelsAsync() + public async Task ChannelsAsync() { - return await SendCommandAsync(new RPCRequest("channels", Array.Empty())).ConfigureAwait(false); + return await SendCommandAsync(new RPCRequest("channels", Array.Empty())).ConfigureAwait(false); } public void Close(string channelId) @@ -127,6 +137,11 @@ namespace BTCPayServer.Payments.Lightning.Eclair CloseAsync(channelId).GetAwaiter().GetResult(); } + public async Task SendAsync(string paymentRequest) + { + await SendCommandAsync(new RPCRequest("send", new[] { paymentRequest })).ConfigureAwait(false); + } + public async Task CloseAsync(string channelId) { if (channelId == null) @@ -227,7 +242,7 @@ namespace BTCPayServer.Payments.Lightning.Eclair throw new ArgumentNullException(nameof(node)); pushAmount = pushAmount ?? LightMoney.Zero; - var result = await SendCommandAsync(new RPCRequest("open", new object[] { node.NodeId, node.Host, node.Port, fundingSatoshi.Satoshi, pushAmount.MilliSatoshi })); + var result = await SendCommandAsync(new RPCRequest("open", new object[] { node.NodeId, fundingSatoshi.Satoshi, pushAmount.MilliSatoshi })); return result.ResultString; } diff --git a/BTCPayServer/Payments/Lightning/Eclair/LightMoney.cs b/BTCPayServer/Payments/Lightning/LightMoney.cs similarity index 96% rename from BTCPayServer/Payments/Lightning/Eclair/LightMoney.cs rename to BTCPayServer/Payments/Lightning/LightMoney.cs index 915ce5be6..8471323b9 100644 --- a/BTCPayServer/Payments/Lightning/Eclair/LightMoney.cs +++ b/BTCPayServer/Payments/Lightning/LightMoney.cs @@ -3,8 +3,9 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; +using NBitcoin; -namespace BTCPayServer.Payments.Lightning.Eclair +namespace BTCPayServer.Payments.Lightning { public enum LightMoneyUnit : ulong { @@ -94,28 +95,31 @@ namespace BTCPayServer.Payments.Lightning.Eclair return a; } - public LightMoney(int satoshis) + public LightMoney(int msatoshis) { - MilliSatoshi = satoshis; + MilliSatoshi = msatoshis; } - public LightMoney(uint satoshis) + public LightMoney(uint msatoshis) { - MilliSatoshi = satoshis; + MilliSatoshi = msatoshis; + } + public LightMoney(Money money) + { + MilliSatoshi = checked(money.Satoshi * 1000); + } + public LightMoney(long msatoshis) + { + MilliSatoshi = msatoshis; } - public LightMoney(long satoshis) - { - MilliSatoshi = satoshis; - } - - public LightMoney(ulong satoshis) + public LightMoney(ulong msatoshis) { // overflow check. // ulong.MaxValue is greater than long.MaxValue checked { - MilliSatoshi = (long)satoshis; + MilliSatoshi = (long)msatoshis; } } @@ -171,7 +175,7 @@ namespace BTCPayServer.Payments.Lightning.Eclair CheckMoneyUnit(unit, "unit"); // overflow safe because (long / int) always fit in decimal // decimal operations are checked by default - return (decimal)MilliSatoshi / (int)unit; + return (decimal)MilliSatoshi / (ulong)unit; } /// /// Convert Money to decimal (same as ToUnit) diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentData.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentData.cs new file mode 100644 index 000000000..907d4b57c --- /dev/null +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentData.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.JsonConverters; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services.Invoices; +using Newtonsoft.Json; + +namespace BTCPayServer.Payments.Lightning +{ + public class LightningLikePaymentData : CryptoPaymentData + { + [JsonConverter(typeof(LightMoneyJsonConverter))] + public LightMoney Amount { get; set; } + public string BOLT11 { get; set; } + public string GetPaymentId() + { + return BOLT11; + } + + public PaymentTypes GetPaymentType() + { + return PaymentTypes.LightningLike; + } + + public string[] GetSearchTerms() + { + return new[] { BOLT11 }; + } + + public decimal GetValue() + { + return Amount.ToDecimal(LightMoneyUnit.BTC); + } + + public bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network) + { + return true; + } + + public bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network) + { + return true; + } + } +} diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs new file mode 100644 index 000000000..5ba66888f --- /dev/null +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Payments.Lightning.CLightning; +using BTCPayServer.Services.Invoices; + +namespace BTCPayServer.Payments.Lightning +{ + public class LightningLikePaymentHandler : PaymentMethodHandlerBase + { + ExplorerClientProvider _ExplorerClientProvider; + public LightningLikePaymentHandler(ExplorerClientProvider explorerClientProvider) + { + _ExplorerClientProvider = explorerClientProvider; + } + public override async Task CreatePaymentMethodDetails(LightningSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, BTCPayNetwork network) + { + var invoice = paymentMethod.ParentEntity; + var due = invoice.ProductInformation.Price / paymentMethod.Rate; + var client = GetClient(supportedPaymentMethod, network); + var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow; + var lightningInvoice = await client.CreateInvoiceAsync(new CreateInvoiceRequest() + { + Amont = new LightMoney(due, LightMoneyUnit.BTC), + Expiry = expiry < TimeSpan.Zero ? TimeSpan.FromSeconds(1) : expiry + }); + return new LightningLikePaymentMethodDetails() + { + BOLT11 = lightningInvoice.PayReq, + InvoiceId = lightningInvoice.Id + }; + } + + public async override Task IsAvailable(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network) + { + try + { + await Test(supportedPaymentMethod, network); + return true; + } + catch { return false; } + } + + public async Task Test(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network) + { + if (!_ExplorerClientProvider.IsAvailable(network)) + throw new Exception($"Full node not available"); + + var explorerClient = _ExplorerClientProvider.GetExplorerClient(network); + var cts = new CancellationTokenSource(5000); + var client = GetClient(supportedPaymentMethod, network); + var status = explorerClient.GetStatusAsync(); + GetInfoResponse info = null; + try + { + + info = await client.GetInfoAsync(cts.Token); + } + catch (Exception ex) + { + throw new Exception($"Error while connecting to the lightning charge {client.Uri} ({ex.Message})"); + } + var address = info.Address?.FirstOrDefault(); + var port = info.Port; + address = address ?? client.Uri.DnsSafeHost; + + if (info.Network != network.CLightningNetworkName) + { + throw new Exception($"Lightning node network {info.Network}, but expected is {network.CLightningNetworkName}"); + } + + if (Math.Abs(info.BlockHeight - (await status).ChainHeight) > 10) + { + throw new Exception($"The lightning node is not synched"); + } + + try + { + await TestConnection(address, port, cts.Token); + } + catch (Exception ex) + { + throw new Exception($"Error while connecting to the lightning node via {address} ({ex.Message})"); + } + } + + private static ChargeClient GetClient(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network) + { + return new ChargeClient(supportedPaymentMethod.GetLightningChargeUrl(true), network.NBitcoinNetwork); + } + + private async Task TestConnection(string addressStr, int port, CancellationToken cancellation) + { + IPAddress address = null; + try + { + address = IPAddress.Parse(addressStr); + } + catch + { + try + { + address = (await Dns.GetHostAddressesAsync(addressStr)).FirstOrDefault(); + } + catch { } + } + + if (address == null) + throw new Exception($"DNS did not resolved {addressStr}"); + + using (var tcp = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)) + { + try + { + await WithTimeout(tcp.ConnectAsync(new IPEndPoint(address, port)), cancellation); + } + catch { return false; } + } + return true; + } + + static Task WithTimeout(Task task, CancellationToken token) + { + TaskCompletionSource tcs = new TaskCompletionSource(); + var registration = token.Register(() => { try { tcs.TrySetResult(true); } catch { } }); +#pragma warning disable CA2008 // Do not create tasks without passing a TaskScheduler + var timeoutTask = tcs.Task; +#pragma warning restore CA2008 // Do not create tasks without passing a TaskScheduler + return Task.WhenAny(task, timeoutTask).Unwrap().ContinueWith(t => registration.Dispose(), TaskScheduler.Default); + } + } +} diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentMethodDetails.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentMethodDetails.cs new file mode 100644 index 000000000..ea56c6fa8 --- /dev/null +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentMethodDetails.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Payments.Lightning +{ + public class LightningLikePaymentMethodDetails : IPaymentMethodDetails + { + public string BOLT11 { get; set; } + public string InvoiceId { get; set; } + + public string GetPaymentDestination() + { + return BOLT11; + } + + public PaymentTypes GetPaymentType() + { + return PaymentTypes.LightningLike; + } + + public decimal GetTxFee() + { + return 0.0m; + } + + public void SetNoTxFee() + { + } + + public void SetPaymentDestination(string newPaymentDestination) + { + BOLT11 = newPaymentDestination; + } + } +} diff --git a/BTCPayServer/Payments/Lightning/LightningSupportedPaymentMethod.cs b/BTCPayServer/Payments/Lightning/LightningSupportedPaymentMethod.cs new file mode 100644 index 000000000..58420e297 --- /dev/null +++ b/BTCPayServer/Payments/Lightning/LightningSupportedPaymentMethod.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Payments.Lightning +{ + public class LightningSupportedPaymentMethod : ISupportedPaymentMethod + { + public string CryptoCode { get; set; } + [Obsolete("Use Get/SetLightningChargeUrl")] + public string LightningChargeUrl { get; set; } + + public Uri GetLightningChargeUrl(bool withCredentials) + { +#pragma warning disable CS0618 // Type or member is obsolete + UriBuilder uri = new UriBuilder(LightningChargeUrl); + if (withCredentials) + { + uri.UserName = Username; + uri.Password = Password; + } +#pragma warning restore CS0618 // Type or member is obsolete + return uri.Uri; + } + + public void SetLightningChargeUrl(Uri uri) + { + if (uri == null) + throw new ArgumentNullException(nameof(uri)); + if (string.IsNullOrEmpty(uri.UserInfo)) + throw new ArgumentException(paramName: nameof(uri), message: "Uri should have credential information"); + var splitted = uri.UserInfo.Split(':'); + if (splitted.Length != 2) + throw new ArgumentException(paramName: nameof(uri), message: "Uri should have credential information"); +#pragma warning disable CS0618 // Type or member is obsolete + Username = splitted[0]; + Password = splitted[1]; + LightningChargeUrl = new UriBuilder(uri) { UserName = "", Password = "" }.Uri.AbsoluteUri; +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Obsolete("Use Get/SetLightningChargeUrl")] + public string Username { get; set; } + [Obsolete("Use Get/SetLightningChargeUrl")] + public string Password { get; set; } + public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, PaymentTypes.LightningLike); + } +} diff --git a/BTCPayServer/Payments/PaymentMethodExtensions.cs b/BTCPayServer/Payments/PaymentMethodExtensions.cs index fa165984d..af1f45e81 100644 --- a/BTCPayServer/Payments/PaymentMethodExtensions.cs +++ b/BTCPayServer/Payments/PaymentMethodExtensions.cs @@ -17,13 +17,27 @@ namespace BTCPayServer.Payments return BTCPayServer.DerivationStrategy.Parse(((JValue)value).Value(), network); } ////////// - else // if(paymentMethodId.PaymentType == PaymentTypes.Lightning) + else if (paymentMethodId.PaymentType == PaymentTypes.LightningLike) { - // return JsonConvert.Deserialize(); + return JsonConvert.DeserializeObject(value.ToString()); } throw new NotSupportedException(); } + public static IPaymentMethodDetails DeserializePaymentMethodDetails(PaymentMethodId paymentMethodId, JObject jobj) + { + if(paymentMethodId.PaymentType == PaymentTypes.BTCLike) + { + return JsonConvert.DeserializeObject(jobj.ToString()); + } + if (paymentMethodId.PaymentType == PaymentTypes.LightningLike) + { + return JsonConvert.DeserializeObject(jobj.ToString()); + } + throw new NotSupportedException(paymentMethodId.PaymentType.ToString()); + } + + public static JToken Serialize(ISupportedPaymentMethod factory) { // Legacy @@ -39,5 +53,6 @@ namespace BTCPayServer.Payments } throw new NotSupportedException(); } + } } diff --git a/BTCPayServer/Payments/PaymentTypes.cs b/BTCPayServer/Payments/PaymentTypes.cs index 2e15f442b..a1387884b 100644 --- a/BTCPayServer/Payments/PaymentTypes.cs +++ b/BTCPayServer/Payments/PaymentTypes.cs @@ -13,6 +13,10 @@ namespace BTCPayServer.Payments /// /// On-Chain UTXO based, bitcoin compatible /// - BTCLike + BTCLike, + /// + /// Lightning payment + /// + LightningLike } } diff --git a/BTCPayServer/Services/BTCPayServerEnvironment.cs b/BTCPayServer/Services/BTCPayServerEnvironment.cs index 2e83ed348..fd8fc5bca 100644 --- a/BTCPayServer/Services/BTCPayServerEnvironment.cs +++ b/BTCPayServer/Services/BTCPayServerEnvironment.cs @@ -35,6 +35,14 @@ namespace BTCPayServer.Services { get; set; } + + public bool IsDevelopping + { + get + { + return ChainType == ChainType.Regtest && Environment.IsDevelopment(); + } + } public override string ToString() { StringBuilder txt = new StringBuilder(); diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 3cd5db9cd..3ec32dbed 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -178,7 +178,7 @@ namespace BTCPayServer.Services.Invoices public IEnumerable GetSupportedPaymentMethod(BTCPayNetworkProvider networks) where T : ISupportedPaymentMethod { return GetSupportedPaymentMethod(null, networks); - } + } public IEnumerable GetSupportedPaymentMethod(BTCPayNetworkProvider networks) { #pragma warning disable CS0618 @@ -365,13 +365,23 @@ namespace BTCPayServer.Services.Invoices cryptoInfo.Url = ServerUrl.WithTrailingSlash() + $"invoice{cryptoSuffix}?id=" + Id; - cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() + if (info.GetId().PaymentType == PaymentTypes.BTCLike) { - BIP72 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}&r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}", - BIP72b = $"{scheme}:?r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}", - BIP73 = ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}"), - BIP21 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}", - }; + cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() + { + BIP72 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}&r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}", + BIP72b = $"{scheme}:?r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}", + BIP73 = ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}"), + BIP21 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}", + }; + } + if (info.GetId().PaymentType == PaymentTypes.LightningLike) + { + cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() + { + BOLT11 = $"lightning:{cryptoInfo.Address}" + }; + } #pragma warning disable CS0618 if (info.CryptoCode == "BTC") { @@ -479,7 +489,7 @@ namespace BTCPayServer.Services.Invoices obj.Add(new JProperty(v.GetId().ToString(), JObject.Parse(serializer.ToString(clone)))); } PaymentMethod = obj; - foreach(var cryptoData in paymentMethods) + foreach (var cryptoData in paymentMethods) { cryptoData.ParentEntity = this; } @@ -516,6 +526,11 @@ namespace BTCPayServer.Services.Invoices /// /// Number of transactions required to pay /// + public int TxRequired { get; set; } + + /// + /// Number of transactions using this payment method + /// public int TxCount { get; set; } /// /// Total amount of network fee to pay to the invoice @@ -573,25 +588,19 @@ namespace BTCPayServer.Services.Invoices } else { - - if (GetId().PaymentType == PaymentTypes.BTCLike) + var details = PaymentMethodExtensions.DeserializePaymentMethodDetails(GetId(), PaymentMethodDetails); + if (details is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod btcLike) { - var method = DeserializePaymentMethodDetails(PaymentMethodDetails); - method.TxFee = TxFee; - method.DepositAddress = BitcoinAddress.Create(DepositAddress, Network?.NBitcoinNetwork); - method.FeeRate = FeeRate; - return method; + btcLike.TxFee = TxFee; + btcLike.DepositAddress = BitcoinAddress.Create(DepositAddress, Network?.NBitcoinNetwork); + btcLike.FeeRate = FeeRate; } + return details; } throw new NotSupportedException(PaymentType); #pragma warning restore CS0618 // Type or member is obsolete } - private T DeserializePaymentMethodDetails(JObject jobj) where T : class, IPaymentMethodDetails - { - return JsonConvert.DeserializeObject(jobj.ToString()); - } - public PaymentMethod SetPaymentMethodDetails(IPaymentMethodDetails paymentMethod) { #pragma warning disable CS0618 // Type or member is obsolete @@ -639,7 +648,7 @@ namespace BTCPayServer.Services.Invoices var paidTxFee = 0m; bool paidEnough = paid >= RoundUp(totalDue, 8); - int txCount = 0; + int txRequired = 0; var payments = ParentEntity.GetPayments() .Where(p => p.Accounted && paymentPredicate(p)) @@ -657,22 +666,24 @@ namespace BTCPayServer.Services.Invoices if (GetId() == _.GetpaymentMethodId()) { cryptoPaid += _.GetCryptoPaymentData().GetValue(); - txCount++; + txRequired++; } return _; }) .ToArray(); + var accounting = new PaymentMethodAccounting(); + accounting.TxCount = txRequired; if (!paidEnough) { - txCount++; + txRequired++; totalDue += GetTxFee(); paidTxFee += GetTxFee(); } - var accounting = new PaymentMethodAccounting(); + accounting.TotalDue = Money.Coins(RoundUp(totalDue, 8)); accounting.Paid = Money.Coins(paid); - accounting.TxCount = txCount; + accounting.TxRequired = txRequired; accounting.CryptoPaid = Money.Coins(cryptoPaid); accounting.Due = Money.Max(accounting.TotalDue - accounting.Paid, Money.Zero); accounting.DueUncapped = accounting.TotalDue - accounting.Paid; @@ -763,6 +774,10 @@ namespace BTCPayServer.Services.Invoices paymentData.Outpoint = Outpoint; return paymentData; } + if(GetpaymentMethodId().PaymentType== PaymentTypes.LightningLike) + { + return JsonConvert.DeserializeObject(CryptoPaymentData); + } throw new NotSupportedException(nameof(CryptoPaymentDataType) + " does not support " + CryptoPaymentDataType); #pragma warning restore CS0618 @@ -778,18 +793,14 @@ namespace BTCPayServer.Services.Invoices Output = paymentData.Output; /// } - else - throw new NotSupportedException(cryptoPaymentData.ToString()); - CryptoPaymentDataType = paymentData.GetPaymentType().ToString(); + CryptoPaymentDataType = cryptoPaymentData.GetPaymentType().ToString(); CryptoPaymentData = JsonConvert.SerializeObject(cryptoPaymentData); #pragma warning restore CS0618 return this; } internal decimal GetValue(PaymentMethodDictionary paymentMethods, PaymentMethodId paymentMethodId, decimal? value = null) { -#pragma warning disable CS0618 - value = value ?? Output.Value.ToDecimal(MoneyUnit.BTC); -#pragma warning restore CS0618 + value = value ?? this.GetCryptoPaymentData().GetValue(); var to = paymentMethodId; var from = this.GetpaymentMethodId(); if (to == from) @@ -838,5 +849,6 @@ namespace BTCPayServer.Services.Invoices bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network); bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network); + PaymentTypes GetPaymentType(); } } diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 57002fbdc..5fdc5d17a 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -363,7 +363,7 @@ namespace BTCPayServer.Services.Invoices { entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.GetAddress() + a.GetpaymentMethodId().ToString()).ToHashSet(); } - if(invoice.Events != null) + if (invoice.Events != null) { entity.Events = invoice.Events.OrderBy(c => c.Timestamp).ToList(); } @@ -461,7 +461,7 @@ namespace BTCPayServer.Services.Invoices AddToTextSearch(invoiceId, addresses.Select(a => a.ToString()).ToArray()); } - public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, string cryptoCode) + public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, string cryptoCode, bool accounted = false) { using (var context = _ContextFactory.CreateContext()) { @@ -471,17 +471,17 @@ namespace BTCPayServer.Services.Invoices CryptoCode = cryptoCode, #pragma warning restore CS0618 ReceivedTime = date.UtcDateTime, - Accounted = false + Accounted = accounted }; entity.SetCryptoPaymentData(paymentData); - + PaymentData data = new PaymentData { Id = paymentData.GetPaymentId(), Blob = ToBytes(entity, null), InvoiceDataId = invoiceId, - Accounted = false + Accounted = accounted }; context.Payments.Add(data); diff --git a/BTCPayServer/Views/Invoice/Checkout.cshtml b/BTCPayServer/Views/Invoice/Checkout.cshtml index ff61b72df..94e80cdb0 100644 --- a/BTCPayServer/Views/Invoice/Checkout.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout.cshtml @@ -159,7 +159,7 @@
- +
Copied diff --git a/BTCPayServer/Views/Stores/AddLightningNode.cshtml b/BTCPayServer/Views/Stores/AddLightningNode.cshtml new file mode 100644 index 000000000..af65241d5 --- /dev/null +++ b/BTCPayServer/Views/Stores/AddLightningNode.cshtml @@ -0,0 +1,47 @@ +@inject BTCPayServer.Services.BTCPayServerEnvironment env +@model LightningNodeViewModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["Title"] = "Add lightning node (Experimental)"; + ViewData.AddActivePage(BTCPayServer.Views.Stores.StoreNavPages.Index); +} + +

@ViewData["Title"]

+@Html.Partial("_StatusMessage", Model.StatusMessage) +
+
+
+
+
+@if(env.IsDevelopping) { + +} +
+
+
+
+
Lightning node url
+ This URL should point to an installed lightning charge server +
+
+ + +
+ +
+ + + +
+ + +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/BTCPayServer/Views/Stores/UpdateStore.cshtml b/BTCPayServer/Views/Stores/UpdateStore.cshtml index f0b184fb4..91cbda281 100644 --- a/BTCPayServer/Views/Stores/UpdateStore.cshtml +++ b/BTCPayServer/Views/Stores/UpdateStore.cshtml @@ -72,7 +72,7 @@
Derivation Scheme
- The DerivationScheme represents the destination of the funds received by your invoice. + The DerivationScheme represents the destination of the funds received by your invoice on chain.
@@ -86,15 +86,42 @@ @foreach(var scheme in Model.DerivationSchemes) - { - - @scheme.Crypto - @scheme.Value - - } + { + + @scheme.Crypto + @scheme.Value + + }
+ +
+
+
Lightning nodes (Experimental)
+ Connection to lightning charge node used to generate lignting network payment +
+
+ Add or modify a lightning node + + + + + + + + + @foreach(var scheme in Model.LightningNodes) + { + + + + + } + +
CryptoAddress
@scheme.CryptoCode@scheme.Address
+
+