Histograms: Add Lightning data and API endpoints (#6217)

* Histograms: Add Lightning data and API endpoints

Ported over from the mobile-working-branch.

Adds histogram data for Lightning and exposes the wallet/lightning histogram data via the API. It also add a dashboard graph for the Lightning balance.

Caveat: The Lightning histogram is calculated by using the current channel balance and going backwards through as much invoices and transactions as we have. The "start" of the LN graph data might not be accurate though. That's because we don't track (and not even have) the LN onchain data. It is calculated by using the current channel balance and going backwards through as much invoices and transactions as we have. So the historic graph data for LN is basically a best effort of trying to reconstruct it with what we have: The LN channel transactions.

* More timeframes

* Refactoring: Remove redundant WalletHistogram types

* Remove store property from dashboard tile view models

* JS error fixes
This commit is contained in:
d11n 2024-11-05 13:40:37 +01:00 committed by GitHub
parent b3945d758a
commit 641bdcff31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 790 additions and 198 deletions

View File

@ -21,6 +21,13 @@ public partial class BTCPayServerClient
return await SendHttpRequest<LightningNodeBalanceData>($"api/v1/server/lightning/{cryptoCode}/balance", null, HttpMethod.Get, token);
}
public virtual async Task<HistogramData> GetLightningNodeHistogram(string cryptoCode, HistogramType? type = null,
CancellationToken token = default)
{
var queryPayload = type == null ? null : new Dictionary<string, object> { { "type", type.ToString() } };
return await SendHttpRequest<HistogramData>($"api/v1/server/lightning/{cryptoCode}/histogram", queryPayload, HttpMethod.Get, token);
}
public virtual async Task ConnectToLightningNode(string cryptoCode, ConnectToNodeRequest request,
CancellationToken token = default)
{

View File

@ -21,6 +21,13 @@ public partial class BTCPayServerClient
return await SendHttpRequest<LightningNodeBalanceData>($"api/v1/stores/{storeId}/lightning/{cryptoCode}/balance", null, HttpMethod.Get, token);
}
public virtual async Task<HistogramData> GetLightningNodeHistogram(string storeId, string cryptoCode, HistogramType? type = null,
CancellationToken token = default)
{
var queryPayload = type == null ? null : new Dictionary<string, object> { { "type", type.ToString() } };
return await SendHttpRequest<HistogramData>($"api/v1/stores/{storeId}/lightning/{cryptoCode}/histogram", queryPayload, HttpMethod.Get, token);
}
public virtual async Task ConnectToLightningNode(string storeId, string cryptoCode, ConnectToNodeRequest request,
CancellationToken token = default)
{

View File

@ -16,6 +16,14 @@ public partial class BTCPayServerClient
{
return await SendHttpRequest<OnChainWalletOverviewData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet", null, HttpMethod.Get, token);
}
public virtual async Task<HistogramData> GetOnChainWalletHistogram(string storeId, string cryptoCode, HistogramType? type = null,
CancellationToken token = default)
{
var queryPayload = type == null ? null : new Dictionary<string, object> { { "type", type.ToString() } };
return await SendHttpRequest<HistogramData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/histogram", queryPayload, HttpMethod.Get, token);
}
public virtual async Task<OnChainWalletFeeRateData> GetOnChainFeeRate(string storeId, string cryptoCode, int? blockTarget = null,
CancellationToken token = default)
{

View File

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models;
public enum HistogramType
{
Week,
Month,
YTD,
Year,
TwoYears,
Day
}
public class HistogramData
{
[JsonConverter(typeof(StringEnumConverter))]
public HistogramType Type { get; set; }
[JsonProperty(ItemConverterType = typeof(NumericStringJsonConverter))]
public List<decimal> Series { get; set; }
[JsonProperty(ItemConverterType = typeof(DateTimeToUnixTimeConverter))]
public List<DateTimeOffset> Labels { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Balance { get; set; }
}

View File

@ -3002,6 +3002,19 @@ namespace BTCPayServer.Tests
Assert.Single(info.NodeURIs);
Assert.NotEqual(0, info.BlockHeight);
// balance
var balance = await client.GetLightningNodeBalance(user.StoreId, "BTC");
Assert.True(LightMoney.Satoshis(1000) <= balance.OffchainBalance.Local);
await TestUtils.EventuallyAsync(async () =>
{
var localBalance = balance.OffchainBalance.Local.ToDecimal(LightMoneyUnit.BTC);
var histogram = await client.GetLightningNodeHistogram(user.StoreId, "BTC");
Assert.Equal(histogram.Balance, histogram.Series.Last());
Assert.Equal(localBalance, histogram.Balance);
Assert.Equal(localBalance, histogram.Series.Last());
});
// As admin, can use the internal node through our store.
await user.MakeAdmin(true);
await user.RegisterInternalLightningNodeAsync("BTC");
@ -3023,6 +3036,10 @@ namespace BTCPayServer.Tests
client = await guest.CreateClient(Policies.CanUseLightningNodeInStore);
// Can use lightning node is only granted to store's owner
await AssertPermissionError("btcpay.store.canuselightningnode", () => client.GetLightningNodeInfo(user.StoreId, "BTC"));
// balance and histogram should not be accessible with view only clients
await AssertPermissionError("btcpay.store.canuselightningnode", () => client.GetLightningNodeBalance(user.StoreId, "BTC"));
await AssertPermissionError("btcpay.store.canuselightningnode", () => client.GetLightningNodeHistogram(user.StoreId, "BTC"));
}
[Fact(Timeout = 60 * 20 * 1000)]
@ -3539,7 +3556,6 @@ namespace BTCPayServer.Tests
var overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode);
Assert.Equal(0m, overview.Balance);
var fee = await client.GetOnChainFeeRate(walletId.StoreId, walletId.CryptoCode);
Assert.NotNull(fee.FeeRate);
@ -3585,6 +3601,17 @@ namespace BTCPayServer.Tests
overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode);
Assert.Equal(0.01m, overview.Balance);
// histogram should not be accessible with view only clients
await AssertHttpError(403, async () =>
{
await viewOnlyClient.GetOnChainWalletHistogram(walletId.StoreId, walletId.CryptoCode);
});
var histogram = await client.GetOnChainWalletHistogram(walletId.StoreId, walletId.CryptoCode);
Assert.Equal(histogram.Balance, histogram.Series.Last());
Assert.Equal(0.01m, histogram.Balance);
Assert.Equal(0.01m, histogram.Series.Last());
Assert.Equal(0, histogram.Series.First());
//the simplest request:
var nodeAddress = await tester.ExplorerNode.GetNewAddressAsync();
var createTxRequest = new CreateOnChainTransactionRequest()

View File

@ -43,10 +43,19 @@ if (!window.appSales) {
}
};
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
const type = e.target.value;
await update(type);
});
function addEventListeners() {
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
console.log("CHANGED", id)
const type = e.target.value;
await update(type);
});
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", addEventListeners);
} else {
addEventListeners();
}
}
};
}

View File

@ -1,9 +1,13 @@
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client.Models
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Components.StoreLightningBalance.StoreLightningBalanceViewModel
@if(!Model.InitialRendering && Model.Balance == null)
@if (!Model.InitialRendering && Model.Balance == null)
{
return;
return;
}
<div id="StoreLightningBalance-@Model.Store.Id" class="widget store-lightning-balance">
<div id="StoreLightningBalance-@Model.StoreId" class="widget store-lightning-balance">
<div class="d-flex gap-3 align-items-center justify-content-between mb-2">
<h6 text-translate="true">Lightning Balance</h6>
@if (Model.CryptoCode != Model.DefaultCurrency && Model.Balance != null)
@ -128,12 +132,32 @@
</div>
}
</div>
@if (Model.Balance.OffchainBalance != null && Model.Balance.OnchainBalance != null)
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 @(Model.Series != null ? "my-3" : "mt-3")">
@if (Model.Balance.OffchainBalance != null && Model.Balance.OnchainBalance != null)
{
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0 ms-n1" type="button" data-bs-toggle="collapse" data-bs-target=".balance-details" aria-expanded="false" aria-controls="balanceDetailsOffchain balanceDetailsOnchain">
<vc:icon symbol="caret-down" />
<span class="ms-1" text-translate="true">Details</span>
</button>
}
@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="StoreLightningBalancePeriod-@Model.StoreId" id="StoreLightningBalancePeriodWeek-@Model.StoreId" value="@HistogramType.Week" @(Model.Type == HistogramType.Week ? "checked" : "")>
<label class="btn btn-link" for="StoreLightningBalancePeriodWeek-@Model.StoreId">1W</label>
<input type="radio" class="btn-check" name="StoreLightningBalancePeriod-@Model.StoreId" id="StoreLightningBalancePeriodMonth-@Model.StoreId" value="@HistogramType.Month" @(Model.Type == HistogramType.Month ? "checked" : "")>
<label class="btn btn-link" for="StoreLightningBalancePeriodMonth-@Model.StoreId">1M</label>
<input type="radio" class="btn-check" name="StoreLightningBalancePeriod-@Model.StoreId" id="StoreLightningBalancePeriodYear-@Model.StoreId" value="@HistogramType.Year" @(Model.Type == HistogramType.Year ? "checked" : "")>
<label class="btn btn-link" for="StoreLightningBalancePeriodYear-@Model.StoreId">1Y</label>
</div>
}
</div>
@if (Model.Series != null)
{
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0 mt-3 ms-n1" type="button" data-bs-toggle="collapse" data-bs-target=".balance-details" aria-expanded="false" aria-controls="balanceDetailsOffchain balanceDetailsOnchain">
<vc:icon symbol="caret-down"/>
<span class="ms-1" text-translate="true">Details</span>
</button>
<div class="ct-chart"></div>
<template>
@Safe.Json(Model)
</template>
}
}
else
@ -143,47 +167,18 @@
<span class="visually-hidden" text-translate="true">Loading...</span>
</div>
</div>
<script src="~/Components/StoreLightningBalance/Default.cshtml.js" asp-append-version="true"></script>
<script>
(async () => {
const url = @Safe.Json(Url.Action("LightningBalance", "UIStores", new { storeId = Model.Store.Id, cryptoCode = Model.CryptoCode }));
const storeId = @Safe.Json(Model.Store.Id);
const url = @Safe.Json(Model.DataUrl);
const storeId = @Safe.Json(Model.StoreId);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`StoreLightningBalance-${storeId}`).outerHTML = await response.text();
const data = document.querySelector(`#StoreLightningBalance-${storeId} template`);
if (data) window.storeLightningBalance.dataLoaded(JSON.parse(data.innerHTML));
}
})();
</script>
}
</div>
<script>
(function () {
const storeId = @Safe.Json(Model.Store.Id);
const cryptoCode = @Safe.Json(Model.CryptoCode);
const defaultCurrency = @Safe.Json(Model.DefaultCurrency);
const divisibility = @Safe.Json(Model.CurrencyData.Divisibility);
const id = `StoreLightningBalance-${storeId}`;
const render = rate => {
const currency = rate ? defaultCurrency : cryptoCode;
document.querySelectorAll(`#${id} .currency`).forEach(c => c.innerText = currency)
document.querySelectorAll(`#${id} [data-balance]`).forEach(c => {
const value = Number.parseFloat(c.dataset.balance);
c.innerText = rate
? DashboardUtils.displayDefaultCurrency(value, rate, currency, divisibility)
: value
});
};
document.addEventListener('DOMContentLoaded', () => {
delegate('change', `#${id} .currency-toggle input`, async e => {
const { target } = e;
if (target.value === defaultCurrency) {
const rate = await DashboardUtils.fetchRate(`${cryptoCode}_${defaultCurrency}`);
if (rate) render(rate);
} else {
render(null);
}
});
});
})();
</script>

View File

@ -0,0 +1,94 @@
if (!window.storeLightningBalance) {
window.storeLightningBalance = {
dataLoaded (model) {
const { storeId, cryptoCode, defaultCurrency, currencyData: { divisibility } } = model;
const id = `StoreLightningBalance-${storeId}`;
const valueTransform = value => rate ? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility) : value
const labelCount = 6
const tooltip = Chartist.plugins.tooltip2({
template: '<div class="chartist-tooltip-value">{{value}}</div><div class="chartist-tooltip-line"></div>',
offset: {
x: 0,
y: -16
},
valueTransformFunction(value, label) {
return valueTransform(value) + ' ' + (rate ? defaultCurrency : cryptoCode)
}
})
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
const dateFormatter = new Intl.DateTimeFormat('default', { month: 'short', day: 'numeric' })
const chartOpts = {
fullWidth: true,
showArea: true,
axisY: {
showLabel: false,
offset: 0
},
plugins: [tooltip]
};
const baseUrl = model.dataUrl;
let data = model;
let rate = null;
const render = data => {
let { series, labels } = data;
const currency = rate ? defaultCurrency : cryptoCode;
document.querySelectorAll(`#${id} .currency`).forEach(c => c.innerText = currency)
document.querySelectorAll(`#${id} [data-balance]`).forEach(c => {
const value = Number.parseFloat(c.dataset.balance);
c.innerText = valueTransform(value)
});
if (!series) return;
const min = Math.min(...series);
const max = Math.max(...series);
const low = Math.max(min - ((max - min) / 5), 0);
const renderOpts = Object.assign({}, chartOpts, { low, axisX: {
labelInterpolationFnc(date, i) {
return i % labelEvery == 0 ? dateFormatter.format(new Date(date)) : null
}
} });
const pointCount = series.length;
const labelEvery = pointCount / labelCount;
const chart = new Chartist.Line(`#${id} .ct-chart`, {
labels: labels,
series: [series]
}, renderOpts);
};
const update = async type => {
const url = `${baseUrl}/${type}`;
const response = await fetch(url);
if (response.ok) {
data = await response.json();
render(data);
}
};
render(data);
function addEventListeners() {
delegate('change', `#${id} [name="StoreLightningBalancePeriod-${storeId}"]`, async e => {
const type = e.target.value;
await update(type);
})
delegate('change', `#${id} .currency-toggle input`, async e => {
const { target } = e;
if (target.value === defaultCurrency) {
rate = await DashboardUtils.fetchRate(`${cryptoCode}_${defaultCurrency}`);
if (rate) render(data);
} else {
rate = null;
render(data);
}
});
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", addEventListeners);
} else {
addEventListeners();
}
}
};
}

View File

@ -1,13 +1,11 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
@ -18,11 +16,14 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreLightningBalance;
public class StoreLightningBalance : ViewComponent
{
private const HistogramType DefaultType = HistogramType.Week;
private readonly StoreRepository _storeRepo;
private readonly CurrencyNameTable _currencies;
private readonly BTCPayServerOptions _btcpayServerOptions;
@ -32,6 +33,7 @@ public class StoreLightningBalance : ViewComponent
private readonly IOptions<ExternalServicesOptions> _externalServiceOptions;
private readonly IAuthorizationService _authorizationService;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly LightningHistogramService _lnHistogramService;
public StoreLightningBalance(
StoreRepository storeRepo,
@ -42,7 +44,8 @@ public class StoreLightningBalance : ViewComponent
IOptions<LightningNetworkOptions> lightningNetworkOptions,
IOptions<ExternalServicesOptions> externalServiceOptions,
IAuthorizationService authorizationService,
PaymentMethodHandlerDictionary handlers)
PaymentMethodHandlerDictionary handlers,
LightningHistogramService lnHistogramService)
{
_storeRepo = storeRepo;
_currencies = currencies;
@ -53,31 +56,32 @@ public class StoreLightningBalance : ViewComponent
_handlers = handlers;
_lightningClientFactory = lightningClientFactory;
_lightningNetworkOptions = lightningNetworkOptions;
_lnHistogramService = lnHistogramService;
}
public async Task<IViewComponentResult> InvokeAsync(StoreLightningBalanceViewModel vm)
public async Task<IViewComponentResult> InvokeAsync(StoreData store, string cryptoCode, bool initialRendering)
{
if (vm.Store == null)
throw new ArgumentNullException(nameof(vm.Store));
if (vm.CryptoCode == null)
throw new ArgumentNullException(nameof(vm.CryptoCode));
var defaultCurrency = store.GetStoreBlob().DefaultCurrency;
var vm = new StoreLightningBalanceViewModel
{
StoreId = store.Id,
CryptoCode = cryptoCode,
InitialRendering = initialRendering,
DefaultCurrency = defaultCurrency,
CurrencyData = _currencies.GetCurrencyData(defaultCurrency, true),
DataUrl = Url.Action("LightningBalanceDashboard", "UIStores", new { storeId = store.Id, cryptoCode })
};
vm.DefaultCurrency = vm.Store.GetStoreBlob().DefaultCurrency;
vm.CurrencyData = _currencies.GetCurrencyData(vm.DefaultCurrency, true);
if (vm.InitialRendering)
return View(vm);
try
{
var lightningClient = await GetLightningClient(vm.Store, vm.CryptoCode);
if (lightningClient == null)
{
vm.InitialRendering = false;
return View(vm);
}
var lightningClient = await GetLightningClient(store, vm.CryptoCode);
if (vm.InitialRendering)
return View(vm);
var balance = await lightningClient.GetBalance();
// balance
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var balance = await lightningClient.GetBalance(cts.Token);
vm.Balance = balance;
vm.TotalOnchain = balance.OnchainBalance != null
? (balance.OnchainBalance.Confirmed ?? 0L) + (balance.OnchainBalance.Reserved ?? 0L) +
@ -87,8 +91,16 @@ public class StoreLightningBalance : ViewComponent
? (balance.OffchainBalance.Opening ?? 0) + (balance.OffchainBalance.Local ?? 0) +
(balance.OffchainBalance.Closing ?? 0)
: null;
}
// histogram
var data = await _lnHistogramService.GetHistogram(lightningClient, DefaultType, cts.Token);
if (data != null)
{
vm.Type = data.Type;
vm.Series = data.Series;
vm.Labels = data.Labels;
}
}
catch (Exception ex) when (ex is NotImplementedException or NotSupportedException)
{
// not all implementations support balance fetching
@ -102,7 +114,7 @@ public class StoreLightningBalance : ViewComponent
return View(vm);
}
private async Task<ILightningClient> GetLightningClient(StoreData store, string cryptoCode )
private async Task<ILightningClient> GetLightningClient(StoreData store, string cryptoCode)
{
var network = _networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var id = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);

View File

@ -1,19 +1,26 @@
using BTCPayServer.Data;
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using BTCPayServer.Services.Rates;
using NBitcoin;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreLightningBalance;
public class StoreLightningBalanceViewModel
{
public string StoreId { get; set; }
public string CryptoCode { get; set; }
public string DefaultCurrency { get; set; }
public CurrencyData CurrencyData { get; set; }
public StoreData Store { get; set; }
public Money TotalOnchain { get; set; }
public LightMoney TotalOffchain { get; set; }
public LightningNodeBalance Balance { get; set; }
public string ProblemDescription { get; set; }
public bool InitialRendering { get; set; }
public bool InitialRendering { get; set; } = true;
public HistogramType Type { get; set; }
public IList<DateTimeOffset> Labels { get; set; }
public IList<decimal> Series { get; set; }
public string DataUrl { get; set; }
}

View File

@ -1,8 +1,8 @@
@using BTCPayServer.Services.Wallets
@using BTCPayServer.Payments
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client.Models
@using BTCPayServer.TagHelpers
@model BTCPayServer.Components.StoreWalletBalance.StoreWalletBalanceViewModel
@inject BTCPayNetworkProvider NetworkProvider
<div id="StoreWalletBalance-@Model.Store.Id" class="widget store-wallet-balance">
<div id="StoreWalletBalance-@Model.StoreId" 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)
@ -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.Store.Id" id="StoreWalletBalancePeriodWeek-@Model.Store.Id" value="@WalletHistogramType.Week" @(Model.Type == WalletHistogramType.Week ? "checked" : "")>
<label class="btn btn-link" for="StoreWalletBalancePeriodWeek-@Model.Store.Id">1W</label>
<input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.Store.Id" id="StoreWalletBalancePeriodMonth-@Model.Store.Id" value="@WalletHistogramType.Month" @(Model.Type == WalletHistogramType.Month ? "checked" : "")>
<label class="btn btn-link" for="StoreWalletBalancePeriodMonth-@Model.Store.Id">1M</label>
<input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.Store.Id" id="StoreWalletBalancePeriodYear-@Model.Store.Id" value="@WalletHistogramType.Year" @(Model.Type == WalletHistogramType.Year ? "checked" : "")>
<label class="btn btn-link" for="StoreWalletBalancePeriodYear-@Model.Store.Id">1Y</label>
<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>
</div>
}
</header>
@ -39,10 +39,10 @@
{
<div class="ct-chart"></div>
}
else if (Model.Store.GetPaymentMethodConfig(PaymentTypes.CHAIN.GetPaymentMethodId(Model.CryptoCode)) is null)
else if (Model.MissingWalletConfig)
{
<p>
We would like to show you a chart of your balance but you have not yet <a href="@Url.Action("SetupWallet", "UIStores", new {storeId = Model.Store.Id, cryptoCode = Model.CryptoCode})">configured a wallet</a>.
We would like to show you a chart of your balance but you have not yet <a href="@Url.Action("SetupWallet", "UIStores", new { storeId = Model.StoreId, cryptoCode = Model.CryptoCode })">configured a wallet</a>.
</p>
}
else
@ -55,7 +55,7 @@
}
<script>
(function () {
const storeId = @Safe.Json(Model.Store.Id);
const storeId = @Safe.Json(Model.StoreId);
const cryptoCode = @Safe.Json(Model.CryptoCode);
const defaultCurrency = @Safe.Json(Model.DefaultCurrency);
const divisibility = @Safe.Json(Model.CurrencyData.Divisibility);
@ -63,16 +63,29 @@
let rate = null;
const id = `StoreWalletBalance-${storeId}`;
const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = WalletHistogramType.Week }));
const valueTransform = value => rate
? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility).toString()
: value
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
const tooltip = Chartist.plugins.tooltip2({
template: '<div class="chartist-tooltip-value">{{value}}</div><div class="chartist-tooltip-line"></div>',
offset: {
x: 0,
y: -16
},
valueTransformFunction(value, label) {
return valueTransform(value) + ' ' + (rate ? defaultCurrency : cryptoCode)
}
})
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
const dateFormatter = new Intl.DateTimeFormat('default', { month: 'short', day: 'numeric' })
const chartOpts = {
fullWidth: true,
showArea: true,
axisY: {
labelInterpolationFnc: valueTransform
}
showLabel: false,
offset: 0
},
plugins: [tooltip]
};
const render = data => {
@ -88,31 +101,17 @@
const min = Math.min(...series);
const max = Math.max(...series);
const low = Math.max(min - ((max - min) / 5), 0);
const tooltip = Chartist.plugins.tooltip2({
template: '<div class="chartist-tooltip-value">{{value}}</div><div class="chartist-tooltip-line"></div>',
offset: {
x: 0,
y: -16
},
valueTransformFunction: valueTransform
})
const renderOpts = Object.assign({}, chartOpts, { low, plugins: [tooltip] });
const chart = new Chartist.Line(`#${id} .ct-chart`, {
labels,
const renderOpts = Object.assign({}, chartOpts, { low, axisX: {
labelInterpolationFnc(date, i) {
return i % labelEvery === 0 ? dateFormatter.format(new Date(date)) : null
}
} });
const pointCount = series.length;
const labelEvery = pointCount / labelCount;
new Chartist.Line(`#${id} .ct-chart`, {
labels: labels,
series: [series]
}, renderOpts);
// prevent y-axis labels from getting cut off
window.setTimeout(() => {
const yLabels = [...document.querySelectorAll('.ct-label.ct-vertical.ct-start')];
if (yLabels) {
const width = Math.max(...(yLabels.map(l => l.innerText.length * 7.5)));
const opts = Object.assign({}, renderOpts, {
axisY: Object.assign({}, renderOpts.axisY, { offset: width })
});
chart.update(null, opts);
}
}, 0)
};
const update = async type => {
@ -126,7 +125,7 @@
render(data);
document.addEventListener('DOMContentLoaded', () => {
function addEventListeners() {
delegate('change', `#${id} [name="StoreWalletBalancePeriod-${storeId}"]`, async e => {
const type = e.target.value;
await update(type);
@ -141,7 +140,13 @@
render(data);
}
});
});
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", addEventListeners);
} else {
addEventListeners();
}
})();
</script>
</div>

View File

@ -1,29 +1,21 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBXplorer;
using NBXplorer.Client;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreWalletBalance;
public class StoreWalletBalance : ViewComponent
{
private const WalletHistogramType DefaultType = WalletHistogramType.Week;
private const HistogramType DefaultType = HistogramType.Week;
private readonly StoreRepository _storeRepo;
private readonly CurrencyNameTable _currencies;
@ -57,7 +49,7 @@ public class StoreWalletBalance : ViewComponent
var vm = new StoreWalletBalanceViewModel
{
Store = store,
StoreId = store.Id,
CryptoCode = cryptoCode,
CurrencyData = _currencies.GetCurrencyData(defaultCurrency, true),
DefaultCurrency = defaultCurrency,
@ -82,6 +74,10 @@ public class StoreWalletBalance : ViewComponent
var balance = await wallet.GetBalance(derivation.AccountDerivation, cts.Token);
vm.Balance = balance.Available.GetValue(network);
}
else
{
vm.MissingWalletConfig = true;
}
}
return View(vm);

View File

@ -1,19 +1,21 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreWalletBalance;
public class StoreWalletBalanceViewModel
{
public string StoreId { get; set; }
public decimal? Balance { get; set; }
public string CryptoCode { get; set; }
public string DefaultCurrency { get; set; }
public CurrencyData CurrencyData { get; set; }
public StoreData Store { get; set; }
public WalletId WalletId { get; set; }
public WalletHistogramType Type { get; set; }
public IList<string> Labels { get; set; }
public HistogramType Type { get; set; }
public IList<DateTimeOffset> Labels { get; set; }
public IList<decimal> Series { get; set; }
public bool MissingWalletConfig { get; set; }
}

View File

@ -1,13 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
@ -31,8 +28,9 @@ namespace BTCPayServer.Controllers.Greenfield
PoliciesSettings policiesSettings, LightningClientFactoryService lightningClientFactory,
IOptions<LightningNetworkOptions> lightningNetworkOptions,
IAuthorizationService authorizationService,
PaymentMethodHandlerDictionary handlers
) : base(policiesSettings, authorizationService, handlers)
PaymentMethodHandlerDictionary handlers,
LightningHistogramService lnHistogramService
) : base(policiesSettings, authorizationService, handlers, lnHistogramService)
{
_lightningClientFactory = lightningClientFactory;
_lightningNetworkOptions = lightningNetworkOptions;
@ -55,6 +53,14 @@ namespace BTCPayServer.Controllers.Greenfield
return base.GetBalance(cryptoCode, cancellationToken);
}
[Authorize(Policy = Policies.CanUseInternalLightningNode,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/server/lightning/{cryptoCode}/histogram")]
public override Task<IActionResult> GetHistogram(string cryptoCode, [FromQuery] HistogramType? type = null, CancellationToken cancellationToken = default)
{
return base.GetHistogram(cryptoCode, type, cancellationToken);
}
[Authorize(Policy = Policies.CanUseInternalLightningNode,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/server/lightning/{cryptoCode}/connect")]

View File

@ -1,8 +1,6 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
@ -34,7 +32,8 @@ namespace BTCPayServer.Controllers.Greenfield
IOptions<LightningNetworkOptions> lightningNetworkOptions,
LightningClientFactoryService lightningClientFactory, PaymentMethodHandlerDictionary handlers,
PoliciesSettings policiesSettings,
IAuthorizationService authorizationService) : base(policiesSettings, authorizationService, handlers)
IAuthorizationService authorizationService,
LightningHistogramService lnHistogramService) : base(policiesSettings, authorizationService, handlers, lnHistogramService)
{
_lightningNetworkOptions = lightningNetworkOptions;
_lightningClientFactory = lightningClientFactory;
@ -57,6 +56,13 @@ namespace BTCPayServer.Controllers.Greenfield
return base.GetBalance(cryptoCode, cancellationToken);
}
[Authorize(Policy = Policies.CanUseLightningNodeInStore, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/histogram")]
public override Task<IActionResult> GetHistogram(string cryptoCode, [FromQuery] HistogramType? type = null, CancellationToken cancellationToken = default)
{
return base.GetHistogram(cryptoCode, type, cancellationToken);
}
[Authorize(Policy = Policies.CanUseLightningNodeInStore,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/connect")]
@ -64,6 +70,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
return base.ConnectToNode(cryptoCode, request, cancellationToken);
}
[Authorize(Policy = Policies.CanUseLightningNodeInStore,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")]
@ -71,6 +78,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
return base.GetChannels(cryptoCode, cancellationToken);
}
[Authorize(Policy = Policies.CanUseLightningNodeInStore,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")]

View File

@ -11,6 +11,7 @@ using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
@ -32,15 +33,18 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly PoliciesSettings _policiesSettings;
private readonly IAuthorizationService _authorizationService;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly LightningHistogramService _lnHistogramService;
protected GreenfieldLightningNodeApiController(
PoliciesSettings policiesSettings,
IAuthorizationService authorizationService,
PaymentMethodHandlerDictionary handlers)
PaymentMethodHandlerDictionary handlers,
LightningHistogramService lnHistogramService)
{
_policiesSettings = policiesSettings;
_authorizationService = authorizationService;
_handlers = handlers;
_lnHistogramService = lnHistogramService;
}
public virtual async Task<IActionResult> GetInfo(string cryptoCode, CancellationToken cancellationToken = default)
@ -87,6 +91,22 @@ namespace BTCPayServer.Controllers.Greenfield
});
}
public virtual async Task<IActionResult> GetHistogram(string cryptoCode, HistogramType? type = null, CancellationToken cancellationToken = default)
{
Enum.TryParse<HistogramType>(type.ToString(), true, out var histType);
var lightningClient = await GetLightningClient(cryptoCode, true);
var data = await _lnHistogramService.GetHistogram(lightningClient, histType, cancellationToken);
if (data == null) return this.CreateAPIError(404, "histogram-not-found", "The lightning histogram was not found.");
return Ok(new HistogramData
{
Type = data.Type,
Balance = data.Balance,
Series = data.Series,
Labels = data.Labels
});
}
public virtual async Task<IActionResult> ConnectToNode(string cryptoCode, ConnectToNodeRequest request, CancellationToken cancellationToken = default)
{
var lightningClient = await GetLightningClient(cryptoCode, true);

View File

@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -20,7 +19,6 @@ using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Payments.PayJoin.Sender;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
@ -58,6 +56,7 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly IFeeProviderFactory _feeProviderFactory;
private readonly UTXOLocker _utxoLocker;
private readonly TransactionLinkProviders _transactionLinkProviders;
private readonly WalletHistogramService _walletHistogramService;
public GreenfieldStoreOnChainWalletsController(
IAuthorizationService authorizationService,
@ -74,6 +73,7 @@ namespace BTCPayServer.Controllers.Greenfield
WalletReceiveService walletReceiveService,
IFeeProviderFactory feeProviderFactory,
UTXOLocker utxoLocker,
WalletHistogramService walletHistogramService,
TransactionLinkProviders transactionLinkProviders
)
{
@ -91,6 +91,7 @@ namespace BTCPayServer.Controllers.Greenfield
_walletReceiveService = walletReceiveService;
_feeProviderFactory = feeProviderFactory;
_utxoLocker = utxoLocker;
_walletHistogramService = walletHistogramService;
_transactionLinkProviders = transactionLinkProviders;
}
@ -114,6 +115,27 @@ namespace BTCPayServer.Controllers.Greenfield
});
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/histogram")]
public async Task<IActionResult> GetOnChainWalletHistogram(string storeId, string paymentMethodId, [FromQuery] string? type = null)
{
if (IsInvalidWalletRequest(paymentMethodId, out var network, out var derivationScheme, out var actionResult))
return actionResult;
var walletId = new WalletId(storeId, network.CryptoCode);
Enum.TryParse<HistogramType>(type, true, out var histType);
var data = await _walletHistogramService.GetHistogram(Store, walletId, histType);
if (data == null) return this.CreateAPIError(404, "histogram-not-found", "The wallet histogram was not found.");
return Ok(new HistogramData
{
Type = data.Type,
Balance = data.Balance,
Series = data.Series,
Labels = data.Labels
});
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate")]
public async Task<IActionResult> GetOnChainFeeRate(string storeId, string paymentMethodId, int? blockTarget = null)

View File

@ -14,18 +14,15 @@ using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBXplorer.Models;
using Newtonsoft.Json.Linq;
using InvoiceData = BTCPayServer.Client.Models.InvoiceData;
using Language = BTCPayServer.Client.Models.Language;
@ -385,6 +382,13 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldStoreLightningNodeApiController>().GetBalance(cryptoCode, token));
}
public override async Task<HistogramData> GetLightningNodeHistogram(string storeId, string cryptoCode, HistogramType? type = null,
CancellationToken token = default)
{
return GetFromActionResult<HistogramData>(
await GetController<GreenfieldStoreLightningNodeApiController>().GetHistogram(cryptoCode, type, token));
}
public override async Task ConnectToLightningNode(string storeId, string cryptoCode,
ConnectToNodeRequest request, CancellationToken token = default)
{
@ -461,6 +465,13 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldInternalLightningNodeApiController>().GetBalance(cryptoCode));
}
public override async Task<HistogramData> GetLightningNodeHistogram(string cryptoCode, HistogramType? type = null,
CancellationToken token = default)
{
return GetFromActionResult<HistogramData>(
await GetController<GreenfieldInternalLightningNodeApiController>().GetHistogram(cryptoCode, type, token));
}
public override async Task ConnectToLightningNode(string cryptoCode, ConnectToNodeRequest request,
CancellationToken token = default)
{
@ -703,6 +714,12 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldStoreOnChainWalletsController>().ShowOnChainWalletOverview(storeId, cryptoCode));
}
public override async Task<HistogramData> GetOnChainWalletHistogram(string storeId, string cryptoCode, HistogramType? type = null, CancellationToken token = default)
{
return GetFromActionResult<HistogramData>(
await GetController<GreenfieldStoreOnChainWalletsController>().GetOnChainWalletHistogram(storeId, cryptoCode, type?.ToString()));
}
public override async Task<OnChainWalletAddressData> GetOnChainWalletReceiveAddress(string storeId,
string cryptoCode, bool forceGenerate = false,
CancellationToken token = default)

View File

@ -1,5 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
@ -78,8 +77,7 @@ public partial class UIStoresController
if (store == null)
return NotFound();
var vm = new StoreLightningBalanceViewModel { Store = store, CryptoCode = cryptoCode };
return ViewComponent("StoreLightningBalance", new { vm });
return ViewComponent("StoreLightningBalance", new { Store = store, CryptoCode = cryptoCode });
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/numbers")]

View File

@ -6,15 +6,20 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Components.StoreLightningBalance;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers;
@ -82,6 +87,32 @@ public partial class UIStoresController
return View(vm);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("{storeId}/lightning/{cryptoCode}/dashboard/balance")]
public IActionResult LightningBalanceDashboard(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
return ViewComponent("StoreLightningBalance", new { Store = store, CryptoCode = cryptoCode });
}
[HttpGet("{storeId}/lightning/{cryptoCode}/dashboard/balance/{type}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> LightningBalanceDashboard(string storeId, string cryptoCode, HistogramType type)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var lightningClient = await GetLightningClient(store, cryptoCode);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var data = await _lnHistogramService.GetHistogram(lightningClient, type, cts.Token);
if (data == null) return NotFound();
return Json(data);
}
[HttpGet("{storeId}/lightning/{cryptoCode}/setup")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult SetupLightningNode(string storeId, string cryptoCode)
@ -321,4 +352,26 @@ public partial class UIStoresController
{
return store.GetPaymentMethodConfig<T>(paymentMethodId, _handlers);
}
private async Task<ILightningClient?> GetLightningClient(StoreData store, string cryptoCode)
{
var network = _networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var id = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
var existing = store.GetPaymentMethodConfig<LightningPaymentMethodConfig>(id, _handlers);
if (existing == null)
return null;
if (existing.GetExternalLightningUrl() is { } connectionString)
{
return _lightningClientFactory.Create(connectionString, network);
}
if (existing.IsInternalNode && _lightningNetworkOptions.InternalLightningByCryptoCode.TryGetValue(cryptoCode, out var internalLightningNode))
{
var result = await _authorizationService.AuthorizeAsync(HttpContext.User, null,
new PolicyRequirement(Policies.CanUseInternalLightningNode));
return result.Succeeded ? internalLightningNode : null;
}
return null;
}
}

View File

@ -1,5 +1,4 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@ -64,7 +63,9 @@ public partial class UIStoresController : Controller
SettingsRepository settingsRepository,
CurrencyNameTable currencyNameTable,
IStringLocalizer stringLocalizer,
EventAggregator eventAggregator)
EventAggregator eventAggregator,
LightningHistogramService lnHistogramService,
LightningClientFactoryService lightningClientFactory)
{
_rateFactory = rateFactory;
_storeRepo = storeRepo;
@ -95,6 +96,8 @@ public partial class UIStoresController : Controller
_dataProtector = dataProtector.CreateProtector("ConfigProtector");
_webhookNotificationManager = webhookNotificationManager;
_lightningNetworkOptions = lightningNetworkOptions.Value;
_lnHistogramService = lnHistogramService;
_lightningClientFactory = lightningClientFactory;
StringLocalizer = stringLocalizer;
}
@ -127,6 +130,8 @@ public partial class UIStoresController : Controller
private readonly WebhookSender _webhookNotificationManager;
private readonly LightningNetworkOptions _lightningNetworkOptions;
private readonly IDataProtector _dataProtector;
private readonly LightningHistogramService _lnHistogramService;
private readonly LightningClientFactoryService _lightningClientFactory;
public string? GeneratedPairingCode { get; set; }
public IStringLocalizer StringLocalizer { get; }

View File

@ -307,14 +307,13 @@ namespace BTCPayServer.Controllers
[HttpGet("{walletId}/histogram/{type}")]
public async Task<IActionResult> WalletHistogram(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletHistogramType type)
WalletId walletId, HistogramType type)
{
var store = GetCurrentStore();
var data = await _walletHistogramService.GetHistogram(store, walletId, type);
if (data == null) return NotFound();
return data == null
? NotFound()
: Json(data);
return Json(data);
}
[HttpGet("{walletId}/receive")]

View File

@ -173,6 +173,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<UserService>();
services.TryAddSingleton<UriResolver>();
services.TryAddSingleton<WalletHistogramService>();
services.TryAddSingleton<LightningHistogramService>();
services.AddSingleton<ApplicationDbContextFactory>();
services.AddOptions<BTCPayServerOptions>().Configure(
(options) =>

View File

@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
namespace BTCPayServer.Services;
public class LightningHistogramService
{
public async Task<HistogramData> GetHistogram(ILightningClient lightningClient, HistogramType type, CancellationToken cancellationToken)
{
var (days, pointCount) = type switch
{
HistogramType.Day => (1, 30),
HistogramType.Week => (7, 30),
HistogramType.Month => (30, 30),
HistogramType.YTD => (DateTimeOffset.Now.DayOfYear - 1, 30),
HistogramType.Year => (365, 30),
HistogramType.TwoYears => (730, 30),
_ => throw new ArgumentException($"HistogramType {type} does not exist.")
};
var to = DateTimeOffset.UtcNow;
var from = to - TimeSpan.FromDays(days);
var ticks = (to - from).Ticks;
var interval = TimeSpan.FromTicks(ticks / pointCount);
try
{
// general balance
var lnBalance = await lightningClient.GetBalance(cancellationToken);
var total = lnBalance.OffchainBalance.Local;
var totalBtc = total.ToDecimal(LightMoneyUnit.BTC);
// prepare transaction data
var lnInvoices = await lightningClient.ListInvoices(cancellationToken);
var lnPayments = await lightningClient.ListPayments(cancellationToken);
var lnTransactions = lnInvoices
.Where(inv => inv.Status == LightningInvoiceStatus.Paid && inv.PaidAt >= from)
.Select(inv => new LnTx { Amount = inv.Amount.ToDecimal(LightMoneyUnit.BTC), Settled = inv.PaidAt.GetValueOrDefault() })
.Concat(lnPayments
.Where(pay => pay.Status == LightningPaymentStatus.Complete && pay.CreatedAt >= from)
.Select(pay => new LnTx { Amount = pay.Amount.ToDecimal(LightMoneyUnit.BTC) * -1, Settled = pay.CreatedAt.GetValueOrDefault() }))
.OrderByDescending(tx => tx.Settled)
.ToList();
// assemble graph data going backwards
var series = new List<decimal>(pointCount);
var labels = new List<DateTimeOffset>(pointCount);
var balance = totalBtc;
for (var i = pointCount; i > 0; i--)
{
var txs = lnTransactions.Where(t =>
t.Settled.Ticks >= from.Ticks + interval.Ticks * i &&
t.Settled.Ticks < from.Ticks + interval.Ticks * (i + 1));
var sum = txs.Sum(tx => tx.Amount);
balance -= sum;
series.Add(balance);
labels.Add(from + interval * (i - 1));
}
// reverse the lists
series.Reverse();
labels.Reverse();
return new HistogramData
{
Type = type,
Balance = totalBtc,
Series = series,
Labels = labels
};
}
catch (Exception)
{
return null;
}
}
private class LnTx
{
public DateTimeOffset Settled { get; set; }
public decimal Amount { get; set; }
}
}

View File

@ -1,21 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Data;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Invoices;
using Dapper;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Services.Wallets;
public enum WalletHistogramType
{
Week,
Month,
Year
}
public class WalletHistogramService
{
private readonly PaymentMethodHandlerDictionary _handlers;
@ -29,7 +21,7 @@ public class WalletHistogramService
_connectionFactory = connectionFactory;
}
public async Task<WalletHistogramData> GetHistogram(StoreData store, WalletId walletId, WalletHistogramType type)
public async Task<HistogramData> GetHistogram(StoreData store, WalletId walletId, HistogramType type)
{
// https://github.com/dgarage/NBXplorer/blob/master/docs/Postgres-Schema.md
if (_connectionFactory.Available)
@ -43,13 +35,15 @@ public class WalletHistogramService
var code = walletId.CryptoCode;
var to = DateTimeOffset.UtcNow;
var labelCount = 6;
(var days, var pointCount) = type switch
var (days, pointCount) = type switch
{
WalletHistogramType.Week => (7, 30),
WalletHistogramType.Month => (30, 30),
WalletHistogramType.Year => (365, 30),
_ => throw new ArgumentException($"WalletHistogramType {type} does not exist.")
HistogramType.Day => (1, 30),
HistogramType.Week => (7, 30),
HistogramType.Month => (30, 30),
HistogramType.YTD => (DateTimeOffset.Now.DayOfYear - 1, 30),
HistogramType.Year => (365, 30),
HistogramType.TwoYears => (730, 30),
_ => throw new ArgumentException($"HistogramType {type} does not exist.")
};
var from = to - TimeSpan.FromDays(days);
var interval = TimeSpan.FromTicks((to - from).Ticks / pointCount);
@ -60,18 +54,15 @@ public class WalletHistogramService
new { code, wallet_id, from, to, interval });
var data = rows.AsList();
var series = new List<decimal>(pointCount);
var labels = new List<string>(labelCount);
var labelEvery = pointCount / labelCount;
var labels = new List<DateTimeOffset>(pointCount);
for (int i = 0; i < data.Count; i++)
{
var r = data[i];
series.Add((decimal)r.balance);
labels.Add((i % labelEvery == 0)
? ((DateTime)r.date).ToString("MMM dd", CultureInfo.InvariantCulture)
: null);
labels.Add((DateTimeOffset)r.date);
}
series[^1] = balance;
return new WalletHistogramData
return new HistogramData
{
Series = series,
Labels = labels,
@ -84,11 +75,3 @@ public class WalletHistogramService
return null;
}
}
public class WalletHistogramData
{
public WalletHistogramType Type { get; set; }
public List<decimal> Series { get; set; }
public List<string> Labels { get; set; }
public decimal Balance { get; set; }
}

View File

@ -81,7 +81,7 @@
<vc:store-numbers vm="@(new StoreNumbersViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })" />
@if (Model.LightningEnabled)
{
<vc:store-lightning-balance vm="@(new StoreLightningBalanceViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })" />
<vc:store-lightning-balance store="store" crypto-code="@Model.CryptoCode" initial-rendering="true" />
<vc:store-lightning-services vm="@(new StoreLightningServicesViewModel { Store = store, CryptoCode = Model.CryptoCode })" permission="@Policies.CanModifyServerSettings" />
}
@if (Model.WalletEnabled)

View File

@ -115,6 +115,49 @@
"type": "string",
"description": "Payout method IDs. Available payment method IDs for Bitcoin are: \n- `\"BTC-CHAIN\"`: Onchain \n-`\"BTC-LN\"`: Lightning",
"example": "BTC-LN"
},
"HistogramData": {
"type": "object",
"description": "Histogram data for wallet balances over time",
"properties": {
"type": {
"type": "string",
"description": "The timespan of the histogram data",
"x-enumNames": [
"Week",
"Month",
"Year"
],
"enum": [
"Week",
"Month",
"Year"
],
"default": "Week"
},
"balance": {
"type": "string",
"format": "decimal",
"description": "The current wallet balance"
},
"series": {
"type": "array",
"description": "An array of historic balances of the wallet",
"items": {
"type": "string",
"format": "decimal",
"description": "The balance of the wallet at a specific time"
}
},
"labels": {
"type": "array",
"description": "An array of timestamps associated with the series data",
"items": {
"type": "integer",
"description": "UNIX timestamp of the balance snapshot"
}
}
}
}
},
"securitySchemes": {

View File

@ -96,6 +96,54 @@
]
}
},
"/api/v1/server/lightning/{cryptoCode}/histogram": {
"get": {
"tags": [
"Lightning (Internal Node)"
],
"summary": "Get node balance histogram",
"parameters": [
{
"name": "cryptoCode",
"in": "path",
"required": true,
"description": "The cryptoCode of the lightning-node to query",
"schema": {
"type": "string"
},
"example": "BTC"
}
],
"description": "View balance histogram of the lightning node",
"operationId": "InternalLightningNodeApi_GetHistogram",
"responses": {
"200": {
"description": "Lightning node balance histogram for off-chain funds",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HistogramData"
}
}
}
},
"503": {
"description": "Unable to access the lightning node"
},
"404": {
"description": "The lightning node configuration was not found"
}
},
"security": [
{
"API_Key": [
"btcpay.server.canuseinternallightningnode"
],
"Basic": []
}
]
}
},
"/api/v1/server/lightning/{cryptoCode}/connect": {
"post": {
"tags": [

View File

@ -114,6 +114,63 @@
]
}
},
"/api/v1/stores/{storeId}/lightning/{cryptoCode}/histogram": {
"get": {
"tags": [
"Lightning (Store)"
],
"summary": "Get node balance histogram",
"parameters": [
{
"name": "cryptoCode",
"in": "path",
"required": true,
"description": "The cryptoCode of the lightning-node to query",
"schema": {
"type": "string"
},
"example": "BTC"
},
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id with the lightning-node configuration to query",
"schema": {
"type": "string"
}
}
],
"description": "View balance histogram of the lightning node",
"operationId": "StoreLightningNodeApi_GetHistogram",
"responses": {
"200": {
"description": "Lightning node balance histogram for off-chain funds",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HistogramData"
}
}
}
},
"503": {
"description": "Unable to access the lightning node"
},
"404": {
"description": "The lightning node configuration was not found"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canuselightningnode"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/lightning/{cryptoCode}/connect": {
"post": {
"tags": [

View File

@ -50,6 +50,56 @@
]
}
},
"/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/histogram": {
"get": {
"tags": [
"Store Wallet (On Chain)"
],
"summary": "Get store on-chain wallet balance histogram",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/PaymentMethodId"
}
],
"description": "View the balance histogram of the specified wallet",
"operationId": "StoreOnChainWallets_ShowOnChainWalletHistogram",
"responses": {
"200": {
"description": "specified wallet",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HistogramData"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store/wallet"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate": {
"get": {
"tags": [