diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 9f933e66e..9dd48bda3 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -415,7 +415,7 @@ namespace BTCPayServer.Tests s.Driver.Quit(); } } - + [Fact(Timeout = TestTimeout)] public async Task CanManageWallet() { @@ -427,16 +427,55 @@ namespace BTCPayServer.Tests // In this test, we try to spend from a manual seed. We import the xpub 49'/0'/0', then try to use the seed // to sign the transaction - var mnemonic = s.GenerateWallet("BTC", "", true, false); + s.GenerateWallet("BTC", "", true, false); + //let's test quickly the receive wallet page + s.Driver.FindElement(By.Id("Wallets")).Click(); + s.Driver.FindElement(By.LinkText("Manage")).Click(); + s.Driver.FindElement(By.Id("WalletReceive")).Click(); + //generate a receiving address + s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); + Assert.True(s.Driver.FindElement(By.ClassName("qr-container")).Displayed); + var receiveAddr = s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value"); + //unreserve + s.Driver.FindElement(By.CssSelector("button[value=unreserve-current-address]")).Click(); + //generate it again, should be the same one as before as nothign got used in the meantime + s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); + Assert.True(s.Driver.FindElement(By.ClassName("qr-container")).Displayed); + Assert.Equal( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); + + //send money to addr and ensure it changed + + var sess = await s.Server.ExplorerClient.CreateWebsocketNotificationSessionAsync(); + sess.ListenAllTrackedSource(); + var nextEvent = sess.NextEventAsync(); + s.Server.ExplorerNode.SendToAddress(BitcoinAddress.Create(receiveAddr, Network.RegTest), + Money.Parse("0.1")); + await nextEvent; + await Task.Delay(200); + s.Driver.Navigate().Refresh(); + s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); + Assert.NotEqual( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); + receiveAddr = s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value"); + //change the wallet and ensure old address is not there and generating a new one does not result in the prev one + s.GoToStore(storeId.storeId); + s.GenerateWallet("BTC", "", true, false); + s.Driver.FindElement(By.Id("Wallets")).Click(); + s.Driver.FindElement(By.LinkText("Manage")).Click(); + s.Driver.FindElement(By.Id("WalletReceive")).Click(); + s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); + Assert.NotEqual( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); + + var invoiceId = s.CreateInvoice(storeId.storeId); var invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId); var address = invoice.EntityToDTO().Addresses["BTC"]; + var result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest)); Assert.True(result.IsWatchOnly); s.GoToStore(storeId.storeId); - mnemonic = s.GenerateWallet("BTC", "", true, true); + var mnemonic = s.GenerateWallet("BTC", "", true, true); var root = new Mnemonic(mnemonic).DeriveExtKey(); invoiceId = s.CreateInvoice(storeId.storeId); diff --git a/BTCPayServer/Controllers/StoresController.BTCLike.cs b/BTCPayServer/Controllers/StoresController.BTCLike.cs index 1503c1a0f..8b01c240f 100644 --- a/BTCPayServer/Controllers/StoresController.BTCLike.cs +++ b/BTCPayServer/Controllers/StoresController.BTCLike.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; @@ -267,6 +268,11 @@ namespace BTCPayServer.Controllers } await _Repo.UpdateStore(store); + _EventAggregator.Publish(new WalletChangedEvent() + { + WalletId = new WalletId(storeId, cryptoCode) + }); + if (willBeExcluded != wasExcluded) { var label = willBeExcluded ? "disabled" : "enabled"; diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 781f3b0e9..ff7f3bd1f 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -58,6 +58,7 @@ namespace BTCPayServer.Controllers PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, SettingsRepository settingsRepository, IAuthorizationService authorizationService, + EventAggregator eventAggregator, CssThemeManager cssThemeManager) { _RateFactory = rateFactory; @@ -74,6 +75,7 @@ namespace BTCPayServer.Controllers _settingsRepository = settingsRepository; _authorizationService = authorizationService; _CssThemeManager = cssThemeManager; + _EventAggregator = eventAggregator; _NetworkProvider = networkProvider; _ExplorerProvider = explorerProvider; _FeeRateProvider = feeRateProvider; @@ -100,6 +102,7 @@ namespace BTCPayServer.Controllers private readonly SettingsRepository _settingsRepository; private readonly IAuthorizationService _authorizationService; private readonly CssThemeManager _CssThemeManager; + private readonly EventAggregator _EventAggregator; [TempData] public bool StoreNotConfigured diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index b88077ca2..921cc45f7 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -8,10 +8,12 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.HostedServices; using BTCPayServer.ModelBinders; using BTCPayServer.Models; using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Payments; using BTCPayServer.Security; using BTCPayServer.Services; using BTCPayServer.Services.Rates; @@ -23,6 +25,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using NBitcoin; using NBitcoin.DataEncoders; @@ -49,6 +52,8 @@ namespace BTCPayServer.Controllers private readonly IAuthorizationService _authorizationService; private readonly IFeeProviderFactory _feeRateProvider; private readonly BTCPayWalletProvider _walletProvider; + private readonly WalletReceiveStateService _WalletReceiveStateService; + private readonly EventAggregator _EventAggregator; public RateFetcher RateFetcher { get; } CurrencyNameTable _currencyTable; @@ -63,7 +68,9 @@ namespace BTCPayServer.Controllers IAuthorizationService authorizationService, ExplorerClientProvider explorerProvider, IFeeProviderFactory feeRateProvider, - BTCPayWalletProvider walletProvider) + BTCPayWalletProvider walletProvider, + WalletReceiveStateService walletReceiveStateService, + EventAggregator eventAggregator) { _currencyTable = currencyTable; Repository = repo; @@ -77,6 +84,8 @@ namespace BTCPayServer.Controllers ExplorerClientProvider = explorerProvider; _feeRateProvider = feeRateProvider; _walletProvider = walletProvider; + _WalletReceiveStateService = walletReceiveStateService; + _EventAggregator = eventAggregator; } // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md @@ -231,6 +240,7 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{walletId}")] + [Route("{walletId}/transactions")] public async Task WalletTransactions( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string labelFilter = null) @@ -294,6 +304,76 @@ namespace BTCPayServer.Controllers return $"{walletId}:{txId}"; } + [HttpGet] + [Route("{walletId}/receive")] + public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, string statusMessage = null) + { + if (walletId?.StoreId == null) + return NotFound(); + DerivationSchemeSettings paymentMethod = GetDerivationSchemeSettings(walletId); + if (paymentMethod == null) + return NotFound(); + var network = NetworkProvider.GetNetwork(walletId?.CryptoCode); + if (network == null) + return NotFound(); + + var address = _WalletReceiveStateService.Get(walletId)?.Address; + if (!string.IsNullOrEmpty(statusMessage)) + { + TempData[WellKnownTempData.SuccessMessage] = statusMessage; + } + + return View(new WalletReceiveViewModel() + { + CryptoCode = walletId.CryptoCode, + Address = address?.ToString(), + CryptoImage = GetImage(paymentMethod.PaymentId, network) + }); + } + + [HttpPost] + [Route("{walletId}/receive")] + public async Task WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, WalletReceiveViewModel viewModel, string command) + { + if (walletId?.StoreId == null) + return NotFound(); + DerivationSchemeSettings paymentMethod = GetDerivationSchemeSettings(walletId); + if (paymentMethod == null) + return NotFound(); + var network = this.NetworkProvider.GetNetwork(walletId?.CryptoCode); + if (network == null) + return NotFound(); + var statusMessage = string.Empty; + var wallet = _walletProvider.GetWallet(network); + switch (command) + { + case "unreserve-current-address": + KeyPathInformation cachedAddress = _WalletReceiveStateService.Get(walletId); + if (cachedAddress == null) + { + break; + } + var address = cachedAddress.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork); + ExplorerClientProvider.GetExplorerClient(network) + .CancelReservation(cachedAddress.DerivationStrategy, new[] {cachedAddress.KeyPath}); + statusMessage = new StatusMessageModel() + { + AllowDismiss =true, + Message = $"Address {address} was unreserved.", + Severity = StatusMessageModel.StatusSeverity.Success, + }.ToString(); + _WalletReceiveStateService.Remove(walletId); + break; + case "generate-new-address": + var reserve = (await wallet.ReserveAddressAsync(paymentMethod.AccountDerivation)); + _WalletReceiveStateService.Set(walletId, reserve); + break; + } + return RedirectToAction(nameof(WalletReceive), new {walletId, statusMessage}); + } + [HttpGet] [Route("{walletId}/send")] public async Task WalletSend( @@ -957,6 +1037,23 @@ namespace BTCPayServer.Controllers return NotFound(); } } + + + + private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network) + { + var res = paymentMethodId.PaymentType == PaymentTypes.BTCLike + ? Url.Content(network.CryptoImagePath) + : Url.Content(network.LightningImagePath); + return "/" + res; + } + } + + public class WalletReceiveViewModel + { + public string CryptoImage { get; set; } + public string CryptoCode { get; set; } + public string Address { get; set; } } diff --git a/BTCPayServer/Events/NewOnChainTransactionEvent.cs b/BTCPayServer/Events/NewOnChainTransactionEvent.cs new file mode 100644 index 000000000..b994f6b49 --- /dev/null +++ b/BTCPayServer/Events/NewOnChainTransactionEvent.cs @@ -0,0 +1,10 @@ +using NBXplorer.Models; + +namespace BTCPayServer.Events +{ + public class NewOnChainTransactionEvent + { + public NewTransactionEvent NewTransactionEvent { get; set; } + public string CryptoCode { get; set; } + } +} diff --git a/BTCPayServer/Events/WalletChangedEvent.cs b/BTCPayServer/Events/WalletChangedEvent.cs new file mode 100644 index 000000000..94bf1c7cb --- /dev/null +++ b/BTCPayServer/Events/WalletChangedEvent.cs @@ -0,0 +1,7 @@ +namespace BTCPayServer.Events +{ + public class WalletChangedEvent + { + public WalletId WalletId { get; set; } + } +} diff --git a/BTCPayServer/HostedServices/WalletReceiveCacheUpdater.cs b/BTCPayServer/HostedServices/WalletReceiveCacheUpdater.cs new file mode 100644 index 000000000..54415ae71 --- /dev/null +++ b/BTCPayServer/HostedServices/WalletReceiveCacheUpdater.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Events; +using BTCPayServer.Services.Wallets; +using Microsoft.Extensions.Hosting; +using NBXplorer; + +namespace BTCPayServer.HostedServices +{ + public class WalletReceiveCacheUpdater : IHostedService + { + private readonly EventAggregator _EventAggregator; + private readonly WalletReceiveStateService _WalletReceiveStateService; + + private readonly CompositeDisposable _Leases = new CompositeDisposable(); + + public WalletReceiveCacheUpdater(EventAggregator eventAggregator, + WalletReceiveStateService walletReceiveStateService) + { + _EventAggregator = eventAggregator; + _WalletReceiveStateService = walletReceiveStateService; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _Leases.Add(_EventAggregator.Subscribe(evt => + _WalletReceiveStateService.Remove(evt.WalletId))); + + _Leases.Add(_EventAggregator.Subscribe(evt => + { + var matching = _WalletReceiveStateService + .GetByDerivation(evt.CryptoCode, evt.NewTransactionEvent.DerivationStrategy).Where(pair => + evt.NewTransactionEvent.Outputs.Any(output => output.ScriptPubKey == pair.Value.ScriptPubKey)); + + foreach (var keyValuePair in matching) + { + _WalletReceiveStateService.Remove(keyValuePair.Key); + } + })); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _Leases.Dispose(); + return Task.CompletedTask; + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 15d84ee4b..890eb47e9 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -177,6 +177,7 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => new NBXplorerFeeProviderFactory(o.GetRequiredService()) { @@ -216,6 +217,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs index e3ee22269..7aa3091d8 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs @@ -10,6 +10,7 @@ using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using NBitcoin; +using NBXplorer.Models; namespace BTCPayServer.Payments.Bitcoin { @@ -35,7 +36,7 @@ namespace BTCPayServer.Payments.Bitcoin { public Task GetFeeRate; public Task GetNetworkFeeRate; - public Task ReserveAddress; + public Task ReserveAddress; } public override void PreparePaymentModel(PaymentModel model, InvoiceResponse invoiceResponse, @@ -141,7 +142,7 @@ namespace BTCPayServer.Payments.Bitcoin onchainMethod.NextNetworkFee = Money.Zero; break; } - onchainMethod.DepositAddress = (await prepare.ReserveAddress).ToString(); + onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString(); return onchainMethod; } } diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index 5554dbdc5..eb4fc5cd5 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -145,6 +145,11 @@ namespace BTCPayServer.Payments.Bitcoin break; case NBXplorer.Models.NewTransactionEvent evt: wallet.InvalidateCache(evt.DerivationStrategy); + _Aggregator.Publish(new NewOnChainTransactionEvent() + { + CryptoCode = wallet.Network.CryptoCode, + NewTransactionEvent = evt + }); foreach (var output in network.GetValidOutputs(evt)) { var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant(); @@ -373,7 +378,7 @@ namespace BTCPayServer.Payments.Bitcoin paymentMethod.Calculate().Due > Money.Zero) { var address = await wallet.ReserveAddressAsync(strategy); - btc.DepositAddress = address.ToString(); + btc.DepositAddress = address.Address.ToString(); await _InvoiceRepository.NewAddress(invoice.Id, btc, wallet.Network); _Aggregator.Publish(new InvoiceNewAddressEvent(invoice.Id, address.ToString(), wallet.Network)); paymentMethod.SetPaymentMethodDetails(btc); diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index a145dde65..da13c7890 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -63,7 +63,7 @@ namespace BTCPayServer.Services.Wallets public TimeSpan CacheSpan { get; private set; } = TimeSpan.FromMinutes(5); - public async Task ReserveAddressAsync(DerivationStrategyBase derivationStrategy) + public async Task ReserveAddressAsync(DerivationStrategyBase derivationStrategy) { if (derivationStrategy == null) throw new ArgumentNullException(nameof(derivationStrategy)); @@ -74,8 +74,7 @@ namespace BTCPayServer.Services.Wallets await _Client.TrackAsync(derivationStrategy).ConfigureAwait(false); pathInfo = await _Client.GetUnusedAsync(derivationStrategy, DerivationFeature.Deposit, 0, true).ConfigureAwait(false); } - - return pathInfo.Address; + return pathInfo; } public async Task<(BitcoinAddress, KeyPath)> GetChangeAddressAsync(DerivationStrategyBase derivationStrategy) diff --git a/BTCPayServer/Services/Wallets/WalletReceiveStateService.cs b/BTCPayServer/Services/Wallets/WalletReceiveStateService.cs new file mode 100644 index 000000000..bd1969eb7 --- /dev/null +++ b/BTCPayServer/Services/Wallets/WalletReceiveStateService.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using NBitcoin; +using NBXplorer.DerivationStrategy; +using NBXplorer.Models; + +namespace BTCPayServer.Services.Wallets +{ + public class WalletReceiveStateService + { + private readonly ConcurrentDictionary _walletReceiveState = + new ConcurrentDictionary(); + + public void Remove(WalletId walletId) + { + _walletReceiveState.TryRemove(walletId, out _); + } + + public KeyPathInformation Get(WalletId walletId) + { + if (_walletReceiveState.ContainsKey(walletId)) + { + return _walletReceiveState[walletId]; + } + + return null; + } + + public void Set(WalletId walletId, KeyPathInformation information) + { + _walletReceiveState.AddOrReplace(walletId, information); + } + + public IEnumerable> GetByDerivation(string cryptoCode, + DerivationStrategyBase derivationStrategyBase) + { + return _walletReceiveState.Where(pair => + pair.Key.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCulture) && + pair.Value.DerivationStrategy == derivationStrategyBase); + } + } +} diff --git a/BTCPayServer/Views/Wallets/WalletReceive.cshtml b/BTCPayServer/Views/Wallets/WalletReceive.cshtml new file mode 100644 index 000000000..8caa19db4 --- /dev/null +++ b/BTCPayServer/Views/Wallets/WalletReceive.cshtml @@ -0,0 +1,121 @@ +@using Microsoft.AspNetCore.Mvc.ModelBinding +@model BTCPayServer.Controllers.WalletReceiveViewModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["Title"] = "Manage wallet"; + ViewData.SetActivePageAndTitle(WalletsNavPages.Receive); +} + +
+
+
+ @if (string.IsNullOrEmpty(Model.Address)) + { + +

Receive @Model.CryptoCode

+ + } + else + { +

Next available @Model.CryptoCode address

+ +
+
+ + + +
+
+ +
+ +
+
+ + + +
+ } + +
+
+
+ +@section HeadScripts + +{ + + + + +} diff --git a/BTCPayServer/Views/Wallets/WalletsNavPages.cs b/BTCPayServer/Views/Wallets/WalletsNavPages.cs index 981c23df3..d7412a65c 100644 --- a/BTCPayServer/Views/Wallets/WalletsNavPages.cs +++ b/BTCPayServer/Views/Wallets/WalletsNavPages.cs @@ -11,6 +11,7 @@ namespace BTCPayServer.Views.Wallets Transactions, Rescan, PSBT, - Settings + Settings, + Receive } } diff --git a/BTCPayServer/Views/Wallets/_Nav.cshtml b/BTCPayServer/Views/Wallets/_Nav.cshtml index 9df785349..dc77bc265 100644 --- a/BTCPayServer/Views/Wallets/_Nav.cshtml +++ b/BTCPayServer/Views/Wallets/_Nav.cshtml @@ -10,6 +10,7 @@ { Send } + Receive Rescan @if (!network.ReadonlyWallet) {