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 @@
Receive
+
Settings
@@ -408,7 +409,7 @@
{
-
+
Contact Us
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();
+}
+
+
+
+
+
+
+
+
+
+ | Address |
+ Label |
+ Reserved At |
+
+
+
+ @foreach (var address in Model.Addresses)
+ {
+
+ | @address.Address |
+
+
+ |
+ @address.ReservedAt?.ToString("g") |
+
+ }
+
+
+
+
+
+
+
+
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
+
-
|
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) {