diff --git a/BTCPayServer.Data/Data/WalletObjectData.cs b/BTCPayServer.Data/Data/WalletObjectData.cs index 7d6c304a6..669bc0631 100644 --- a/BTCPayServer.Data/Data/WalletObjectData.cs +++ b/BTCPayServer.Data/Data/WalletObjectData.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Data { @@ -19,6 +20,8 @@ namespace BTCPayServer.Data } public const string Label = "label"; public const string Tx = "tx"; + public const string CPFP = "CPFP"; + public const string RBF = "RBF"; public const string Payjoin = "payjoin"; public const string Invoice = "invoice"; public const string PaymentRequest = "payment-request"; @@ -33,24 +36,27 @@ namespace BTCPayServer.Data public string Type { get; set; } public string Id { get; set; } public string Data { get; set; } - +#nullable enable + public JObject? GetData() => Data is null ? null : JObject.Parse(Data); +#nullable restore public List Bs { get; set; } public List As { get; set; } - - public IEnumerable<(string type, string id, string linkdata, string objectdata)> GetLinks() +#nullable enable + public IEnumerable<(string type, string id, JObject? linkdata, JObject? objectdata)> GetLinks() { + static JObject? AsJObj(string? data) => data is null ? null : JObject.Parse(data); if (Bs is not null) foreach (var c in Bs) { - yield return (c.BType, c.BId, c.Data, c.B?.Data); + yield return (c.BType, c.BId, AsJObj(c.Data), AsJObj(c.B?.Data)); } if (As is not null) foreach (var c in As) { - yield return (c.AType, c.AId, c.Data, c.A?.Data); + yield return (c.AType, c.AId, AsJObj(c.Data), AsJObj(c.A?.Data)); } } - +#nullable restore public IEnumerable GetNeighbours() { if (Bs != null) diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index 5701837de..9e1c7ef9f 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -26,6 +26,7 @@ using BTCPayServer.Services.Fees; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; using BTCPayServer.Validation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Memory; @@ -33,6 +34,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NBitcoin; +using NBitcoin.RPC; using NBitcoin.Scripting.Parser; using NBXplorer.DerivationStrategy; using NBXplorer.Models; @@ -430,6 +432,61 @@ namespace BTCPayServer.Tests } PaymentMethodId BTC = PaymentTypes.CHAIN.GetPaymentMethodId("BTC"); PaymentMethodId LTC = PaymentTypes.CHAIN.GetPaymentMethodId("LTC"); + + [Fact] + public void CalculateMinFeeBump() + { + // Check IncrementalFee is respected + var replacementInfo = new ReplacementInfo + ( + new MempoolEntry() + { + // RBF only work if there is no descendants + BaseFee = Money.Satoshis(1000), + VirtualSizeBytes = 100, + DescendantFees = Money.Satoshis(1000), + DescendantVirtualSizeBytes = 100, + + AncestorVirtualSizeBytes = 350, + AncestorFees = Money.Satoshis(3000), + }, + IncrementalRelayFee: new FeeRate(1.0m), + MinMempoolFeeRate: new FeeRate(2.0m) + ); + + var minFeeRate = replacementInfo.CalculateNewMinFeeRate(); + var bump = replacementInfo.CalculateBumpResult(minFeeRate); + Assert.True(bump.NewTxFeeRate >= replacementInfo.MinMempoolFeeRate); + Assert.True(bump.NewEffectiveFeeRate.SatoshiPerByte >= replacementInfo.IncrementalRelayFee.SatoshiPerByte + new FeeRate(replacementInfo.Entry.AncestorFees, replacementInfo.Entry.AncestorVirtualSizeBytes).SatoshiPerByte); + + Assert.Equal(new FeeRate(Money.Satoshis(3350), 350), minFeeRate); + Assert.Equal(Money.Satoshis(1350), bump.NewTxFee); + + // Check MinMempoolFee is respected + replacementInfo = new ReplacementInfo + ( + new MempoolEntry() + { + BaseFee = Money.Satoshis(90), + VirtualSizeBytes = 100, + DescendantFees = Money.Satoshis(90), + DescendantVirtualSizeBytes = 100, + + AncestorVirtualSizeBytes = 350, + AncestorFees = Money.Satoshis(3000), + }, + IncrementalRelayFee: new FeeRate(0.0m), + MinMempoolFeeRate: new FeeRate(2.0m) + ); + minFeeRate = replacementInfo.CalculateNewMinFeeRate(); + bump = replacementInfo.CalculateBumpResult(minFeeRate); + Assert.True(bump.NewTxFeeRate >= replacementInfo.MinMempoolFeeRate); + Assert.True(bump.NewEffectiveFeeRate.SatoshiPerByte >= replacementInfo.IncrementalRelayFee.SatoshiPerByte + new FeeRate(replacementInfo.Entry.AncestorFees, replacementInfo.Entry.AncestorVirtualSizeBytes).SatoshiPerByte); + + Assert.Equal(new FeeRate(Money.Satoshis(3110), 350), minFeeRate); + Assert.Equal(Money.Satoshis(200), bump.NewTxFee); + } + [Fact] public void CanCalculateDust() { diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 397ed7379..224876384 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -679,5 +679,10 @@ retry: Driver.WaitForAndClick(By.Id("page-primary")); } } + + public void ClickCancel() + { + Driver.FindElement(By.Id("CancelWizard")).Click(); + } } } diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 1ef34386d..4445b1f9b 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -10,6 +10,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Models; using BTCPayServer.Client; using BTCPayServer.Client.Models; @@ -206,6 +207,95 @@ namespace BTCPayServer.Tests Assert.Equal(4, new SelectElement(s.Driver.FindElement(By.Id("FormId"))).Options.Count); } + [Fact(Timeout = TestTimeout)] + public async Task CanUseBumpFee() + { + using var s = CreateSeleniumTester(); + await s.StartAsync(); + s.RegisterNewUser(true); + s.CreateNewStore(); + s.GenerateWallet(isHotWallet: true); + + for (int i = 0; i < 3; i++) + { + s.CreateInvoice(); + s.GoToInvoiceCheckout(); + s.PayInvoice(); + s.GoToInvoices(s.StoreId); + } + var client = await s.AsTestAccount().CreateClient(); + var txs = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Select(t => t.TransactionHash).ToArray(); + Assert.Equal(3, txs.Length); + + s.GoToWallet(navPages: WalletsNavPages.Transactions); + ClickBumpFee(s, txs[0]); + + // Because a single transaction is selected, we should be able to select CPFP only (Because no change are available, we can't do RBF) + s.Driver.FindElement(By.Name("txId")); + Assert.Equal("true", s.Driver.FindElement(By.Id("BumpMethod")).GetAttribute("disabled")); + Assert.Equal("CPFP", new SelectElement(s.Driver.FindElement(By.Id("BumpMethod"))).SelectedOption.Text); + s.ClickCancel(); + + // Same but using mass action + SelectTransactions(s, txs[0]); + s.Driver.FindElement(By.Id("BumpFee")).Click(); + s.Driver.FindElement(By.Name("txId")); + s.ClickCancel(); + + // Because two transactions are select we can only mass bump on CPFP + SelectTransactions(s, txs[0], txs[1]); + s.Driver.FindElement(By.Id("BumpFee")).Click(); + s.Driver.ElementDoesNotExist(By.Name("txId")); + Assert.Equal("true", s.Driver.FindElement(By.Id("BumpMethod")).GetAttribute("disabled")); + Assert.Equal("CPFP", new SelectElement(s.Driver.FindElement(By.Id("BumpMethod"))).SelectedOption.Text); + + s.ClickPagePrimary(); + s.Driver.FindElement(By.Id("BroadcastTransaction")).Click(); + Assert.Contains("Transaction broadcasted successfully", s.FindAlertMessage().Text); + + // The CPFP tag should be applied to the new tx + s.Driver.Navigate().Refresh(); + s.Driver.WaitWalletTransactionsLoaded(); + var cpfpTx = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Select(t => t.TransactionHash).ToArray()[0]; + + // The CPFP should be RBF-able + Assert.DoesNotContain(cpfpTx, txs); + s.Driver.FindElement(By.CssSelector($"{TxRowSelector(cpfpTx)} .transaction-label[data-value=\"CPFP\"]")); + ClickBumpFee(s, cpfpTx); + Assert.Null(s.Driver.FindElement(By.Id("BumpMethod")).GetAttribute("disabled")); + Assert.Equal("RBF", new SelectElement(s.Driver.FindElement(By.Id("BumpMethod"))).SelectedOption.Text); + + s.ClickPagePrimary(); + s.Driver.FindElement(By.Id("BroadcastTransaction")).Click(); + + s.Driver.Navigate().Refresh(); + s.Driver.WaitWalletTransactionsLoaded(); + var rbfTx = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Select(t => t.TransactionHash).ToArray()[0]; + + // CPFP has been replaced, so it should not be found + s.Driver.AssertElementNotFound(By.CssSelector(TxRowSelector(cpfpTx))); + + // However, the new transaction should have copied the CPFP tag from the transaction it replaced, and have a RBF label as well. + s.Driver.FindElement(By.CssSelector($"{TxRowSelector(rbfTx)} .transaction-label[data-value=\"CPFP\"]")); + s.Driver.FindElement(By.CssSelector($"{TxRowSelector(rbfTx)} .transaction-label[data-value=\"RBF\"]")); + } + static string TxRowSelector(uint256 txId) => $".transaction-row[data-value=\"{txId}\"]"; + + private void SelectTransactions(SeleniumTester s, params uint256[] txs) + { + s.Driver.WaitWalletTransactionsLoaded(); + foreach (var txId in txs) + { + s.Driver.SetCheckbox(By.CssSelector($"{TxRowSelector(txId)} .mass-action-select"), true); + } + } + + private static void ClickBumpFee(SeleniumTester s, uint256 txId) + { + s.Driver.WaitWalletTransactionsLoaded(); + s.Driver.FindElement(By.CssSelector($"{TxRowSelector(txId)} .bumpFee-btn")).Click(); + } + [Fact(Timeout = TestTimeout)] public async Task CanUseCPFP() { @@ -225,6 +315,7 @@ namespace BTCPayServer.Tests // Let's CPFP from the invoices page s.Driver.SetCheckbox(By.CssSelector(".mass-action-select-all"), true); s.Driver.FindElement(By.Id("BumpFee")).Click(); + s.ClickPagePrimary(); s.Driver.FindElement(By.Id("BroadcastTransaction")).Click(); s.FindAlertMessage(); Assert.Contains($"/stores/{s.StoreId}/invoices", s.Driver.Url); @@ -234,15 +325,21 @@ namespace BTCPayServer.Tests s.Driver.SetCheckbox(By.CssSelector(".mass-action-select-all"), true); s.Driver.FindElement(By.Id("BumpFee")).Click(); Assert.Contains($"/stores/{s.StoreId}/invoices", s.Driver.Url); - Assert.Contains("any UTXO available", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error).Text); + Assert.Contains("No UTXOs available", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error).Text); // But we should be able to bump from the wallet's page s.GoToWallet(navPages: WalletsNavPages.Transactions); s.Driver.SetCheckbox(By.CssSelector(".mass-action-select-all"), true); s.Driver.FindElement(By.Id("BumpFee")).Click(); - s.Driver.FindElement(By.Id("BroadcastTransaction")).Click(); + s.ClickPagePrimary(); + s.Driver.FindElement(By.Id("BroadcastTransaction")).Click(); Assert.Contains($"/wallets/{s.WalletId}", s.Driver.Url); Assert.Contains("Transaction broadcasted successfully", s.FindAlertMessage().Text); + + // The CPFP tag should be applied to the new tx + s.Driver.Navigate().Refresh(); + s.Driver.WaitWalletTransactionsLoaded(); + s.Driver.FindElement(By.CssSelector(".transaction-label[data-value=\"CPFP\"]")); } [Fact(Timeout = TestTimeout)] diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index fa593a355..6e08fd605 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -112,11 +112,13 @@ + + PreserveNewest $(IncludeRazorContentInPack) diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs index 474521e92..396c3b571 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs @@ -790,12 +790,12 @@ namespace BTCPayServer.Controllers.Greenfield }; } - private OnChainWalletObjectData.OnChainWalletObjectLink ToModel((string type, string id, string linkdata, string objectdata) data) + private OnChainWalletObjectData.OnChainWalletObjectLink ToModel((string type, string id, JObject? linkdata, JObject? objectdata) data) { return new OnChainWalletObjectData.OnChainWalletObjectLink() { - LinkData = string.IsNullOrEmpty(data.linkdata) ? null : JObject.Parse(data.linkdata), - ObjectData = string.IsNullOrEmpty(data.objectdata) ? null : JObject.Parse(data.objectdata), + LinkData = data.linkdata, + ObjectData = data.objectdata, Type = data.type, Id = data.id, }; diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 5a363f898..838f217fd 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -660,6 +660,8 @@ namespace BTCPayServer.Controllers var bumpableAddresses = await GetAddresses(btc, selectedItems); var utxos = await explorer.GetUTXOsAsync(derivationScheme); var bumpableUTXOs = utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0 && bumpableAddresses.Contains(u.ScriptPubKey.Hash.ToString())).ToArray(); + if (bumpableUTXOs.Length == 0) + return NotSupported("No UTXOs available for bumping the selected invoices"); var parameters = new MultiValueDictionary(); foreach (var utxo in bumpableUTXOs) { @@ -668,7 +670,7 @@ namespace BTCPayServer.Controllers return View("PostRedirect", new PostRedirectViewModel { AspController = "UIWallets", - AspAction = nameof(UIWalletsController.WalletCPFP), + AspAction = nameof(UIWalletsController.WalletBumpFee), RouteParameters = { { "walletId", new WalletId(storeId, network.CryptoCode).ToString() }, { "returnUrl", Url.Action(nameof(ListInvoices), new { storeId }) } diff --git a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs index dac759aae..541a89c17 100644 --- a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs @@ -63,88 +63,6 @@ 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 derivationScheme = (this.GetCurrentStore().GetDerivationSchemeSettings(_handlers, network.CryptoCode))?.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] = StringLocalizer["There isn't any UTXO available to bump fee"].Value; - return LocalRedirect(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); - - try - { - 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(WalletSign), - RouteParameters = { - { "walletId", walletId.ToString() } - }, - FormParameters = - { - { "walletId", walletId.ToString() }, - { "psbt", psbt.ToHex() }, - { "backUrl", returnUrl }, - { "returnUrl", returnUrl } - } - }); - } - catch (Exception ex) - { - TempData[WellKnownTempData.ErrorMessage] = ex.Message; - - return LocalRedirect(returnUrl); - } - } - [HttpPost("{walletId}/sign")] public async Task WalletSign([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletPSBTViewModel vm, string command = null) @@ -152,8 +70,6 @@ namespace BTCPayServer.Controllers var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState); - vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath; - if (psbt is null || vm.InvalidPSBT) { return View("WalletSigningOptions", new WalletSigningOptionsModel @@ -361,6 +277,16 @@ namespace BTCPayServer.Controllers else { var balanceChange = psbtObject.GetBalance(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath); + var replacement = Money.Satoshis(vm.SigningContext.BalanceChangeFromReplacement); + if (replacement != Money.Zero) + { + vm.ReplacementBalanceChange = new WalletPSBTReadyViewModel.AmountViewModel() + { + BalanceChange = ValueToString(replacement, network), + Positive = replacement >= Money.Zero + }; + balanceChange += replacement; + } vm.BalanceChange = ValueToString(balanceChange, network); vm.CanCalculateBalance = true; vm.Positive = balanceChange >= Money.Zero; diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index 9c435f95f..4da6097f6 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net.Mime; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; @@ -22,6 +23,7 @@ using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.PayJoin; using BTCPayServer.Payouts; using BTCPayServer.Services; +using BTCPayServer.Services.Fees; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Labels; using BTCPayServer.Services.Rates; @@ -29,6 +31,7 @@ using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets.Export; using Dapper; +using ExchangeSharp.BinanceGroup; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -43,6 +46,10 @@ using NBXplorer; using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json; +using static BTCPayServer.Models.WalletViewModels.WalletBumpFeeViewModel; +using static BTCPayServer.Services.Wallets.ReplacementInfo; +using static Microsoft.EntityFrameworkCore.DbLoggerCategory; +using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database; using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers @@ -141,8 +148,8 @@ namespace BTCPayServer.Controllers [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string transactionId) { - return View("Confirm", new ConfirmModel("Abort Pending Transaction", - "Proceeding with this action will invalidate Pending Transaction and all accepted signatures.", + return View("Confirm", new ConfirmModel("Abort Pending Transaction", + "Proceeding with this action will invalidate Pending Transaction and all accepted signatures.", "Confirm Abort")); } [HttpPost("{walletId}/pending/{transactionId}/cancel")] @@ -188,7 +195,8 @@ namespace BTCPayServer.Controllers CryptoCode = network.CryptoCode, SigningContext = new SigningContextModel(currentPsbt) { - PendingTransactionId = transactionId, PSBT = currentPsbt.ToBase64(), + PendingTransactionId = transactionId, + PSBT = currentPsbt.ToBase64(), }, }; await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network); @@ -196,6 +204,292 @@ namespace BTCPayServer.Controllers return View("WalletPSBTDecoded", vm); } + [Route("{walletId}/transactions/bump")] + [Route("{walletId}/transactions/{transactionId}/bump")] + public async Task WalletBumpFee([ModelBinder(typeof(WalletIdModelBinder))] + [FromQuery] + WalletId walletId, + WalletBumpFeeViewModel model, + CancellationToken cancellationToken = default) + { + var paymentMethod = GetDerivationSchemeSettings(walletId); + if (paymentMethod is null) + return NotFound(); + + var wallet = _walletProvider.GetWallet(walletId.CryptoCode); + var bumpable = await wallet.GetBumpableTransactions(paymentMethod.AccountDerivation, cancellationToken); + + var bumpTarget = model.GetBumpTarget() + // Remove from the selected targets everything that isn't bumpable + .Filter(bumpable.Where(o => (o.Value.CPFP || o.Value.RBF) && o.Value.ReplacementInfo != null).Select(o => o.Key).ToHashSet()); + + var explorer = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); + var txs = await GetUnconfWalletTxInfo(explorer, paymentMethod.AccountDerivation, bumpTarget.GetTransactionIds(), cancellationToken); + + // Remove from the selected targets everything for which we don't have the transaction info + bumpTarget = bumpTarget.Filter(txs.Select(t => t.Key).ToHashSet()); + + model.ReturnUrl ??= Url.WalletTransactions(walletId)!; + + decimal minBumpFee = 0.0m; + if (bumpTarget.GetSingleTransactionId() is { } txId) + { + var inf = bumpable[txId]; + if (inf.RBF) + model.BumpFeeMethods.Add(new("RBF", "RBF")); + if (inf.CPFP) + model.BumpFeeMethods.Add(new("CPFP", "CPFP")); + + // We calculate the effective fee rate using all the ancestors and descendant. + model.CurrentFeeSatoshiPerByte = inf.ReplacementInfo!.GetEffectiveFeeRate().SatoshiPerByte; + minBumpFee = inf.ReplacementInfo.CalculateNewMinFeeRate().SatoshiPerByte; + } + else if (bumpTarget.GetTransactionIds().Any()) + { + model.BumpFeeMethods.Add(new("CPFP", "CPFP")); + // If we bump multiple transactions, we calculate the effective fee rate without + // taking into account descendants. This isn't super correct... but good enough for our purposes. + // This is because we would have the risk of double counting the fees otherwise. + var currentFeeRate = GetTransactionsFeeInfo(bumpTarget, txs, null).CurrentFeeRate.SatoshiPerByte; + model.CurrentFeeSatoshiPerByte = currentFeeRate; + minBumpFee = currentFeeRate + 1.0m; + } + else + { + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = + bumpable switch + { + { Support: BumpableSupport.NotCompatible } => StringLocalizer["This version of NBXplorer is not compatible. Please update to 2.5.22 or above"], + { Support: BumpableSupport.NotConfigured } => StringLocalizer["Please set NBXPlorer's PostgreSQL connection string to make this feature available."], + { Support: BumpableSupport.NotSynched } => StringLocalizer["Please wait for your node to be synched"], + _ => StringLocalizer["None of the selected transaction can be fee bumped"] + } + }); + return LocalRedirect(model.ReturnUrl); + } + + model.IsMultiSigOnServer = paymentMethod.IsMultiSigOnServer; + var feeProvider = _feeRateProvider.CreateFeeProvider(wallet.Network); + var recommendedFees = await GetRecommendedFees(wallet.Network, _feeRateProvider); + + foreach (var option in recommendedFees) + { + if (option is null) + continue; + if (minBumpFee is decimal v && option.FeeRate < v) + option.FeeRate = v; + } + + model.RecommendedSatoshiPerByte = + recommendedFees.Where(option => option != null).ToList(); + model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate; + + if (HttpContext.Request.Method != HttpMethods.Post) + { + model.Command = null; + } + if (!ModelState.IsValid || model.Command is null || model.FeeSatoshiPerByte is null) + return View(nameof(WalletBumpFee), model); + + var targetFeeRate = new FeeRate(model.FeeSatoshiPerByte.Value); + model.BumpMethod ??= model.BumpFeeMethods switch + { + { Count: 1 } => model.BumpFeeMethods[0].Value, + _ => "RBF" + }; + PSBT? psbt = null; + SigningContextModel? signingContext = null; + var feeBumpUrl = Url.Action(nameof(WalletBumpFee), new { walletId, transactionId = bumpTarget.GetSingleTransactionId(), model.FeeSatoshiPerByte, model.BumpMethod, model.TransactionHashes, model.Outpoints })!; + if (model.BumpMethod == "CPFP") + { + var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation); + + List bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint)); + if (bumpableUTXOs.Count == 0) + { + TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["There isn't any UTXO available to bump fee with CPFP"].Value; + return LocalRedirect(model.ReturnUrl); + } + + var createPSBT = new CreatePSBTRequest() + { + RBF = true, + AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo, + IncludeOnlyOutpoints = bumpableUTXOs, + SpendAllMatchingOutpoints = true, + FeePreference = new FeePreference() + { + ExplicitFee = GetTransactionsFeeInfo(bumpTarget, txs, targetFeeRate).MissingFee, + ExplicitFeeRate = targetFeeRate + } + }; + + try + { + var psbtResponse = await explorer.CreatePSBTAsync(paymentMethod.AccountDerivation, createPSBT, cancellationToken); + + signingContext = new SigningContextModel + { + EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR, + ChangeAddress = psbtResponse.ChangeAddress?.ToString(), + PSBT = psbtResponse.PSBT.ToHex() + }; + psbt = psbtResponse.PSBT; + } + catch (Exception ex) + { + TempData[WellKnownTempData.ErrorMessage] = ex.Message; + + return LocalRedirect(model.ReturnUrl); + } + } + else if (model.BumpMethod == "RBF") + { + // RBF is only supported for a single tx + var tx = txs[bumpTarget.GetSingleTransactionId()!]; + var changeOutput = tx.Outputs.FirstOrDefault(o => o.Feature == DerivationFeature.Change); + if (tx.Inputs.Count != tx.Transaction?.Inputs.Count || + changeOutput is null) + { + this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]); + return View(nameof(WalletBumpFee), model); + } + IActionResult ChangeTooSmall(WalletBumpFeeViewModel model, Money? missing) + { + if (missing is not null) + ModelState.AddModelError(nameof(model.FeeSatoshiPerByte), StringLocalizer["The change output is too small to pay for additional fee. (Missing {0} BTC)", missing.ToDecimal(MoneyUnit.BTC)]); + else + ModelState.AddModelError(nameof(model.FeeSatoshiPerByte), StringLocalizer["The change output is too small to pay for additional fee."]); + return View(nameof(WalletBumpFee), model); + } + + var bumpResult = bumpable[tx.TransactionId].ReplacementInfo!.CalculateBumpResult(targetFeeRate); + var createPSBT = new CreatePSBTRequest() + { + RBF = true, + AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo, + IncludeOnlyOutpoints = tx.Transaction.Inputs.Select(i => i.PrevOut).ToList(), + SpendAllMatchingOutpoints = true, + DisableFingerprintRandomization = true, + FeePreference = new FeePreference() + { + ExplicitFee = bumpResult.NewTxFee + }, + ExplicitChangeAddress = changeOutput.Address, + Destinations = tx.Transaction.Outputs.AsIndexedOutputs() + .Select(o => new CreatePSBTDestination() + { + Amount = o.N == changeOutput.Index ? (Money)o.TxOut.Value - bumpResult.BumpTxFee : (Money)o.TxOut.Value, + Destination = o.TxOut.ScriptPubKey, + }).ToList() + }; + var missingFundsOutput = createPSBT.Destinations.FirstOrDefault(d => d.Amount < Money.Zero); + if (missingFundsOutput is not null) + return ChangeTooSmall(model, -missingFundsOutput.Amount); + + try + { + var psbtResponse = await explorer.CreatePSBTAsync(paymentMethod.AccountDerivation, createPSBT, cancellationToken); + + signingContext = new SigningContextModel + { + EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR, + ChangeAddress = psbtResponse.ChangeAddress?.ToString(), + PSBT = psbtResponse.PSBT.ToHex(), + BalanceChangeFromReplacement = (-(Money)tx.BalanceChange).Satoshi + }; + psbt = psbtResponse.PSBT; + } + catch (NBXplorerException ex) when (ex.Error.Code == "output-too-small") + { + return ChangeTooSmall(model, null); + } + catch (NBXplorerException ex) + { + ModelState.AddModelError(nameof(model.TransactionId), StringLocalizer["Unable to create the replacement transaction ({0})", ex.Error.Message]); + return View(nameof(WalletBumpFee), model); + } + } + + if (psbt is not null && signingContext is not null) + { + if (psbt.TryGetFinalizedHash(out var hash)) + await this.WalletRepository.EnsureWalletObject(new WalletObjectId(walletId, WalletObjectData.Types.Tx, hash.ToString()), + new Newtonsoft.Json.Linq.JObject() + { + ["bumpFeeMethod"] = model.BumpMethod + }); + switch (model.Command) + { + case "createpending": + var pt = await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt); + return RedirectToWalletList(walletId); + default: + // case "sign": + return await WalletSign(walletId, new WalletPSBTViewModel() + { + SigningContext = signingContext, + BackUrl = feeBumpUrl, + ReturnUrl = model.ReturnUrl + }); + } + } + + // Ask choice to user + return View(nameof(WalletBumpFee), model); + } + + private async Task> GetUnconfWalletTxInfo(ExplorerClient client, DerivationStrategyBase derivationStrategyBase, HashSet txs, CancellationToken cancellationToken) + { + var txWalletInfo = new Dictionary(); + var getTransactionAsync = txs.Select(t => client.GetTransactionAsync(derivationStrategyBase, t, cancellationToken)).ToArray(); + await Task.WhenAll(getTransactionAsync); + foreach (var t in getTransactionAsync) + { + var r = await t; + if (r is not + { + Confirmations: 0, + Transaction: not null + }) + continue; + txWalletInfo.Add(r.TransactionId, r); + } + return txWalletInfo; + } + + private (Money MissingFee, FeeRate CurrentFeeRate) GetTransactionsFeeInfo(BumpTarget target, Dictionary txs, FeeRate? newFeeRate) + { + Money missingFee = Money.Zero; + int totalSize = 0; + Money totalFee = Money.Zero; + // In theory, we should calculate using the effective fee rate of all bumped transactions. + // In practice, it's a bit complicated to get... meh, that's good enough. + foreach (var bumpedTx in target.GetTransactionIds().Select(o => txs[o])) + { + var size = bumpedTx.Metadata?.VirtualSize ?? bumpedTx.Transaction?.GetVirtualSize() ?? 200; + var feePaid = bumpedTx.Metadata?.Fees; + if (feePaid is null) + // This shouldn't normally happen, as NBX indexes the fee if the transaction is in the mempool + continue; + if (newFeeRate is not null) + { + var expectedFeePaid = newFeeRate.GetFee(size); + missingFee += Money.Max(Money.Zero, expectedFeePaid - feePaid); + } + totalSize += size; + totalFee += feePaid; + } + return (missingFee, new FeeRate(totalFee, totalSize)); + } + + private IActionResult RedirectToWalletList(WalletId walletId) + { + return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); + } [HttpPost] [Route("{walletId}")] @@ -273,7 +567,7 @@ namespace BTCPayServer.Controllers ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel(); wallets.Wallets.Add(walletVm); walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode; - + walletVm.CryptoCode = wallet.Network.CryptoCode; walletVm.StoreId = wallet.Store.Id; @@ -310,9 +604,9 @@ namespace BTCPayServer.Controllers // We can't filter at the database level if we need to apply label filter var preFiltering = string.IsNullOrEmpty(labelFilter); var model = new ListTransactionsViewModel { Skip = skip, Count = count }; - + model.PendingTransactions = await _pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId); - + model.Labels.AddRange( (await WalletRepository.GetWalletLabels(walletId)) .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))); @@ -324,7 +618,6 @@ namespace BTCPayServer.Controllers transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null, cancellationToken: cancellationToken); walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray()); } - if (labelFilter != null) { model.PaginationQuery = new Dictionary { { "labelFilter", labelFilter } }; @@ -335,6 +628,7 @@ namespace BTCPayServer.Controllers } else { + var bumpable = transactions.Any(tx => tx.Confirmations == 0) ? await wallet.GetBumpableTransactions(paymentMethod.AccountDerivation, cancellationToken) : new(); var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(walletId.CryptoCode); foreach (var tx in transactions) { @@ -345,7 +639,10 @@ namespace BTCPayServer.Controllers vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0; vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network); vm.IsConfirmed = tx.Confirmations != 0; - + // If support isn't possible, we want the user to be able to click so he can see why it doesn't work + vm.CanBumpFee = + tx.Confirmations == 0 && + (bumpable.Support is not BumpableSupport.Ok || (bumpable.TryGetValue(tx.TransactionId, out var i) ? i.RBF || i.CPFP : false)); if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo)) { var labels = _labelService.CreateTransactionTagModels(transactionInfo, Request); @@ -381,7 +678,8 @@ namespace BTCPayServer.Controllers { var store = GetCurrentStore(); var data = await _walletHistogramService.GetHistogram(store, walletId, type); - if (data == null) return NotFound(); + if (data == null) + return NotFound(); return Json(data); } @@ -463,7 +761,7 @@ namespace BTCPayServer.Controllers await cashCow.SendCommandAsync("rescanblockchain"); } var addresses = Enumerable.Range(0, 200).Select(_ => c.GetUnusedAsync(paymentMethod.AccountDerivation, DerivationFeature.Deposit, reserve: true)).ToArray(); - + await Task.WhenAll(addresses); await cashCow.GenerateAsync(addresses.Length / 8); var b = cashCow.PrepareBatch(); @@ -548,30 +846,7 @@ namespace BTCPayServer.Controllers } }; } - var feeProvider = _feeRateProvider.CreateFeeProvider(network); - var recommendedFees = - new[] - { - TimeSpan.FromMinutes(10.0), TimeSpan.FromMinutes(60.0), TimeSpan.FromHours(6.0), - TimeSpan.FromHours(24.0), - }.Select(async time => - { - try - { - var result = await feeProvider.GetFeeRateAsync( - (int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time)); - return new WalletSendModel.FeeRateOption() - { - Target = time, - FeeRate = result.SatoshiPerByte - }; - } - catch (Exception) - { - return null; - } - }) - .ToArray(); + var recommendedFeesAsync = GetRecommendedFees(network, _feeRateProvider); var balance = _walletProvider.GetWallet(network).GetBalance(paymentMethod.AccountDerivation); model.NBXSeedAvailable = await GetSeed(walletId, network) != null; var Balance = await balance; @@ -581,18 +856,18 @@ namespace BTCPayServer.Controllers else model.ImmatureBalance = Balance.Immature.GetValue(network); - await Task.WhenAll(recommendedFees); + var recommendedFees = await recommendedFeesAsync; model.RecommendedSatoshiPerByte = - recommendedFees.Select(tuple => tuple.GetAwaiter().GetResult()).Where(option => option != null).ToList(); + recommendedFees.Where(option => option != null).ToList(); - model.FeeSatoshiPerByte = recommendedFees[1].GetAwaiter().GetResult()?.FeeRate; + model.FeeSatoshiPerByte = recommendedFees.Skip(1).FirstOrDefault()?.FeeRate; model.CryptoDivisibility = network.Divisibility; using (CancellationTokenSource cts = new CancellationTokenSource()) { try { cts.CancelAfter(TimeSpan.FromSeconds(5)); - var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(walletId.StoreId), cts.Token) + var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(walletId.StoreId), cts.Token) .WithCancellation(cts.Token); if (result.BidAsk != null) { @@ -612,6 +887,33 @@ namespace BTCPayServer.Controllers return View(model); } + private static async Task GetRecommendedFees(BTCPayNetwork network, IFeeProviderFactory feeProviderFactory) + { + var feeProvider = feeProviderFactory.CreateFeeProvider(network); + List options = new(); + foreach (var time in new[] { + TimeSpan.FromMinutes(10.0), TimeSpan.FromMinutes(60.0), TimeSpan.FromHours(6.0), + TimeSpan.FromHours(24.0), + }) + { + try + { + var result = await feeProvider.GetFeeRateAsync( + (int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time)); + options.Add(new WalletSendModel.FeeRateOption() + { + Target = time, + FeeRate = result.SatoshiPerByte + }); + } + catch (Exception) + { + options.Add(null); + } + } + return options.ToArray(); + } + private async Task GetSeed(WalletId walletId, BTCPayNetwork network) { return await CanUseHotWallet() && @@ -929,7 +1231,7 @@ namespace BTCPayServer.Controllers { SigningContext = signingContext, ReturnUrl = vm.ReturnUrl, - BackUrl = vm.BackUrl + BackUrl = this.Url.WalletSend(walletId) }); case "analyze-psbt": var name = @@ -1041,11 +1343,11 @@ namespace BTCPayServer.Controllers { var psbt = PSBT.Parse(vm.SigningContext.PSBT, NetworkProvider.GetNetwork(walletId.CryptoCode).NBitcoinNetwork); var pendingTransaction = await _pendingTransactionService.CollectSignature(walletId.CryptoCode, psbt, false, CancellationToken.None); - + if (pendingTransaction != null) return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } - + var redirectVm = new PostRedirectViewModel { AspController = "UIWallets", @@ -1088,6 +1390,7 @@ namespace BTCPayServer.Controllers signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)); redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress); redirectVm.FormParameters.Add("SigningContext.PendingTransactionId", signingContext.PendingTransactionId); + redirectVm.FormParameters.Add("SigningContext.BalanceChangeFromReplacement", signingContext.BalanceChangeFromReplacement.ToString()); } private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm) @@ -1355,15 +1658,11 @@ namespace BTCPayServer.Controllers parameters.Add($"transactionHashes[{i}]", tx); i++; } - - var backUrl = Url.Action(nameof(WalletTransactions), new { walletId })!; - parameters.Add("returnUrl", backUrl); - parameters.Add("backUrl", backUrl); return View("PostRedirect", new PostRedirectViewModel { AspController = "UIWallets", - AspAction = nameof(WalletCPFP), + AspAction = nameof(WalletBumpFee), RouteParameters = { { "walletId", walletId.ToString() } }, FormParameters = parameters }); diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index ce100e322..ede9a3670 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -41,9 +41,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using NBitcoin; using NBitcoin.Payment; +using NBitcoin.RPC; using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using InvoiceCryptoInfo = BTCPayServer.Services.Invoices.InvoiceCryptoInfo; namespace BTCPayServer @@ -442,6 +444,66 @@ namespace BTCPayServer finally { try { webSocket.Dispose(); } catch { } } } + public static async Task GetMempoolInfo(this RPCClient rpc, CancellationToken cancellationToken) + { + var mempoolInfo = await rpc.SendCommandAsync(new RPCRequest("getmempoolinfo", []) + { + ThrowIfRPCError = false, + }, cancellationToken); + if (mempoolInfo is null || mempoolInfo.Error is not null) + return null; + var result = new GetMempoolInfoResponse(); + var incrementalRelayFee = mempoolInfo.Result["incrementalrelayfee"]?.Value(); + if (incrementalRelayFee is not null) + { + result.IncrementalRelayFeeRate = new FeeRate(Money.Coins(incrementalRelayFee.Value), 1000); + } + + var mempoolminfee = mempoolInfo.Result["mempoolminfee"]?.Value(); + if (mempoolminfee is not null) + { + result.MempoolMinfeeRate = new FeeRate(Money.Coins(mempoolminfee.Value), 1000); + } + result.FullRBF = mempoolInfo.Result["fullrbf"]?.Value(); + return result; + } + + public static async Task> FetchMempoolEntries(this RPCClient rpc, IEnumerable txHashes, CancellationToken cancellationToken) + { + var batch = rpc.PrepareBatch(); + var tasks = new List<(uint256 Id, Task MempoolEntry)>(); + var metadatas = new Dictionary(); + foreach (var id in txHashes) + { + tasks.Add((id, batch.GetMempoolEntryAsync(id, false, cancellationToken))); + } + if (tasks.Count == 0) + return metadatas; + try + { + await batch.SendBatchAsync(cancellationToken); + foreach (var t in tasks) + { + try + { + var entry = await t.MempoolEntry; + if (entry is null) + continue; + metadatas.TryAdd(t.Id, entry); + } + catch + { + break; + } + } + } + // If it fails, that's OK, we don't care about the mempool entry information that much + catch + { + } + return metadatas; + } + public static IEnumerable GetAllBitcoinPaymentData(this InvoiceEntity invoice, BitcoinLikePaymentHandler handler, bool accountedOnly) { return invoice.GetPayments(accountedOnly) diff --git a/BTCPayServer/GetMempoolInfoResponse.cs b/BTCPayServer/GetMempoolInfoResponse.cs new file mode 100644 index 000000000..531eed687 --- /dev/null +++ b/BTCPayServer/GetMempoolInfoResponse.cs @@ -0,0 +1,11 @@ +using NBitcoin; + +namespace BTCPayServer +{ + public class GetMempoolInfoResponse + { + public FeeRate IncrementalRelayFeeRate { get; internal set; } + public FeeRate MempoolMinfeeRate { get; internal set; } + public bool? FullRBF { get; internal set; } + } +} diff --git a/BTCPayServer/HostedServices/NBXplorerWaiter.cs b/BTCPayServer/HostedServices/NBXplorerWaiter.cs index 799b7a0b4..35b9a112b 100644 --- a/BTCPayServer/HostedServices/NBXplorerWaiter.cs +++ b/BTCPayServer/HostedServices/NBXplorerWaiter.cs @@ -8,6 +8,7 @@ using BTCPayServer.Events; using BTCPayServer.Logging; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NBitcoin; using NBXplorer; using NBXplorer.Models; @@ -28,12 +29,13 @@ namespace BTCPayServer.HostedServices public NBXplorerState State { get; set; } public StatusResult Status { get; set; } public string Error { get; set; } + public GetMempoolInfoResponse MempoolInfo { get; set; } } readonly ConcurrentDictionary _Summaries = new ConcurrentDictionary(); - public void Publish(BTCPayNetworkBase network, NBXplorerState state, StatusResult status, string error) + public void Publish(BTCPayNetworkBase network, NBXplorerState state, StatusResult status, GetMempoolInfoResponse mempoolInfo, string error) { - var summary = new NBXplorerSummary() { Network = network, State = state, Status = status, Error = error }; + var summary = new NBXplorerSummary() { Network = network, State = state, Status = status, MempoolInfo = mempoolInfo, Error = error }; _Summaries.AddOrUpdate(network.CryptoCode.ToUpperInvariant(), summary, (k, v) => summary); } @@ -89,7 +91,7 @@ namespace BTCPayServer.HostedServices _Client = client; _Aggregator = aggregator; _Dashboard = dashboard; - _Dashboard.Publish(_Network, State, null, null); + _Dashboard.Publish(_Network, State, null, null, null); } readonly NBXplorerDashboard _Dashboard; @@ -139,6 +141,7 @@ namespace BTCPayServer.HostedServices var oldState = State; string error = null; StatusResult status = null; + var mempoolInfoAsync = this._Client.RPCClient.GetMempoolInfo(cancellation); try { switch (State) @@ -204,7 +207,13 @@ namespace BTCPayServer.HostedServices Logs.PayServer.LogError($"{_Network.CryptoCode}: NBXplorer error `{error}`"); } - _Dashboard.Publish(_Network, State, status, error); + GetMempoolInfoResponse mempoolInfo = null; + try + { + mempoolInfo = await mempoolInfoAsync; + } + catch { } + _Dashboard.Publish(_Network, State, status, mempoolInfo, error); if (oldState != State) { if (State == NBXplorerState.Synching) diff --git a/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs b/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs index 305ebcfbf..1d4bab847 100644 --- a/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs +++ b/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -12,8 +13,10 @@ using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.PaymentRequests; +using Microsoft.Extensions.Logging; using NBitcoin; using NBXplorer.DerivationStrategy; +using Newtonsoft.Json.Linq; namespace BTCPayServer.HostedServices { @@ -75,36 +78,99 @@ namespace BTCPayServer.HostedServices matchedObjects.Add(new ObjectTypeId(WalletObjectData.Types.Utxo, new OutPoint(transactionEvent.NewTransactionEvent.TransactionData.TransactionHash, txOut.N).ToString())); } + matchedObjects.Add(new ObjectTypeId(WalletObjectData.Types.Tx, transactionEvent.NewTransactionEvent.TransactionData.TransactionHash.ToString())); var objs = await _walletRepository.GetWalletObjects(new GetWalletObjectsQuery() { TypesIds = matchedObjects.Distinct().ToArray() }); - var links = new List(); + var links = new List(); + var newObjs = new List(); foreach (var walletObjectDatas in objs.GroupBy(data => data.Key.WalletId)) { - var txWalletObject = new WalletObjectId(walletObjectDatas.Key, - WalletObjectData.Types.Tx, txHash); + var wid = walletObjectDatas.Key; + var txWalletObject = new WalletObjectId(wid, WalletObjectData.Types.Tx, txHash); + + // if we are replacing a transaction, we add the same links to the new transaction + // Note that unlike CPFP, RBF tag may be applied to transactions that may not be fee bumps + { + if (transactionEvent.NewTransactionEvent.Replacing is not null) + { + + foreach (var replaced in transactionEvent.NewTransactionEvent.Replacing) + { + var replacedwoId = new WalletObjectId(wid, + WalletObjectData.Types.Tx, replaced.ToString()); + var replacedo = await _walletRepository.GetWalletObject(replacedwoId); + var replacedLinks = replacedo?.GetLinks().Where(t => t.type != WalletObjectData.Types.Tx) ?? []; + if (replacedLinks.Count() != 0) + { + var rbf = new WalletObjectId(wid, WalletObjectData.Types.RBF, ""); + var label = WalletRepository.CreateLabel(rbf); + newObjs.Add(label.ObjectData); + links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, label.Id)); + links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, rbf, new JObject() + { + ["txs"] = JArray.FromObject(new[] { replaced.ToString() }) + })); + } + foreach (var link in replacedLinks) + { + links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(walletObjectDatas.Key, link.type, link.id), txWalletObject, link.linkdata)); + } + } + } + } + foreach (var walletObjectData in walletObjectDatas) { - links.Add( - WalletRepository.NewWalletObjectLinkData(txWalletObject, walletObjectData.Key)); - //if the object is an address, we also link the labels to the tx - if (walletObjectData.Value.Type == WalletObjectData.Types.Address) + // if we detect it's a CPFP, we add a CPFP label { - var neighbours = walletObjectData.Value.GetNeighbours().ToArray(); - var labels = neighbours - .Where(data => data.Type == WalletObjectData.Types.Label).Select(data => - new WalletObjectId(walletObjectDatas.Key, data.Type, data.Id)); - foreach (var label in labels) + // Only for non confirmed transaction where all inputs and outputs belong to the wallet and issued by us + if ( + walletObjectData.Value.Type is WalletObjectData.Types.Tx && + walletObjectData.Value.GetData()?["bumpFeeMethod"] is JValue { Value: "CPFP" } && + transactionEvent.NewTransactionEvent is { BlockId: null, TransactionData: { Transaction: { } tx } } txEvt && + txEvt.Inputs.Count == tx.Inputs.Count && + txEvt.Outputs.Count == tx.Outputs.Count) { - links.Add(WalletRepository.NewWalletObjectLinkData(label, txWalletObject)); - var attachments = neighbours.Where(data => data.Type == label.Id); - foreach (var attachment in attachments) + var cpfp = new WalletObjectId(wid, WalletObjectData.Types.CPFP, ""); + var label = WalletRepository.CreateLabel(cpfp); + newObjs.Add(label.ObjectData); + links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, label.Id)); + links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, cpfp, new JObject() { - links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(walletObjectDatas.Key, attachment.Type, attachment.Id), txWalletObject)); + ["outpoints"] = JArray.FromObject(txEvt.Inputs.Select(i => $"{i.TransactionId}-{i.Index}")) + })); + } + } + + + // if we the tx is matching some known address and utxo, we link them to this tx + { + if (walletObjectData.Value.Type is WalletObjectData.Types.Utxo or WalletObjectData.Types.Address) + links.Add( + WalletRepository.NewWalletObjectLinkData(txWalletObject, walletObjectData.Key)); + } + // if the object is an address, we also link its labels (the ones added in the wallet receive page) + { + if (walletObjectData.Value.Type == WalletObjectData.Types.Address) + { + var neighbours = walletObjectData.Value.GetNeighbours().ToArray(); + var labels = neighbours + .Where(data => data.Type == WalletObjectData.Types.Label).Select(data => + new WalletObjectId(wid, data.Type, data.Id)); + foreach (var label in labels) + { + links.Add(WalletRepository.NewWalletObjectLinkData(label, txWalletObject)); + var attachments = neighbours.Where(data => data.Type == label.Id); + foreach (var attachment in attachments) + { + links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(wid, attachment.Type, attachment.Id), txWalletObject)); + } } } } } } - await _walletRepository.EnsureCreated(null,links); + + await _walletRepository.EnsureCreated(newObjs, links); break; } diff --git a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs index 96725d60b..33f8dda33 100644 --- a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs @@ -10,6 +10,7 @@ namespace BTCPayServer.Models.WalletViewModels { public DateTimeOffset Timestamp { get; set; } public bool IsConfirmed { get; set; } + public bool CanBumpFee { get; set; } public string Comment { get; set; } public string Id { get; set; } public string Link { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs b/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs index ce4ab9932..a1a4be0cc 100644 --- a/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs +++ b/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs @@ -19,5 +19,6 @@ namespace BTCPayServer.Models.WalletViewModels public string ChangeAddress { get; set; } public string PendingTransactionId { get; set; } + public long BalanceChangeFromReplacement { get; set; } } } diff --git a/BTCPayServer/Models/WalletViewModels/WalletBumpFeeViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletBumpFeeViewModel.cs new file mode 100644 index 000000000..24e965751 --- /dev/null +++ b/BTCPayServer/Models/WalletViewModels/WalletBumpFeeViewModel.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Rendering; +using NBitcoin; +using static BTCPayServer.Models.WalletViewModels.WalletBumpFeeViewModel; + +namespace BTCPayServer.Models.WalletViewModels +{ + public class WalletBumpFeeViewModel + { + public string ReturnUrl { get; set; } + [Display(Name = "Transaction Id")] + public uint256 TransactionId { get; set; } + public List BumpFeeMethods { get; set; } = new(); + public string[] Outpoints { get; set; } + public string[] TransactionHashes { get; set; } + public List RecommendedSatoshiPerByte { get; set; } + [Display] + public decimal? FeeSatoshiPerByte { get; set; } + [Display] + public decimal? CurrentFeeSatoshiPerByte { get; set; } + public bool IsMultiSigOnServer { get; set; } + [Display(Name = "Fee bump method")] + public string BumpMethod { get; set; } + + public string Command { get; set; } +#nullable enable + public record BumpTarget(HashSet Outpoints, HashSet TxIds) + { + + public uint256? GetSingleTransactionId() + => this switch + { + { TxIds: { Count: 1 } ids, Outpoints: { Count: 0 } } => ids.First(), + { TxIds: { Count: 0 }, Outpoints: { Count: 1 } outpoints } => outpoints.First().Hash, + _ => null + }; + public HashSet GetTransactionIds() + => (TxIds.Concat(Outpoints.Select(o => o.Hash))).ToHashSet(); + + public BumpTarget Filter(HashSet elligibleTxs) + => new BumpTarget( + Outpoints.Where(o => elligibleTxs.Contains(o.Hash)).ToHashSet(), + TxIds.Where(t => elligibleTxs.Contains(t)).ToHashSet()); + + public List GetMatchedOutpoints(IEnumerable outpoints) + { + List matches = new(); + HashSet bumpedTxs = new(); + foreach (var outpoint in outpoints) + { + if (Outpoints.Contains(outpoint)) + { + matches.Add(outpoint); + bumpedTxs.Add(outpoint.Hash); + } + else if (TxIds.Contains(outpoint.Hash) && bumpedTxs.Add(outpoint.Hash)) + { + matches.Add(outpoint); + } + } + return matches; + } + } + + public BumpTarget GetBumpTarget() + { + if (TransactionId is not null) + return new BumpTarget(new(), new([TransactionId])); + HashSet outpoints = new(); + HashSet txids = new(); + foreach (var o in Outpoints ?? []) + { + try + { + outpoints.Add(OutPoint.Parse(o)); + } + catch { } + } + foreach (var o in TransactionHashes ?? []) + { + try + { + txids.Add(uint256.Parse(o)); + } + catch { } + } + return new BumpTarget(outpoints, txids); + } +#nullable restore + } +} diff --git a/BTCPayServer/Services/Attachment.cs b/BTCPayServer/Services/Attachment.cs index bd3ca7b5e..3db9b20a4 100644 --- a/BTCPayServer/Services/Attachment.cs +++ b/BTCPayServer/Services/Attachment.cs @@ -12,12 +12,13 @@ namespace BTCPayServer.Services public string Type { get; } public string Id { get; } public JObject? Data { get; } - - public Attachment(string type, string? id = null, JObject? data = null) + public JObject? LinkData { get; } + public Attachment(string type, string? id = null, JObject? data = null, JObject? linkData = null) { Type = type; Id = id ?? string.Empty; Data = data; + LinkData = linkData; } public static Attachment Payjoin() { diff --git a/BTCPayServer/Services/Labels/LabelService.cs b/BTCPayServer/Services/Labels/LabelService.cs index 5a8a4498e..243f31fb3 100644 --- a/BTCPayServer/Services/Labels/LabelService.cs +++ b/BTCPayServer/Services/Labels/LabelService.cs @@ -8,6 +8,7 @@ using BTCPayServer.Models.WalletViewModels; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using NBitcoin; using Newtonsoft.Json.Linq; namespace BTCPayServer.Services.Labels; @@ -92,6 +93,20 @@ public class LabelService ? null : _linkGenerator.InvoiceLink(tag.Id, req.Scheme, req.Host, req.PathBase); } + else if (tag.Type == WalletObjectData.Types.RBF) + { + var txs = ((tag.LinkData?["txs"] as JArray)?.Select(e => e.ToString()) ?? []).ToHashSet(); + var txsStr = string.Join(", ", txs); + model.Tooltip = $"This is transaction is replacing the following transactions: {txsStr}"; + model.Link = "#"; + } + else if (tag.Type == WalletObjectData.Types.CPFP) + { + var txs = ((tag.LinkData?["outpoints"] as JArray)?.Select(e => OutPoint.Parse(e.ToString()).Hash) ?? []).ToHashSet(); + var txsStr = string.Join(", ", txs); + model.Tooltip = $"This is transaction is paying for fee for the following transactions: {txsStr}"; + model.Link = "#"; + } else if (tag.Type == WalletObjectData.Types.PaymentRequest) { model.Tooltip = $"Received through a payment request {tag.Id}"; diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs index e5393d7f9..528d91669 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -259,16 +259,15 @@ namespace BTCPayServer.Services Comment = data?["comment"]?.Value() }; result.Add(obj.Id, info); - foreach (var neighbour in obj.GetNeighbours()) + foreach (var link in obj.GetLinks()) { - var neighbourData = neighbour.Data is null ? null : JObject.Parse(neighbour.Data); - if (neighbour.Type == WalletObjectData.Types.Label) + if (link.type == WalletObjectData.Types.Label) { - info.LabelColors.TryAdd(neighbour.Id, neighbourData?["color"]?.Value() ?? ColorPalette.Default.DeterministicColor(neighbour.Id)); + info.LabelColors.TryAdd(link.id, link.objectdata?["color"]?.Value() ?? ColorPalette.Default.DeterministicColor(link.id)); } else { - info.Attachments.Add(new Attachment(neighbour.Type, neighbour.Id, neighbourData)); + info.Attachments.Add(new Attachment(link.type, link.id, link.objectdata, link.linkdata)); } } } @@ -422,6 +421,17 @@ namespace BTCPayServer.Services } + public static (WalletObjectData ObjectData, WalletObjectId Id) CreateLabel(WalletObjectId obj) + => CreateLabel(obj.WalletId, obj.Type); + public static (WalletObjectData ObjectData, WalletObjectId Id) CreateLabel(WalletId walletId, string txt) + { + var id = new WalletObjectId(walletId, WalletObjectData.Types.Label, txt); + return (WalletRepository.NewWalletObjectData(id, + new JObject + { + ["color"] = ColorPalette.Default.DeterministicColor(txt) + }), id); + } public static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null) { return new WalletObjectData() @@ -460,10 +470,9 @@ namespace BTCPayServer.Services objs.Add(NewWalletObjectData(id)); foreach (var l in labels.Select(l => l.Trim().Truncate(MaxLabelSize))) { - var labelObjId = new WalletObjectId(id.WalletId, WalletObjectData.Types.Label, l); - objs.Add(NewWalletObjectData(labelObjId, - new JObject() {["color"] = ColorPalette.Default.DeterministicColor(l)})); - links.Add(NewWalletObjectLinkData(labelObjId, id)); + var label = CreateLabel(id.WalletId, l); + objs.Add(label.ObjectData); + links.Add(NewWalletObjectLinkData(label.Id, id)); } await EnsureCreated(objs, links); } @@ -490,10 +499,9 @@ namespace BTCPayServer.Services objs.Add(NewWalletObjectData(txObjId)); foreach (var attachment in req.attachments) { - var labelObjId = new WalletObjectId(req.walletId, WalletObjectData.Types.Label, attachment.Type); - objs.Add(NewWalletObjectData(labelObjId, - new JObject() {["color"] = ColorPalette.Default.DeterministicColor(attachment.Type)})); - links.Add(NewWalletObjectLinkData(labelObjId, txObjId)); + var label = CreateLabel(req.walletId, attachment.Type); + objs.Add(label.ObjectData); + links.Add(NewWalletObjectLinkData(label.Id, txObjId)); if (attachment.Data is not null || attachment.Id.Length != 0) { var data = new WalletObjectId(req.walletId, attachment.Type, attachment.Id); diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index 6ba5147d4..379c433ba 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -5,16 +5,21 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Data; +using BTCPayServer.HostedServices; using BTCPayServer.Logging; using Dapper; +using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NBitcoin; +using NBitcoin.RPC; using NBXplorer; using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json.Linq; +using static BTCPayServer.Services.TransactionLinkProviders; +using static NBitcoin.Protocol.Behaviors.ChainBehavior; namespace BTCPayServer.Services.Wallets { @@ -40,9 +45,30 @@ namespace BTCPayServer.Services.Wallets public DerivationStrategyBase Strategy { get; set; } public BTCPayWallet Wallet { get; set; } } + + +#nullable enable + public record BumpableInfo(bool RBF, bool CPFP, ReplacementInfo? ReplacementInfo); +#nullable restore + public enum BumpableSupport + { + NotConfigured, + NotCompatible, + NotSynched, + Ok + } + public class BumpableTransactions : Dictionary + { + public BumpableTransactions() + { + } + + public BumpableSupport Support { get; internal set; } + } public class BTCPayWallet { public WalletRepository WalletRepository { get; } + public NBXplorerDashboard Dashboard { get; } public NBXplorerConnectionFactory NbxplorerConnectionFactory { get; } public Logs Logs { get; } @@ -50,6 +76,7 @@ namespace BTCPayServer.Services.Wallets private readonly IMemoryCache _MemoryCache; public BTCPayWallet(ExplorerClient client, IMemoryCache memoryCache, BTCPayNetwork network, WalletRepository walletRepository, + NBXplorerDashboard dashboard, ApplicationDbContextFactory dbContextFactory, NBXplorerConnectionFactory nbxplorerConnectionFactory, Logs logs) { ArgumentNullException.ThrowIfNull(client); @@ -58,6 +85,7 @@ namespace BTCPayServer.Services.Wallets _Client = client; _Network = network; WalletRepository = walletRepository; + Dashboard = dashboard; _dbContextFactory = dbContextFactory; NbxplorerConnectionFactory = nbxplorerConnectionFactory; _MemoryCache = memoryCache; @@ -275,6 +303,121 @@ namespace BTCPayServer.Services.Wallets return lines; } } + public async Task GetBumpableTransactions(DerivationStrategyBase derivationStrategyBase, CancellationToken cancellationToken) + { + var result = new BumpableTransactions(); + result.Support = BumpableSupport.NotConfigured; + if (!NbxplorerConnectionFactory.Available) + return result; + result.Support = BumpableSupport.NotCompatible; + var state = this.Dashboard.Get(Network.CryptoCode); + if (AsVersion(state?.Status?.Version ?? "") < new Version("2.5.22")) + return result; + result.Support = BumpableSupport.NotSynched; + if (state?.Status.IsFullySynched is not true) + return result; + result.Support = BumpableSupport.Ok; + await using var ctx = await NbxplorerConnectionFactory.OpenConnection(); + var cmd = new CommandDefinition( + commandText: """ + WITH unconfs AS ( + SELECT code, tx_id, raw + FROM txs + WHERE code=@code AND raw IS NOT NULL AND mempool IS TRUE AND replaced_by IS NULL AND blk_id IS NULL), + tracked_txs AS ( + SELECT code, tx_id, + COUNT(*) FILTER (WHERE is_out IS FALSE) input_count, + COUNT(*) FILTER (WHERE is_out IS TRUE AND feature = 'Change') change_count + FROM nbxv1_tracked_txs + WHERE code = @code AND wallet_id=@walletId AND mempool IS TRUE AND replaced_by IS NULL AND blk_id IS NULL + GROUP BY code, tx_id + ), + unspent_utxos AS ( + SELECT code, tx_id, COUNT(*) FILTER (WHERE input_tx_id IS NULL) unspent_count + FROM wallets_utxos + WHERE code = @code AND wallet_id=@walletId AND mempool IS TRUE AND replaced_by IS NULL AND blk_id IS NULL + GROUP BY code, tx_id + ) + SELECT tt.tx_id, u.raw, tt.input_count, tt.change_count, uu.unspent_count FROM unconfs u + JOIN tracked_txs tt USING (code, tx_id) + JOIN unspent_utxos uu USING (code, tx_id); + """, + parameters: new + { + code = Network.CryptoCode, + walletId = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, derivationStrategyBase.ToString()) + }, + cancellationToken: cancellationToken); + + // We can only replace mempool transaction where all inputs belong to us. (output_count and input_count count those belonging to us) + var rows = (await ctx.QueryAsync<(string tx_id, byte[] raw, int input_count, int change_count, int unspent_count)>(cmd)); + if (Enumerable.TryGetNonEnumeratedCount(rows, out int c) && c == 0) + return result; + + HashSet canRBF = new(); + HashSet canCPFP = new(); + foreach (var r in rows) + { + Transaction tx; + try + { + tx = Transaction.Load(r.raw, Network.NBitcoinNetwork); + } + catch + { + continue; + } + if ((state.MempoolInfo?.FullRBF is true || tx.RBF) && tx.Inputs.Count == r.input_count && + r.change_count > 0) + { + canRBF.Add(uint256.Parse(r.tx_id)); + } + if (r.unspent_count > 0) + { + canCPFP.Add(uint256.Parse(r.tx_id)); + } + } + + // Then only transactions that doesn't have any descendant (outside itself) + var entries = await _Client.RPCClient.FetchMempoolEntries(canRBF.Concat(canCPFP).ToHashSet(), cancellationToken); + foreach (var entry in entries) + { + if (entry.Value.DescendantCount != 1) + { + canRBF.Remove(entry.Key); + } + } + if (state is not + { + MempoolInfo: + { + IncrementalRelayFeeRate: { } incRelayFeeRate, + MempoolMinfeeRate: { } minFeeRate + } + }) + { + incRelayFeeRate = new FeeRate(1.0m); + minFeeRate = new FeeRate(1.0m); + } + foreach (var r in rows) + { + var id = uint256.Parse(r.tx_id); + if (!entries.TryGetValue(id, out var mempoolEntry)) + { + canCPFP.Remove(id); + canRBF.Remove(id); + } + result.Add(id, new(canRBF.Contains(id), canCPFP.Contains(id), new ReplacementInfo(mempoolEntry, incRelayFeeRate, minFeeRate))); + } + return result; + } + + private Version AsVersion(string version) + { + if (Version.TryParse(version.Split('-').FirstOrDefault(), out var v)) + return v; + return new Version("0.0.0.0"); + } private static TransactionHistoryLine FromTransactionInformation(TransactionInformation t) { @@ -366,7 +509,46 @@ namespace BTCPayServer.Services.Wallets }); } } + public record ReplacementInfo(MempoolEntry Entry, FeeRate IncrementalRelayFee, FeeRate MinMempoolFeeRate) + { + public record BumpResult(Money NewTxFee, Money BumpTxFee, FeeRate NewTxFeeRate, FeeRate NewEffectiveFeeRate); + public BumpResult CalculateBumpResult(FeeRate newEffectiveFeeRate) + { + var packageFeeRate = GetEffectiveFeeRate(); + var newTotalFee = GetFeeRoundUp(newEffectiveFeeRate, GetPackageVirtualSize()); + var oldTotalFee = GetPackageFee(); + var bump = newTotalFee - oldTotalFee; + var newTxFee = Entry.BaseFee + bump; + var newTxFeeRate = new FeeRate(newTxFee, Entry.VirtualSizeBytes); + var totalFeeRate = new FeeRate(newTotalFee, GetPackageVirtualSize()); + return new BumpResult(newTxFee, bump, newTxFeeRate, totalFeeRate); + } + static Money GetFeeRoundUp(FeeRate feeRate, int vsize) => (Money)((feeRate.FeePerK.Satoshi * vsize + 999) / 1000); + public FeeRate CalculateNewMinFeeRate() + { + var packageFeeRate = GetEffectiveFeeRate(); + var newMinFeeRate = new FeeRate(packageFeeRate.SatoshiPerByte + IncrementalRelayFee.SatoshiPerByte); + var bump = CalculateBumpResult(newMinFeeRate); + if (bump.NewTxFeeRate < MinMempoolFeeRate) + { + // We need to pay a bit more fee for the transaction to be relayed + var newTxFee = GetFeeRoundUp(MinMempoolFeeRate, Entry.VirtualSizeBytes); + newMinFeeRate = new FeeRate(GetPackageFee() - Entry.BaseFee + newTxFee, GetPackageVirtualSize()); + } + return newMinFeeRate; + } + + public int GetPackageVirtualSize() => + Entry.DescendantVirtualSizeBytes + Entry.AncestorVirtualSizeBytes - Entry.VirtualSizeBytes; + public Money GetPackageFee() => + Entry.DescendantFees + Entry.AncestorFees - Entry.BaseFee; + // Note: This isn't a correct way to calculate the package fee rate, but it is good enough for our purpose. + // It is only accounting the fee from direct ancestors/descendants. (not of uncles/cousins/brothers) + // Another more precise fee rate is documented https://x.com/ajtowns/status/1886025911562309967 + // But it is more complex to calculate, as we need to recursively fetch the mempool for all the descendants + public FeeRate GetEffectiveFeeRate() => new FeeRate(GetPackageFee(), GetPackageVirtualSize()); + } public class TransactionHistoryLine { public DateTimeOffset SeenAt { get; set; } diff --git a/BTCPayServer/Services/Wallets/BTCPayWalletProvider.cs b/BTCPayServer/Services/Wallets/BTCPayWalletProvider.cs index 8390c7cb3..d581cf444 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWalletProvider.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWalletProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using BTCPayServer.HostedServices; using BTCPayServer.Logging; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -21,6 +22,7 @@ namespace BTCPayServer.Services.Wallets BTCPayNetworkProvider networkProvider, NBXplorerConnectionFactory nbxplorerConnectionFactory, WalletRepository walletRepository, + NBXplorerDashboard dashboard, Logs logs) { ArgumentNullException.ThrowIfNull(client); @@ -35,7 +37,7 @@ namespace BTCPayServer.Services.Wallets var explorerClient = _Client.GetExplorerClient(network.CryptoCode); if (explorerClient == null) continue; - _Wallets.Add(network.CryptoCode.ToUpperInvariant(), new BTCPayWallet(explorerClient, new MemoryCache(_Options), network, WalletRepository, dbContextFactory, nbxplorerConnectionFactory, Logs)); + _Wallets.Add(network.CryptoCode.ToUpperInvariant(), new BTCPayWallet(explorerClient, new MemoryCache(_Options), network, WalletRepository, dashboard, dbContextFactory, nbxplorerConnectionFactory, Logs)); } } diff --git a/BTCPayServer/Views/UIWallets/SigningContext.cshtml b/BTCPayServer/Views/UIWallets/SigningContext.cshtml index b1b6546f7..e85df08f6 100644 --- a/BTCPayServer/Views/UIWallets/SigningContext.cshtml +++ b/BTCPayServer/Views/UIWallets/SigningContext.cshtml @@ -1,4 +1,4 @@ -@model BTCPayServer.Models.WalletViewModels.SigningContextModel +@model BTCPayServer.Models.WalletViewModels.SigningContextModel @if (Model != null) { @@ -8,4 +8,5 @@ + } diff --git a/BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml b/BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml new file mode 100644 index 000000000..9fdeed9cf --- /dev/null +++ b/BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml @@ -0,0 +1,156 @@ +@using Microsoft.AspNetCore.Mvc.ModelBinding +@using BTCPayServer.Controllers +@using BTCPayServer.Services +@using BTCPayServer.Components.LabelManager +@model WalletBumpFeeViewModel +@{ + var walletId = Context.GetRouteValue("walletId").ToString(); + var cancelUrl = this.Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId }); + Layout = "_LayoutWizard"; + ViewData.SetActivePage(WalletsNavPages.Send, StringLocalizer["Bump fee"], walletId); +} + +@section Navbar { + + + +} + +@section PageHeadContent +{ + +} + +@section PageFootContent +{ + + + + + +} + + + +
+

@ViewData["Title"]

+
+ +
+ + + @if (Model.TransactionHashes is not null) + { + for (int i = 0; i < Model.TransactionHashes.Length; i++) + { + + } + } + @if (Model.Outpoints is not null) + { + for (int i = 0; i < Model.Outpoints.Length; i++) + { + + } + } + @if (!ViewContext.ModelState.IsValid) + { +
    + @foreach (var errors in ViewData.ModelState.Where(pair => pair.Key == string.Empty && pair.Value.ValidationState == ModelValidationState.Invalid)) + { + foreach (var error in errors.Value.Errors) + { +
  • @error.ErrorMessage
  • + } + } +
+ } + @if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId) + { +
+
+ + + +
+
+ } + @if (Model.CurrentFeeSatoshiPerByte is not null) + { +
+
+ + +
+
+ } +
+
+ + + + +
+ @if (Model.RecommendedSatoshiPerByte.Any()) + { +
+
Confirm in the next …
+
+ @for (var index = 0; index < Model.RecommendedSatoshiPerByte.Count; index++) + { + var feeRateOption = Model.RecommendedSatoshiPerByte[index]; + + + + } +
+
+ } +
+
+
+
+ + +
+
+
+ + +
+ @Html.HiddenFor(a => a.IsMultiSigOnServer) + @if (Model.IsMultiSigOnServer) + { + + } + else + { + + } +
+ +
diff --git a/BTCPayServer/Views/UIWallets/_PSBTInfo.cshtml b/BTCPayServer/Views/UIWallets/_PSBTInfo.cshtml index 6e272c8fd..906cf6b83 100644 --- a/BTCPayServer/Views/UIWallets/_PSBTInfo.cshtml +++ b/BTCPayServer/Views/UIWallets/_PSBTInfo.cshtml @@ -8,7 +8,24 @@ @Model.BalanceChange

} - +@if (Model.ReplacementBalanceChange is not null) +{ +
+

Replacements

+ + + + + + + + + + + +
Amount
@Model.ReplacementBalanceChange.BalanceChange
+
+}

Inputs

diff --git a/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml b/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml index c9b9ea4a0..4d0d486a4 100644 --- a/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml +++ b/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml @@ -6,14 +6,14 @@ } @foreach (var transaction in Model.Transactions) { - - + - - - -
+
+ @transaction.Timestamp.ToBrowserDate() + + @transaction.Balance -
+
+
+ @if (transaction.CanBumpFee) + { + + + Bump fee + + }