From 632d4433e0ef026997f12fde48d092b1fa76f98f Mon Sep 17 00:00:00 2001 From: "thgO.O" <107907441+thgO-O@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:30:23 -0300 Subject: [PATCH] feat: add Reserved Addresses view with filtering, pagination and labels (#6796) * feat: add reservedAt metadata when address is generated from receive * feat: add link to Reserved Addresses in wallet navigation * feat: add ReservedAddressesViewModel with labels and reserved timestamp * feat: implement Reserved Addresses view with filtering, pagination and label management * feat: add GetReservedAddressesWithDetails with label and timestamp support * feat: add ReservedAddresses endpoint * test: add Reserved Addresses view test with label, filter and pagination * test: use stable ID for filter input instead of placeholder * Moving Reserved Addresses to Receive page * feat: sync labels created via Label Manager using labelmanager:changed event * refactor: optimize GetReservedAddressesWithDetails using direct SQL query * feat: add link to filter Reserved Addresses by label from Wallet Labels view * refactor: remove legacy selenium test * test: add playwright tests with label filtering, pagination and redirect from Wallet Labels view * refactor: optimize Reserved Addresses filtering with Set and Safe.Json --------- Co-authored-by: rockstardev <5191402+rockstardev@users.noreply.github.com> --- BTCPayServer.Tests/PlaywrightTests.cs | 86 +++++++++ .../Components/MainNav/Default.cshtml | 3 +- .../Controllers/UIWalletsController.cs | 20 ++ .../ReservedAddressesViewModel.cs | 19 ++ BTCPayServer/Services/WalletRepository.cs | 76 ++++++++ BTCPayServer/Services/Wallets/BTCPayWallet.cs | 17 +- .../Views/UIStores/WalletSettings.cshtml | 2 +- .../Views/UIWallets/ReservedAddresses.cshtml | 179 ++++++++++++++++++ .../Views/UIWallets/WalletLabels.cshtml | 10 +- .../Views/UIWallets/WalletReceive.cshtml | 9 + BTCPayServer/wwwroot/main/site.js | 9 + 11 files changed, 422 insertions(+), 8 deletions(-) create mode 100644 BTCPayServer/Models/WalletViewModels/ReservedAddressesViewModel.cs create mode 100644 BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs index 44fc4cb93..4484a8c65 100644 --- a/BTCPayServer.Tests/PlaywrightTests.cs +++ b/BTCPayServer.Tests/PlaywrightTests.cs @@ -849,5 +849,91 @@ namespace BTCPayServer.Tests }); Assert.Contains(tx.ToString(), await File.ReadAllTextAsync(await download.PathAsync())); } + + [Fact] + public async Task CanUseReservedAddressesView() + { + await using var s = CreatePlaywrightTester(); + await s.StartAsync(); + await s.RegisterNewUser(true); + await s.CreateNewStore(); + var walletId = new WalletId(s.StoreId, "BTC"); + s.WalletId = walletId; + await s.GenerateWallet(); + + await s.GoToWallet(walletId, WalletsNavPages.Receive); + + for (var i = 0; i < 10; i++) + { + var currentAddress = await s.Page.GetAttributeAsync("#Address", "data-text"); + await s.Page.ClickAsync("button[value=generate-new-address]"); + await TestUtils.EventuallyAsync(async () => + { + var newAddress = await s.Page.GetAttributeAsync("#Address[data-text]", "data-text"); + Assert.False(string.IsNullOrEmpty(newAddress)); + Assert.NotEqual(currentAddress, newAddress); + }); + } + + await s.Page.ClickAsync("#reserved-addresses-button"); + await s.Page.WaitForSelectorAsync("#reserved-addresses"); + + const string labelInputSelector = "#reserved-addresses table tbody tr .ts-control input"; + await s.Page.WaitForSelectorAsync(labelInputSelector); + + // Test Label Manager + await s.Page.FillAsync(labelInputSelector, "test-label"); + await s.Page.Keyboard.PressAsync("Enter"); + await TestUtils.EventuallyAsync(async () => + { + var text = await s.Page.InnerTextAsync("#reserved-addresses table tbody"); + Assert.Contains("test-label", text); + }); + + //Test Pagination + await TestUtils.EventuallyAsync(async () => + { + var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr"); + var visible = await Task.WhenAll(rows.Select(async r => await r.IsVisibleAsync())); + Assert.Equal(10, visible.Count(v => v)); + }); + + await s.Page.ClickAsync(".pagination li:last-child a"); + + await TestUtils.EventuallyAsync(async () => + { + var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr"); + var visible = await Task.WhenAll(rows.Select(async r => await r.IsVisibleAsync())); + Assert.Single(visible, v => v); + }); + + await s.Page.ClickAsync(".pagination li:first-child a"); + await s.Page.WaitForSelectorAsync("#reserved-addresses"); + + // Test Filter + await s.Page.FillAsync("#filter-reserved-addresses", "test-label"); + await TestUtils.EventuallyAsync(async () => + { + var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr"); + var visible = await Task.WhenAll(rows.Select(async r => await r.IsVisibleAsync())); + Assert.Single(visible, v => v); + }); + + //Test WalletLabels redirect with filter + await s.GoToWallet(walletId, WalletsNavPages.Settings); + await s.Page.ClickAsync("#manage-wallet-labels-button"); + await s.Page.WaitForSelectorAsync("table"); + await s.Page.ClickAsync("a:has-text('Addresses')"); + + await s.Page.WaitForSelectorAsync("#reserved-addresses"); + var currentFilter = await s.Page.InputValueAsync("#filter-reserved-addresses"); + Assert.Equal("test-label", currentFilter); + await TestUtils.EventuallyAsync(async () => + { + var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr"); + var visible = await Task.WhenAll(rows.Select(r => r.IsVisibleAsync())); + Assert.Single(visible, v => v); + }); + } } } diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml index 8549b292c..2233ce141 100644 --- a/BTCPayServer/Components/MainNav/Default.cshtml +++ b/BTCPayServer/Components/MainNav/Default.cshtml @@ -117,6 +117,7 @@ + @@ -408,7 +409,7 @@ { diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index af491e305..f55d9a7d1 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -763,6 +763,26 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl }); } + [HttpGet("{walletId}/addresses")] + public async Task ReservedAddresses( + [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId) + { + var paymentMethod = GetDerivationSchemeSettings(walletId); + if (paymentMethod == null) + return NotFound(); + + var labeledAddresses = await WalletRepository.GetReservedAddressesWithDetails(walletId); + + var vm = new ReservedAddressesViewModel + { + WalletId = walletId.ToString(), + CryptoCode = walletId.CryptoCode, + Addresses = labeledAddresses + }; + + return View(vm); + } + private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod) { var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); diff --git a/BTCPayServer/Models/WalletViewModels/ReservedAddressesViewModel.cs b/BTCPayServer/Models/WalletViewModels/ReservedAddressesViewModel.cs new file mode 100644 index 000000000..b61a911af --- /dev/null +++ b/BTCPayServer/Models/WalletViewModels/ReservedAddressesViewModel.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace BTCPayServer.Models.WalletViewModels; + +public class ReservedAddressesViewModel +{ + public string WalletId { get; set; } + public string CryptoCode { get; set; } + public List Addresses { get; set; } +} + +public class ReservedAddress +{ + public string Address { get; set; } + public List Labels { get; set; } = new(); + public DateTimeOffset? ReservedAt { get; set; } +} + diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs index 528d91669..cb1db0262 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.Data; using System.Data.Common; using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Data; +using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Services.Wallets; using Dapper; using Microsoft.EntityFrameworkCore; @@ -299,6 +301,80 @@ namespace BTCPayServer.Services .Select(FormatToLabel).ToArray(); } + public async Task> GetReservedAddressesWithDetails(WalletId walletId) + { + await using var ctx = _ContextFactory.CreateContext(); + await using var conn = ctx.Database.GetDbConnection(); + await conn.OpenAsync(); + + const string sql = """ + SELECT + w."Id", + w."Data"->>'reservedAt' AS "ReservedAt", + l."Id" AS "LabelId", + l."Data"->>'color' AS "LabelColor" + FROM "WalletObjects" w + LEFT JOIN "WalletObjectLinks" n + ON n."AType" = 'address' + AND n."AId" = w."Id" + AND n."WalletId" = w."WalletId" + LEFT JOIN "WalletObjects" l + ON l."Id" = n."BId" + AND l."Type" = 'label' + AND l."WalletId" = w."WalletId" + WHERE w."WalletId" = @WalletId + AND w."Type" = 'address' + AND w."Data"->>'generatedBy' = 'receive' + """; + + var parameters = new DynamicParameters(); + parameters.Add("WalletId", walletId.ToString(), DbType.String); + + var rows = await conn.QueryAsync(sql, parameters); + + var addressesById = new Dictionary(); + var labelTrackers = new Dictionary>(); + + foreach (var row in rows) + { + string id = row.Id; + + if (!addressesById.TryGetValue(id, out var addr)) + { + DateTimeOffset? reservedAt = null; + if (DateTimeOffset.TryParse(row.ReservedAt, out DateTimeOffset parsed)) + reservedAt = parsed; + + addr = new ReservedAddress + { + Address = id, + ReservedAt = reservedAt, + Labels = new List() + }; + addressesById[id] = addr; + labelTrackers[id] = new HashSet(); + } + + if (row.LabelId == null) continue; + + string labelId = row.LabelId; + if (!labelTrackers[id].Add(labelId)) continue; + + string color = row.LabelColor ?? ColorPalette.Default.DeterministicColor(labelId); + + addr.Labels.Add(new TransactionTagModel + { + Text = labelId, + Color = color, + TextColor = ColorPalette.Default.TextColor(color) + }); + } + + return addressesById.Values + .OrderByDescending(a => a.ReservedAt) + .ToList(); + } + private (string Label, string Color) FormatToLabel(WalletObjectData o) { return o.Data is null diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index 53f0460ed..7f1ff46ad 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -117,12 +117,21 @@ namespace BTCPayServer.Services.Wallets await _Client.TrackAsync(derivationStrategy).ConfigureAwait(false); pathInfo = await _Client.GetUnusedAsync(derivationStrategy, DerivationFeature.Deposit, 0, true).ConfigureAwait(false); } - if (storeId != null) + + if (storeId == null) return pathInfo; + + var metadata = new JObject { - await WalletRepository.EnsureWalletObject( - new WalletObjectId(new WalletId(storeId, Network.CryptoCode), WalletObjectData.Types.Address, pathInfo.Address.ToString()), - new JObject() { ["generatedBy"] = generatedBy }); + ["generatedBy"] = generatedBy + }; + + if (generatedBy == "receive") + { + metadata["reservedAt"] = DateTimeOffset.UtcNow; } + + await WalletRepository.EnsureWalletObject( + new WalletObjectId(new WalletId(storeId, Network.CryptoCode), WalletObjectData.Types.Address, pathInfo.Address.ToString()), metadata); return pathInfo; } diff --git a/BTCPayServer/Views/UIStores/WalletSettings.cshtml b/BTCPayServer/Views/UIStores/WalletSettings.cshtml index c40f38446..5e6b355d8 100644 --- a/BTCPayServer/Views/UIStores/WalletSettings.cshtml +++ b/BTCPayServer/Views/UIStores/WalletSettings.cshtml @@ -74,7 +74,7 @@ - Manage labels diff --git a/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml b/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml new file mode 100644 index 000000000..c878c6526 --- /dev/null +++ b/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml @@ -0,0 +1,179 @@ +@using BTCPayServer.Components.LabelManager +@using BTCPayServer.Services +@model ReservedAddressesViewModel +@inject BTCPayServer.Security.ContentSecurityPolicies Csp + +@{ + var walletId = Context.GetRouteValue("walletId")?.ToString(); + var storeId = Context.GetRouteValue("storeId")?.ToString(); + var cryptoCode = Context.GetRouteValue("cryptoCode")?.ToString(); + var wallet = walletId != null ? WalletId.Parse(walletId) : new WalletId(storeId, cryptoCode); +} + +@{ + ViewData.SetActivePage(WalletsNavPages.Receive, "Reserved Addresses"); + Csp.Add("worker-src", "blob:"); + Csp.UnsafeEval(); +} + + +
+ + +
+ + + + + + + + + + @foreach (var address in Model.Addresses) + { + + + + + + } + +
AddressLabelReserved At
@address.Address + + @address.ReservedAt?.ToString("g")
+ +
+
    +
  • + « +
  • +
  • + Showing {{ pageStart + 1 }}–{{ pageEnd }} of {{ @Model.Addresses.Count }} +
  • +
  • + » +
  • +
+ +
+ Page Size: +
+ + + + + +
+
+
+
+ +
+

No reserved addresses found.

+
+
+ +@section PageHeadContent { + + + + + +} + +@section PageFootContent { + + + +} diff --git a/BTCPayServer/Views/UIWallets/WalletLabels.cshtml b/BTCPayServer/Views/UIWallets/WalletLabels.cshtml index dc6693fdc..a5baeab40 100644 --- a/BTCPayServer/Views/UIWallets/WalletLabels.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletLabels.cshtml @@ -33,9 +33,15 @@ @label.Label + -
- + + Addresses + + +
diff --git a/BTCPayServer/Views/UIWallets/WalletReceive.cshtml b/BTCPayServer/Views/UIWallets/WalletReceive.cshtml index f695c5e6c..2276a9146 100644 --- a/BTCPayServer/Views/UIWallets/WalletReceive.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletReceive.cshtml @@ -98,6 +98,15 @@
+ + + } @if (env.CheatMode) diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js index b17e85ecf..8298c2020 100644 --- a/BTCPayServer/wwwroot/main/site.js +++ b/BTCPayServer/wwwroot/main/site.js @@ -79,6 +79,15 @@ async function initLabelManager (elementId) { })); }, async onChange (values) { + const labels = Array.isArray(values) ? values : values.split(','); + + element.dispatchEvent(new CustomEvent("labelmanager:changed", { + detail: { + walletObjectId, + labels: labels + } + })); + const selectElementI = selectElement ? document.getElementById(selectElement) : null; if (selectElementI){ while (selectElementI.options.length > 0) {