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
+{
+
+
+
+
+
+}
+
+
+
+
+
+
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 |
+
+
+
+
+}