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>
This commit is contained in:
thgO.O
2025-07-03 21:30:23 -03:00
committed by GitHub
parent 5d5eb19142
commit 632d4433e0
11 changed files with 422 additions and 8 deletions

View File

@@ -849,5 +849,91 @@ namespace BTCPayServer.Tests
}); });
Assert.Contains(tx.ToString(), await File.ReadAllTextAsync(await download.PathAsync())); 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);
});
}
} }
} }

View File

@@ -117,6 +117,7 @@
<li class="nav-item nav-item-sub"> <li class="nav-item nav-item-sub">
<a id="WalletNav-Receive" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Receive, scheme.WalletId.ToString())" asp-area="" asp-controller="UIWallets" asp-action="WalletReceive" asp-route-walletId="@scheme.WalletId" text-translate="true">Receive</a> <a id="WalletNav-Receive" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Receive, scheme.WalletId.ToString())" asp-area="" asp-controller="UIWallets" asp-action="WalletReceive" asp-route-walletId="@scheme.WalletId" text-translate="true">Receive</a>
</li> </li>
<li class="nav-item nav-item-sub"> <li class="nav-item nav-item-sub">
<a id="WalletNav-Settings" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Settings, scheme.WalletId.ToString()) @ViewData.ActivePageClass(StoreNavPages.OnchainSettings, categoryId)" asp-area="" asp-controller="UIStores" asp-action="WalletSettings" asp-route-cryptoCode="@scheme.WalletId.CryptoCode" asp-route-storeId="@scheme.WalletId.StoreId" text-translate="true">Settings</a> <a id="WalletNav-Settings" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Settings, scheme.WalletId.ToString()) @ViewData.ActivePageClass(StoreNavPages.OnchainSettings, categoryId)" asp-area="" asp-controller="UIStores" asp-action="WalletSettings" asp-route-cryptoCode="@scheme.WalletId.CryptoCode" asp-route-storeId="@scheme.WalletId.StoreId" text-translate="true">Settings</a>
</li> </li>
@@ -408,7 +409,7 @@
{ {
<li class="nav-item"> <li class="nav-item">
<a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs"> <a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs">
<vc:icon symbol="nav-contact"/> <vc:icon symbol="nav-contact"/>
<span text-translate="true">Contact Us</span> <span text-translate="true">Contact Us</span>
</a> </a>
</li> </li>

View File

@@ -763,6 +763,26 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl }); return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl });
} }
[HttpGet("{walletId}/addresses")]
public async Task<IActionResult> 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) private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod)
{ {
var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);

View File

@@ -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<ReservedAddress> Addresses { get; set; }
}
public class ReservedAddress
{
public string Address { get; set; }
public List<TransactionTagModel> Labels { get; set; } = new();
public DateTimeOffset? ReservedAt { get; set; }
}

View File

@@ -1,11 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data;
using System.Data.Common; using System.Data.Common;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
using Dapper; using Dapper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -299,6 +301,80 @@ namespace BTCPayServer.Services
.Select(FormatToLabel).ToArray(); .Select(FormatToLabel).ToArray();
} }
public async Task<List<ReservedAddress>> 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<string, ReservedAddress>();
var labelTrackers = new Dictionary<string, HashSet<string>>();
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<TransactionTagModel>()
};
addressesById[id] = addr;
labelTrackers[id] = new HashSet<string>();
}
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) private (string Label, string Color) FormatToLabel(WalletObjectData o)
{ {
return o.Data is null return o.Data is null

View File

@@ -117,12 +117,21 @@ namespace BTCPayServer.Services.Wallets
await _Client.TrackAsync(derivationStrategy).ConfigureAwait(false); await _Client.TrackAsync(derivationStrategy).ConfigureAwait(false);
pathInfo = await _Client.GetUnusedAsync(derivationStrategy, DerivationFeature.Deposit, 0, true).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( ["generatedBy"] = generatedBy
new WalletObjectId(new WalletId(storeId, Network.CryptoCode), WalletObjectData.Types.Address, pathInfo.Address.ToString()), };
new JObject() { ["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; return pathInfo;
} }

View File

@@ -74,7 +74,7 @@
</div> </div>
</div> </div>
<a class="btn btn-secondary" asp-controller="UIWallets" asp-action="WalletLabels" <a id="manage-wallet-labels-button" class="btn btn-secondary" asp-controller="UIWallets" asp-action="WalletLabels"
asp-route-walletId="@Model.WalletId" text-translate="true">Manage labels</a> asp-route-walletId="@Model.WalletId" text-translate="true">Manage labels</a>
<button type="submit" class="btn btn-primary" id="SaveWalletSettings" form="walletSettingsForm" text-translate="true">Save Wallet Settings</button> <button type="submit" class="btn btn-primary" id="SaveWalletSettings" form="walletSettingsForm" text-translate="true">Save Wallet Settings</button>

View File

@@ -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();
}
<div id="reserved-addresses">
<div class="sticky-header">
<vc:wallet-nav wallet-id="wallet"/>
<input id="filter-reserved-addresses" type="text" v-model="filter" class="form-control my-3" placeholder="@StringLocalizer["Filter by address, label or date"]" />
</div>
<div v-if="filteredItems.length > 0" class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th text-translate="true">Address</th>
<th text-translate="true">Label</th>
<th text-translate="true">Reserved At</th>
</tr>
</thead>
<tbody>
@foreach (var address in Model.Addresses)
{
<tr v-show='filteredItemsSet.has(@Safe.Json(address.Address))'>
<td>@address.Address</td>
<td>
<vc:label-manager
wallet-object-id="new WalletObjectId(wallet, WalletObjectData.Types.Address, address.Address)"
selected-labels="address.Labels.Select(l => l.Text).ToArray()"
rich-label-info="address.Labels.Where(l => !string.IsNullOrEmpty(l.Link)).ToDictionary(l => l.Text, l => new RichLabelInfo { Link = l.Link, Tooltip = l.Tooltip })"
exclude-types="false"
display-inline="true" />
</td>
<td>@address.ReservedAt?.ToString("g")</td>
</tr>
}
</tbody>
</table>
<div class="d-flex flex-wrap gap-3 justify-content-between mt-3 align-items-center">
<ul class="pagination mb-0">
<li class="page-item" :class="{ disabled: pageStart === 0 }">
<a class="page-link p-0" href="#" v-on:click.prevent="page = page - 1">«</a>
</li>
<li class="page-item disabled">
<span class="page-link p-0" text-translate="true">Showing {{ pageStart + 1 }}{{ pageEnd }} of {{ @Model.Addresses.Count }}</span>
</li>
<li class="page-item" :class="{ disabled: pageEnd >= @Model.Addresses.Count }">
<a class="page-link p-0" href="#" v-on:click.prevent="page = page + 1">»</a>
</li>
</ul>
<div class="d-flex gap-2 align-items-center">
<span class="text-muted" text-translate="true">Page Size:</span>
<div class="btn-group gap-2" role="group">
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 10}" v-on:click="setPageSize(10)">10</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 25}" v-on:click="setPageSize(25)">25</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 50}" v-on:click="setPageSize(50)">50</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 100}" v-on:click="setPageSize(100)">100</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 250}" v-on:click="setPageSize(250)">250</button>
</div>
</div>
</div>
</div>
<div v-else>
<p text-translate="true">No reserved addresses found.</p>
</div>
</div>
@section PageHeadContent {
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<style>
.table td, .table th {
vertical-align: middle;
}
</style>
<style>
.btn-link.active {
--btcpay-btn-active-color: var(--btcpay-body-link);
}
.btn-link {
--btcpay-btn-color: var(--btcpay-body-text-muted);
}
.btn-link:hover {
--btcpay-btn-color: var(--btcpay-body-link-accent);
}
</style>
}
@section PageFootContent {
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
new Vue({
el: '#reserved-addresses',
data: {
addresses: @Safe.Json(Model.Addresses),
page: 0,
pageSize: 10,
filter: "@Context.Request.Query["filter"]".toLowerCase(),
},
computed: {
pageStart() {
return this.page * this.pageSize;
},
pageEnd() {
return Math.min(this.pageStart + this.pageSize, this.addresses.length);
},
filteredItems() {
const search = this.filter.toLowerCase();
const result = [];
for (const addr of this.addresses) {
const matchesAddress = addr.address.toLowerCase().includes(search);
const matchesLabel = addr.labels?.some(l => l.text.toLowerCase().includes(search));
const matchesDate = addr.reservedAt?.toLowerCase().includes(search);
if (matchesAddress || matchesLabel || matchesDate) result.push(addr);
}
return result.slice(this.pageStart, this.pageEnd);
},
filteredItemsSet() {
return new Set(this.filteredItems.map(x => x.address));
}
},
mounted() {
initLabelManagers();
const labelManagerList = document.querySelectorAll("input.label-manager");
labelManagerList.forEach(labelManager => {
labelManager.addEventListener("labelmanager:changed", ({ detail }) => {
const { walletObjectId, labels: newLabels } = detail;
const targetAddress = this.addresses.find(addr => addr.address === walletObjectId);
if (!targetAddress) return;
const existingLabels = targetAddress.labels?.map(l => l.text) || [];
const merged = Array.from(new Set([...existingLabels, ...newLabels])).map(text => ({ text }));
this.$set(targetAddress, 'labels', merged);
});
});
},
methods: {
setPageSize(size) {
this.pageSize = size;
this.page = 0;
}
},
watch: {
filter() {
this.$nextTick(() => {
initLabelManagers();
});
}
}
});
});
</script>
}

View File

@@ -33,9 +33,15 @@
<span>@label.Label</span> <span>@label.Label</span>
</div> </div>
</td> </td>
<td class="text-end"> <td class="text-end">
<form method="post" asp-action="RemoveWalletLabel" asp-route-walletId="@Model.WalletId" asp-route-id="@label.Label"> <a class="btn btn-link p-0 me-3"
<button class="btn btn-link btn-delete p-0" type="submit" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["The label {0} will be removed from this wallet and its associated transactions.", Html.Encode(label.Label)]" data-confirm-input="@StringLocalizer["DELETE"]" text-translate="true">Remove</button> href="@Url.Action("ReservedAddresses", "UIWallets", new { walletId = Model.WalletId.ToString(), filter = label.Label })"
title="View Reserved Addresses with this label">
Addresses
</a>
<form method="post" asp-action="RemoveWalletLabel" asp-route-walletId="@Model.WalletId" asp-route-id="@label.Label" class="d-inline">
<button class="btn btn-link btn-delete p-0 me-3" type="submit" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["The label {0} will be removed from this wallet and its associated transactions.", Html.Encode(label.Label)]" data-confirm-input="@StringLocalizer["DELETE"]" text-translate="true">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -98,6 +98,15 @@
<div class="mt-4"> <div class="mt-4">
<button type="submit" name="command" value="generate-new-address" class="btn btn-primary w-100" text-translate="true">Generate another address</button> <button type="submit" name="command" value="generate-new-address" class="btn btn-primary w-100" text-translate="true">Generate another address</button>
</div> </div>
<div class="payment-box mt-3">
<a class="btn btn-outline-info w-100"
id="reserved-addresses-button"
asp-controller="UIWallets" asp-action="ReservedAddresses" asp-route-walletId="@walletId">
Reserved Addresses
</a>
</div>
</div> </div>
} }
@if (env.CheatMode) @if (env.CheatMode)

View File

@@ -79,6 +79,15 @@ async function initLabelManager (elementId) {
})); }));
}, },
async onChange (values) { 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; const selectElementI = selectElement ? document.getElementById(selectElement) : null;
if (selectElementI){ if (selectElementI){
while (selectElementI.options.length > 0) { while (selectElementI.options.length > 0) {