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.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
@ -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}")]
@ -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);
}
@ -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,11 +856,11 @@ 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())
{
@ -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 =
@ -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 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)
{
// if we detect it's a CPFP, we add a CPFP label
{
// 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)
{
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()
{
["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 the labels to the tx
}
// 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(walletObjectDatas.Key, data.Type, data.Id));
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(walletObjectDatas.Key, attachment.Type, attachment.Id), txWalletObject));
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>