feat(dashboard): support multi-crypto wallet balance widgets (#7110)
What changed, and why it matters
This commit is a routine feature update for the BTCPay Server dashboard. It adds the ability to display wallet balance widgets for multiple cryptocurrencies at once, fixes a potential crash when looking up a cryptocurrency handler, and adjusts the dashboard layout. There is no indication of a security fix or vulnerability being addressed.
No security action required. Treat as a normal feature/bugfix commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change extends the StoreWalletBalance view component to accept an optional cryptoCode parameter, allowing the dashboard to render one balance widget per enabled wallet cryptocurrency. It also switches from _handlers.GetBitcoinHandler to TryGetBitcoinHandler and adds null checks to avoid exceptions when a handler or wallet is unavailable. The dashboard markup and CSS are updated to support multiple widgets in a row. No security-relevant code paths (authorization, input validation, cryptography, secrets, etc.) are modified.
Changed components
BTCPayServer/Components/StoreWalletBalance/Default.cshtmlBTCPayServer/Components/StoreWalletBalance/StoreWalletBalance.csBTCPayServer/Controllers/UIStoresController.Dashboard.csBTCPayServer/Models/StoreViewModels/StoreDashboardViewModel.csBTCPayServer/Views/UIStores/Dashboard.cshtmlBTCPayServer/wwwroot/main/site.cssInspect captured patch +95 / −50
diff --git a/BTCPayServer/Components/StoreWalletBalance/Default.cshtml b/BTCPayServer/Components/StoreWalletBalance/Default.cshtml
index 21903e0..2d8468b 100644
--- a/BTCPayServer/Components/StoreWalletBalance/Default.cshtml
+++ b/BTCPayServer/Components/StoreWalletBalance/Default.cshtml
@@ -2,16 +2,16 @@
@using BTCPayServer.Client.Models
@using BTCPayServer.TagHelpers
@model BTCPayServer.Components.StoreWalletBalance.StoreWalletBalanceViewModel
-<div id="StoreWalletBalance-@Model.StoreId" class="widget store-wallet-balance">
+<div id="StoreWalletBalance-@Model.StoreId-@Model.CryptoCode" class="widget store-wallet-balance">
<div class="d-flex gap-3 align-items-center justify-content-between mb-2">
<h6 text-translate="true">Wallet Balance</h6>
@if (Model.CryptoCode != Model.DefaultCurrency)
{
<div class="btn-group btn-group-sm gap-0 currency-toggle" role="group">
- <input type="radio" class="btn-check" name="StoreWalletBalance-currency" id="StoreWalletBalance-currency_@Model.CryptoCode" value="@Model.CryptoCode" autocomplete="off" checked>
- <label class="btn btn-outline-secondary px-2 py-1" for="StoreWalletBalance-currency_@Model.CryptoCode">@Model.CryptoCode</label>
- <input type="radio" class="btn-check" name="StoreWalletBalance-currency" id="StoreWalletBalance-currency_@Model.DefaultCurrency" value="@Model.DefaultCurrency" autocomplete="off">
- <label class="btn btn-outline-secondary px-2 py-1" for="StoreWalletBalance-currency_@Model.DefaultCurrency">@Model.DefaultCurrency</label>
+ <input type="radio" class="btn-check" name="StoreWalletBalance-currency-@Model.StoreId-@Model.CryptoCode" id="StoreWalletBalance-currency_@Model.StoreId-@Model.CryptoCode" value="@Model.CryptoCode" autocomplete="off" checked>
+ <label class="btn btn-outline-secondary px-2 py-1" for="StoreWalletBalance-currency_@Model.StoreId-@Model.CryptoCode">@Model.CryptoCode</label>
+ <input type="radio" class="btn-check" name="StoreWalletBalance-currency-@Model.StoreId-@Model.CryptoCode" id="StoreWalletBalance-currency_@Model.StoreId-@Model.DefaultCurrency-@Model.CryptoCode" value="@Model.DefaultCurrency" autocomplete="off">
+ <label class="btn btn-outline-secondary px-2 py-1" for="StoreWalletBalance-currency_@Model.StoreId-@Model.DefaultCurrency-@Model.CryptoCode">@Model.DefaultCurrency</label>
</div>
}
</div>
@@ -26,12 +26,12 @@
@if (Model.Series != null)
{
<div class="btn-group only-for-js mt-1" role="group" aria-label="Period">
- <input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.StoreId" id="StoreWalletBalancePeriodWeek-@Model.StoreId" value="@HistogramType.Week" @(Model.Type == HistogramType.Week ? "checked" : "")>
- <label class="btn btn-link" for="StoreWalletBalancePeriodWeek-@Model.StoreId">1W</label>
- <input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.StoreId" id="StoreWalletBalancePeriodMonth-@Model.StoreId" value="@HistogramType.Month" @(Model.Type == HistogramType.Month ? "checked" : "")>
- <label class="btn btn-link" for="StoreWalletBalancePeriodMonth-@Model.StoreId">1M</label>
- <input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.StoreId" id="StoreWalletBalancePeriodYear-@Model.StoreId" value="@HistogramType.Year" @(Model.Type == HistogramType.Year ? "checked" : "")>
- <label class="btn btn-link" for="StoreWalletBalancePeriodYear-@Model.StoreId">1Y</label>
+ <input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.StoreId-@Model.CryptoCode" id="StoreWalletBalancePeriodWeek-@Model.StoreId-@Model.CryptoCode" value="@HistogramType.Week" @(Model.Type == HistogramType.Week ? "checked" : "")>
+ <label class="btn btn-link" for="StoreWalletBalancePeriodWeek-@Model.StoreId-@Model.CryptoCode">1W</label>
+ <input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.StoreId-@Model.CryptoCode" id="StoreWalletBalancePeriodMonth-@Model.StoreId-@Model.CryptoCode" value="@HistogramType.Month" @(Model.Type == HistogramType.Month ? "checked" : "")>
+ <label class="btn btn-link" for="StoreWalletBalancePeriodMonth-@Model.StoreId-@Model.CryptoCode">1M</label>
+ <input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.StoreId-@Model.CryptoCode" id="StoreWalletBalancePeriodYear-@Model.StoreId-@Model.CryptoCode" value="@HistogramType.Year" @(Model.Type == HistogramType.Year ? "checked" : "")>
+ <label class="btn btn-link" for="StoreWalletBalancePeriodYear-@Model.StoreId-@Model.CryptoCode">1Y</label>
</div>
}
</header>
@@ -60,7 +60,7 @@
let data = { series: @Safe.Json(Model.Series), labels: @Safe.Json(Model.Labels), balance: @Safe.Json(Model.Balance) };
let rate = null;
- const id = `StoreWalletBalance-${storeId}`;
+ const id = `StoreWalletBalance-${storeId}-${cryptoCode}`;
const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = HistogramType.Week }));
const valueTransform = value => rate ? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility) : value
const labelCount = 6
@@ -124,7 +124,7 @@
render(data);
function addEventListeners() {
- delegate('change', `#${id} [name="StoreWalletBalancePeriod-${storeId}"]`, async e => {
+ delegate('change', `#${id} [name="StoreWalletBalancePeriod-${storeId}-${cryptoCode}"]`, async e => {
const type = e.target.value;
await update(type);
})
diff --git a/BTCPayServer/Components/StoreWalletBalance/StoreWalletBalance.cs b/BTCPayServer/Components/StoreWalletBalance/StoreWalletBalance.cs
index a992b56..7106989 100644
--- a/BTCPayServer/Components/StoreWalletBalance/StoreWalletBalance.cs
+++ b/BTCPayServer/Components/StoreWalletBalance/StoreWalletBalance.cs
@@ -40,9 +40,11 @@ public class StoreWalletBalance : ViewComponent
_handlers = handlers;
}
- public async Task<IViewComponentResult> InvokeAsync(StoreData store)
+ public async Task<IViewComponentResult> InvokeAsync(StoreData store, string? cryptoCode = null)
{
- var cryptoCode = _networkProvider.DefaultNetwork.CryptoCode;
+ cryptoCode = string.IsNullOrEmpty(cryptoCode)
+ ? _networkProvider.DefaultNetwork.CryptoCode
+ : cryptoCode;
var walletId = new WalletId(store.Id, cryptoCode);
var data = await _walletHistogramService.GetHistogram(store, walletId, DefaultType);
var defaultCurrency = store.GetStoreBlob().DefaultCurrency;
@@ -66,13 +68,13 @@ public class StoreWalletBalance : ViewComponent
else
{
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(3));
- var wallet = _walletProvider.GetWallet(_networkProvider.DefaultNetwork);
+ var wallet = _walletProvider.GetWallet(cryptoCode);
var derivation = store.GetDerivationSchemeSettings(_handlers, walletId.CryptoCode);
- var network = _handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
- if (derivation is not null)
+ var handler = _handlers.TryGetBitcoinHandler(walletId.CryptoCode);
+ if (wallet is not null && derivation is not null && handler is not null)
{
var balance = await wallet.GetBalance(derivation.AccountDerivation, cts.Token);
- vm.Balance = balance.Available.GetValue(network);
+ vm.Balance = balance.Available.GetValue(handler.Network);
}
else
{
diff --git a/BTCPayServer/Controllers/UIStoresController.Dashboard.cs b/BTCPayServer/Controllers/UIStoresController.Dashboard.cs
index 32ec77d..207ff49 100644
--- a/BTCPayServer/Controllers/UIStoresController.Dashboard.cs
+++ b/BTCPayServer/Controllers/UIStoresController.Dashboard.cs
@@ -44,7 +44,12 @@ public partial class UIStoresController
StoreName = CurrentStore.StoreName,
CryptoCode = cryptoCode,
Network = _networkProvider.DefaultNetwork,
- IsSetUp = walletEnabled || lightningEnabled
+ IsSetUp = walletEnabled || lightningEnabled,
+ EnabledWalletCryptos = derivationSchemes
+ .Where(scheme => scheme.Enabled && scheme.WalletSupported)
+ .Select(scheme => scheme.Crypto)
+ .Distinct()
+ .ToList()
};
// Widget data
diff --git a/BTCPayServer/Models/StoreViewModels/StoreDashboardViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreDashboardViewModel.cs
index 0311002..7f3be77 100644
--- a/BTCPayServer/Models/StoreViewModels/StoreDashboardViewModel.cs
+++ b/BTCPayServer/Models/StoreViewModels/StoreDashboardViewModel.cs
@@ -12,6 +12,7 @@ public class StoreDashboardViewModel
public bool LightningEnabled { get; set; }
public bool LightningSupported { get; set; }
public bool IsSetUp { get; set; }
+ public List<string> EnabledWalletCryptos { get; set; } = new();
public List<AppData> Apps { get; set; } = new();
public BTCPayNetworkBase Network { get; set; }
}
diff --git a/BTCPayServer/Views/UIStores/Dashboard.cshtml b/BTCPayServer/Views/UIStores/Dashboard.cshtml
index 611b104..5af068b 100644
--- a/BTCPayServer/Views/UIStores/Dashboard.cshtml
+++ b/BTCPayServer/Views/UIStores/Dashboard.cshtml
@@ -54,46 +54,55 @@
</script>
<div id="Dashboard">
<vc:ui-extension-point location="dashboard" model="@Model" />
- @if (Model.WalletEnabled)
+ @if (Model.WalletEnabled && Model.EnabledWalletCryptos.Count > 0)
{
- <vc:store-wallet-balance store="@store" />
+ <div class="dashboard-row wallet-balances">
+ @foreach (var walletCryptoCode in Model.EnabledWalletCryptos)
+ {
+ <vc:store-wallet-balance store="@store" crypto-code="@walletCryptoCode" />
+ }
+ </div>
}
- else
- {
- <div class="widget setup-guide">
- <header>
- <h5 class="mb-4 text-muted" text-translate="true">This store is ready to accept transactions, good job!</h5>
- </header>
- <div class="list-group" id="SetupGuide">
- <div class="list-group-item d-flex align-items-center" id="SetupGuide-LightningDone">
- <vc:icon symbol="done" />
- <div class="content">
- <h5 class="mb-0 text-success" text-translate="true">Set up a Lightning node</h5>
+ <div class="dashboard-row dashboard-row-secondary">
+ @if (!Model.WalletEnabled)
+ {
+ <div class="widget setup-guide">
+ <header>
+ <h5 class="mb-4 text-muted" text-translate="true">This store is ready to accept transactions, good job!</h5>
+ </header>
+ <div class="list-group" id="SetupGuide">
+ <div class="list-group-item d-flex align-items-center" id="SetupGuide-LightningDone">
+ <vc:icon symbol="done" />
+ <div class="content">
+ <h5 class="mb-0 text-success" text-translate="true">Set up a Lightning node</h5>
+ </div>
</div>
+ <a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode"
+ id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center">
+ <vc:icon symbol="wallet-new" />
+ <div class="content">
+ <h5 class="mb-0" text-translate="true">Set up a wallet</h5>
+ </div>
+ <vc:icon symbol="caret-right" />
+ </a>
+ <vc:ui-extension-point location="dashboard-setup-guide-payment" model="@Model" />
</div>
- <a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode"
- id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center">
- <vc:icon symbol="wallet-new" />
- <div class="content">
- <h5 class="mb-0" text-translate="true">Set up a wallet</h5>
- </div>
- <vc:icon symbol="caret-right" />
- </a>
- <vc:ui-extension-point location="dashboard-setup-guide-payment" model="@Model" />
</div>
- </div>
- }
- <vc:store-numbers store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
+ }
+ <vc:store-numbers store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
+ @if (Model.WalletEnabled)
+ {
+ <vc:store-recent-transactions store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
+ }
+ </div>
+ <div class="dashboard-row dashboard-row-tertiary">
+ <vc:store-recent-invoices store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
+ </div>
@if (Model.LightningEnabled)
{
<vc:store-lightning-balance store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
<vc:store-lightning-services store="store" crypto-code="@Model.CryptoCode" permission="@Policies.CanModifyServerSettings" />
}
- @if (Model.WalletEnabled)
- {
- <vc:store-recent-transactions store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
- }
- <vc:store-recent-invoices store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
@foreach (var app in Model.Apps)
{
<vc:app-sales app-id="@app.Id" app-type="@app.AppType" />
diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css
index 7b99c83..5ed9147 100644
--- a/BTCPayServer/wwwroot/main/site.css
+++ b/BTCPayServer/wwwroot/main/site.css
@@ -375,6 +375,34 @@ h2 .icon.icon-info {
grid-template-columns: repeat(12, 1fr);
}
+#Dashboard .dashboard-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--btcpay-space-m);
+ grid-column-start: 1;
+ grid-column-end: 13;
+}
+
+#Dashboard .dashboard-row .widget {
+ flex: 1 1 320px;
+}
+
+#Dashboard .dashboard-row.wallet-balances .widget {
+ flex: 1 1 420px;
+}
+
+#Dashboard .dashboard-row.dashboard-row-secondary .widget.store-numbers {
+ flex: 1 1 320px;
+}
+
+#Dashboard .dashboard-row.dashboard-row-secondary .widget.store-recent-transactions {
+ flex: 2 1 520px;
+}
+
+#Dashboard .dashboard-row.dashboard-row-tertiary .widget {
+ flex: 1 1 100%;
+}
+
.widget {
--widget-padding: var(--btcpay-space-m);
--widget-chart-width: 100vw;
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.