diff --git a/BTCPayServer.Client/Models/StoreBaseData.cs b/BTCPayServer.Client/Models/StoreBaseData.cs index 4799b1dc7..704df4119 100644 --- a/BTCPayServer.Client/Models/StoreBaseData.cs +++ b/BTCPayServer.Client/Models/StoreBaseData.cs @@ -46,6 +46,7 @@ namespace BTCPayServer.Client.Models public double? PaymentTolerance { get; set; } public bool? AnyoneCanCreateInvoice { get; set; } public string DefaultCurrency { get; set; } + public List AdditionalTrackedRates { get; set; } public bool? LightningAmountInSatoshi { get; set; } public bool? LightningPrivateRouteHints { get; set; } diff --git a/BTCPayServer.Tests/CSVInvoicesTester.cs b/BTCPayServer.Tests/CSVInvoicesTester.cs new file mode 100644 index 000000000..358499096 --- /dev/null +++ b/BTCPayServer.Tests/CSVInvoicesTester.cs @@ -0,0 +1,46 @@ +using System.Linq; +using Xunit; + +namespace BTCPayServer.Tests; + +internal class CSVInvoicesTester(string text) : CSVTester(text) +{ + string invoice = ""; + int payment = 0; + + public CSVInvoicesTester ForInvoice(string invoice) + { + this.payment = 0; + this.invoice = invoice; + return this; + } + public CSVInvoicesTester SelectPayment(int payment) + { + this.payment = payment; + return this; + } + public CSVInvoicesTester AssertCount(int count) + { + Assert.Equal(count, _lines + .Count(l => l[_indexes["InvoiceId"]] == invoice)); + return this; + } + + public CSVInvoicesTester AssertValues(params (string, string)[] values) + { + var payments = _lines + .Where(l => l[_indexes["InvoiceId"]] == invoice) + .ToArray(); + var line = payments[payment]; + foreach (var (key, value) in values) + { + Assert.Equal(value, line[_indexes[key]]); + } + return this; + } + + public string GetPaymentId() => _lines + .Where(l => l[_indexes["InvoiceId"]] == invoice) + .Select(l => l[_indexes["PaymentId"]]) + .FirstOrDefault(); +} diff --git a/BTCPayServer.Tests/CSVTester.cs b/BTCPayServer.Tests/CSVTester.cs new file mode 100644 index 000000000..4c1325510 --- /dev/null +++ b/BTCPayServer.Tests/CSVTester.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Linq; + +namespace BTCPayServer.Tests; + +public class CSVTester +{ + protected readonly Dictionary _indexes; + protected readonly List _lines; + + public CSVTester(string text) + { + var lines = text.Split("\r\n").ToList(); + var headers = lines[0].Split(','); + _indexes = headers.Select((h,i) => (h,i)).ToDictionary(h => h.h, h => h.i); + _lines = lines.Skip(1).ToList().Select(l => l.Split(',')).ToList(); + } +} diff --git a/BTCPayServer.Tests/POSTests.cs b/BTCPayServer.Tests/POSTests.cs index c40d2c5a7..cfd182d72 100644 --- a/BTCPayServer.Tests/POSTests.cs +++ b/BTCPayServer.Tests/POSTests.cs @@ -220,16 +220,10 @@ fruit tea: } await s.GoToInvoices(s.StoreId); - await s.Page.ClickAsync("#view-report"); + await s.ClickViewReport(); - await s.Page.WaitForSelectorAsync($"xpath=//*[text()=\"{freeInvoiceId}\"]"); - - var download = await s.Page.RunAndWaitForDownloadAsync(async () => - { - await s.ClickPagePrimary(); - }); - var csvTxt = await new StreamReader(await download.CreateReadStreamAsync()).ReadToEndAsync(); - var csvTester = CSVTester.ParseCSV(csvTxt); + var csvTxt = await s.DownloadReportCSV(); + var csvTester = new CSVInvoicesTester(csvTxt); csvTester .ForInvoice(posInvoiceId) .AssertCount(2) @@ -275,61 +269,13 @@ fruit tea: ("PaymentCurrency", "BTC"), ("PaymentAmount", "0.40000000"), ("PaymentInvoiceAmount", "2000.00"), - ("Rate", "5000")) + ("PaymentRate", "5000")) .SelectPayment(1) .AssertValues( ("InvoiceStatus", ""), ("PaymentCurrency", "BTC"), ("PaymentAmount", "0.60000000"), - ("Rate", "5000")); - } - - class CSVTester - { - public static CSVTester ParseCSV(string csvText) => new(csvText); - private readonly Dictionary _indexes; - private string invoice = ""; - private int payment = 0; - private readonly List _lines; - - public CSVTester(string text) - { - var lines = text.Split("\r\n").ToList(); - var headers = lines[0].Split(','); - _indexes = headers.Select((h,i) => (h,i)).ToDictionary(h => h.h, h => h.i); - _lines = lines.Skip(1).ToList().Select(l => l.Split(',')).ToList(); - } - - public CSVTester ForInvoice(string invoice) - { - this.payment = 0; - this.invoice = invoice; - return this; - } - public CSVTester SelectPayment(int payment) - { - this.payment = payment; - return this; - } - public CSVTester AssertCount(int count) - { - Assert.Equal(count, _lines - .Count(l => l[_indexes["InvoiceId"]] == invoice)); - return this; - } - - public CSVTester AssertValues(params (string, string)[] values) - { - var payments = _lines - .Where(l => l[_indexes["InvoiceId"]] == invoice) - .ToArray(); - var line = payments[payment]; - foreach (var (key, value) in values) - { - Assert.Equal(value, line[_indexes[key]]); - } - return this; - } + ("PaymentRate", "5000")); } [Fact(Timeout = LongRunningTestTimeout)] diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs index ce583d053..b84f34467 100644 --- a/BTCPayServer.Tests/PlaywrightTester.cs +++ b/BTCPayServer.Tests/PlaywrightTester.cs @@ -375,6 +375,14 @@ namespace BTCPayServer.Tests public async Task AddDerivationScheme(string cryptoCode = "BTC", string derivationScheme = "tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]") { + if (cryptoCode != "BTC" && derivationScheme == + "tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]") + { + derivationScheme = new BitcoinExtPubKey("tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu", Network.RegTest) + .ToNetwork(NBitcoin.Altcoins.Litecoin.Instance.Regtest) + .ToString()! + "-[legacy]"; + } + if (!(await Page.ContentAsync()).Contains($"Setup {cryptoCode} Wallet")) await GoToWalletSettings(cryptoCode); @@ -651,6 +659,12 @@ namespace BTCPayServer.Tests public Task BumpFee(uint256? txId = null) => Page.ClickAsync($"{TxRowSelector(txId)} .bumpFee-btn"); static string TxRowSelector(uint256? txId = null) => txId is null ? ".transaction-row:first-of-type" : $".transaction-row[data-value=\"{txId}\"]"; + public async Task AssertRowContains(uint256 txId, string expected) + { + var text = await Page.InnerTextAsync(TxRowSelector(txId)); + Assert.Contains(expected.NormalizeWhitespaces(), text.NormalizeWhitespaces()); + } + public Task AssertHasLabels(string label) => AssertHasLabels(null, label); public async Task AssertHasLabels(uint256? txId, string label) { @@ -733,5 +747,20 @@ namespace BTCPayServer.Tests } public async Task Broadcast() => await page.ClickAsync("#BroadcastTransaction"); } + + public async Task ClickViewReport() + { + await Page.ClickAsync("#view-report"); + await Page.WaitForSelectorAsync("#raw-data-table"); + } + + public async Task DownloadReportCSV() + { + var download = await Page.RunAndWaitForDownloadAsync(async () => + { + await ClickPagePrimary(); + }); + return await new StreamReader(await download.CreateReadStreamAsync()).ReadToEndAsync(); + } } } diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs index ebfd0f2af..272c96eb4 100644 --- a/BTCPayServer.Tests/PlaywrightTests.cs +++ b/BTCPayServer.Tests/PlaywrightTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using BTCPayServer.Abstractions.Models; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Payments; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; @@ -15,11 +16,15 @@ using BTCPayServer.Views.Manage; using BTCPayServer.Views.Server; using BTCPayServer.Views.Stores; using BTCPayServer.Views.Wallets; +using Dapper; +using ExchangeSharp; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Playwright; using static Microsoft.Playwright.Assertions; using NBitcoin; using NBitcoin.Payment; +using NBXplorer; using NBXplorer.Models; using Newtonsoft.Json.Linq; using Xunit; @@ -592,6 +597,185 @@ namespace BTCPayServer.Tests Assert.Equal(TimeSpan.FromMinutes(15), newStore.InvoiceExpiration); } + [Fact] + [Trait("Altcoins", "Altcoins")] + public async Task CanExposeRates() + { + await using var s = CreatePlaywrightTester(); + s.Server.ActivateLTC(); + await s.StartAsync(); + await s.RegisterNewUser(true); + await s.CreateNewStore(); + + await s.AddDerivationScheme("BTC", new ExtKey().Neuter().GetWif(Network.RegTest).ToString() + "-[legacy]"); + await s.AddDerivationScheme("LTC", new ExtKey().Neuter().GetWif(NBitcoin.Altcoins.Litecoin.Instance.Regtest).ToString() + "-[legacy]"); + + await s.GoToStore(); + await s.Page.FillAsync("[name='DefaultCurrency']", "USD"); + await s.Page.FillAsync("[name='AdditionalTrackedRates']", "CAD,JPY,EUR"); + await s.ClickPagePrimary(); + + await s.GoToStore(StoreNavPages.Rates); + await s.Page.ClickAsync($"#PrimarySource_ShowScripting_submit"); + await s.FindAlertMessage(); + + // BTC can solves USD,EUR,CAD + // LTC can solves and JPY and USD + await s.Page.FillAsync("[name='PrimarySource.Script']", + """ + BTC_JPY = bitflyer(BTC_JPY); + + BTC_USD = coingecko(BTC_USD); + BTC_EUR = coingecko(BTC_EUR); + BTC_CAD = coingecko(BTC_CAD); + LTC_BTC = coingecko(LTC_BTC); + + LTC_USD = coingecko(LTC_USD); + LTC_JPY = LTC_BTC * BTC_JPY; + """); + await s.ClickPagePrimary(); + var expectedSolvablePairs = new[] + { + (Crypto: "BTC", Currency: "JPY"), + (Crypto: "BTC", Currency: "USD"), + (Crypto: "BTC", Currency: "CAD"), + (Crypto: "BTC", Currency: "EUR"), + (Crypto: "LTC", Currency: "JPY"), + (Crypto: "LTC", Currency: "USD"), + }; + var expectedUnsolvablePairs = new[] + { + (Crypto: "LTC", Currency: "CAD"), + (Crypto: "LTC", Currency: "EUR"), + }; + + Dictionary txIds = new(); + foreach (var cryptoCode in new[] { "BTC", "LTC" }) + { + await s.Server.GetExplorerNode(cryptoCode).EnsureGenerateAsync(1); + await s.GoToWallet(new(s.StoreId, cryptoCode), WalletsNavPages.Receive); + var address = await s.Page.GetAttributeAsync("#Address", "data-text"); + var network = s.Server.GetNetwork(cryptoCode); + + var txId = uint256.Zero; + await s.Server.WaitForEvent(async () => + { + txId = await s.Server.GetExplorerNode(cryptoCode) + .SendToAddressAsync(BitcoinAddress.Create(address!, network.NBitcoinNetwork), Money.Coins(1)); + }); + txIds.Add(cryptoCode, txId); + // The rates are fetched asynchronously... let's wait it's done. + await Task.Delay(500); + var pmo = await s.GoToWalletTransactions(new(s.StoreId, cryptoCode)); + await pmo.WaitTransactionsLoaded(); + if (cryptoCode == "BTC") + { + await pmo.AssertRowContains(txId, "4,500.00 CAD"); + await pmo.AssertRowContains(txId, "700,000 JPY"); + await pmo.AssertRowContains(txId, "4 000,00 EUR"); + await pmo.AssertRowContains(txId, "5,000.00 USD"); + } + else if (cryptoCode == "LTC") + { + await pmo.AssertRowContains(txId, "4,321 JPY"); + await pmo.AssertRowContains(txId, "500.00 USD"); + } + } + await s.GoToWallet(new(s.StoreId, "BTC"), WalletsNavPages.Transactions); + await s.ClickViewReport(); + + var csvTxt = await s.DownloadReportCSV(); + var csvTester = new CSVWalletsTester(csvTxt); + + foreach (var cryptoCode in new[] { "BTC", "LTC" }) + { + if (cryptoCode == "BTC") + { + csvTester + .ForTxId(txIds[cryptoCode].ToString()) + .AssertValues( + ("Rate (USD)", "5000"), + ("Rate (CAD)", "4500"), + ("Rate (JPY)", "700000"), + ("Rate (EUR)", "4000") + ); + } + else + { + csvTester + .ForTxId(txIds[cryptoCode].ToString()) + .AssertValues( + ("Rate (USD)", "500"), + ("Rate (CAD)", ""), + ("Rate (JPY)", "4320.9876543209875"), + ("Rate (EUR)", "") + ); + } + } + + var invId = await s.CreateInvoice(storeId: s.StoreId, amount: 10_000); + await s.GoToInvoiceCheckout(invId); + await s.PayInvoice(); + await s.GoToInvoices(s.StoreId); + await s.ClickViewReport(); + + await s.Page.ReloadAsync(); + csvTxt = await s.DownloadReportCSV(); + var csvInvTester = new CSVInvoicesTester(csvTxt); + csvInvTester + .ForInvoice(invId) + .AssertValues( + ("Rate (BTC_CAD)", "4500"), + ("Rate (BTC_JPY)", "700000"), + ("Rate (BTC_EUR)", "4000"), + ("Rate (BTC_USD)", "5000"), + ("Rate (LTC_USD)", "500"), + ("Rate (LTC_JPY)", "4320.9876543209875"), + ("Rate (LTC_CAD)", ""), + ("Rate (LTC_EUR)", "") + ); + + var txId2 = new uint256(csvInvTester.GetPaymentId().Split("-")[0]); + var pmo2 = await s.GoToWalletTransactions(new(s.StoreId, "BTC")); + await pmo2.WaitTransactionsLoaded(); + await pmo2.AssertRowContains(txId2, "5,000.00 USD"); + + // When removing the wallet rates, we should still have the rates from the invoice + var ctx = s.Server.PayTester.GetService().CreateContext(); + Assert.Equal(1, await ctx.Database + .GetDbConnection() + .ExecuteAsync(""" + UPDATE "WalletObjects" SET "Data"='{}'::JSONB WHERE "Id"=@txId + """, new{ txId = txId2.ToString() })); + + pmo2 = await s.GoToWalletTransactions(new(s.StoreId, "BTC")); + await pmo2.WaitTransactionsLoaded(); + await pmo2.AssertRowContains(txId2, "5,000.00 USD"); + } + + class CSVWalletsTester(string text) : CSVTester(text) + { + string txId = ""; + + public CSVWalletsTester ForTxId(string txId) + { + this.txId = txId; + return this; + } + + public CSVWalletsTester AssertValues(params (string, string)[] values) + { + var line = _lines + .First(l => l[_indexes["TransactionId"]] == txId); + foreach (var (key, value) in values) + { + Assert.Equal(value, line[_indexes[key]]); + } + return this; + } + } + + [Fact] public async Task CanManageWallet() { diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 528cf00f9..cfff82a43 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -275,5 +275,16 @@ namespace BTCPayServer.Tests PayTester.Dispose(); TestLogs.LogInformation("BTCPayTester disposed"); } + + public RPCClient GetExplorerNode(string cryptoCode) => + cryptoCode == "BTC" ? ExplorerNode : + cryptoCode == "LTC" ? LTCExplorerNode : + throw new NotSupportedException(); + + public BTCPayNetwork GetNetwork(string cryptoCode) + => cryptoCode == "BTC" ? NetworkProvider.GetNetwork("BTC") : + cryptoCode == "LTC" ? NetworkProvider.GetNetwork("LTC") : + cryptoCode == "LBTC" ? NetworkProvider.GetNetwork("LBTC") : + throw new NotSupportedException(); } } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesController.cs index 198de6244..639ce8711 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesController.cs @@ -68,7 +68,7 @@ namespace BTCPayServer.Controllers.GreenField var result = new List(); foreach (var rateTask in rateTasks) { - var rateTaskResult = rateTask.Value.Result; + var rateTaskResult = await rateTask.Value; result.Add(new StoreRateResult() { diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs index fc3fc7310..43fdace4f 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs @@ -208,6 +208,7 @@ namespace BTCPayServer.Controllers.Greenfield //we do not include PaymentMethodCriteria because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572) NetworkFeeMode = storeBlob.NetworkFeeMode, DefaultCurrency = storeBlob.DefaultCurrency, + AdditionalTrackedRates = (storeBlob.AdditionalTrackedRates ?? []).ToList(), Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null), LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi, LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints, @@ -259,6 +260,7 @@ namespace BTCPayServer.Controllers.Greenfield //we do not include OnChainMinValue and LightningMaxValue because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572) blob.NetworkFeeMode = restModel.NetworkFeeMode.Value; blob.DefaultCurrency = restModel.DefaultCurrency; + blob.AdditionalTrackedRates = restModel.AdditionalTrackedRates?.ToArray(); blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null); blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi.Value; blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints.Value; diff --git a/BTCPayServer/Controllers/UIStoresController.Settings.cs b/BTCPayServer/Controllers/UIStoresController.Settings.cs index a8ec9b508..23f72b8b1 100644 --- a/BTCPayServer/Controllers/UIStoresController.Settings.cs +++ b/BTCPayServer/Controllers/UIStoresController.Settings.cs @@ -40,6 +40,7 @@ public partial class UIStoresController PaymentTolerance = storeBlob.PaymentTolerance, InvoiceExpiration = (int)storeBlob.InvoiceExpiration.TotalMinutes, DefaultCurrency = storeBlob.DefaultCurrency, + AdditionalTrackedRates = string.Join(',', storeBlob.AdditionalTrackedRates?.ToArray() ?? []), BOLT11Expiration = (long)storeBlob.RefundBOLT11Expiration.TotalDays, Archived = store.Archived, MonitoringExpiration = (int)storeBlob.MonitoringExpiration.TotalMinutes, @@ -81,7 +82,8 @@ public partial class UIStoresController blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice; blob.NetworkFeeMode = model.NetworkFeeMode; blob.PaymentTolerance = model.PaymentTolerance; - blob.DefaultCurrency = model.DefaultCurrency; + blob.DefaultCurrency = model.DefaultCurrency.ToUpperInvariant().Trim(); + blob.AdditionalTrackedRates = model.AdditionalTrackedRates?.Split(',', StringSplitOptions.RemoveEmptyEntries); blob.ShowRecommendedFee = model.ShowRecommendedFee; blob.RecommendedFeeBlockTarget = model.RecommendedFeeBlockTarget; blob.InvoiceExpiration = TimeSpan.FromMinutes(model.InvoiceExpiration); @@ -174,7 +176,7 @@ public partial class UIStoresController storeId = CurrentStore.Id }); } - + [HttpPost("{storeId}/archive")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task ToggleArchive(string storeId) @@ -207,7 +209,7 @@ public partial class UIStoresController TempData[WellKnownTempData.SuccessMessage] = "Store successfully deleted."; return RedirectToAction(nameof(UIHomeController.Index), "UIHome"); } - + [HttpGet("{storeId}/checkout")] public async Task CheckoutAppearance() { @@ -281,7 +283,7 @@ public partial class UIStoresController } } } - + var userId = GetUserId(); if (userId is null) return NotFound(); diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index f55d9a7d1..deb07cd75 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -76,6 +76,7 @@ namespace BTCPayServer.Controllers private readonly DefaultRulesCollection _defaultRules; private readonly Dictionary _paymentModelExtensions; private readonly TransactionLinkProviders _transactionLinkProviders; + private readonly InvoiceRepository _invoiceRepository; private readonly PullPaymentHostedService _pullPaymentHostedService; private readonly WalletHistogramService _walletHistogramService; @@ -109,6 +110,7 @@ namespace BTCPayServer.Controllers Dictionary paymentModelExtensions, IStringLocalizer stringLocalizer, TransactionLinkProviders transactionLinkProviders, + InvoiceRepository invoiceRepository, DisplayFormatter displayFormatter) { _pendingTransactionService = pendingTransactionService; @@ -118,6 +120,7 @@ namespace BTCPayServer.Controllers _handlers = handlers; _paymentModelExtensions = paymentModelExtensions; _transactionLinkProviders = transactionLinkProviders; + _invoiceRepository = invoiceRepository; Repository = repo; WalletRepository = walletRepository; RateFetcher = rateProvider; @@ -621,6 +624,7 @@ namespace BTCPayServer.Controllers var model = new ListTransactionsViewModel { Skip = skip, Count = count }; model.PendingTransactions = await _pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId); + model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList(); model.Labels.AddRange( (await WalletRepository.GetWalletLabels(walletId)) @@ -663,6 +667,8 @@ namespace BTCPayServer.Controllers var labels = _labelService.CreateTransactionTagModels(transactionInfo, Request); vm.Tags.AddRange(labels); vm.Comment = transactionInfo.Comment; + vm.InvoiceId = transactionInfo.Attachments.FirstOrDefault(a => a.Type == WalletObjectData.Types.Invoice)?.Id; + vm.WalletRateBook = transactionInfo.Rates; } if (labelFilter == null || @@ -670,6 +676,28 @@ namespace BTCPayServer.Controllers model.Transactions.Add(vm); } + var trackedCurrencies = GetCurrentStore().GetStoreBlob().GetTrackedRates(); + var rates = await _invoiceRepository.GetRatesOfInvoices(model.Transactions.Select(r => r.InvoiceId).Where(r => r is not null).ToHashSet()); + foreach (var vm in model.Transactions) + { + if (vm.InvoiceId is null) + continue; + rates.TryGetValue(vm.InvoiceId, out var book); + vm.InvoiceRateBook = book; + } + + foreach (var vm in model.Transactions) + { + var book = vm.InvoiceRateBook ?? new(); + if (vm.WalletRateBook is not null) + book.AddRates(vm.WalletRateBook); + foreach (var trackedCurrency in trackedCurrencies) + { + var exists = book.TryGetRate(new CurrencyPair(network.CryptoCode, trackedCurrency), out var rate); + vm.Rates.Add(exists ? _displayFormatter.Currency(rate, trackedCurrency) : null); + } + } + model.Total = preFiltering ? null : model.Transactions.Count; // if we couldn't filter at the db level, we need to apply skip and count if (!preFiltering) diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 1792860a5..1279e6f61 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Globalization; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using BTCPayServer.Client.JsonConverters; using BTCPayServer.Client.Models; using BTCPayServer.Controllers; @@ -53,9 +54,7 @@ namespace BTCPayServer.Data } set { - _DefaultCurrency = value; - if (!string.IsNullOrEmpty(_DefaultCurrency)) - _DefaultCurrency = _DefaultCurrency.Trim().ToUpperInvariant(); + _DefaultCurrency = NormalizeCurrency(value); } } @@ -285,6 +284,32 @@ namespace BTCPayServer.Data (PrimaryRateSettings ?? new()).GetRateRules(defaultRules, Spread), FallbackRateSettings?.GetRateRules(defaultRules, Spread)); } + + public HashSet GetTrackedRates() => AdditionalTrackedRates.Concat([DefaultCurrency]).ToHashSet(); + + private string[] _additionalTrackedRates; + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)] + public string[] AdditionalTrackedRates + { + get + { + return _additionalTrackedRates ?? Array.Empty(); + } + set + { + if (value is not null) + _additionalTrackedRates = value + .Select(NormalizeCurrency) + .Where(v => v is not null).ToArray(); + else + _additionalTrackedRates = null; + } + } + + private string NormalizeCurrency(string v) => + v is null ? null : + Regex.Replace(v.ToUpperInvariant(), "[^A-Z]", "").Trim() is { Length: > 0 } normalized ? normalized : null; } public class PaymentMethodCriteria { diff --git a/BTCPayServer/Data/WalletTransactionInfo.cs b/BTCPayServer/Data/WalletTransactionInfo.cs index 95f41949a..42d54df82 100644 --- a/BTCPayServer/Data/WalletTransactionInfo.cs +++ b/BTCPayServer/Data/WalletTransactionInfo.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using BTCPayServer.Client.Models; using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Labels; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -19,7 +20,7 @@ namespace BTCPayServer.Data } [JsonIgnore] public WalletId WalletId { get; } - public string Comment { get; set; } = string.Empty; + public string? Comment { get; set; } = string.Empty; [JsonIgnore] public List Attachments { get; set; } = new List(); @@ -82,6 +83,8 @@ namespace BTCPayServer.Data } } + public RateBook? Rates { get; set; } + public WalletTransactionInfo Merge(WalletTransactionInfo? value) { var result = new WalletTransactionInfo(WalletId); diff --git a/BTCPayServer/HostedServices/OnChainRateTrackerHostedService.cs b/BTCPayServer/HostedServices/OnChainRateTrackerHostedService.cs new file mode 100644 index 000000000..ab0a07dd1 --- /dev/null +++ b/BTCPayServer/HostedServices/OnChainRateTrackerHostedService.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.Logging; +using BTCPayServer.Rating; +using BTCPayServer.Services; +using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.HostedServices; + +public class OnChainRateTrackerHostedService( + EventAggregator eventAggregator, + Logs logger, + WalletRepository walletRepository, + DefaultRulesCollection defaultRateRules, + RateFetcher rateFetcher, + StoreRepository storeRepository) : EventHostedServiceBase(eventAggregator, logger) +{ + protected override void SubscribeToEvents() + { + Subscribe(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is NewOnChainTransactionEvent newOnChainTransactionEvent) + await ProcessEventCore(newOnChainTransactionEvent, cancellationToken); + } + + private async Task ProcessEventCore(NewOnChainTransactionEvent transactionEvent, CancellationToken cancellationToken) + { + var derivation = transactionEvent.NewTransactionEvent.DerivationStrategy; + if (derivation is null) + return; + var now = DateTimeOffset.UtcNow; + // Too late + if ((transactionEvent.NewTransactionEvent.TransactionData.Timestamp - now).Duration() > TimeSpan.FromMinutes(10)) + return; + var cryptoCode = transactionEvent.NewTransactionEvent.CryptoCode; + + var stores = await storeRepository.GetStoresFromDerivation(transactionEvent.PaymentMethodId, derivation); + foreach (var storeId in stores) + { + var store = await storeRepository.FindStore(storeId); + if (store is null) + continue; + var blob = store.GetStoreBlob(); + var trackedCurrencies = blob.GetTrackedRates(); + var rules = blob.GetRateRules(defaultRateRules); + var fetching = rateFetcher.FetchRates( + trackedCurrencies + .Select(t => new CurrencyPair(cryptoCode, t)) + .ToHashSet(), rules, new StoreIdRateContext(storeId), CancellationToken); + JObject rates = new(); + foreach (var rate in fetching) + { + var result = await rate.Value; + if (result.BidAsk is { } ba) + rates.Add(rate.Key.Right, ba.Center.ToString(CultureInfo.InvariantCulture)); + } + if (!rates.Properties().Any()) + continue; + + var wid = new WalletId(storeId, cryptoCode); + var txObject = new WalletObjectId(wid, WalletObjectData.Types.Tx, transactionEvent.NewTransactionEvent.TransactionData.TransactionHash.ToString()); + + await walletRepository.AddOrUpdateWalletObjectData(txObject, new WalletRepository.UpdateOperation.MergeObject(new JObject() + { + [ "rates" ] = rates, + })); + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 6cdc4da26..9e4264011 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -435,6 +435,7 @@ o.GetRequiredService>().ToDictionary(o => o.P services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs b/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs index bc84908fc..637f1fe26 100644 --- a/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs @@ -27,7 +27,7 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Apply the brand color to the store's backend as well")] public bool ApplyBrandColorToBackend { get; set; } - + [Display(Name = "Logo")] public IFormFile LogoFile { get; set; } public string LogoUrl { get; set; } @@ -56,6 +56,10 @@ namespace BTCPayServer.Models.StoreViewModels [MaxLength(10)] public string DefaultCurrency { get; set; } + [Display(Name = "Additional rates to track")] + [MaxLength(30)] + public string AdditionalTrackedRates { get; set; } + [Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")] [Range(0, 365 * 10)] public long BOLT11Expiration { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs index 33f8dda33..90b9afe37 100644 --- a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using BTCPayServer.Data; +using BTCPayServer.Services.Invoices; namespace BTCPayServer.Models.WalletViewModels { @@ -17,11 +18,17 @@ namespace BTCPayServer.Models.WalletViewModels public bool Positive { get; set; } public string Balance { get; set; } public HashSet Tags { get; set; } = new(); + public string Rate { get; set; } + public List Rates { get; set; } = new(); + public RateBook WalletRateBook { get; set; } + public RateBook InvoiceRateBook { get; set; } + public string InvoiceId { get; set; } } public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new(); public List Transactions { get; set; } = new(); public override int CurrentPageCount => Transactions.Count; public string CryptoCode { get; set; } public PendingTransaction[] PendingTransactions { get; set; } + public List Rates { get; set; } } } diff --git a/BTCPayServer/Payments/IPaymentMethodHandler.cs b/BTCPayServer/Payments/IPaymentMethodHandler.cs index f07b3ab9e..a57774257 100644 --- a/BTCPayServer/Payments/IPaymentMethodHandler.cs +++ b/BTCPayServer/Payments/IPaymentMethodHandler.cs @@ -308,9 +308,13 @@ namespace BTCPayServer.Payments // We need to fetch the rates necessary for the evaluation of the payment method criteria var currency = Prompt.Currency; if (currency is not null) + { RequiredRates.Add(currency); + foreach (var r in StoreBlob.AdditionalTrackedRates ?? []) + OptionalRates.Add(new CurrencyPair(currency, r)); + } if (currency is not null - && Status is PaymentMethodContext.ContextStatus.WaitingForCreation or PaymentMethodContext.ContextStatus.WaitingForActivation) + && Status is PaymentMethodContext.ContextStatus.WaitingForCreation or PaymentMethodContext.ContextStatus.WaitingForActivation) { foreach (var paymentMethodCriteria in StoreBlob.PaymentMethodCriteria .Where(c => c.Value?.Currency is not null && c.PaymentMethod == PaymentMethodId)) diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 731f404f8..05888dbba 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -315,55 +315,19 @@ namespace BTCPayServer.Services.Invoices throw new InvalidOperationException("The Currency of the invoice isn't set"); return GetRate(new CurrencyPair(currency, Currency)); } - public RateRules GetRateRules() - { - StringBuilder builder = new StringBuilder(); + + public RateRules GetRateRules() => GetInvoiceRates().GetRateRules(); + + public bool TryGetRate(string currency, out decimal rate) => GetInvoiceRates().TryGetRate(new(currency, Currency), out rate); + + public bool TryGetRate(CurrencyPair pair, out decimal rate) => GetInvoiceRates().TryGetRate(pair, out rate); + + public decimal GetRate(CurrencyPair pair) => GetInvoiceRates().GetRate(pair); + #pragma warning disable CS0618 // Type or member is obsolete - foreach (var r in Rates) - { - if (r.Key.Contains('_', StringComparison.Ordinal)) - builder.AppendLine($"{r.Key} = {r.Value.ToString(CultureInfo.InvariantCulture)};"); - else - builder.AppendLine($"{r.Key}_{Currency} = {r.Value.ToString(CultureInfo.InvariantCulture)};"); - } + private RateBook GetInvoiceRates() => new RateBook(Currency, Rates); #pragma warning restore CS0618 // Type or member is obsolete - if (RateRules.TryParse(builder.ToString(), out var rules)) - return rules; - throw new FormatException("Invalid rate rules"); - } - public bool TryGetRate(string currency, out decimal rate) - { - return TryGetRate(new CurrencyPair(currency, Currency), out rate); - } - public bool TryGetRate(CurrencyPair pair, out decimal rate) - { -#pragma warning disable CS0618 // Type or member is obsolete - if (pair.Right == Currency && Rates.TryGetValue(pair.Left, out rate)) // Fast lane - return true; -#pragma warning restore CS0618 // Type or member is obsolete - var rule = GetRateRules().GetRuleFor(pair); - rule.Reevaluate(); - if (rule.BidAsk is null) - { - rate = 0.0m; - return false; - } - rate = rule.BidAsk.Bid; - return true; - } - public decimal GetRate(CurrencyPair pair) - { - ArgumentNullException.ThrowIfNull(pair); -#pragma warning disable CS0618 // Type or member is obsolete - if (pair.Right == Currency && Rates.TryGetValue(pair.Left, out var rate)) // Fast lane - return rate; -#pragma warning restore CS0618 // Type or member is obsolete - var rule = GetRateRules().GetRuleFor(pair); - rule.Reevaluate(); - if (rule.BidAsk is null) - throw new InvalidOperationException($"Rate rule is not evaluated ({rule.Errors.First()})"); - return rule.BidAsk.Bid; - } + public void AddRate(CurrencyPair pair, decimal rate) { #pragma warning disable CS0618 // Type or member is obsolete diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index b8697626f..c3b790e92 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -58,7 +58,7 @@ namespace BTCPayServer.Services.Invoices Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)), StoreId = storeId, Version = InvoiceEntity.Lastest_Version, - // Truncating was an unintended side effect of previous code. Might want to remove that one day + // Truncating was an unintended side effect of previous code. Might want to remove that one day InvoiceTime = DateTimeOffset.UtcNow.TruncateMilliSeconds(), Metadata = new InvoiceMetadata(), #pragma warning disable CS0618 @@ -171,6 +171,27 @@ namespace BTCPayServer.Services.Invoices .ToListAsync(); } + public async Task> GetRatesOfInvoices(HashSet invoiceIds) + { + if (invoiceIds.Count == 0) + return new(); + var res = new Dictionary(); + using var ctx = _applicationDbContextFactory.CreateContext(); + var conn = ctx.Database.GetDbConnection(); + var result = await conn.QueryAsync<(string Id, string Rate, string Currency)>( + """ + SELECT "Id", "Blob2"->'rates' AS "Rate", "Currency" FROM unnest(@invoices) AS searched_invoices("Id") + JOIN "Invoices" USING ("Id") + WHERE "Blob2"->'rates' IS NOT NULL; + """, new { invoices = invoiceIds.ToArray() }); + foreach (var inv in result) + { + var rates = RateBook.Parse(inv.Rate, inv.Currency); + res.Add(inv.Id, rates); + } + return res; + } + public async Task GetAppsTaggingStore(string storeId) { ArgumentNullException.ThrowIfNull(storeId); @@ -910,7 +931,7 @@ retry: CurrencyValue = p.Select(v => v.CurrencyValue).Sum() }); return new InvoiceStatistics(contributions) - { + { TotalSettled = totalSettledCurrency, TotalProcessing = totalProcessingCurrency, Total = totalSettledCurrency + totalProcessingCurrency diff --git a/BTCPayServer/Services/Invoices/RateBook.cs b/BTCPayServer/Services/Invoices/RateBook.cs new file mode 100644 index 000000000..848e50e36 --- /dev/null +++ b/BTCPayServer/Services/Invoices/RateBook.cs @@ -0,0 +1,149 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using BTCPayServer.Data; +using BTCPayServer.Rating; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Services.Invoices; + +public class RateBook +{ + public static RateBook Parse(string rates, string defaultCurrency) + { + ArgumentNullException.ThrowIfNull(rates); + ArgumentNullException.ThrowIfNull(defaultCurrency); + if (rates == "") + return new(defaultCurrency, new()); + var o = JObject.Parse(rates); + var ratesDict = new Dictionary(); + foreach (var property in o.Properties()) + { + ratesDict.Add(property.Name, decimal.Parse(property.Value.ToString(), CultureInfo.InvariantCulture)); + } + return new RateBook(defaultCurrency, ratesDict); + } + + public RateBook() + { + Rates = new(); + } + public RateBook( + string defaultCurrency, + Dictionary rates + ) + { + Rates = new(rates.Count); + foreach (var rate in rates) + { + if (!rate.Key.Contains('_', StringComparison.Ordinal)) + Rates.Add(new CurrencyPair(rate.Key, defaultCurrency), rate.Value); + else + Rates.Add(CurrencyPair.Parse(rate.Key), rate.Value); + } + } + public Dictionary Rates { get; } + + public decimal? TryGetRate(CurrencyPair pair) + { + if (GetFastLaneRate(pair, out var tryGetRate)) return tryGetRate; + + var rule = GetRateRules().GetRuleFor(pair); + rule.Reevaluate(); + if (rule.BidAsk is null) + return null; + return rule.BidAsk.Bid; + } + + private bool GetFastLaneRate(CurrencyPair pair, out decimal v) + { + ArgumentNullException.ThrowIfNull(pair); + if (Rates.TryGetValue(pair, out var rate)) // Fast lane + { + v = rate; + return true; + } + v = 0m; + return false; + } + + public decimal GetRate(CurrencyPair pair) + { + if (GetFastLaneRate(pair, out var v)) return v; + var rule = GetRateRules().GetRuleFor(pair); + rule.Reevaluate(); + if (rule.BidAsk is null) + throw new InvalidOperationException($"Rate rule is not evaluated ({rule.Errors.First()})"); + return rule.BidAsk.Bid; + } + + public bool TryGetRate(CurrencyPair pair, out decimal rate) + { + if (GetFastLaneRate(pair, out rate)) return true; + var rule = GetRateRules().GetRuleFor(pair); + rule.Reevaluate(); + if (rule.BidAsk is null) + { + rate = 0.0m; + return false; + } + + rate = rule.BidAsk.Bid; + return true; + } + + public RateRules GetRateRules() + { + var builder = new StringBuilder(); + foreach (var r in Rates) + { + builder.AppendLine($"{r.Key} = {r.Value.ToString(CultureInfo.InvariantCulture)};"); + } + + if (RateRules.TryParse(builder.ToString(), out var rules)) + return rules; + throw new FormatException("Invalid rate rules"); + } + + public void AddRates(RateBook? otherBook) + { + if (otherBook is null) + return; + foreach (var rate in otherBook.Rates) + { + this.Rates.TryAdd(rate.Key, rate.Value); + } + } + + public static RateBook? FromTxWalletObject(WalletObjectData txObject) + { + var rates = txObject.GetData()?["rates"] as JObject; + if (rates is null) + return null; + var cryptoCode = WalletId.Parse(txObject.WalletId).CryptoCode; + return FromJObject(rates, cryptoCode); + } + + public static RateBook? FromJObject(JObject rates, string cryptoCode) + { + var result = new RateBook(); + foreach (var property in rates.Properties()) + { + var rate = decimal.Parse(property.Value.ToString(), CultureInfo.InvariantCulture); + result.Rates.TryAdd(new CurrencyPair(cryptoCode, property.Name), rate); + } + return result; + } + + public void AddCurrencies(HashSet trackedCurrencies) + { + foreach (var r in Rates) + { + trackedCurrencies.Add(r.Key.Left); + trackedCurrencies.Add(r.Key.Right); + } + } +} diff --git a/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs b/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs index cb1d32a39..6370618bf 100644 --- a/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs +++ b/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs @@ -6,8 +6,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Rating; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; using Newtonsoft.Json.Linq; namespace BTCPayServer.Services.Reporting; @@ -26,14 +29,13 @@ public class InvoicesReportProvider : ReportProvider foreach (var field in viewDefinitionFields) _baseFields.Add(field.Name); } - public bool HasField(string fieldName) => _dict.ContainsKey(fieldName); public List Fields { get; } = new(); public Dictionary Values { get; } = new(); - public void TryAdd(string fieldName, object? value) + public void TryAdd(string fieldName, object? value, string? columnType = null) { - var type = GeColumnType(value); + var type = columnType ?? GetColumnType(value); if (type is null || _baseFields.Contains(fieldName)) return; var field = new StoreReportResponse.Field(fieldName, type); @@ -47,7 +49,7 @@ public class InvoicesReportProvider : ReportProvider Values.TryAdd(fieldName, value); } - private string? GeColumnType(object? value) + private string? GetColumnType(object? value) => value switch { null => "text", @@ -69,13 +71,11 @@ public class InvoicesReportProvider : ReportProvider } public HashSet CartItems = new(); - public void HasCartItem(string itemId) - { - CartItems.Add(itemId); - } + public void HasCartItem(string itemId) => CartItems.Add(itemId); } private readonly InvoiceRepository _invoiceRepository; + private readonly StoreRepository _storeRepository; public override string Name { get; } = "Invoices"; @@ -104,7 +104,7 @@ public class InvoicesReportProvider : ReportProvider new("PaymentReceivedDate", "datetime"), new("PaymentId", "text"), - new("Rate", "amount"), + new("PaymentRate", "amount"), new("PaymentAddress", "text"), new("PaymentMethodId", "text"), new("PaymentCurrency", "text"), @@ -114,13 +114,28 @@ public class InvoicesReportProvider : ReportProvider } }; + var trackedCurrencies = (await _storeRepository.FindStore(queryContext.StoreId))?.GetStoreBlob().GetTrackedRates().ToHashSet() ?? new(); + var metadataFields = new MetadataFields(queryContext.ViewDefinition.Fields); foreach (var invoiceEntity in invoices) { var payments = invoiceEntity.GetPayments(true); - metadataFields.Values.Clear(); + foreach (var currencyPair in + (from p in invoiceEntity + .GetPaymentPrompts() + .Select(c => c.Currency) + from c in trackedCurrencies.Concat([invoiceEntity.Currency]) + where p != c + select new CurrencyPair(p, c)).Distinct()) + { + if (!invoiceEntity.TryGetRate(currencyPair, out var rate)) + metadataFields.TryAdd($"Rate ({currencyPair})", null, "number"); + else + metadataFields.TryAdd($"Rate ({currencyPair})", rate); + } + var firstPayment = payments.FirstOrDefault(); if (firstPayment is not null) { @@ -275,9 +290,10 @@ public class InvoicesReportProvider : ReportProvider } } - public InvoicesReportProvider(DisplayFormatter displayFormatter, InvoiceRepository invoiceRepository) + public InvoicesReportProvider(DisplayFormatter displayFormatter, InvoiceRepository invoiceRepository, StoreRepository storeRepository) { DisplayFormatter = displayFormatter; _invoiceRepository = invoiceRepository; + _storeRepository = storeRepository; } } diff --git a/BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs b/BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs index 1a5ce361a..11036acf7 100644 --- a/BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs +++ b/BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs @@ -1,10 +1,12 @@ #nullable enable using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Data; using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Rating; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; using Dapper; @@ -12,42 +14,33 @@ using NBitcoin; namespace BTCPayServer.Services.Reporting; -public class OnChainWalletReportProvider : ReportProvider +public class OnChainWalletReportProvider( + NBXplorerConnectionFactory nbxplorerConnectionFactory, + StoreRepository storeRepository, + InvoiceRepository invoiceRepository, + PaymentMethodHandlerDictionary handlers, + WalletRepository walletRepository) + : ReportProvider { - public OnChainWalletReportProvider( - NBXplorerConnectionFactory NbxplorerConnectionFactory, - StoreRepository storeRepository, - PaymentMethodHandlerDictionary handlers, - WalletRepository walletRepository) - { - this.NbxplorerConnectionFactory = NbxplorerConnectionFactory; - StoreRepository = storeRepository; - _handlers = handlers; - WalletRepository = walletRepository; - } - - private NBXplorerConnectionFactory NbxplorerConnectionFactory { get; } - private StoreRepository StoreRepository { get; } - private PaymentMethodHandlerDictionary _handlers; - private WalletRepository WalletRepository { get; } public override string Name => "Wallets"; + ViewDefinition CreateViewDefinition() { return new() { Fields = { - new ("Date", "datetime"), - new ("Crypto", "string"), + new("Date", "datetime"), + new("Crypto", "string"), // For proper rendering of explorer links, Crypto should always be before tx_id - new ("TransactionId", "tx_id"), - new ("InvoiceId", "invoice_id"), - new ("Confirmed", "boolean"), - new ("BalanceChange", "amount") + new("TransactionId", "tx_id"), + new("InvoiceId", "invoice_id"), + new("Confirmed", "boolean"), + new("BalanceChange", "amount"), }, Charts = { - new () + new() { Name = "Group by Crypto", Totals = { "Crypto" }, @@ -58,37 +51,39 @@ public class OnChainWalletReportProvider : ReportProvider }; } - public override bool IsAvailable() - { - return NbxplorerConnectionFactory.Available; - } + public override bool IsAvailable() => nbxplorerConnectionFactory.Available; public override async Task Query(QueryContext queryContext, CancellationToken cancellation) { queryContext.ViewDefinition = CreateViewDefinition(); - await using var conn = await NbxplorerConnectionFactory.OpenConnection(); - var store = await StoreRepository.FindStore(queryContext.StoreId); + await using var conn = await nbxplorerConnectionFactory.OpenConnection(); + var store = await storeRepository.FindStore(queryContext.StoreId); if (store is null) return; + Dictionary<(string CryptoCode, string TxId), RateBook> walletBooks = new(); + HashSet cryptoCodes = new(); var interval = DateTimeOffset.UtcNow - queryContext.From; - foreach (var (pmi, settings) in store.GetPaymentMethodConfigs(_handlers)) + foreach (var (pmi, settings) in store.GetPaymentMethodConfigs(handlers)) { - var network = ((IHasNetwork)_handlers[pmi]).Network; + var network = ((IHasNetwork)handlers[pmi]).Network; + cryptoCodes.Add(network.CryptoCode); var walletId = new WalletId(store.Id, network.CryptoCode); var command = new CommandDefinition( - commandText: - "SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change " + - "FROM get_wallets_recent(@wallet_id, @code, @asset_id, @interval, NULL, NULL) r " + - "JOIN txs t USING (code, tx_id) " + - "ORDER BY r.seen_at", - parameters: new - { - asset_id = GetAssetId(network), - wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(network.CryptoCode, settings.AccountDerivation.ToString()), - code = network.CryptoCode, - interval - }, - cancellationToken: cancellation); + commandText: + """ + SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change + FROM get_wallets_recent(@wallet_id, @code, @asset_id, @interval, NULL, NULL) r + JOIN txs t USING (code, tx_id) + ORDER BY r.seen_at + """, + parameters: new + { + asset_id = GetAssetId(network), + wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(network.CryptoCode, settings.AccountDerivation.ToString()), + code = network.CryptoCode, + interval + }, + cancellationToken: cancellation); var rows = await conn.QueryAsync(command); foreach (var r in rows) @@ -105,22 +100,67 @@ public class OnChainWalletReportProvider : ReportProvider values.Add((long?)r.blk_height is not null); values.Add(new FormattedAmount(balanceChange, network.Divisibility).ToJObject()); } - var objects = await WalletRepository.GetWalletObjects(new GetWalletObjectsQuery + + var objects = await walletRepository.GetWalletObjects(new GetWalletObjectsQuery { Ids = queryContext.Data.Select(d => (string)d[2]!).ToArray(), WalletId = walletId, - Type = "tx" + Type = WalletObjectData.Types.Tx }); foreach (var row in queryContext.Data) { - if (!objects.TryGetValue(new WalletObjectId(walletId, "tx", (string)row[2]!), out var txObject)) + if (!objects.TryGetValue(new WalletObjectId(walletId, WalletObjectData.Types.Tx, (string)row[2]!), out var txObject)) continue; - var invoiceId = txObject.GetLinks().Where(t => t.type == "invoice").Select(t => t.id).FirstOrDefault(); + var invoiceId = txObject.GetLinks().Where(t => t.type == WalletObjectData.Types.Invoice).Select(t => t.id).FirstOrDefault(); row[3] = invoiceId; + if (RateBook.FromTxWalletObject(txObject) is {} book) + walletBooks.Add(GetKey(row), book); + } + } + + // The currencies appearing in this report are: + // - The currently tracked rates of the store + // - The rates that were tracked at the invoices level + // - The rates that were tracked at the wallet level + var trackedCurrencies = store.GetStoreBlob().GetTrackedRates().ToHashSet(); + var rates = await invoiceRepository.GetRatesOfInvoices(queryContext.Data.Select(r => r[3]).OfType().ToHashSet()); + foreach (var book in rates.Select(r => r.Value)) + { + book.AddCurrencies(trackedCurrencies); + } + foreach (var row in queryContext.Data) + { + walletBooks.TryGetValue(GetKey(row), out var rateData); + rateData?.AddCurrencies(trackedCurrencies); + } + trackedCurrencies.ExceptWith(cryptoCodes); + foreach (var trackedCurrency in trackedCurrencies) + { + // We don't use amount here. Rounding the rates is dangerous when the price of the + // shitcoin is very low. + queryContext.ViewDefinition.Fields.Add(new($"Rate ({trackedCurrency})", "number")); + } + + foreach (var row in queryContext.Data) + { + var k = GetKey(row); + walletBooks.TryGetValue(k, out var rateData); + var invoiceId = row[3] as string; + rates.TryGetValue(invoiceId ?? "", out var r); + r ??= new("", new()); + r.AddRates(rateData); + foreach (var trackedCurrency in trackedCurrencies) + { + if (r.TryGetRate(new CurrencyPair(k.CryptoCode, trackedCurrency)) is decimal v) + row.Add(v); + else + row.Add(null); } } } + private (string CryptoCode, string TxId) GetKey(IList row) => ((string)row[1]!, (string)row[2]!); + private string? GetAssetId(BTCPayNetwork network) { if (network is Plugins.Altcoins.ElementsBTCPayNetwork elNetwork) diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index ac2003abd..33d57e8b9 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -14,6 +14,7 @@ using Dapper; using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitcoin.DataEncoders; +using NBXplorer.DerivationStrategy; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; @@ -703,6 +704,20 @@ retry: """, new { storeId })) is true; } + public async Task GetStoresFromDerivation(PaymentMethodId paymentMethodId, DerivationStrategyBase derivation) + { + await using var ctx = _ContextFactory.CreateContext(); + var connection = ctx.Database.GetDbConnection(); + var res = await connection.QueryAsync( + """ + SELECT "Id" FROM "Stores" + WHERE jsonb_extract_path_text("DerivationStrategies", @pmi, 'accountDerivation') = @derivation; + """, + new { pmi = paymentMethodId.ToString(), derivation = derivation.ToString() } + ); + return res.ToArray(); + } + public async Task GetDefaultStoreTemplate() { var data = new StoreData(); diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs index 28199e0e0..b92d0e022 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -8,10 +8,13 @@ using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Data; using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Wallets; using Dapper; using Microsoft.EntityFrameworkCore; using NBitcoin; +using NBXplorer.DerivationStrategy; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Npgsql; @@ -258,7 +261,8 @@ namespace BTCPayServer.Services var data = obj.Data is null ? null : JObject.Parse(obj.Data); var info = new WalletTransactionInfo(walletId) { - Comment = data?["comment"]?.Value() + Comment = data?["comment"]?.Value(), + Rates = data?["rates"] is JObject o ? RateBook.FromJObject(o, walletId.CryptoCode) : null }; result.Add(obj.Id, info); foreach (var link in obj.GetLinks()) @@ -485,9 +489,9 @@ namespace BTCPayServer.Services ArgumentNullException.ThrowIfNull(id); ArgumentNullException.ThrowIfNull(comment); if (!string.IsNullOrEmpty(comment)) - await ModifyWalletObjectData(id, (o) => o["comment"] = comment.Trim().Truncate(MaxCommentSize)); + await AddOrUpdateWalletObjectData(id, new UpdateOperation.MergeObject(new(){ ["comment"] = comment.Trim().Truncate(MaxCommentSize) })); else - await ModifyWalletObjectData(id, (o) => o.Remove("comment")); + await AddOrUpdateWalletObjectData(id, new UpdateOperation.RemoveProperty("comment")); } @@ -512,6 +516,8 @@ namespace BTCPayServer.Services Data = data?.ToString() }; } + + [Obsolete("Use AddOrUpdateWalletObjectData instead")] public async Task ModifyWalletObjectData(WalletObjectId id, Action modify) { ArgumentNullException.ThrowIfNull(id); @@ -683,17 +689,57 @@ namespace BTCPayServer.Services await conn.ExecuteAsync("INSERT INTO \"WalletObjects\" VALUES (@WalletId, @Type, @Id, @Data::JSONB) ON CONFLICT DO NOTHING", walletObjectDatas); } + public record UpdateOperation + { + public record RemoveProperty(string Property) : UpdateOperation; + + public record MergeObject(JObject Data) : UpdateOperation; + } + + public async Task AddOrUpdateWalletObjectData(WalletObjectId walletObjectId, UpdateOperation? op) + { + if (op is UpdateOperation.MergeObject { Data: { } data }) + { + using var ctx = this._ContextFactory.CreateContext(); + var conn = ctx.Database.GetDbConnection(); + await conn.ExecuteAsync(""" + INSERT INTO "WalletObjects" VALUES (@WalletId, @Type, @Id, @Data::JSONB) + ON CONFLICT ("WalletId", "Type", "Id") + DO UPDATE SET "Data" = COALESCE("WalletObjects"."Data", '{}'::JSONB) || EXCLUDED."Data" + """, + new { WalletId = walletObjectId.WalletId.ToString(), Type = walletObjectId.Type, Id = walletObjectId.Id, Data = data.ToString() }); + } + if (op is UpdateOperation.RemoveProperty { Property: { } prop }) + { + using var ctx = this._ContextFactory.CreateContext(); + var conn = ctx.Database.GetDbConnection(); + await conn.ExecuteAsync(""" + INSERT INTO "WalletObjects" VALUES (@WalletId, @Type, @Id) + ON CONFLICT ("WalletId", "Type", "Id") + DO UPDATE SET "Data" = COALESCE("WalletObjects"."Data", '{}'::JSONB) - @Property + """, + new { WalletId = walletObjectId.WalletId.ToString(), Type = walletObjectId.Type, Id = walletObjectId.Id, Property = prop }); + } + else if (op is null) + { + await EnsureWalletObject(walletObjectId); + } + } + public async Task EnsureCreated(List? walletObjects, List? walletObjectLinks) { walletObjects ??= new List(); walletObjectLinks ??= new List(); + if (walletObjects.Count is 0 && walletObjectLinks.Count is 0) + return; var objs = walletObjects.Concat(ExtractObjectsFromLinks(walletObjectLinks).Except(walletObjects)).ToArray(); await using var ctx = _ContextFactory.CreateContext(); var connection = ctx.Database.GetDbConnection(); await EnsureWalletObjects(ctx,connection, objs); await EnsureWalletObjectLinks(ctx,connection, walletObjectLinks); } + #nullable restore } } diff --git a/BTCPayServer/Views/UIReports/StoreReports.cshtml b/BTCPayServer/Views/UIReports/StoreReports.cshtml index 218261f4a..304778c6c 100644 --- a/BTCPayServer/Views/UIReports/StoreReports.cshtml +++ b/BTCPayServer/Views/UIReports/StoreReports.cshtml @@ -129,7 +129,7 @@

Raw data

-
+
diff --git a/BTCPayServer/Views/UIStores/GeneralSettings.cshtml b/BTCPayServer/Views/UIStores/GeneralSettings.cshtml index f7315881b..f5d86eb7c 100644 --- a/BTCPayServer/Views/UIStores/GeneralSettings.cshtml +++ b/BTCPayServer/Views/UIStores/GeneralSettings.cshtml @@ -57,7 +57,7 @@ - +
@@ -119,6 +119,12 @@
+
+ + +
The rates of those currencies, in addition to the default currency, will be recorded when a new invoice is created. The rates will then be accessible through reports.
+ +
diff --git a/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml index ee3e4871d..ce90337e5 100644 --- a/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml @@ -13,6 +13,7 @@ var cryptoCode = Context.GetRouteValue("cryptoCode")?.ToString(); var labelFilter = Context.Request.Query["labelFilter"].ToString(); var wallet = walletId != null ? WalletId.Parse(walletId) : new WalletId(storeId, cryptoCode); + storeId = wallet.StoreId; ViewData.SetActivePage(WalletsNavPages.Transactions, StringLocalizer["{0} Transactions", Model.CryptoCode], walletId); } @@ -42,7 +43,7 @@ order: 1; } } - + #LoadingIndicator { margin-bottom: 1.5rem; } @@ -59,24 +60,24 @@ const $list = document.getElementById('WalletTransactionsList'); const $dropdowns = document.getElementById('Dropdowns'); const $indicator = document.getElementById('LoadingIndicator'); - + delegate('click', '#GoToTop', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); - + if ($actions && $actions.offsetTop - window.innerHeight > 0) { document.getElementById('GoToTop').classList.remove('d-none'); } - + const count = @Safe.Json(Model.Count); const skipInitial = @Safe.Json(Model.Skip); const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true})); // The next time we load transactions, skip will become 0 let skip = @Safe.Json(Model.Skip) - count; - + async function loadMoreTransactions() { $indicator.classList.remove('d-none'); - + const skipNext = skip + count; const url = loadMoreUrl.replace(`skip=${skipInitial}`, `skip=${skipNext}`) const response = await fetch(url, { @@ -85,13 +86,13 @@ 'X-Requested-With': 'XMLHttpRequest' } }); - + if (response.ok) { const html = await response.text(); const responseEmpty = html.trim() === ''; $list.insertAdjacentHTML('beforeend', html); skip = skipNext; - + if (responseEmpty) { // in case the response html was empty, remove the observer and stop loading observer.unobserve($actions); @@ -105,19 +106,19 @@ } } } - + $indicator.classList.add('d-none'); formatDateTimes(document.querySelector('#WalletTransactions .switch-time-format').dataset.mode); initLabelManagers(); } - + const observer = new IntersectionObserver(async entries => { const { isIntersecting } = entries[0]; if (isIntersecting) { await loadMoreTransactions(); } }, { rootMargin: '128px' }); - + // the actions div marks the end of the list table observer.observe($actions); @@ -156,7 +157,19 @@
} +
- + @@ -190,10 +203,10 @@ @@ -221,6 +234,10 @@ + @foreach (var rate in Model.Rates) + { + + } @@ -230,7 +247,7 @@ + @foreach (var rate in transaction.Rates) + { + + }
IdStateState Signatures Scheme Actions@ptblob?.SignaturesCollected @ptblob?.SignaturesNeeded/@ptblob?.SignaturesTotal - @(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View") - - Abort
Label Transaction AmountRate (@rate)
-
+
0 selected @@ -265,6 +282,6 @@

@ViewLocalizer["If BTCPay Server shows you an invalid balance, {0}.
If some transactions appear in BTCPay Server, but are missing in another wallet, {1}.", - Html.ActionLink(StringLocalizer["rescan your wallet"], "WalletRescan", "UIWallets", new { walletId = Context.GetRouteValue("walletId") }), + Html.ActionLink(StringLocalizer["rescan your wallet"], "WalletRescan", "UIWallets", new { walletId = Context.GetRouteValue("walletId") }), new HtmlString($"{StringLocalizer["follow these instructions"]}")]

diff --git a/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml b/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml index 4d0d486a4..83dcf6b9a 100644 --- a/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml +++ b/BTCPayServer/Views/UIWallets/_WalletTransactionsList.cshtml @@ -27,6 +27,12 @@
@transaction.Balance + @rate +
@if (transaction.CanBumpFee) diff --git a/BTCPayServer/wwwroot/js/store-reports.js b/BTCPayServer/wwwroot/js/store-reports.js index 6b7d74bcc..4e5c97d61 100644 --- a/BTCPayServer/wwwroot/js/store-reports.js +++ b/BTCPayServer/wwwroot/js/store-reports.js @@ -143,8 +143,8 @@ document.addEventListener("DOMContentLoaded", () => { return chart && (chart.rows.length || chart.hasGrandTotal); }, titleCase(str, shorten) { - const result = str.replace(/([A-Z])/g, " $1"); - const title = result.charAt(0).toUpperCase() + result.slice(1) + const result = str.replace(/([a-z])([A-Z])/g, '$1 $2'); // only split camelCase + const title = result.charAt(0).toUpperCase() + result.slice(1); return shorten && title.endsWith(' Amount') ? 'Amount' : title; }, displayValue, diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css index 4f602914f..7e29922d4 100644 --- a/BTCPayServer/wwwroot/main/site.css +++ b/BTCPayServer/wwwroot/main/site.css @@ -1182,6 +1182,10 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) { text-align: right; white-space: nowrap; } +.rate-col { + text-align: right; + white-space: nowrap; +} .actions-col { text-align: right; } diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json index 4fcb5c5be..73024ac89 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json @@ -507,6 +507,15 @@ "default": "USD", "example": "USD" }, + "additionalTrackedRates": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional rates to track.\nThe rates of those currencies, in addition to the default currency, will be recorded when a new invoice is created. The rates will then be accessible through reports.", + "default": [], + "example": ["JPY", "EUR"] + }, "invoiceExpiration": { "default": 900, "minimum": 60,