Feature: RBF and UX improvement to fee bumping

This commit is contained in:
nicolas.dorier 2025-01-29 19:26:22 +09:00
parent df82860ada
commit 460a9a5ca9
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
25 changed files with 1215 additions and 188 deletions

View file

@ -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<WalletObjectLinkData> Bs { get; set; }
public List<WalletObjectLinkData> 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<WalletObjectData> GetNeighbours()
{
if (Bs != null)

View file

@ -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()
{

View file

@ -679,5 +679,10 @@ retry:
Driver.WaitForAndClick(By.Id("page-primary"));
}
}
public void ClickCancel()
{
Driver.FindElement(By.Id("CancelWizard")).Click();
}
}
}

View file

@ -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)]

View file

@ -112,11 +112,13 @@
<ItemGroup>
<Watch Include="Views\**\*.*"></Watch>
<Watch Remove="Views\Shared\LocalhostBrowserSupport.cshtml" />
<Watch Remove="Views\Shared\_BackAndReturn.cshtml" />
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Watch Remove="Views\UIServer\CreateDictionary.cshtml" />
<Watch Remove="Views\UIServer\EditDictionary.cshtml" />
<Watch Remove="Views\UIServer\ListDictionaries.cshtml" />
<Watch Remove="Views\UIWallets\WalletBumpFee.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>$(IncludeRazorContentInPack)</Pack>

View file

@ -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,
};

View file

@ -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<string, string>();
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 }) }

View file

@ -63,88 +63,6 @@ namespace BTCPayServer.Controllers
return psbt;
}
[HttpPost("{walletId}/cpfp")]
public async Task<IActionResult> WalletCPFP([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string[] outpoints, string[] transactionHashes, string returnUrl)
{
outpoints ??= Array.Empty<string>();
transactionHashes ??= Array.Empty<string>();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(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<IActionResult> WalletSign([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTViewModel vm, string command = null)
@ -152,8 +70,6 @@ namespace BTCPayServer.Controllers
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(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;

View file

@ -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<IActionResult> 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<OutPoint> 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<Dictionary<uint256, TransactionInformation>> GetUnconfWalletTxInfo(ExplorerClient client, DerivationStrategyBase derivationStrategyBase, HashSet<uint256> txs, CancellationToken cancellationToken)
{
var txWalletInfo = new Dictionary<uint256, TransactionInformation>();
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<uint256, TransactionInformation> 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<string, object> { { "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<WalletSendModel.FeeRateOption?[]> GetRecommendedFees(BTCPayNetwork network, IFeeProviderFactory feeProviderFactory)
{
var feeProvider = feeProviderFactory.CreateFeeProvider(network);
List<WalletSendModel.FeeRateOption?> 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<string?> 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<BTCPayNetwork>(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
});

View file

@ -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<GetMempoolInfoResponse> 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<decimal>();
if (incrementalRelayFee is not null)
{
result.IncrementalRelayFeeRate = new FeeRate(Money.Coins(incrementalRelayFee.Value), 1000);
}
var mempoolminfee = mempoolInfo.Result["mempoolminfee"]?.Value<decimal>();
if (mempoolminfee is not null)
{
result.MempoolMinfeeRate = new FeeRate(Money.Coins(mempoolminfee.Value), 1000);
}
result.FullRBF = mempoolInfo.Result["fullrbf"]?.Value<bool>();
return result;
}
public static async Task<Dictionary<uint256, MempoolEntry>> FetchMempoolEntries(this RPCClient rpc, IEnumerable<uint256> txHashes, CancellationToken cancellationToken)
{
var batch = rpc.PrepareBatch();
var tasks = new List<(uint256 Id, Task<MempoolEntry> MempoolEntry)>();
var metadatas = new Dictionary<uint256, MempoolEntry>();
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<BitcoinLikePaymentData> GetAllBitcoinPaymentData(this InvoiceEntity invoice, BitcoinLikePaymentHandler handler, bool accountedOnly)
{
return invoice.GetPayments(accountedOnly)

View file

@ -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; }
}
}

View file

@ -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<string, NBXplorerSummary> _Summaries = new ConcurrentDictionary<string, NBXplorerSummary>();
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)

View file

@ -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<WalletObjectLinkData>();
var links = new List<WalletObjectLinkData>();
var newObjs = new List<WalletObjectData>();
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;
}

View file

@ -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; }

View file

@ -19,5 +19,6 @@ namespace BTCPayServer.Models.WalletViewModels
public string ChangeAddress { get; set; }
public string PendingTransactionId { get; set; }
public long BalanceChangeFromReplacement { get; set; }
}
}

View file

@ -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<SelectListItem> BumpFeeMethods { get; set; } = new();
public string[] Outpoints { get; set; }
public string[] TransactionHashes { get; set; }
public List<WalletSendModel.FeeRateOption> 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<OutPoint> Outpoints, HashSet<uint256> 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<uint256> GetTransactionIds()
=> (TxIds.Concat(Outpoints.Select(o => o.Hash))).ToHashSet();
public BumpTarget Filter(HashSet<uint256> elligibleTxs)
=> new BumpTarget(
Outpoints.Where(o => elligibleTxs.Contains(o.Hash)).ToHashSet(),
TxIds.Where(t => elligibleTxs.Contains(t)).ToHashSet());
public List<OutPoint> GetMatchedOutpoints(IEnumerable<OutPoint> outpoints)
{
List<OutPoint> matches = new();
HashSet<uint256> 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<OutPoint> outpoints = new();
HashSet<uint256> 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
}
}

View file

@ -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()
{

View file

@ -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}";

View file

@ -259,16 +259,15 @@ namespace BTCPayServer.Services
Comment = data?["comment"]?.Value<string>()
};
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<string>() ?? ColorPalette.Default.DeterministicColor(neighbour.Id));
info.LabelColors.TryAdd(link.id, link.objectdata?["color"]?.Value<string>() ?? 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);

View file

@ -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<uint256, BumpableInfo>
{
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<BumpableTransactions> 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<uint256> canRBF = new();
HashSet<uint256> 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; }

View file

@ -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));
}
}

View file

@ -1,4 +1,4 @@
@model BTCPayServer.Models.WalletViewModels.SigningContextModel
@model BTCPayServer.Models.WalletViewModels.SigningContextModel
@if (Model != null)
{
@ -8,4 +8,5 @@
<input type="hidden" asp-for="EnforceLowR" value="@Model.EnforceLowR" />
<input type="hidden" asp-for="ChangeAddress" value="@Model.ChangeAddress" />
<input type="hidden" asp-for="PendingTransactionId" value="@Model.PendingTransactionId" />
<input type="hidden" asp-for="BalanceChangeFromReplacement" value="@Model.BalanceChangeFromReplacement" />
}

View file

@ -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 {
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
}
@section PageHeadContent
{
<style>
.crypto-fee-link {
padding-left: 1rem;
padding-right: 1rem;
}
.btn-group > .crypto-fee-link:last-of-type {
border-top-right-radius: .2rem !important;
border-bottom-right-radius: .2rem !important;
}
.buttons .btn {
flex: 1 0 45%;
}
</style>
}
@section PageFootContent
{
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode-reader/VueQrcodeReader.umd.min.js" asp-append-version="true"></script>
<script src="~/js/wallet/wallet-camera-scanner.js" asp-append-version="true"></script>
<script src="~/js/wallet/WalletSend.js" asp-append-version="true"></script>
}
<partial name="CameraScanner" />
<header class="text-center">
<h1>@ViewData["Title"]</h1>
</header>
<form method="post" asp-action="WalletBumpFee" asp-route-walletId="@walletId" asp-route-transactionId="@Model.TransactionId" class="my-5" id="SendForm">
<input type="hidden" asp-for="ReturnUrl" />
@if (Model.TransactionHashes is not null)
{
for (int i = 0; i < Model.TransactionHashes.Length; i++)
{
<input type="hidden" asp-for="TransactionHashes[i]" />
}
}
@if (Model.Outpoints is not null)
{
for (int i = 0; i < Model.Outpoints.Length; i++)
{
<input type="hidden" asp-for="Outpoints[i]" />
}
}
@if (!ViewContext.ModelState.IsValid)
{
<ul class="text-danger">
@foreach (var errors in ViewData.ModelState.Where(pair => pair.Key == string.Empty && pair.Value.ValidationState == ModelValidationState.Invalid))
{
foreach (var error in errors.Value.Errors)
{
<li>@error.ErrorMessage</li>
}
}
</ul>
}
@if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId)
{
<div class="list-group list-group-flush">
<div>
<label asp-for="TransactionId" class="form-label"></label>
<input name="txId" value="@txId" readonly class="form-control" disabled />
<span asp-validation-for="TransactionId" class="text-danger"></span>
</div>
</div>
}
@if (Model.CurrentFeeSatoshiPerByte is not null)
{
<div class="d-flex flex-wrap gap-3 my-4">
<div>
<label asp-for="CurrentFeeSatoshiPerByte" class="form-label">
<span text-translate="true">Current effective fee rate</span>
<span class="text-secondary">(sat/vB)</span>
</label>
<input asp-for="CurrentFeeSatoshiPerByte" type="number" min="0" step="any" readonly class="form-control" disabled style="max-width:14ch;" />
</div>
</div>
}
<div class="d-flex flex-wrap gap-3 my-4">
<div>
<label asp-for="FeeSatoshiPerByte" class="form-label">
<span text-translate="true">New effective fee rate</span>
<span class="text-secondary">(sat/vB)</span>
</label>
<input asp-for="FeeSatoshiPerByte" type="number" inputmode="numeric" min="0" step="any" class="form-control" style="max-width:14ch;" />
<span asp-validation-for="FeeSatoshiPerByte" class="text-danger"></span>
<span id="FeeRate-Error" class="text-danger"></span>
</div>
@if (Model.RecommendedSatoshiPerByte.Any())
{
<div>
<div class="form-label text-secondary" text-translate="true">Confirm in the next …</div>
<div class="btn-group btn-group-toggle feerate-options" role="group" data-bs-toggle="buttons">
@for (var index = 0; index < Model.RecommendedSatoshiPerByte.Count; index++)
{
var feeRateOption = Model.RecommendedSatoshiPerByte[index];
<button type="button" class="btn btn-sm btn-secondary crypto-fee-link" value="@feeRateOption.FeeRate" data-bs-toggle="tooltip" title="@feeRateOption.FeeRate sat/b">
@feeRateOption.Target.TimeString()
</button>
<input type="hidden" asp-for="RecommendedSatoshiPerByte[index].Target" />
<input type="hidden" asp-for="RecommendedSatoshiPerByte[index].FeeRate" />
}
</div>
</div>
}
</div>
<div class="d-flex flex-wrap gap-3 my-4">
<div>
<div class="form-group">
<label asp-for="BumpMethod" class="form-label"></label>
<select asp-for="BumpMethod" asp-items="@Model.BumpFeeMethods"
disabled="@(Model.BumpFeeMethods.Count == 1)"
class="form-select w-auto"></select>
</div>
</div>
</div>
<div class="d-grid d-sm-flex flex-wrap gap-3 buttons">
@Html.HiddenFor(a => a.IsMultiSigOnServer)
@if (Model.IsMultiSigOnServer)
{
<button type="submit" id="page-primary" name="command" value="createpending" class="btn btn-primary">Create pending transaction</button>
}
else
{
<button type="submit" id="page-primary" name="command" value="sign" class="btn btn-primary" text-translate="true">Sign transaction</button>
}
</div>
</form>

View file

@ -8,7 +8,24 @@
<span class="text-@(Model.Positive ? "success" : "danger")">@Model.BalanceChange</span>
</p>
}
@if (Model.ReplacementBalanceChange is not null)
{
<div id="replacements">
<h4 class="mb-n3" text-translate="true">Replacements</h4>
<table class="table">
<thead>
<tr>
<th text-translate="true" class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-end text-@(Model.ReplacementBalanceChange.Positive ? "success" : "danger")">@Model.ReplacementBalanceChange.BalanceChange</td>
</tr>
</tbody>
</table>
</div>
}
<div id="inputs">
<h4 class="mb-n3" text-translate="true">Inputs</h4>
<table class="table">

View file

@ -6,14 +6,14 @@
}
@foreach (var transaction in Model.Transactions)
{
<tr class="mass-action-row">
<td class="only-for-js mass-action-select-col">
<tr class="mass-action-row transaction-row" data-value="@transaction.Id">
<td class="align-middle only-for-js mass-action-select-col">
<input name="selectedTransactions" type="checkbox" class="form-check-input mass-action-select" form="WalletActions" value="@transaction.Id" />
</td>
<td class="date-col">
<td class="align-middle date-col">
@transaction.Timestamp.ToBrowserDate()
</td>
<td class="text-start">
<td class="align-middle text-start">
<vc:label-manager
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Tx, transaction.Id)"
selected-labels="transaction.Tags.Select(t => t.Text).ToArray()"
@ -24,11 +24,18 @@
<td>
<vc:truncate-center text="@transaction.Id" link="@transaction.Link" classes="truncate-center-id" />
</td>
<td class="amount-col">
<td class="align-middle amount-col">
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span>
</td>
<td class="text-end">
<div class="d-inline-block">
<td class="align-middle text-end">
<div class="d-inline-flex gap-3 align-items-center">
@if (transaction.CanBumpFee)
{
<a asp-action="WalletBumpFee" asp-route-walletId="@Context.GetRouteValue("walletId")" asp-route-transactionId="@transaction.Id" class="btn btn-link p-0 bumpFee-btn">
<vc:icon symbol="actions-send" />
<span text-translate="true">Bump fee</span>
</a>
}
<button class="btn btn-link p-0 @(!string.IsNullOrEmpty(transaction.Comment) ? "text-primary" : "text-secondary")" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<vc:icon symbol="actions-comment" />
</button>