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.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
@@ -15,6 +16,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
@@ -611,7 +613,8 @@ namespace BTCPayServer.Tests
await s.RegisterNewUser(true);
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.GoToStore();
@@ -685,9 +688,41 @@ namespace BTCPayServer.Tests
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 csvTester = new CSVWalletsTester(csvTxt);
@@ -698,6 +733,8 @@ namespace BTCPayServer.Tests
csvTester
.ForTxId(txIds[cryptoCode].ToString())
.AssertValues(
("FeeRate", feeRate.SatoshiPerByte.ToString(CultureInfo.InvariantCulture)),
("Fee", fee.ToString()),
("Rate (USD)", "5000"),
("Rate (CAD)", "4500"),
("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);
await s.GoToInvoiceCheckout(invId);
await s.PayInvoice();
@@ -1160,51 +1214,51 @@ namespace BTCPayServer.Tests
{
await s.Page.ClickAsync("button:has-text('Pay')");
await s.Page.WaitForLoadStateAsync();
await s.Page.WaitForSelectorAsync("iframe[name='btcpay']", new() { Timeout = 10000 });
var iframe = s.Page.Frame("btcpay");
Assert.NotNull(iframe);
await iframe.FillAsync("#test-payment-amount", "0.05");
await iframe.ClickAsync("#FakePayment");
await iframe.WaitForSelectorAsync("#CheatSuccessMessage", new() { Timeout = 10000 });
invoiceId = s.Page.Url.Split('/').Last();
}
await s.GoToInvoices();
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.WaitForLoadStateAsync();
await s.GoToStore();
await s.Page.ClickAsync("#StoreNav-PaymentRequests");
await s.Page.WaitForLoadStateAsync();
var opening2 = s.Page.Context.WaitForPageAsync();
await s.Page.ClickAsync("a:has-text('View')");
await using (_ = await s.SwitchPage(opening2))
{
await s.Page.WaitForLoadStateAsync();
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");
await s.Page.ClickAsync("button:has-text('Mark as settled')");
await s.Page.WaitForLoadStateAsync();
}
await s.GoToStore();
await s.Page.ClickAsync("#StoreNav-PaymentRequests");
await s.Page.WaitForLoadStateAsync();
var listContent = await s.Page.ContentAsync();
var isSettledInList = listContent.Contains("Settled");
var isPendingInList = listContent.Contains("Pending");
var settledBadgeExists = await s.Page.Locator(".badge:has-text('Settled')").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.False(isPendingInList && pendingBadgeExists > 0, "Payment request should not show as Pending anymore");
}
@@ -1224,10 +1278,10 @@ namespace BTCPayServer.Tests
var admin = s.AsTestAccount();
await s.GoToHome();
await s.GoToServer(ServerNavPages.Policies);
Assert.True(await s.Page.Locator("#EnableRegistration").IsCheckedAsync());
Assert.False(await s.Page.Locator("#RequiresUserApproval").IsCheckedAsync());
await s.Page.Locator("#RequiresUserApproval").ClickAsync();
await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "Policies updated successfully");
@@ -1255,7 +1309,7 @@ namespace BTCPayServer.Tests
{
Assert.Equal("1", await s.Page.Locator("#NotificationsBadge").TextContentAsync());
});
await s.Page.Locator("#NotificationsHandle").ClickAsync();
Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", await s.Page.Locator("#NotificationsList .notification").TextContentAsync());
await s.Page.Locator("#NotificationsMarkAllAsSeen").ClickAsync();
@@ -1302,7 +1356,7 @@ namespace BTCPayServer.Tests
Assert.Equal(1, await rows.CountAsync());
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 s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit");
await Expect(s.Page.Locator("#Approved")).Not.ToBeVisibleAsync();
@@ -1313,12 +1367,12 @@ namespace BTCPayServer.Tests
Assert.Equal(1, await rows.CountAsync());
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());
await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit");
await s.Page.ClickAsync("#Approved");
await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "User successfully updated");
await s.GoToServer(ServerNavPages.Users);
Assert.Contains(unapproved.RegisterDetails.Email, await s.Page.GetAttributeAsync("#SearchTerm", "value"));
Assert.Equal(1, await rows.CountAsync());

View File

@@ -658,6 +658,7 @@ namespace BTCPayServer.Controllers
vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0;
vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network);
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
vm.CanBumpFee =
tx.Confirmations == 0 &&

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
namespace BTCPayServer.Models.WalletViewModels
{
@@ -23,6 +24,7 @@ namespace BTCPayServer.Models.WalletViewModels
public RateBook WalletRateBook { get; set; }
public RateBook InvoiceRateBook { 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 List<TransactionViewModel> Transactions { get; set; } = new();

View File

@@ -9,6 +9,7 @@ using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper;
using NBitcoin;
@@ -19,7 +20,8 @@ public class OnChainWalletReportProvider(
StoreRepository storeRepository,
InvoiceRepository invoiceRepository,
PaymentMethodHandlerDictionary handlers,
WalletRepository walletRepository)
WalletRepository walletRepository,
BTCPayWalletProvider walletProvider)
: ReportProvider
{
public override string Name => "Wallets";
@@ -37,6 +39,8 @@ public class OnChainWalletReportProvider(
new("InvoiceId", "invoice_id"),
new("Confirmed", "boolean"),
new("BalanceChange", "amount"),
new("Fee", "amount"),
new("FeeRate", "number"),
},
Charts =
{
@@ -68,10 +72,12 @@ public class OnChainWalletReportProvider(
var network = ((IHasNetwork)handlers[pmi]).Network;
cryptoCodes.Add(network.CryptoCode);
var walletId = new WalletId(store.Id, network.CryptoCode);
var selectFee = walletProvider.GetWallet(network).SelectFeeColumns();
var command = new CommandDefinition(
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
JOIN txs t USING (code, tx_id)
ORDER BY r.seen_at
@@ -99,6 +105,11 @@ public class OnChainWalletReportProvider(
values.Add(null);
values.Add((long?)r.blk_height is not null);
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

View File

@@ -252,6 +252,7 @@ namespace BTCPayServer.Services.Wallets
}
return await completionSource.Task;
}
public bool ForceInefficientPath { get; set; }
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)
{
@@ -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'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;
if (!NbxplorerConnectionFactory.Available || needAdditionalFiltering)
if (ForceInefficientPath || !NbxplorerConnectionFactory.Available || needAdditionalFiltering)
{
var txs = await FetchTransactions(derivationStrategyBase);
var txinfos = txs.UnconfirmedTransactions.Transactions.Concat(txs.ConfirmedTransactions.Transactions)
@@ -282,7 +283,7 @@ namespace BTCPayServer.Services.Wallets
{
await using var ctx = await NbxplorerConnectionFactory.OpenConnection();
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 " +
"JOIN txs t USING (code, tx_id) " +
"ORDER BY r.seen_at DESC",
@@ -295,7 +296,7 @@ namespace BTCPayServer.Services.Wallets
interval = interval is TimeSpan t ? t : TimeSpan.FromDays(365 * 1000)
},
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);
var lines = new List<TransactionHistoryLine>(c);
foreach (var row in rows)
@@ -307,12 +308,22 @@ namespace BTCPayServer.Services.Wallets
SeenAt = row.seen_at,
TransactionId = uint256.Parse(row.tx_id),
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;
}
}
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)
{
var result = new BumpableTransactions();
@@ -440,7 +451,9 @@ namespace BTCPayServer.Services.Wallets
Confirmations = t.Confirmations,
Height = t.Height,
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 BlockHash { 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">Transaction</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)
{
<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">
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span>
</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)
{
<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;
white-space: nowrap;
}
.fee-col {
text-align: right;
white-space: nowrap;
}
.rate-col {
text-align: right;
white-space: nowrap;