From 300d84c5d8de24f841b46a3bd6c0029ccd058fda Mon Sep 17 00:00:00 2001 From: Nicolas Dorier Date: Thu, 10 Feb 2022 12:24:28 +0900 Subject: [PATCH] [UX/UI] Add CPFP (#3395) * Add CPFP * Sign PSBT should go back to the initial page --- BTCPayServer.Common/BTCPayNetwork.cs | 1 - BTCPayServer.Tests/FastTests.cs | 3 +- BTCPayServer.Tests/PSBTTests.cs | 2 +- BTCPayServer.Tests/SeleniumTester.cs | 45 ++--- BTCPayServer.Tests/SeleniumTests.cs | 44 ++++- .../Controllers/UIInvoiceController.UI.cs | 50 +++++- .../Controllers/UIInvoiceController.cs | 8 +- .../Controllers/UIManageController.APIKeys.cs | 12 +- .../Controllers/UIWalletsController.PSBT.cs | 170 +++++++++++++----- .../Controllers/UIWalletsController.cs | 83 +++++---- .../Data/AddressInvoiceDataExtensions.cs | 2 +- BTCPayServer/Extensions.cs | 14 +- BTCPayServer/Extensions/StoreExtensions.cs | 1 - BTCPayServer/Models/PostRedictViewModel.cs | 3 +- .../WalletSigningOptionsModel.cs | 6 +- BTCPayServer/Payments/PaymentMethodId.cs | 1 - .../Services/Invoices/InvoiceRepository.cs | 2 +- BTCPayServer/Views/Shared/PostRedirect.cshtml | 101 +++++++---- .../Views/UIInvoice/Checkout-Testing.cshtml | 4 +- .../Views/UIInvoice/ListInvoices.cshtml | 3 +- .../Views/UIWallets/SignWithSeed.cshtml | 17 +- .../Views/UIWallets/WalletPSBTDecoded.cshtml | 26 +-- .../Views/UIWallets/WalletSend.cshtml | 2 +- .../Views/UIWallets/WalletSendVault.cshtml | 12 +- .../UIWallets/WalletSigningOptions.cshtml | 6 +- .../Views/UIWallets/WalletTransactions.cshtml | 18 +- 26 files changed, 432 insertions(+), 204 deletions(-) diff --git a/BTCPayServer.Common/BTCPayNetwork.cs b/BTCPayServer.Common/BTCPayNetwork.cs index 6b37624a0..0bef9aef6 100644 --- a/BTCPayServer.Common/BTCPayNetwork.cs +++ b/BTCPayServer.Common/BTCPayNetwork.cs @@ -127,7 +127,6 @@ namespace BTCPayServer public string BlockExplorerLinkDefault { get; set; } public string DisplayName { get; set; } public int Divisibility { get; set; } = 8; - [Obsolete("Should not be needed")] public bool IsBTC { get diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index 3f4980d69..e21ba64b7 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -204,8 +204,7 @@ namespace BTCPayServer.Tests { // Local link, this is fine } - else if (attributeValue.StartsWith("http://") || attributeValue.StartsWith("https://") || - attributeValue.StartsWith("@")) + else if (attributeValue.StartsWith("http://") || attributeValue.StartsWith("https://")) { // This can be an external link. Treating it as such. var rel = GetAttributeValue(node, "rel"); diff --git a/BTCPayServer.Tests/PSBTTests.cs b/BTCPayServer.Tests/PSBTTests.cs index b3480f368..ee12569ed 100644 --- a/BTCPayServer.Tests/PSBTTests.cs +++ b/BTCPayServer.Tests/PSBTTests.cs @@ -140,7 +140,7 @@ namespace BTCPayServer.Tests var postRedirectView = Assert.IsType(view); var postRedirectViewModel = Assert.IsType(postRedirectView.Model); Assert.Equal(actionName, postRedirectViewModel.AspAction); - var redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt" || p.Key == "SigningContext.PSBT").Value; + var redirectedPSBT = postRedirectViewModel.FormParameters.Single(p => p.Key == "psbt" || p.Key == "SigningContext.PSBT").Value?.FirstOrDefault(); return redirectedPSBT; } } diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 62caad445..46179897c 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -85,6 +85,11 @@ namespace BTCPayServer.Tests Driver.AssertNoError(); } + public void PayInvoice() + { + Driver.FindElement(By.Id("FakePayment")).Click(); + } + /// /// Use this ServerUri when trying to browse with selenium /// Because for some reason, the selenium container can't resolve the tests container domain name @@ -151,6 +156,7 @@ namespace BTCPayServer.Tests } Driver.WaitForElement(By.Id("StoreSelectorCreate")).Click(); var name = "Store" + RandomUtils.GetUInt64(); + TestLogs.LogInformation($"Created store {name}"); Driver.WaitForElement(By.Id("Name")).SendKeys(name); Driver.WaitForElement(By.Id("Create")).Click(); Driver.FindElement(By.Id("StoreNav-StoreSettings")).Click(); @@ -161,7 +167,7 @@ namespace BTCPayServer.Tests return (name, storeId); } - public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit) + public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool? importkeys = null, bool isHotWallet = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit) { var isImport = !string.IsNullOrEmpty(seed); GoToWalletSettings(cryptoCode); @@ -181,11 +187,11 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Id("ImportWalletOptionsLink")).Click(); Driver.FindElement(By.Id("ImportSeedLink")).Click(); Driver.FindElement(By.Id("ExistingMnemonic")).SendKeys(seed); - Driver.SetCheckbox(By.Id("SavePrivateKeys"), privkeys); + Driver.SetCheckbox(By.Id("SavePrivateKeys"), isHotWallet); } else { - var option = privkeys ? "Hotwallet" : "Watchonly"; + var option = isHotWallet ? "Hotwallet" : "Watchonly"; TestLogs.LogInformation($"Generating new seed ({option})"); Driver.FindElement(By.Id("GenerateWalletLink")).Click(); Driver.FindElement(By.Id($"Generate{option}Link")).Click(); @@ -195,7 +201,8 @@ namespace BTCPayServer.Tests Driver.FindElement(By.CssSelector($"#ScriptPubKeyType option[value={format}]")).Click(); Driver.ToggleCollapse("AdvancedSettings"); - Driver.SetCheckbox(By.Id("ImportKeysToRPC"), importkeys); + if (importkeys is bool v) + Driver.SetCheckbox(By.Id("ImportKeysToRPC"), v); Driver.FindElement(By.Id("Continue")).Click(); if (isImport) @@ -366,7 +373,10 @@ namespace BTCPayServer.Tests public void GoToStore(string storeId, StoreNavPages storeNavPage = StoreNavPages.General) { if (storeId is not null) + { GoToUrl($"/stores/{storeId}/"); + StoreId = storeId; + } Driver.FindElement(By.Id("StoreNav-StoreSettings")).Click(); @@ -412,8 +422,9 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Id($"StoreSelectorMenuItem-{storeId}")).Click(); } - public void GoToInvoiceCheckout(string invoiceId) + public void GoToInvoiceCheckout(string? invoiceId = null) { + invoiceId ??= InvoiceId; Driver.FindElement(By.Id("StoreNav-Invoices")).Click(); Driver.FindElement(By.Id($"invoice-checkout-{invoiceId}")).Click(); CheckForJSErrors(); @@ -433,6 +444,7 @@ namespace BTCPayServer.Tests else { GoToUrl(storeId == null ? "/invoices/" : $"/stores/{storeId}/invoices/"); + StoreId = storeId; } } @@ -473,6 +485,7 @@ namespace BTCPayServer.Tests ) { GoToInvoices(storeId); + Driver.FindElement(By.Id("CreateNewInvoice")).Click(); if (amount is decimal v) Driver.FindElement(By.Id("Amount")).SendKeys(v.ToString(CultureInfo.InvariantCulture)); @@ -487,8 +500,12 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Id("Create")).Click(); var statusElement = FindAlertMessage(expectedSeverity); - return expectedSeverity == StatusMessageModel.StatusSeverity.Success ? statusElement.Text.Split(" ")[1] : null; + var inv = expectedSeverity == StatusMessageModel.StatusSeverity.Success ? statusElement.Text.Split(" ")[1] : null; + InvoiceId = inv; + TestLogs.LogInformation($"Created invoice {inv}"); + return inv; } + string InvoiceId; public async Task FundStoreWallet(WalletId walletId = null, int coins = 1, decimal denomination = 1m) { @@ -503,22 +520,6 @@ namespace BTCPayServer.Tests } } - public void PayInvoice(WalletId walletId, string invoiceId) - { - GoToInvoiceCheckout(invoiceId); - var bip21 = Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) - .GetAttribute("href"); - Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21); - - GoToWallet(walletId); - Driver.FindElement(By.Id("bip21parse")).Click(); - Driver.SwitchTo().Alert().SendKeys(bip21); - Driver.SwitchTo().Alert().Accept(); - Driver.FindElement(By.Id("SignTransaction")).Click(); - Driver.FindElement(By.Id("SignWithSeed")).Click(); - Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click(); - } - private void CheckForJSErrors() { //wait for seleniun update: https://stackoverflow.com/questions/57520296/selenium-webdriver-3-141-0-driver-manage-logs-availablelogtypes-throwing-syste diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index b8c657fc7..f58b12e9e 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -64,6 +64,48 @@ namespace BTCPayServer.Tests Assert.Contains("Starting listening NBXplorer", s.Driver.PageSource); s.Driver.Quit(); } + [Fact(Timeout = TestTimeout)] + public async Task CanUseCPFP() + { + using var s = CreateSeleniumTester(); + await s.StartAsync(); + s.RegisterNewUser(true); + s.CreateNewStore(); + s.GenerateWallet(isHotWallet: true); + await s.FundStoreWallet(); + for (int i = 0; i < 3; i++) + { + s.CreateInvoice(); + s.GoToInvoiceCheckout(); + s.PayInvoice(); + s.GoToInvoices(s.StoreId); + } + // Let's CPFP from the invoices page + s.Driver.SetCheckbox(By.Id("selectAllCheckbox"), true); + s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click(); + s.Driver.FindElement(By.Id("BumpFee")).Click(); + s.Driver.FindElement(By.Id("BroadcastTransaction")).Click(); + s.FindAlertMessage(); + Assert.Contains($"/stores/{s.StoreId}/invoices", s.Driver.Url); + + // CPFP again should fail because all invoices got bumped + s.GoToInvoices(); + s.Driver.SetCheckbox(By.Id("selectAllCheckbox"), true); + s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click(); + s.Driver.FindElement(By.Id("BumpFee")).Click(); + var err = s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error); + Assert.Contains("any UTXO available", err.Text); + Assert.Contains($"/stores/{s.StoreId}/invoices", s.Driver.Url); + + // But we should be able to bump from the wallet's page + s.GoToWallet(navPages: WalletsNavPages.Transactions); + s.Driver.SetCheckbox(By.Id("selectAllCheckbox"), true); + s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click(); + s.Driver.FindElement(By.Id("BumpFee")).Click(); + s.Driver.FindElement(By.Id("BroadcastTransaction")).Click(); + s.FindAlertMessage(); + Assert.Contains($"/wallets/{s.WalletId}", s.Driver.Url); + } [Fact(Timeout = TestTimeout)] [Trait("Lightning", "Lightning")] @@ -932,7 +974,7 @@ namespace BTCPayServer.Tests { var cryptoCode = "BTC"; s.CreateNewStore(); - s.GenerateWallet(cryptoCode, "melody lizard phrase voice unique car opinion merge degree evil swift cargo", privkeys: isHotwallet); + s.GenerateWallet(cryptoCode, "melody lizard phrase voice unique car opinion merge degree evil swift cargo", isHotWallet: isHotwallet); s.GoToWalletSettings(cryptoCode); if (isHotwallet) Assert.Contains("View seed", s.Driver.PageSource); diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 9a1ccdd29..10dd84ffa 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -15,6 +15,7 @@ using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Filters; using BTCPayServer.HostedServices; +using BTCPayServer.Models; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Payments; using BTCPayServer.Rating; @@ -29,6 +30,7 @@ using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitpayClient; using NBXplorer; +using NBXplorer.Models; using Newtonsoft.Json.Linq; using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest; using StoreData = BTCPayServer.Data.StoreData; @@ -441,17 +443,61 @@ namespace BTCPayServer.Controllers await _InvoiceRepository.MassArchive(selectedItems); TempData[WellKnownTempData.SuccessMessage] = $"{selectedItems.Length} invoice{(selectedItems.Length == 1 ? "" : "s")} archived."; break; - + case "unarchive": await _InvoiceRepository.MassArchive(selectedItems, false); TempData[WellKnownTempData.SuccessMessage] = $"{selectedItems.Length} invoice{(selectedItems.Length == 1 ? "" : "s")} unarchived."; break; + case "cpfp": + if (selectedItems.Length == 0) + return NotSupported("No invoice has been selected"); + var network = _NetworkProvider.BTC; + var explorer = _ExplorerClients.GetExplorerClient(_NetworkProvider.BTC); + IActionResult NotSupported(string err) + { + TempData[WellKnownTempData.ErrorMessage] = err; + return RedirectToAction(nameof(ListInvoices), new { storeId }); + } + if (explorer is null) + return NotSupported("This feature is only available to BTC wallets"); + if (this.GetCurrentStore().Role != StoreRoles.Owner) + return Forbid(); + + var settings = (this.GetCurrentStore().GetDerivationSchemeSettings(_NetworkProvider, network.CryptoCode)); + var derivationScheme = settings.AccountDerivation; + if (derivationScheme is null) + return NotSupported("This feature is only available to BTC wallets"); + var bumpableAddresses = (await GetAddresses(selectedItems)) + .Where(p => p.GetPaymentMethodId().IsBTCOnChain) + .Select(p => p.GetAddress()).ToHashSet(); + var utxos = await explorer.GetUTXOsAsync(derivationScheme); + var bumpableUTXOs = utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0 && bumpableAddresses.Contains(u.ScriptPubKey.Hash.ToString())).ToArray(); + var parameters = new MultiValueDictionary(); + foreach (var utxo in bumpableUTXOs) + { + parameters.Add($"outpoints[]", utxo.Outpoint.ToString()); + } + return View("PostRedirect", new PostRedirectViewModel + { + AspController = "UIWallets", + AspAction = nameof(UIWalletsController.WalletCPFP), + RouteParameters = { + { "walletId", new WalletId(storeId, network.CryptoCode).ToString() }, + { "returnUrl", Url.Action(nameof(ListInvoices), new { storeId }) } + }, + FormParameters = parameters, + }); } } - return RedirectToAction(nameof(ListInvoices), new { storeId }); } + private async Task GetAddresses(string[] selectedItems) + { + using var ctx = _dbContextFactory.CreateContext(); + return await ctx.AddressInvoices.Where(i => selectedItems.Contains(i.InvoiceDataId)).ToArrayAsync(); + } + [HttpGet("i/{invoiceId}")] [HttpGet("i/{invoiceId}/{paymentMethodId}")] [HttpGet("invoice")] diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index adb8e2b51..6c10795df 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -42,6 +42,8 @@ namespace BTCPayServer.Controllers private readonly ApplicationDbContextFactory _dbContextFactory; private readonly PullPaymentHostedService _paymentHostedService; private readonly LanguageService _languageService; + private readonly ExplorerClientProvider _ExplorerClients; + private readonly UIWalletsController _walletsController; public WebhookSender WebhookNotificationManager { get; } @@ -58,7 +60,9 @@ namespace BTCPayServer.Controllers ApplicationDbContextFactory dbContextFactory, PullPaymentHostedService paymentHostedService, WebhookSender webhookNotificationManager, - LanguageService languageService) + LanguageService languageService, + ExplorerClientProvider explorerClients, + UIWalletsController walletsController) { _CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); _StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository)); @@ -72,6 +76,8 @@ namespace BTCPayServer.Controllers _paymentHostedService = paymentHostedService; WebhookNotificationManager = webhookNotificationManager; _languageService = languageService; + this._ExplorerClients = explorerClients; + _walletsController = walletsController; } diff --git a/BTCPayServer/Controllers/UIManageController.APIKeys.cs b/BTCPayServer/Controllers/UIManageController.APIKeys.cs index 7d605c67a..23169021d 100644 --- a/BTCPayServer/Controllers/UIManageController.APIKeys.cs +++ b/BTCPayServer/Controllers/UIManageController.APIKeys.cs @@ -312,18 +312,16 @@ namespace BTCPayServer.Controllers var redirectVm = new PostRedirectViewModel() { FormUrl = viewModel.RedirectUrl.AbsoluteUri, - Parameters = + FormParameters = { - new KeyValuePair("apiKey", key.Id), - new KeyValuePair("userId", key.UserId) - } + { "apiKey", key.Id }, + { "userId", key.UserId }, + }, }; foreach (var permission in permissions) { - redirectVm.Parameters.Add( - new KeyValuePair("permissions[]", permission)); + redirectVm.FormParameters.Add("permissions[]", permission); } - return View("PostRedirect", redirectVm); } diff --git a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs index 8e23247ee..424fdcb53 100644 --- a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs @@ -70,6 +70,115 @@ namespace BTCPayServer.Controllers return psbt; } + [HttpPost("{walletId}/cpfp")] + public async Task WalletCPFP([ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, string[] outpoints, string[] transactionHashes, string returnUrl) + { + outpoints ??= Array.Empty(); + transactionHashes ??= Array.Empty(); + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + var explorer = ExplorerClientProvider.GetExplorerClient(network); + var fr = _feeRateProvider.CreateFeeProvider(network); + + var targetFeeRate = await fr.GetFeeRateAsync(1); + // Since we don't know the actual fee rate paid by a tx from NBX + // we just assume that it is 20 blocks + var assumedFeeRate = await fr.GetFeeRateAsync(20); + + var settings = (this.GetCurrentStore().GetDerivationSchemeSettings(NetworkProvider, network.CryptoCode)); + var derivationScheme = settings.AccountDerivation; + if (derivationScheme is null) + return NotFound(); + + var utxos = await explorer.GetUTXOsAsync(derivationScheme); + var outpointsHashet = outpoints.ToHashSet(); + var transactionHashesSet = transactionHashes.ToHashSet(); + var bumpableUTXOs = utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0 && + (outpointsHashet.Contains(u.Outpoint.ToString()) || + transactionHashesSet.Contains(u.Outpoint.Hash.ToString()))).ToArray(); + + if (bumpableUTXOs.Length == 0) + { + TempData[WellKnownTempData.ErrorMessage] = "There isn't any UTXO available to bump fee"; + return Redirect(returnUrl); + } + Money bumpFee = Money.Zero; + foreach (var txid in bumpableUTXOs.Select(u => u.TransactionHash).ToHashSet()) + { + var tx = await explorer.GetTransactionAsync(txid); + var vsize = tx.Transaction.GetVirtualSize(); + var assumedFeePaid = assumedFeeRate.GetFee(vsize); + var expectedFeePaid = targetFeeRate.GetFee(vsize); + bumpFee += Money.Max(Money.Zero, expectedFeePaid - assumedFeePaid); + } + var returnAddress = (await explorer.GetUnusedAsync(derivationScheme, NBXplorer.DerivationStrategy.DerivationFeature.Deposit)).Address; + TransactionBuilder builder = explorer.Network.NBitcoinNetwork.CreateTransactionBuilder(); + builder.AddCoins(bumpableUTXOs.Select(utxo => utxo.AsCoin(derivationScheme))); + // The fee of the bumped transaction should pay for both, the fee + // of the bump transaction and those that are being bumped + builder.SendEstimatedFees(targetFeeRate); + builder.SendFees(bumpFee); + builder.SendAll(returnAddress); + var psbt = builder.BuildPSBT(false); + psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() + { + PSBT = psbt, + DerivationScheme = derivationScheme + })).PSBT; + return View("PostRedirect", new PostRedirectViewModel + { + AspController = "UIWallets", + AspAction = nameof(UIWalletsController.WalletSign), + RouteParameters = { + { "walletId", walletId.ToString() }, + { "returnUrl", returnUrl } + }, + FormParameters = + { + { "walletId", walletId.ToString() }, + { "psbt", psbt.ToHex() } + } + }); + } + + [HttpPost("{walletId}/sign")] + public async Task WalletSign([ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, WalletPSBTViewModel vm, string returnUrl = null, string command = null) + { + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + var psbt = await vm.GetPSBT(network.NBitcoinNetwork); + vm.SigningContext.PSBT ??= psbt.ToBase64(); + if (returnUrl is null) + returnUrl = Url.Action(nameof(WalletTransactions), new { walletId }); + + switch (command) + { + case "vault": + return ViewVault(walletId, vm.SigningContext); + case "seed": + return SignWithSeed(walletId, vm.SigningContext); + default: + break; + } + + if (await CanUseHotWallet()) + { + var derivationScheme = GetDerivationSchemeSettings(walletId); + if (derivationScheme.IsHotWallet) + { + var extKey = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) + .GetMetadataAsync(derivationScheme.AccountDerivation, + WellknownMetadataKeys.MasterHDKey); + if (extKey != null) + { + return SignWithSeed(walletId, + new SignWithSeedViewModel { SeedOrKey = extKey, SigningContext = vm.SigningContext }); + } + } + } + return View("WalletSigningOptions", new WalletSigningOptionsModel(vm.SigningContext, returnUrl)); + } + [HttpGet("{walletId}/psbt")] public async Task WalletPSBT([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletPSBTViewModel vm) @@ -118,14 +227,12 @@ namespace BTCPayServer.Controllers return View(vm); } + vm.PSBT = psbt.ToBase64(); vm.PSBTHex = psbt.ToHex(); - var res = await TryHandleSigningCommands(walletId, psbt, command, vm.SigningContext, nameof(WalletPSBT)); - if (res != null) - { - return res; - } switch (command) { + case "sign": + return await WalletSign(walletId, vm, nameof(WalletPSBT)); case "decode": ModelState.Remove(nameof(vm.PSBT)); ModelState.Remove(nameof(vm.FileName)); @@ -407,6 +514,12 @@ namespace BTCPayServer.Controllers vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"; return View(nameof(WalletPSBT), vm); } + else + { + var wallet = _walletProvider.GetWallet(network); + var derivationSettings = GetDerivationSchemeSettings(walletId); + wallet.InvalidateCache(derivationSettings.AccountDerivation); + } } catch (Exception ex) { @@ -418,7 +531,12 @@ namespace BTCPayServer.Controllers { TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash()})"; } - return RedirectToWalletTransaction(walletId, transaction); + var returnUrl = this.HttpContext.Request.Query["returnUrl"].FirstOrDefault(); + if (returnUrl is not null) + { + return Redirect(returnUrl); + } + return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } case "analyze-psbt": return RedirectToWalletPSBT(new WalletPSBTViewModel() @@ -460,45 +578,5 @@ namespace BTCPayServer.Controllers PSBT = sourcePSBT.ToBase64() }); } - - private async Task TryHandleSigningCommands(WalletId walletId, PSBT psbt, string command, - SigningContextModel signingContext, string actionBack) - { - signingContext.PSBT = psbt.ToBase64(); - switch (command) - { - case "sign": - var routeBack = new Dictionary - { - {"action", actionBack }, {"walletId", walletId.ToString()} - }; - return View("WalletSigningOptions", new WalletSigningOptionsModel(signingContext, routeBack)); - case "vault": - return ViewVault(walletId, signingContext); - case "seed": - return SignWithSeed(walletId, signingContext); - case "nbx-seed": - if (await CanUseHotWallet()) - { - var derivationScheme = GetDerivationSchemeSettings(walletId); - if (derivationScheme.IsHotWallet) - { - var extKey = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) - .GetMetadataAsync(derivationScheme.AccountDerivation, - WellknownMetadataKeys.MasterHDKey); - return SignWithSeed(walletId, - new SignWithSeedViewModel { SeedOrKey = extKey, SigningContext = signingContext }); - } - } - TempData.SetStatusMessageModel(new StatusMessageModel - { - Severity = StatusMessageModel.StatusSeverity.Error, - Message = "NBX seed functionality is not available" - }); - break; - } - - return null; - } } } diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index 55940ad85..517262680 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -723,17 +723,16 @@ namespace BTCPayServer.Controllers { PayJoinBIP21 = vm.PayJoinBIP21, EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR, - ChangeAddress = psbtResponse.ChangeAddress?.ToString() + ChangeAddress = psbtResponse.ChangeAddress?.ToString(), + PSBT = psbt.ToHex() }; - - var res = await TryHandleSigningCommands(walletId, psbt, command, signingContext, nameof(WalletSend)); - if (res != null) - { - return res; - } - switch (command) { + case "sign": + return await WalletSign(walletId, new WalletPSBTViewModel() + { + SigningContext = signingContext + }); case "analyze-psbt": var name = $"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt"; @@ -823,10 +822,11 @@ namespace BTCPayServer.Controllers { AspController = "UIWallets", AspAction = nameof(WalletPSBTReady), - Parameters = + RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } }, + FormParameters = { - new KeyValuePair("SigningKey", vm.SigningKey), - new KeyValuePair("SigningKeyPath", vm.SigningKeyPath) + { "SigningKey", vm.SigningKey }, + { "SigningKeyPath", vm.SigningKeyPath } } }; AddSigningContext(redirectVm, vm.SigningContext); @@ -834,7 +834,11 @@ namespace BTCPayServer.Controllers !string.IsNullOrEmpty(vm.SigningContext.PSBT)) { //if a hw device signed a payjoin, we want it broadcast instantly - redirectVm.Parameters.Add(new KeyValuePair("command", "broadcast")); + redirectVm.FormParameters.Add("command", "broadcast"); + } + if (this.HttpContext.Request.Query["returnUrl"].FirstOrDefault() is string returnUrl) + { + redirectVm.RouteParameters.Add("returnUrl", returnUrl); } return View("PostRedirect", redirectVm); } @@ -843,11 +847,11 @@ namespace BTCPayServer.Controllers { if (signingContext is null) return; - redirectVm.Parameters.Add(new KeyValuePair("SigningContext.PSBT", signingContext.PSBT)); - redirectVm.Parameters.Add(new KeyValuePair("SigningContext.OriginalPSBT", signingContext.OriginalPSBT)); - redirectVm.Parameters.Add(new KeyValuePair("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21)); - redirectVm.Parameters.Add(new KeyValuePair("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture))); - redirectVm.Parameters.Add(new KeyValuePair("SigningContext.ChangeAddress", signingContext.ChangeAddress)); + redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT); + redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT); + redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21); + redirectVm.FormParameters.Add("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)); + redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress); } private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm) @@ -856,10 +860,11 @@ namespace BTCPayServer.Controllers { AspController = "UIWallets", AspAction = nameof(WalletPSBT), - Parameters = + RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } }, + FormParameters = { - new KeyValuePair("psbt", vm.PSBT), - new KeyValuePair("fileName", vm.FileName) + { "psbt", vm.PSBT }, + { "fileName", vm.FileName } } }; return View("PostRedirect", redirectVm); @@ -956,18 +961,6 @@ namespace BTCPayServer.Controllers return v.ToString() + " " + network.CryptoCode; } - private IActionResult RedirectToWalletTransaction(WalletId walletId, Transaction transaction) - { - var network = NetworkProvider.GetNetwork(walletId.CryptoCode); - if (transaction != null) - { - var wallet = _walletProvider.GetWallet(network); - var derivationSettings = GetDerivationSchemeSettings(walletId); - wallet.InvalidateCache(derivationSettings.AccountDerivation); - } - return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); - } - [HttpGet("{walletId}/rescan")] public async Task WalletRescan( [ModelBinder(typeof(WalletIdModelBinder))] @@ -1067,6 +1060,7 @@ namespace BTCPayServer.Controllers public async Task WalletActions( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string command, + string[] selectedTransactions, CancellationToken cancellationToken = default) { var derivationScheme = GetDerivationSchemeSettings(walletId); @@ -1075,6 +1069,31 @@ namespace BTCPayServer.Controllers switch (command) { + case "cpfp": + { + selectedTransactions ??= Array.Empty(); + if (selectedTransactions.Length == 0) + { + TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected"; + return RedirectToAction(nameof(WalletTransactions), new { walletId }); + } + var parameters = new MultiValueDictionary(); + parameters.Add("walletId", walletId.ToString()); + int i = 0; + foreach (var tx in selectedTransactions) + { + parameters.Add($"transactionHashes[{i}]", tx); + i++; + } + parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId })); + return View("PostRedirect", new PostRedirectViewModel + { + AspController = "UIWallets", + AspAction = nameof(UIWalletsController.WalletCPFP), + RouteParameters = { { "walletId", walletId.ToString() } }, + FormParameters = parameters + }); + } case "prune": { var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken); diff --git a/BTCPayServer/Data/AddressInvoiceDataExtensions.cs b/BTCPayServer/Data/AddressInvoiceDataExtensions.cs index 7dfc5cfc3..4d2a52942 100644 --- a/BTCPayServer/Data/AddressInvoiceDataExtensions.cs +++ b/BTCPayServer/Data/AddressInvoiceDataExtensions.cs @@ -20,7 +20,7 @@ namespace BTCPayServer.Data addressInvoiceData.Address = address + "#" + paymentMethodId.ToString(); return addressInvoiceData; } - public static PaymentMethodId GetpaymentMethodId(this AddressInvoiceData addressInvoiceData) + public static PaymentMethodId GetPaymentMethodId(this AddressInvoiceData addressInvoiceData) { if (addressInvoiceData.Address == null) return null; diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index a83c3986d..93a2f0ee5 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -498,14 +498,14 @@ namespace BTCPayServer { AspController = "UIHome", AspAction = "RecoverySeedBackup", - Parameters = + FormParameters = { - new KeyValuePair("cryptoCode", vm.CryptoCode), - new KeyValuePair("mnemonic", vm.Mnemonic), - new KeyValuePair("passphrase", vm.Passphrase), - new KeyValuePair("isStored", vm.IsStored ? "true" : "false"), - new KeyValuePair("requireConfirm", vm.RequireConfirm ? "true" : "false"), - new KeyValuePair("returnUrl", vm.ReturnUrl) + { "cryptoCode", vm.CryptoCode }, + { "mnemonic", vm.Mnemonic }, + { "passphrase", vm.Passphrase }, + { "isStored", vm.IsStored ? "true" : "false" }, + { "requireConfirm", vm.RequireConfirm ? "true" : "false" }, + { "returnUrl", vm.ReturnUrl } } }; return controller.View("PostRedirect", redirectVm); diff --git a/BTCPayServer/Extensions/StoreExtensions.cs b/BTCPayServer/Extensions/StoreExtensions.cs index bc3058786..f61b54bd8 100644 --- a/BTCPayServer/Extensions/StoreExtensions.cs +++ b/BTCPayServer/Extensions/StoreExtensions.cs @@ -13,6 +13,5 @@ namespace BTCPayServer .FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode); return paymentMethod; } - } } diff --git a/BTCPayServer/Models/PostRedictViewModel.cs b/BTCPayServer/Models/PostRedictViewModel.cs index dccf065b9..36f8a13c6 100644 --- a/BTCPayServer/Models/PostRedictViewModel.cs +++ b/BTCPayServer/Models/PostRedictViewModel.cs @@ -8,6 +8,7 @@ namespace BTCPayServer.Models public string AspController { get; set; } public string FormUrl { get; set; } - public List> Parameters { get; set; } = new List>(); + public MultiValueDictionary FormParameters { get; set; } = new MultiValueDictionary(); + public Dictionary RouteParameters { get; set; } = new Dictionary(); } } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSigningOptionsModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSigningOptionsModel.cs index 5de74a299..1436bf329 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSigningOptionsModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSigningOptionsModel.cs @@ -6,13 +6,13 @@ namespace BTCPayServer.Models.WalletViewModels { public WalletSigningOptionsModel( SigningContextModel signingContext, - IDictionary routeDataBack) + string returnUrl) { SigningContext = signingContext; - RouteDataBack = routeDataBack; + ReturnUrl = returnUrl; } public SigningContextModel SigningContext { get; } - public IDictionary RouteDataBack { get; } + public string ReturnUrl { get; } } } diff --git a/BTCPayServer/Payments/PaymentMethodId.cs b/BTCPayServer/Payments/PaymentMethodId.cs index 1151e1aee..811570eb3 100644 --- a/BTCPayServer/Payments/PaymentMethodId.cs +++ b/BTCPayServer/Payments/PaymentMethodId.cs @@ -25,7 +25,6 @@ namespace BTCPayServer.Payments CryptoCode = cryptoCode.ToUpperInvariant(); } - [Obsolete("Should only be used for legacy stuff")] public bool IsBTCOnChain { get diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 36b236e7b..4bc420c3d 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -585,7 +585,7 @@ namespace BTCPayServer.Services.Invoices } if (invoice.AddressInvoices != null) { - entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.GetAddress() + a.GetpaymentMethodId().ToString()).ToHashSet(); + entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.GetAddress() + a.GetPaymentMethodId().ToString()).ToHashSet(); } if (invoice.Events != null) { diff --git a/BTCPayServer/Views/Shared/PostRedirect.cshtml b/BTCPayServer/Views/Shared/PostRedirect.cshtml index 00dfdfa58..efa45384c 100644 --- a/BTCPayServer/Views/Shared/PostRedirect.cshtml +++ b/BTCPayServer/Views/Shared/PostRedirect.cshtml @@ -1,46 +1,71 @@ -@model PostRedirectViewModel -@{ - Layout = null; - - var routeData = Context.GetRouteData(); - var routeParams = new Dictionary(); - if (routeData != null) - { - routeParams["walletId"] = routeData.Values["walletId"]?.ToString(); - } - var action = Model.FormUrl ?? Url.Action(Model.AspAction, Model.AspController, routeParams); +@model PostRedirectViewModel +@{ + Layout = null; } - - Post Redirect + + Post Redirect -
- @Html.AntiForgeryToken() - @foreach (var o in Model.Parameters) - { - - } - -
- - + @if (Model.FormUrl is null) + { +
+ @Html.AntiForgeryToken() + @foreach (var o in Model.FormParameters) + { + foreach (var v in o.Value) + { + + } + } + +
+ } + else + { +
+ @Html.AntiForgeryToken() + @foreach (var o in Model.FormParameters) + { + foreach (var v in o.Value) + { + + } + } + +
+ } + + diff --git a/BTCPayServer/Views/UIInvoice/Checkout-Testing.cshtml b/BTCPayServer/Views/UIInvoice/Checkout-Testing.cshtml index 7ea86bce0..962df2242 100644 --- a/BTCPayServer/Views/UIInvoice/Checkout-Testing.cshtml +++ b/BTCPayServer/Views/UIInvoice/Checkout-Testing.cshtml @@ -1,4 +1,4 @@ -@model PaymentModel +@model PaymentModel

@@ -13,7 +13,7 @@
@Model.CryptoCode
- +

{{$t("This is the same as running bitcoin-cli.sh sendtoaddress xxx")}}

diff --git a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml index 42098e6d7..243e91582 100644 --- a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml +++ b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml @@ -310,11 +310,12 @@ Actions diff --git a/BTCPayServer/Views/UIWallets/SignWithSeed.cshtml b/BTCPayServer/Views/UIWallets/SignWithSeed.cshtml index eef167690..067a75267 100644 --- a/BTCPayServer/Views/UIWallets/SignWithSeed.cshtml +++ b/BTCPayServer/Views/UIWallets/SignWithSeed.cshtml @@ -3,22 +3,19 @@ var walletId = Context.GetRouteValue("walletId").ToString(); Layout = "_LayoutWizard"; ViewData.SetActivePage(WalletsNavPages.Send, "Sign PSBT", walletId); + var returnUrl = this.Context.Request.Query["returnUrl"].FirstOrDefault(); } @section Navbar { - + @if (returnUrl is string) + { + - + -} - -@section PageFootContent -{ - + } }
@@ -40,7 +37,7 @@
- +
diff --git a/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml b/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml index 6590f2993..588780807 100644 --- a/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml @@ -1,12 +1,13 @@ -@model WalletPSBTViewModel +@model WalletPSBTViewModel @addTagHelper *, BundlerMinifier.TagHelpers @{ - var walletId = Context.GetRouteValue("walletId").ToString(); - var isReady = !Model.HasErrors; - var isSignable = !isReady && Model.NBXSeedAvailable; - var needsExport = !isSignable && !isReady; - Layout = "_LayoutWizard"; - ViewData.SetActivePage(WalletsNavPages.PSBT, isReady ? "Confirm broadcasting this transaction" : "Transaction Details", walletId); + var walletId = Context.GetRouteValue("walletId").ToString(); + var isReady = !Model.HasErrors; + var isSignable = !isReady && Model.NBXSeedAvailable; + var needsExport = !isSignable && !isReady; + Layout = "_LayoutWizard"; + ViewData.SetActivePage(WalletsNavPages.PSBT, isReady ? "Confirm broadcasting this transaction" : "Transaction Details", walletId); + var returnUrl = this.Context.Request.Query["returnUrl"].FirstOrDefault(); } @section PageHeadContent { @@ -37,9 +38,12 @@ } @section Navbar { - + @if (returnUrl is string) + { + + } }
@@ -56,13 +60,13 @@
- +
} else if (isReady) { -
+ @@ -75,7 +79,7 @@ else if (isReady) } else { - + }
diff --git a/BTCPayServer/Views/UIWallets/WalletSend.cshtml b/BTCPayServer/Views/UIWallets/WalletSend.cshtml index 2cb933636..a7a37edbd 100644 --- a/BTCPayServer/Views/UIWallets/WalletSend.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletSend.cshtml @@ -230,7 +230,7 @@
- +
diff --git a/BTCPayServer/Views/UIWallets/WalletSendVault.cshtml b/BTCPayServer/Views/UIWallets/WalletSendVault.cshtml index 6c0fb0c41..49a6f8c43 100644 --- a/BTCPayServer/Views/UIWallets/WalletSendVault.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletSendVault.cshtml @@ -3,15 +3,19 @@ var walletId = Context.GetRouteValue("walletId").ToString(); Layout = "_LayoutWizard"; ViewData.SetActivePage(WalletsNavPages.Send, "Sign the transaction", walletId); + var returnUrl = this.Context.Request.Query["returnUrl"].FirstOrDefault(); } @section Navbar { - + @if (returnUrl is string) + { + - + + } }
@@ -27,7 +31,7 @@
-