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
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); 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, public virtual async Task ConnectToLightningNode(string cryptoCode, ConnectToNodeRequest request,
CancellationToken token = default) 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); 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, public virtual async Task ConnectToLightningNode(string storeId, string cryptoCode, ConnectToNodeRequest request,
CancellationToken token = default) 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); 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, public virtual async Task<OnChainWalletFeeRateData> GetOnChainFeeRate(string storeId, string cryptoCode, int? blockTarget = null,
CancellationToken token = default) 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

@@ -3001,6 +3001,19 @@ namespace BTCPayServer.Tests
var info = await client.GetLightningNodeInfo(user.StoreId, "BTC"); var info = await client.GetLightningNodeInfo(user.StoreId, "BTC");
Assert.Single(info.NodeURIs); Assert.Single(info.NodeURIs);
Assert.NotEqual(0, info.BlockHeight); 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. // As admin, can use the internal node through our store.
await user.MakeAdmin(true); await user.MakeAdmin(true);
@@ -3023,6 +3036,10 @@ namespace BTCPayServer.Tests
client = await guest.CreateClient(Policies.CanUseLightningNodeInStore); client = await guest.CreateClient(Policies.CanUseLightningNodeInStore);
// Can use lightning node is only granted to store's owner // Can use lightning node is only granted to store's owner
await AssertPermissionError("btcpay.store.canuselightningnode", () => client.GetLightningNodeInfo(user.StoreId, "BTC")); 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)] [Fact(Timeout = 60 * 20 * 1000)]
@@ -3538,8 +3555,7 @@ namespace BTCPayServer.Tests
}); });
var overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode); var overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode);
Assert.Equal(0m, overview.Balance); Assert.Equal(0m, overview.Balance);
var fee = await client.GetOnChainFeeRate(walletId.StoreId, walletId.CryptoCode); var fee = await client.GetOnChainFeeRate(walletId.StoreId, walletId.CryptoCode);
Assert.NotNull(fee.FeeRate); Assert.NotNull(fee.FeeRate);
@@ -3585,6 +3601,17 @@ namespace BTCPayServer.Tests
overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode); overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode);
Assert.Equal(0.01m, overview.Balance); 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: //the simplest request:
var nodeAddress = await tester.ExplorerNode.GetNewAddressAsync(); var nodeAddress = await tester.ExplorerNode.GetNewAddressAsync();
var createTxRequest = new CreateOnChainTransactionRequest() var createTxRequest = new CreateOnChainTransactionRequest()

View File

@@ -42,11 +42,20 @@ if (!window.appSales) {
render(data, period); render(data, period);
} }
}; };
function addEventListeners() {
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
console.log("CHANGED", id)
const type = e.target.value;
await update(type);
});
}
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => { if (document.readyState === "loading") {
const type = e.target.value; window.addEventListener("DOMContentLoaded", addEventListeners);
await update(type); } 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 @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"> <div class="d-flex gap-3 align-items-center justify-content-between mb-2">
<h6 text-translate="true">Lightning Balance</h6> <h6 text-translate="true">Lightning Balance</h6>
@if (Model.CryptoCode != Model.DefaultCurrency && Model.Balance != null) @if (Model.CryptoCode != Model.DefaultCurrency && Model.Balance != null)
@@ -128,12 +132,32 @@
</div> </div>
} }
</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"> <div class="ct-chart"></div>
<vc:icon symbol="caret-down"/> <template>
<span class="ms-1" text-translate="true">Details</span> @Safe.Json(Model)
</button> </template>
} }
} }
else else
@@ -143,47 +167,18 @@
<span class="visually-hidden" text-translate="true">Loading...</span> <span class="visually-hidden" text-translate="true">Loading...</span>
</div> </div>
</div> </div>
<script src="~/Components/StoreLightningBalance/Default.cshtml.js" asp-append-version="true"></script>
<script> <script>
(async () => { (async () => {
const url = @Safe.Json(Url.Action("LightningBalance", "UIStores", new { storeId = Model.Store.Id, cryptoCode = Model.CryptoCode })); const url = @Safe.Json(Model.DataUrl);
const storeId = @Safe.Json(Model.Store.Id); const storeId = @Safe.Json(Model.StoreId);
const response = await fetch(url); const response = await fetch(url);
if (response.ok) { if (response.ok) {
document.getElementById(`StoreLightningBalance-${storeId}`).outerHTML = await response.text(); 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> </script>
} }
</div> </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;
using System.Linq; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security; using BTCPayServer.Security;
@@ -18,11 +16,14 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreLightningBalance; namespace BTCPayServer.Components.StoreLightningBalance;
public class StoreLightningBalance : ViewComponent public class StoreLightningBalance : ViewComponent
{ {
private const HistogramType DefaultType = HistogramType.Week;
private readonly StoreRepository _storeRepo; private readonly StoreRepository _storeRepo;
private readonly CurrencyNameTable _currencies; private readonly CurrencyNameTable _currencies;
private readonly BTCPayServerOptions _btcpayServerOptions; private readonly BTCPayServerOptions _btcpayServerOptions;
@@ -32,6 +33,7 @@ public class StoreLightningBalance : ViewComponent
private readonly IOptions<ExternalServicesOptions> _externalServiceOptions; private readonly IOptions<ExternalServicesOptions> _externalServiceOptions;
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly PaymentMethodHandlerDictionary _handlers; private readonly PaymentMethodHandlerDictionary _handlers;
private readonly LightningHistogramService _lnHistogramService;
public StoreLightningBalance( public StoreLightningBalance(
StoreRepository storeRepo, StoreRepository storeRepo,
@@ -42,7 +44,8 @@ public class StoreLightningBalance : ViewComponent
IOptions<LightningNetworkOptions> lightningNetworkOptions, IOptions<LightningNetworkOptions> lightningNetworkOptions,
IOptions<ExternalServicesOptions> externalServiceOptions, IOptions<ExternalServicesOptions> externalServiceOptions,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
PaymentMethodHandlerDictionary handlers) PaymentMethodHandlerDictionary handlers,
LightningHistogramService lnHistogramService)
{ {
_storeRepo = storeRepo; _storeRepo = storeRepo;
_currencies = currencies; _currencies = currencies;
@@ -53,31 +56,32 @@ public class StoreLightningBalance : ViewComponent
_handlers = handlers; _handlers = handlers;
_lightningClientFactory = lightningClientFactory; _lightningClientFactory = lightningClientFactory;
_lightningNetworkOptions = lightningNetworkOptions; _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) var defaultCurrency = store.GetStoreBlob().DefaultCurrency;
throw new ArgumentNullException(nameof(vm.Store)); var vm = new StoreLightningBalanceViewModel
if (vm.CryptoCode == null) {
throw new ArgumentNullException(nameof(vm.CryptoCode)); 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; if (vm.InitialRendering)
vm.CurrencyData = _currencies.GetCurrencyData(vm.DefaultCurrency, true); return View(vm);
try try
{ {
var lightningClient = await GetLightningClient(vm.Store, vm.CryptoCode); var lightningClient = await GetLightningClient(store, vm.CryptoCode);
if (lightningClient == null)
{
vm.InitialRendering = false;
return View(vm);
}
if (vm.InitialRendering) // balance
return View(vm); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var balance = await lightningClient.GetBalance(cts.Token);
var balance = await lightningClient.GetBalance();
vm.Balance = balance; vm.Balance = balance;
vm.TotalOnchain = balance.OnchainBalance != null vm.TotalOnchain = balance.OnchainBalance != null
? (balance.OnchainBalance.Confirmed ?? 0L) + (balance.OnchainBalance.Reserved ?? 0L) + ? (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.Opening ?? 0) + (balance.OffchainBalance.Local ?? 0) +
(balance.OffchainBalance.Closing ?? 0) (balance.OffchainBalance.Closing ?? 0)
: null; : 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) catch (Exception ex) when (ex is NotImplementedException or NotSupportedException)
{ {
// not all implementations support balance fetching // not all implementations support balance fetching
@@ -102,7 +114,7 @@ public class StoreLightningBalance : ViewComponent
return View(vm); 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 network = _networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var id = PaymentTypes.LN.GetPaymentMethodId(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.Lightning;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using NBitcoin; using NBitcoin;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreLightningBalance; namespace BTCPayServer.Components.StoreLightningBalance;
public class StoreLightningBalanceViewModel public class StoreLightningBalanceViewModel
{ {
public string StoreId { get; set; }
public string CryptoCode { get; set; } public string CryptoCode { get; set; }
public string DefaultCurrency { get; set; } public string DefaultCurrency { get; set; }
public CurrencyData CurrencyData { get; set; } public CurrencyData CurrencyData { get; set; }
public StoreData Store { get; set; }
public Money TotalOnchain { get; set; } public Money TotalOnchain { get; set; }
public LightMoney TotalOffchain { get; set; } public LightMoney TotalOffchain { get; set; }
public LightningNodeBalance Balance { get; set; } public LightningNodeBalance Balance { get; set; }
public string ProblemDescription { 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.Abstractions.TagHelpers
@using BTCPayServer.Payments @using BTCPayServer.Client.Models
@using BTCPayServer.TagHelpers
@model BTCPayServer.Components.StoreWalletBalance.StoreWalletBalanceViewModel @model BTCPayServer.Components.StoreWalletBalance.StoreWalletBalanceViewModel
@inject BTCPayNetworkProvider NetworkProvider <div id="StoreWalletBalance-@Model.StoreId" class="widget store-wallet-balance">
<div id="StoreWalletBalance-@Model.Store.Id" class="widget store-wallet-balance">
<div class="d-flex gap-3 align-items-center justify-content-between mb-2"> <div class="d-flex gap-3 align-items-center justify-content-between mb-2">
<h6 text-translate="true">Wallet Balance</h6> <h6 text-translate="true">Wallet Balance</h6>
@if (Model.CryptoCode != Model.DefaultCurrency) @if (Model.CryptoCode != Model.DefaultCurrency)
@@ -26,12 +26,12 @@
@if (Model.Series != null) @if (Model.Series != null)
{ {
<div class="btn-group only-for-js mt-1" role="group" aria-label="Period"> <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" : "")> <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.Store.Id">1W</label> <label class="btn btn-link" for="StoreWalletBalancePeriodWeek-@Model.StoreId">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" : "")> <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.Store.Id">1M</label> <label class="btn btn-link" for="StoreWalletBalancePeriodMonth-@Model.StoreId">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" : "")> <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.Store.Id">1Y</label> <label class="btn btn-link" for="StoreWalletBalancePeriodYear-@Model.StoreId">1Y</label>
</div> </div>
} }
</header> </header>
@@ -39,10 +39,10 @@
{ {
<div class="ct-chart"></div> <div class="ct-chart"></div>
} }
else if (Model.Store.GetPaymentMethodConfig(PaymentTypes.CHAIN.GetPaymentMethodId(Model.CryptoCode)) is null) else if (Model.MissingWalletConfig)
{ {
<p> <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> </p>
} }
else else
@@ -55,66 +55,65 @@
} }
<script> <script>
(function () { (function () {
const storeId = @Safe.Json(Model.Store.Id); const storeId = @Safe.Json(Model.StoreId);
const cryptoCode = @Safe.Json(Model.CryptoCode); const cryptoCode = @Safe.Json(Model.CryptoCode);
const defaultCurrency = @Safe.Json(Model.DefaultCurrency); const defaultCurrency = @Safe.Json(Model.DefaultCurrency);
const divisibility = @Safe.Json(Model.CurrencyData.Divisibility); const divisibility = @Safe.Json(Model.CurrencyData.Divisibility);
let data = { series: @Safe.Json(Model.Series), labels: @Safe.Json(Model.Labels), balance: @Safe.Json(Model.Balance) }; let data = { series: @Safe.Json(Model.Series), labels: @Safe.Json(Model.Labels), balance: @Safe.Json(Model.Balance) };
let rate = null; let rate = null;
const id = `StoreWalletBalance-${storeId}`; const id = `StoreWalletBalance-${storeId}`;
const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = WalletHistogramType.Week })); const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = HistogramType.Week }));
const valueTransform = value => rate const valueTransform = value => rate ? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility) : value
? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility).toString() const labelCount = 6
: value 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 = { const chartOpts = {
fullWidth: true, fullWidth: true,
showArea: true, showArea: true,
axisY: { axisY: {
labelInterpolationFnc: valueTransform showLabel: false,
} offset: 0
},
plugins: [tooltip]
}; };
const render = data => { const render = data => {
let { series, labels } = data; let { series, labels } = data;
const currency = rate ? defaultCurrency : cryptoCode; const currency = rate ? defaultCurrency : cryptoCode;
document.querySelectorAll(`#${id} .currency`).forEach(c => c.innerText = currency) document.querySelectorAll(`#${id} .currency`).forEach(c => c.innerText = currency)
document.querySelectorAll(`#${id} [data-balance]`).forEach(c => { document.querySelectorAll(`#${id} [data-balance]`).forEach(c => {
const value = Number.parseFloat(c.dataset.balance); const value = Number.parseFloat(c.dataset.balance);
c.innerText = valueTransform(value) c.innerText = valueTransform(value)
}); });
if (!series) return; if (!series) return;
const min = Math.min(...series); const min = Math.min(...series);
const max = Math.max(...series); const max = Math.max(...series);
const low = Math.max(min - ((max - min) / 5), 0); const low = Math.max(min - ((max - min) / 5), 0);
const tooltip = Chartist.plugins.tooltip2({ const renderOpts = Object.assign({}, chartOpts, { low, axisX: {
template: '<div class="chartist-tooltip-value">{{value}}</div><div class="chartist-tooltip-line"></div>', labelInterpolationFnc(date, i) {
offset: { return i % labelEvery === 0 ? dateFormatter.format(new Date(date)) : null
x: 0, }
y: -16 } });
}, const pointCount = series.length;
valueTransformFunction: valueTransform const labelEvery = pointCount / labelCount;
}) new Chartist.Line(`#${id} .ct-chart`, {
const renderOpts = Object.assign({}, chartOpts, { low, plugins: [tooltip] }); labels: labels,
const chart = new Chartist.Line(`#${id} .ct-chart`, {
labels,
series: [series] series: [series]
}, renderOpts); }, 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 => { const update = async type => {
const url = baseUrl.replace(/\/week$/gi, `/${type}`); const url = baseUrl.replace(/\/week$/gi, `/${type}`);
const response = await fetch(url); const response = await fetch(url);
@@ -123,10 +122,10 @@
render(data); render(data);
} }
}; };
render(data); render(data);
document.addEventListener('DOMContentLoaded', () => { function addEventListeners() {
delegate('change', `#${id} [name="StoreWalletBalancePeriod-${storeId}"]`, async e => { delegate('change', `#${id} [name="StoreWalletBalancePeriod-${storeId}"]`, async e => {
const type = e.target.value; const type = e.target.value;
await update(type); await update(type);
@@ -141,7 +140,13 @@
render(data); render(data);
} }
}); });
}); }
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", addEventListeners);
} else {
addEventListeners();
}
})(); })();
</script> </script>
</div> </div>

View File

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

View File

@@ -1,19 +1,21 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Data; using BTCPayServer.Client.Models;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets; using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreWalletBalance; namespace BTCPayServer.Components.StoreWalletBalance;
public class StoreWalletBalanceViewModel public class StoreWalletBalanceViewModel
{ {
public string StoreId { get; set; }
public decimal? Balance { get; set; } public decimal? Balance { get; set; }
public string CryptoCode { get; set; } public string CryptoCode { get; set; }
public string DefaultCurrency { get; set; } public string DefaultCurrency { get; set; }
public CurrencyData CurrencyData { get; set; } public CurrencyData CurrencyData { get; set; }
public StoreData Store { get; set; }
public WalletId WalletId { get; set; } public WalletId WalletId { get; set; }
public WalletHistogramType Type { get; set; } public HistogramType Type { get; set; }
public IList<string> Labels { get; set; } public IList<DateTimeOffset> Labels { get; set; }
public IList<decimal> Series { 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;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -31,8 +28,9 @@ namespace BTCPayServer.Controllers.Greenfield
PoliciesSettings policiesSettings, LightningClientFactoryService lightningClientFactory, PoliciesSettings policiesSettings, LightningClientFactoryService lightningClientFactory,
IOptions<LightningNetworkOptions> lightningNetworkOptions, IOptions<LightningNetworkOptions> lightningNetworkOptions,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
PaymentMethodHandlerDictionary handlers PaymentMethodHandlerDictionary handlers,
) : base(policiesSettings, authorizationService, handlers) LightningHistogramService lnHistogramService
) : base(policiesSettings, authorizationService, handlers, lnHistogramService)
{ {
_lightningClientFactory = lightningClientFactory; _lightningClientFactory = lightningClientFactory;
_lightningNetworkOptions = lightningNetworkOptions; _lightningNetworkOptions = lightningNetworkOptions;
@@ -55,6 +53,14 @@ namespace BTCPayServer.Controllers.Greenfield
return base.GetBalance(cryptoCode, cancellationToken); 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, [Authorize(Policy = Policies.CanUseInternalLightningNode,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/server/lightning/{cryptoCode}/connect")] [HttpPost("~/api/v1/server/lightning/{cryptoCode}/connect")]

View File

@@ -1,8 +1,6 @@
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
@@ -34,7 +32,8 @@ namespace BTCPayServer.Controllers.Greenfield
IOptions<LightningNetworkOptions> lightningNetworkOptions, IOptions<LightningNetworkOptions> lightningNetworkOptions,
LightningClientFactoryService lightningClientFactory, PaymentMethodHandlerDictionary handlers, LightningClientFactoryService lightningClientFactory, PaymentMethodHandlerDictionary handlers,
PoliciesSettings policiesSettings, PoliciesSettings policiesSettings,
IAuthorizationService authorizationService) : base(policiesSettings, authorizationService, handlers) IAuthorizationService authorizationService,
LightningHistogramService lnHistogramService) : base(policiesSettings, authorizationService, handlers, lnHistogramService)
{ {
_lightningNetworkOptions = lightningNetworkOptions; _lightningNetworkOptions = lightningNetworkOptions;
_lightningClientFactory = lightningClientFactory; _lightningClientFactory = lightningClientFactory;
@@ -56,6 +55,13 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return base.GetBalance(cryptoCode, cancellationToken); 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, [Authorize(Policy = Policies.CanUseLightningNodeInStore,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -64,6 +70,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return base.ConnectToNode(cryptoCode, request, cancellationToken); return base.ConnectToNode(cryptoCode, request, cancellationToken);
} }
[Authorize(Policy = Policies.CanUseLightningNodeInStore, [Authorize(Policy = Policies.CanUseLightningNodeInStore,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")] [HttpGet("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")]
@@ -71,6 +78,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return base.GetChannels(cryptoCode, cancellationToken); return base.GetChannels(cryptoCode, cancellationToken);
} }
[Authorize(Policy = Policies.CanUseLightningNodeInStore, [Authorize(Policy = Policies.CanUseLightningNodeInStore,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")] [HttpPost("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")]

View File

@@ -11,6 +11,7 @@ using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
@@ -32,15 +33,18 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly PoliciesSettings _policiesSettings; private readonly PoliciesSettings _policiesSettings;
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly PaymentMethodHandlerDictionary _handlers; private readonly PaymentMethodHandlerDictionary _handlers;
private readonly LightningHistogramService _lnHistogramService;
protected GreenfieldLightningNodeApiController( protected GreenfieldLightningNodeApiController(
PoliciesSettings policiesSettings, PoliciesSettings policiesSettings,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
PaymentMethodHandlerDictionary handlers) PaymentMethodHandlerDictionary handlers,
LightningHistogramService lnHistogramService)
{ {
_policiesSettings = policiesSettings; _policiesSettings = policiesSettings;
_authorizationService = authorizationService; _authorizationService = authorizationService;
_handlers = handlers; _handlers = handlers;
_lnHistogramService = lnHistogramService;
} }
public virtual async Task<IActionResult> GetInfo(string cryptoCode, CancellationToken cancellationToken = default) public virtual async Task<IActionResult> GetInfo(string cryptoCode, CancellationToken cancellationToken = default)
@@ -86,6 +90,22 @@ namespace BTCPayServer.Controllers.Greenfield
: null : null
}); });
} }
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) public virtual async Task<IActionResult> ConnectToNode(string cryptoCode, ConnectToNodeRequest request, CancellationToken cancellationToken = default)
{ {

View File

@@ -2,7 +2,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -20,7 +19,6 @@ using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Payments.PayJoin.Sender; using BTCPayServer.Payments.PayJoin.Sender;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
@@ -58,6 +56,7 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly IFeeProviderFactory _feeProviderFactory; private readonly IFeeProviderFactory _feeProviderFactory;
private readonly UTXOLocker _utxoLocker; private readonly UTXOLocker _utxoLocker;
private readonly TransactionLinkProviders _transactionLinkProviders; private readonly TransactionLinkProviders _transactionLinkProviders;
private readonly WalletHistogramService _walletHistogramService;
public GreenfieldStoreOnChainWalletsController( public GreenfieldStoreOnChainWalletsController(
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
@@ -74,6 +73,7 @@ namespace BTCPayServer.Controllers.Greenfield
WalletReceiveService walletReceiveService, WalletReceiveService walletReceiveService,
IFeeProviderFactory feeProviderFactory, IFeeProviderFactory feeProviderFactory,
UTXOLocker utxoLocker, UTXOLocker utxoLocker,
WalletHistogramService walletHistogramService,
TransactionLinkProviders transactionLinkProviders TransactionLinkProviders transactionLinkProviders
) )
{ {
@@ -91,6 +91,7 @@ namespace BTCPayServer.Controllers.Greenfield
_walletReceiveService = walletReceiveService; _walletReceiveService = walletReceiveService;
_feeProviderFactory = feeProviderFactory; _feeProviderFactory = feeProviderFactory;
_utxoLocker = utxoLocker; _utxoLocker = utxoLocker;
_walletHistogramService = walletHistogramService;
_transactionLinkProviders = transactionLinkProviders; _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)] [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate")] [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate")]
public async Task<IActionResult> GetOnChainFeeRate(string storeId, string paymentMethodId, int? blockTarget = null) 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.Data;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield; using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NBitcoin; using NBitcoin;
using NBXplorer.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using InvoiceData = BTCPayServer.Client.Models.InvoiceData; using InvoiceData = BTCPayServer.Client.Models.InvoiceData;
using Language = BTCPayServer.Client.Models.Language; using Language = BTCPayServer.Client.Models.Language;
@@ -385,6 +382,13 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldStoreLightningNodeApiController>().GetBalance(cryptoCode, token)); 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, public override async Task ConnectToLightningNode(string storeId, string cryptoCode,
ConnectToNodeRequest request, CancellationToken token = default) ConnectToNodeRequest request, CancellationToken token = default)
{ {
@@ -461,6 +465,13 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldInternalLightningNodeApiController>().GetBalance(cryptoCode)); 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, public override async Task ConnectToLightningNode(string cryptoCode, ConnectToNodeRequest request,
CancellationToken token = default) CancellationToken token = default)
{ {
@@ -702,6 +713,12 @@ namespace BTCPayServer.Controllers.Greenfield
return GetFromActionResult<OnChainWalletOverviewData>( return GetFromActionResult<OnChainWalletOverviewData>(
await GetController<GreenfieldStoreOnChainWalletsController>().ShowOnChainWalletOverview(storeId, cryptoCode)); 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, public override async Task<OnChainWalletAddressData> GetOnChainWalletReceiveAddress(string storeId,
string cryptoCode, bool forceGenerate = false, string cryptoCode, bool forceGenerate = false,

View File

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

View File

@@ -6,15 +6,20 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Components.StoreLightningBalance;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers; namespace BTCPayServer.Controllers;
@@ -82,6 +87,32 @@ public partial class UIStoresController
return View(vm); 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")] [HttpGet("{storeId}/lightning/{cryptoCode}/setup")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult SetupLightningNode(string storeId, string cryptoCode) public IActionResult SetupLightningNode(string storeId, string cryptoCode)
@@ -321,4 +352,26 @@ public partial class UIStoresController
{ {
return store.GetPaymentMethodConfig<T>(paymentMethodId, _handlers); 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 #nullable enable
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
@@ -64,7 +63,9 @@ public partial class UIStoresController : Controller
SettingsRepository settingsRepository, SettingsRepository settingsRepository,
CurrencyNameTable currencyNameTable, CurrencyNameTable currencyNameTable,
IStringLocalizer stringLocalizer, IStringLocalizer stringLocalizer,
EventAggregator eventAggregator) EventAggregator eventAggregator,
LightningHistogramService lnHistogramService,
LightningClientFactoryService lightningClientFactory)
{ {
_rateFactory = rateFactory; _rateFactory = rateFactory;
_storeRepo = storeRepo; _storeRepo = storeRepo;
@@ -95,6 +96,8 @@ public partial class UIStoresController : Controller
_dataProtector = dataProtector.CreateProtector("ConfigProtector"); _dataProtector = dataProtector.CreateProtector("ConfigProtector");
_webhookNotificationManager = webhookNotificationManager; _webhookNotificationManager = webhookNotificationManager;
_lightningNetworkOptions = lightningNetworkOptions.Value; _lightningNetworkOptions = lightningNetworkOptions.Value;
_lnHistogramService = lnHistogramService;
_lightningClientFactory = lightningClientFactory;
StringLocalizer = stringLocalizer; StringLocalizer = stringLocalizer;
} }
@@ -127,6 +130,8 @@ public partial class UIStoresController : Controller
private readonly WebhookSender _webhookNotificationManager; private readonly WebhookSender _webhookNotificationManager;
private readonly LightningNetworkOptions _lightningNetworkOptions; private readonly LightningNetworkOptions _lightningNetworkOptions;
private readonly IDataProtector _dataProtector; private readonly IDataProtector _dataProtector;
private readonly LightningHistogramService _lnHistogramService;
private readonly LightningClientFactoryService _lightningClientFactory;
public string? GeneratedPairingCode { get; set; } public string? GeneratedPairingCode { get; set; }
public IStringLocalizer StringLocalizer { get; } public IStringLocalizer StringLocalizer { get; }

View File

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

View File

@@ -173,6 +173,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<UserService>(); services.TryAddSingleton<UserService>();
services.TryAddSingleton<UriResolver>(); services.TryAddSingleton<UriResolver>();
services.TryAddSingleton<WalletHistogramService>(); services.TryAddSingleton<WalletHistogramService>();
services.TryAddSingleton<LightningHistogramService>();
services.AddSingleton<ApplicationDbContextFactory>(); services.AddSingleton<ApplicationDbContextFactory>();
services.AddOptions<BTCPayServerOptions>().Configure( services.AddOptions<BTCPayServerOptions>().Configure(
(options) => (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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.JsonConverters; using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using Dapper; using Dapper;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Services.Wallets; namespace BTCPayServer.Services.Wallets;
public enum WalletHistogramType
{
Week,
Month,
Year
}
public class WalletHistogramService public class WalletHistogramService
{ {
private readonly PaymentMethodHandlerDictionary _handlers; private readonly PaymentMethodHandlerDictionary _handlers;
@@ -29,7 +21,7 @@ public class WalletHistogramService
_connectionFactory = connectionFactory; _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 // https://github.com/dgarage/NBXplorer/blob/master/docs/Postgres-Schema.md
if (_connectionFactory.Available) if (_connectionFactory.Available)
@@ -43,13 +35,15 @@ public class WalletHistogramService
var code = walletId.CryptoCode; var code = walletId.CryptoCode;
var to = DateTimeOffset.UtcNow; var to = DateTimeOffset.UtcNow;
var labelCount = 6; var (days, pointCount) = type switch
(var days, var pointCount) = type switch
{ {
WalletHistogramType.Week => (7, 30), HistogramType.Day => (1, 30),
WalletHistogramType.Month => (30, 30), HistogramType.Week => (7, 30),
WalletHistogramType.Year => (365, 30), HistogramType.Month => (30, 30),
_ => throw new ArgumentException($"WalletHistogramType {type} does not exist.") 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 from = to - TimeSpan.FromDays(days);
var interval = TimeSpan.FromTicks((to - from).Ticks / pointCount); var interval = TimeSpan.FromTicks((to - from).Ticks / pointCount);
@@ -60,18 +54,15 @@ public class WalletHistogramService
new { code, wallet_id, from, to, interval }); new { code, wallet_id, from, to, interval });
var data = rows.AsList(); var data = rows.AsList();
var series = new List<decimal>(pointCount); var series = new List<decimal>(pointCount);
var labels = new List<string>(labelCount); var labels = new List<DateTimeOffset>(pointCount);
var labelEvery = pointCount / labelCount;
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
{ {
var r = data[i]; var r = data[i];
series.Add((decimal)r.balance); series.Add((decimal)r.balance);
labels.Add((i % labelEvery == 0) labels.Add((DateTimeOffset)r.date);
? ((DateTime)r.date).ToString("MMM dd", CultureInfo.InvariantCulture)
: null);
} }
series[^1] = balance; series[^1] = balance;
return new WalletHistogramData return new HistogramData
{ {
Series = series, Series = series,
Labels = labels, Labels = labels,
@@ -84,11 +75,3 @@ public class WalletHistogramService
return null; 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 })" /> <vc:store-numbers vm="@(new StoreNumbersViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })" />
@if (Model.LightningEnabled) @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" /> <vc:store-lightning-services vm="@(new StoreLightningServicesViewModel { Store = store, CryptoCode = Model.CryptoCode })" permission="@Policies.CanModifyServerSettings" />
} }
@if (Model.WalletEnabled) @if (Model.WalletEnabled)

View File

@@ -115,6 +115,49 @@
"type": "string", "type": "string",
"description": "Payout method IDs. Available payment method IDs for Bitcoin are: \n- `\"BTC-CHAIN\"`: Onchain \n-`\"BTC-LN\"`: Lightning", "description": "Payout method IDs. Available payment method IDs for Bitcoin are: \n- `\"BTC-CHAIN\"`: Onchain \n-`\"BTC-LN\"`: Lightning",
"example": "BTC-LN" "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": { "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": { "/api/v1/server/lightning/{cryptoCode}/connect": {
"post": { "post": {
"tags": [ "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": { "/api/v1/stores/{storeId}/lightning/{cryptoCode}/connect": {
"post": { "post": {
"tags": [ "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": { "/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate": {
"get": { "get": {
"tags": [ "tags": [