mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 06:21:44 +01:00
Feature: RBF and UX improvement to fee bumping
This commit is contained in:
parent
df82860ada
commit
460a9a5ca9
25 changed files with 1215 additions and 188 deletions
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
{
|
||||
|
|
|
@ -679,5 +679,10 @@ retry:
|
|||
Driver.WaitForAndClick(By.Id("page-primary"));
|
||||
}
|
||||
}
|
||||
|
||||
public void ClickCancel()
|
||||
{
|
||||
Driver.FindElement(By.Id("CancelWizard")).Click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 }) }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
|
|
11
BTCPayServer/GetMempoolInfoResponse.cs
Normal file
11
BTCPayServer/GetMempoolInfoResponse.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -19,5 +19,6 @@ namespace BTCPayServer.Models.WalletViewModels
|
|||
public string ChangeAddress { get; set; }
|
||||
|
||||
public string PendingTransactionId { get; set; }
|
||||
public long BalanceChangeFromReplacement { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
{
|
||||
|
|
|
@ -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}";
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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" />
|
||||
}
|
||||
|
|
156
BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml
Normal file
156
BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml
Normal 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>
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue