Add fee information in wallet tx report and tx list (#6857)

This commit is contained in:
Nicolas Dorier
2025-07-17 22:24:02 +09:00
committed by GitHub
parent 03c5f4bb3c
commit 86881ba5a3
8 changed files with 125 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@@ -15,6 +16,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Views.Manage; using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server; using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
@@ -611,7 +613,8 @@ namespace BTCPayServer.Tests
await s.RegisterNewUser(true); await s.RegisterNewUser(true);
await s.CreateNewStore(); await s.CreateNewStore();
await s.AddDerivationScheme("BTC", new ExtKey().Neuter().GetWif(Network.RegTest).ToString() + "-[legacy]"); var btcDerivationScheme = new ExtKey().Neuter().GetWif(Network.RegTest).ToString() + "-[legacy]";
await s.AddDerivationScheme("BTC", btcDerivationScheme);
await s.AddDerivationScheme("LTC", new ExtKey().Neuter().GetWif(NBitcoin.Altcoins.Litecoin.Instance.Regtest).ToString() + "-[legacy]"); await s.AddDerivationScheme("LTC", new ExtKey().Neuter().GetWif(NBitcoin.Altcoins.Litecoin.Instance.Regtest).ToString() + "-[legacy]");
await s.GoToStore(); await s.GoToStore();
@@ -685,9 +688,41 @@ namespace BTCPayServer.Tests
await pmo.AssertRowContains(txId, "500.00 USD"); await pmo.AssertRowContains(txId, "500.00 USD");
} }
} }
await s.GoToWallet(new(s.StoreId, "BTC"), WalletsNavPages.Transactions);
await s.ClickViewReport();
var fee = Money.Zero;
var feeRate = FeeRate.Zero;
// Quick check on some internal of wallet that isn't related to this test
var wallet = s.Server.PayTester.GetService<BTCPayWalletProvider>().GetWallet("BTC");
var derivation = s.Server.GetNetwork("BTC").NBXplorerNetwork.DerivationStrategyFactory.Parse(btcDerivationScheme);
foreach (var forceHasFeeInfo in new bool?[]{ true, false, null})
foreach (var inefficient in new[] { true, false })
{
wallet.ForceInefficientPath = inefficient;
wallet.ForceHasFeeInformation = forceHasFeeInfo;
wallet.InvalidateCache(derivation);
var fetched = await wallet.FetchTransactionHistory(derivation);
var tx = fetched.First(f => f.TransactionId == txIds["BTC"]);
if (forceHasFeeInfo is true or null || inefficient)
{
Assert.NotNull(tx.Fee);
Assert.NotNull(tx.FeeRate);
fee = tx.Fee;
feeRate = tx.FeeRate;
}
else
{
Assert.Null(tx.Fee);
Assert.Null(tx.FeeRate);
}
}
wallet.InvalidateCache(derivation);
wallet.ForceHasFeeInformation = null;
wallet.ForceInefficientPath = false;
var pmo3 = await s.GoToWalletTransactions(new(s.StoreId, "BTC"));
await pmo3.AssertRowContains(txIds["BTC"], $"{fee} ({feeRate})");
await s.ClickViewReport();
var csvTxt = await s.DownloadReportCSV(); var csvTxt = await s.DownloadReportCSV();
var csvTester = new CSVWalletsTester(csvTxt); var csvTester = new CSVWalletsTester(csvTxt);
@@ -698,6 +733,8 @@ namespace BTCPayServer.Tests
csvTester csvTester
.ForTxId(txIds[cryptoCode].ToString()) .ForTxId(txIds[cryptoCode].ToString())
.AssertValues( .AssertValues(
("FeeRate", feeRate.SatoshiPerByte.ToString(CultureInfo.InvariantCulture)),
("Fee", fee.ToString()),
("Rate (USD)", "5000"), ("Rate (USD)", "5000"),
("Rate (CAD)", "4500"), ("Rate (CAD)", "4500"),
("Rate (JPY)", "700000"), ("Rate (JPY)", "700000"),
@@ -717,6 +754,23 @@ namespace BTCPayServer.Tests
} }
} }
// This shouldn't crash if NBX doesn't support fee fetching
wallet.ForceHasFeeInformation = false;
await s.Page.ReloadAsync();
csvTxt = await s.DownloadReportCSV();
csvTester = new CSVWalletsTester(csvTxt);
csvTester
.ForTxId(txIds["BTC"].ToString())
.AssertValues(
("FeeRate", ""),
("Fee", ""),
("Rate (USD)", "5000"),
("Rate (CAD)", "4500"),
("Rate (JPY)", "700000"),
("Rate (EUR)", "4000")
);
wallet.ForceHasFeeInformation = null;
var invId = await s.CreateInvoice(storeId: s.StoreId, amount: 10_000); var invId = await s.CreateInvoice(storeId: s.StoreId, amount: 10_000);
await s.GoToInvoiceCheckout(invId); await s.GoToInvoiceCheckout(invId);
await s.PayInvoice(); await s.PayInvoice();
@@ -1160,51 +1214,51 @@ namespace BTCPayServer.Tests
{ {
await s.Page.ClickAsync("button:has-text('Pay')"); await s.Page.ClickAsync("button:has-text('Pay')");
await s.Page.WaitForLoadStateAsync(); await s.Page.WaitForLoadStateAsync();
await s.Page.WaitForSelectorAsync("iframe[name='btcpay']", new() { Timeout = 10000 }); await s.Page.WaitForSelectorAsync("iframe[name='btcpay']", new() { Timeout = 10000 });
var iframe = s.Page.Frame("btcpay"); var iframe = s.Page.Frame("btcpay");
Assert.NotNull(iframe); Assert.NotNull(iframe);
await iframe.FillAsync("#test-payment-amount", "0.05"); await iframe.FillAsync("#test-payment-amount", "0.05");
await iframe.ClickAsync("#FakePayment"); await iframe.ClickAsync("#FakePayment");
await iframe.WaitForSelectorAsync("#CheatSuccessMessage", new() { Timeout = 10000 }); await iframe.WaitForSelectorAsync("#CheatSuccessMessage", new() { Timeout = 10000 });
invoiceId = s.Page.Url.Split('/').Last(); invoiceId = s.Page.Url.Split('/').Last();
} }
await s.GoToInvoices(); await s.GoToInvoices();
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-toggle"); await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-toggle");
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-menu button:has-text('Mark as settled')"); await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-menu button:has-text('Mark as settled')");
await s.Page.WaitForLoadStateAsync(); await s.Page.WaitForLoadStateAsync();
await s.GoToStore(); await s.GoToStore();
await s.Page.ClickAsync("#StoreNav-PaymentRequests"); await s.Page.ClickAsync("#StoreNav-PaymentRequests");
await s.Page.WaitForLoadStateAsync(); await s.Page.WaitForLoadStateAsync();
var opening2 = s.Page.Context.WaitForPageAsync(); var opening2 = s.Page.Context.WaitForPageAsync();
await s.Page.ClickAsync("a:has-text('View')"); await s.Page.ClickAsync("a:has-text('View')");
await using (_ = await s.SwitchPage(opening2)) await using (_ = await s.SwitchPage(opening2))
{ {
await s.Page.WaitForLoadStateAsync(); await s.Page.WaitForLoadStateAsync();
var markSettledExists = await s.Page.Locator("button:has-text('Mark as settled')").CountAsync(); var markSettledExists = await s.Page.Locator("button:has-text('Mark as settled')").CountAsync();
Assert.True(markSettledExists > 0, "Mark as settled button should be visible on public page after invoice is settled"); Assert.True(markSettledExists > 0, "Mark as settled button should be visible on public page after invoice is settled");
await s.Page.ClickAsync("button:has-text('Mark as settled')"); await s.Page.ClickAsync("button:has-text('Mark as settled')");
await s.Page.WaitForLoadStateAsync(); await s.Page.WaitForLoadStateAsync();
} }
await s.GoToStore(); await s.GoToStore();
await s.Page.ClickAsync("#StoreNav-PaymentRequests"); await s.Page.ClickAsync("#StoreNav-PaymentRequests");
await s.Page.WaitForLoadStateAsync(); await s.Page.WaitForLoadStateAsync();
var listContent = await s.Page.ContentAsync(); var listContent = await s.Page.ContentAsync();
var isSettledInList = listContent.Contains("Settled"); var isSettledInList = listContent.Contains("Settled");
var isPendingInList = listContent.Contains("Pending"); var isPendingInList = listContent.Contains("Pending");
var settledBadgeExists = await s.Page.Locator(".badge:has-text('Settled')").CountAsync(); var settledBadgeExists = await s.Page.Locator(".badge:has-text('Settled')").CountAsync();
var pendingBadgeExists = await s.Page.Locator(".badge:has-text('Pending')").CountAsync(); var pendingBadgeExists = await s.Page.Locator(".badge:has-text('Pending')").CountAsync();
Assert.True(isSettledInList || settledBadgeExists > 0, "Payment request should show as Settled in the list"); Assert.True(isSettledInList || settledBadgeExists > 0, "Payment request should show as Settled in the list");
Assert.False(isPendingInList && pendingBadgeExists > 0, "Payment request should not show as Pending anymore"); Assert.False(isPendingInList && pendingBadgeExists > 0, "Payment request should not show as Pending anymore");
} }
@@ -1224,10 +1278,10 @@ namespace BTCPayServer.Tests
var admin = s.AsTestAccount(); var admin = s.AsTestAccount();
await s.GoToHome(); await s.GoToHome();
await s.GoToServer(ServerNavPages.Policies); await s.GoToServer(ServerNavPages.Policies);
Assert.True(await s.Page.Locator("#EnableRegistration").IsCheckedAsync()); Assert.True(await s.Page.Locator("#EnableRegistration").IsCheckedAsync());
Assert.False(await s.Page.Locator("#RequiresUserApproval").IsCheckedAsync()); Assert.False(await s.Page.Locator("#RequiresUserApproval").IsCheckedAsync());
await s.Page.Locator("#RequiresUserApproval").ClickAsync(); await s.Page.Locator("#RequiresUserApproval").ClickAsync();
await s.ClickPagePrimary(); await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "Policies updated successfully"); await s.FindAlertMessage(partialText: "Policies updated successfully");
@@ -1255,7 +1309,7 @@ namespace BTCPayServer.Tests
{ {
Assert.Equal("1", await s.Page.Locator("#NotificationsBadge").TextContentAsync()); Assert.Equal("1", await s.Page.Locator("#NotificationsBadge").TextContentAsync());
}); });
await s.Page.Locator("#NotificationsHandle").ClickAsync(); await s.Page.Locator("#NotificationsHandle").ClickAsync();
Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", await s.Page.Locator("#NotificationsList .notification").TextContentAsync()); Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", await s.Page.Locator("#NotificationsList .notification").TextContentAsync());
await s.Page.Locator("#NotificationsMarkAllAsSeen").ClickAsync(); await s.Page.Locator("#NotificationsMarkAllAsSeen").ClickAsync();
@@ -1302,7 +1356,7 @@ namespace BTCPayServer.Tests
Assert.Equal(1, await rows.CountAsync()); Assert.Equal(1, await rows.CountAsync());
Assert.Contains(autoApproved.RegisterDetails.Email, await rows.First.TextContentAsync()); Assert.Contains(autoApproved.RegisterDetails.Email, await rows.First.TextContentAsync());
await Expect(s.Page.Locator("#UsersList tr.user-overview-row:first-child .user-approved")).Not.ToBeVisibleAsync(); await Expect(s.Page.Locator("#UsersList tr.user-overview-row:first-child .user-approved")).Not.ToBeVisibleAsync();
await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit"); await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit");
await Expect(s.Page.Locator("#Approved")).Not.ToBeVisibleAsync(); await Expect(s.Page.Locator("#Approved")).Not.ToBeVisibleAsync();
@@ -1313,12 +1367,12 @@ namespace BTCPayServer.Tests
Assert.Equal(1, await rows.CountAsync()); Assert.Equal(1, await rows.CountAsync());
Assert.Contains(unapproved.RegisterDetails.Email, await rows.First.TextContentAsync()); Assert.Contains(unapproved.RegisterDetails.Email, await rows.First.TextContentAsync());
Assert.Contains("Pending Approval", await s.Page.Locator("#UsersList tr.user-overview-row:first-child .user-status").TextContentAsync()); Assert.Contains("Pending Approval", await s.Page.Locator("#UsersList tr.user-overview-row:first-child .user-status").TextContentAsync());
await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit"); await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit");
await s.Page.ClickAsync("#Approved"); await s.Page.ClickAsync("#Approved");
await s.ClickPagePrimary(); await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "User successfully updated"); await s.FindAlertMessage(partialText: "User successfully updated");
await s.GoToServer(ServerNavPages.Users); await s.GoToServer(ServerNavPages.Users);
Assert.Contains(unapproved.RegisterDetails.Email, await s.Page.GetAttributeAsync("#SearchTerm", "value")); Assert.Contains(unapproved.RegisterDetails.Email, await s.Page.GetAttributeAsync("#SearchTerm", "value"));
Assert.Equal(1, await rows.CountAsync()); Assert.Equal(1, await rows.CountAsync());

View File

@@ -658,6 +658,7 @@ namespace BTCPayServer.Controllers
vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0; vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0;
vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network); vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network);
vm.IsConfirmed = tx.Confirmations != 0; vm.IsConfirmed = tx.Confirmations != 0;
vm.HistoryLine = tx;
// If support isn't possible, we want the user to be able to click so he can see why it doesn't work // 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 = vm.CanBumpFee =
tx.Confirmations == 0 && tx.Confirmations == 0 &&

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
namespace BTCPayServer.Models.WalletViewModels namespace BTCPayServer.Models.WalletViewModels
{ {
@@ -23,6 +24,7 @@ namespace BTCPayServer.Models.WalletViewModels
public RateBook WalletRateBook { get; set; } public RateBook WalletRateBook { get; set; }
public RateBook InvoiceRateBook { get; set; } public RateBook InvoiceRateBook { get; set; }
public string InvoiceId { get; set; } public string InvoiceId { get; set; }
public TransactionHistoryLine HistoryLine { get; set; }
} }
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new(); public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new();
public List<TransactionViewModel> Transactions { get; set; } = new(); public List<TransactionViewModel> Transactions { get; set; } = new();

View File

@@ -9,6 +9,7 @@ using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Rating; using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper; using Dapper;
using NBitcoin; using NBitcoin;
@@ -19,7 +20,8 @@ public class OnChainWalletReportProvider(
StoreRepository storeRepository, StoreRepository storeRepository,
InvoiceRepository invoiceRepository, InvoiceRepository invoiceRepository,
PaymentMethodHandlerDictionary handlers, PaymentMethodHandlerDictionary handlers,
WalletRepository walletRepository) WalletRepository walletRepository,
BTCPayWalletProvider walletProvider)
: ReportProvider : ReportProvider
{ {
public override string Name => "Wallets"; public override string Name => "Wallets";
@@ -37,6 +39,8 @@ public class OnChainWalletReportProvider(
new("InvoiceId", "invoice_id"), new("InvoiceId", "invoice_id"),
new("Confirmed", "boolean"), new("Confirmed", "boolean"),
new("BalanceChange", "amount"), new("BalanceChange", "amount"),
new("Fee", "amount"),
new("FeeRate", "number"),
}, },
Charts = Charts =
{ {
@@ -68,10 +72,12 @@ public class OnChainWalletReportProvider(
var network = ((IHasNetwork)handlers[pmi]).Network; var network = ((IHasNetwork)handlers[pmi]).Network;
cryptoCodes.Add(network.CryptoCode); cryptoCodes.Add(network.CryptoCode);
var walletId = new WalletId(store.Id, network.CryptoCode); var walletId = new WalletId(store.Id, network.CryptoCode);
var selectFee = walletProvider.GetWallet(network).SelectFeeColumns();
var command = new CommandDefinition( var command = new CommandDefinition(
commandText: commandText:
""" $"""
SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, {selectFee}
FROM get_wallets_recent(@wallet_id, @code, @asset_id, @interval, NULL, NULL) r FROM get_wallets_recent(@wallet_id, @code, @asset_id, @interval, NULL, NULL) r
JOIN txs t USING (code, tx_id) JOIN txs t USING (code, tx_id)
ORDER BY r.seen_at ORDER BY r.seen_at
@@ -99,6 +105,11 @@ public class OnChainWalletReportProvider(
values.Add(null); values.Add(null);
values.Add((long?)r.blk_height is not null); values.Add((long?)r.blk_height is not null);
values.Add(new FormattedAmount(balanceChange, network.Divisibility).ToJObject()); values.Add(new FormattedAmount(balanceChange, network.Divisibility).ToJObject());
decimal? fee = r.fee is null ? null : Money.Satoshis((long)r.fee).ToDecimal(MoneyUnit.BTC);
decimal? feeRate = r.feerate is null ? null : (decimal)r.feerate;
values.Add(fee is null ? null : new FormattedAmount(fee.Value, network.Divisibility).ToJObject());
values.Add(feeRate);
} }
var objects = await walletRepository.GetWalletObjects(new GetWalletObjectsQuery var objects = await walletRepository.GetWalletObjects(new GetWalletObjectsQuery

View File

@@ -252,6 +252,7 @@ namespace BTCPayServer.Services.Wallets
} }
return await completionSource.Task; return await completionSource.Task;
} }
public bool ForceInefficientPath { get; set; }
List<TransactionInformation> dummy = new List<TransactionInformation>(); List<TransactionInformation> dummy = new List<TransactionInformation>();
public async Task<IList<TransactionHistoryLine>> FetchTransactionHistory(DerivationStrategyBase derivationStrategyBase, int? skip = null, int? count = null, TimeSpan? interval = null, CancellationToken cancellationToken = default) public async Task<IList<TransactionHistoryLine>> FetchTransactionHistory(DerivationStrategyBase derivationStrategyBase, int? skip = null, int? count = null, TimeSpan? interval = null, CancellationToken cancellationToken = default)
{ {
@@ -259,7 +260,7 @@ namespace BTCPayServer.Services.Wallets
// * Sometimes we can ask the DB to do the filtering of rows: If that's the case, we should try to filter at the DB level directly as it is the most efficient. // * Sometimes we can ask the DB to do the filtering of rows: If that's the case, we should try to filter at the DB level directly as it is the most efficient.
// * Sometimes we can't query the DB or the given network need to do additional filtering. In such case, we can't really filter at the DB level, and we need to fetch all transactions in memory. // * Sometimes we can't query the DB or the given network need to do additional filtering. In such case, we can't really filter at the DB level, and we need to fetch all transactions in memory.
var needAdditionalFiltering = _Network.FilterValidTransactions(dummy) != dummy; var needAdditionalFiltering = _Network.FilterValidTransactions(dummy) != dummy;
if (!NbxplorerConnectionFactory.Available || needAdditionalFiltering) if (ForceInefficientPath || !NbxplorerConnectionFactory.Available || needAdditionalFiltering)
{ {
var txs = await FetchTransactions(derivationStrategyBase); var txs = await FetchTransactions(derivationStrategyBase);
var txinfos = txs.UnconfirmedTransactions.Transactions.Concat(txs.ConfirmedTransactions.Transactions) var txinfos = txs.UnconfirmedTransactions.Transactions.Concat(txs.ConfirmedTransactions.Transactions)
@@ -282,7 +283,7 @@ namespace BTCPayServer.Services.Wallets
{ {
await using var ctx = await NbxplorerConnectionFactory.OpenConnection(); await using var ctx = await NbxplorerConnectionFactory.OpenConnection();
var cmd = new CommandDefinition( var cmd = new CommandDefinition(
commandText: "SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, r.asset_id, COALESCE((SELECT height FROM get_tip('BTC')) - t.blk_height + 1, 0) AS confs " + commandText: $"SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, r.asset_id, COALESCE((SELECT height FROM get_tip('BTC')) - t.blk_height + 1, 0) AS confs, {SelectFeeColumns()} " +
"FROM get_wallets_recent(@wallet_id, @code, @interval, @count, @skip) r " + "FROM get_wallets_recent(@wallet_id, @code, @interval, @count, @skip) r " +
"JOIN txs t USING (code, tx_id) " + "JOIN txs t USING (code, tx_id) " +
"ORDER BY r.seen_at DESC", "ORDER BY r.seen_at DESC",
@@ -295,7 +296,7 @@ namespace BTCPayServer.Services.Wallets
interval = interval is TimeSpan t ? t : TimeSpan.FromDays(365 * 1000) interval = interval is TimeSpan t ? t : TimeSpan.FromDays(365 * 1000)
}, },
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
var rows = await ctx.QueryAsync<(string tx_id, DateTimeOffset seen_at, string blk_id, long? blk_height, long balance_change, string asset_id, long confs)>(cmd); var rows = await ctx.QueryAsync<(string tx_id, DateTimeOffset seen_at, string blk_id, long? blk_height, long balance_change, string asset_id, long confs, long? fee, decimal? feerate)>(cmd);
rows.TryGetNonEnumeratedCount(out int c); rows.TryGetNonEnumeratedCount(out int c);
var lines = new List<TransactionHistoryLine>(c); var lines = new List<TransactionHistoryLine>(c);
foreach (var row in rows) foreach (var row in rows)
@@ -307,12 +308,22 @@ namespace BTCPayServer.Services.Wallets
SeenAt = row.seen_at, SeenAt = row.seen_at,
TransactionId = uint256.Parse(row.tx_id), TransactionId = uint256.Parse(row.tx_id),
Confirmations = row.confs, Confirmations = row.confs,
BlockHash = string.IsNullOrEmpty(row.blk_id) ? null : uint256.Parse(row.blk_id) BlockHash = string.IsNullOrEmpty(row.blk_id) ? null : uint256.Parse(row.blk_id),
Fee = row.fee is null ? null : Money.Satoshis(row.fee.Value),
FeeRate = row.feerate is null ? null : new FeeRate(row.feerate.Value)
}); });
} }
return lines; return lines;
} }
} }
internal string SelectFeeColumns()
=> HasFeeInformation() ? "(metadata->'fees')::BIGINT AS fee, (metadata->'feeRate')::NUMERIC AS feerate" : "NULL AS fee, NULL AS feerate";
public bool? ForceHasFeeInformation { get; set; }
internal bool HasFeeInformation()
=> ForceHasFeeInformation ?? AsVersion(this.Dashboard.Get(Network.CryptoCode)?.Status?.Version ?? "") >= new Version("2.5.18");
public async Task<BumpableTransactions> GetBumpableTransactions(DerivationStrategyBase derivationStrategyBase, CancellationToken cancellationToken) public async Task<BumpableTransactions> GetBumpableTransactions(DerivationStrategyBase derivationStrategyBase, CancellationToken cancellationToken)
{ {
var result = new BumpableTransactions(); var result = new BumpableTransactions();
@@ -440,7 +451,9 @@ namespace BTCPayServer.Services.Wallets
Confirmations = t.Confirmations, Confirmations = t.Confirmations,
Height = t.Height, Height = t.Height,
SeenAt = t.Timestamp, SeenAt = t.Timestamp,
TransactionId = t.TransactionId TransactionId = t.TransactionId,
Fee = t.Metadata?.Fees,
FeeRate = t.Metadata?.FeeRate
}; };
} }
@@ -570,5 +583,7 @@ namespace BTCPayServer.Services.Wallets
public uint256 TransactionId { get; set; } public uint256 TransactionId { get; set; }
public uint256 BlockHash { get; set; } public uint256 BlockHash { get; set; }
public IMoney BalanceChange { get; set; } public IMoney BalanceChange { get; set; }
public FeeRate FeeRate { get; set; }
public Money Fee { get; set; }
} }
} }

View File

@@ -234,6 +234,7 @@
<th text-translate="true" style="min-width:125px">Label</th> <th text-translate="true" style="min-width:125px">Label</th>
<th text-translate="true">Transaction</th> <th text-translate="true">Transaction</th>
<th text-translate="true" class="amount-col">Amount</th> <th text-translate="true" class="amount-col">Amount</th>
<th text-translate="true" class="fee-col">Transaction fee</th>
@foreach (var rate in Model.Rates) @foreach (var rate in Model.Rates)
{ {
<th class="rate-col"><span text-translate="true">Rate</span>&nbsp;<span>(@rate)</span></th> <th class="rate-col"><span text-translate="true">Rate</span>&nbsp;<span>(@rate)</span></th>

View File

@@ -27,6 +27,14 @@
<td class="align-middle amount-col"> <td class="align-middle amount-col">
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span> <span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span>
</td> </td>
<td class="align-middle fee-col">
<span data-sensitive class="@(transaction.IsConfirmed ? "" : " opacity-50")">
@if (transaction.HistoryLine is { Fee: { } fee, FeeRate: { } fr })
{
<span><span>@fee</span> <span class="text-muted">(@fr)</span></span>
}
</span>
</td>
@foreach (var rate in transaction.Rates) @foreach (var rate in transaction.Rates)
{ {
<td class="align-middle rate-col"> <td class="align-middle rate-col">

View File

@@ -1182,6 +1182,10 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
.fee-col {
text-align: right;
white-space: nowrap;
}
.rate-col { .rate-col {
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;