Fix reserved addresses after wallet replacement (#7304)
What changed, and why it matters
This commit fixes a bug where BTCPay Server's 'reserved addresses' list could show addresses from a previously replaced wallet. After a user swaps one wallet for another, the old reserved addresses are now filtered out by checking which addresses actually belong to the current wallet's derivation scheme in the NBXplorer database. The commit also adds a Playwright test that verifies old addresses disappear after wallet replacement and reappear if the original wallet is restored.
Review whether any other wallet views (transactions, labels, pull payments, payment requests) similarly rely on WalletId-scoped data without validating the current derivation scheme. Ensure the new NBXplorer query is performant for stores with many reserved addresses and that the error messages do not leak internal database state.
Security signals we found
Information disclosure: reserved addresses from a replaced wallet could remain visible to the store owner or users with wallet access
Address/wallet confusion: UI displayed addresses not controlled by the currently configured wallet
Missing authorization boundary between wallet derivations sharing the same WalletId
Evidence from the diff
The change modifies UIWalletsController.WalletReceiveReserveAddress to filter reserved addresses returned by WalletRepository.GetReservedAddressesWithDetails against the current wallet derivation tracked by NBXplorer. A new private method FilterReservedAddressesToCurrentDerivation queries the NBXplorer database to keep only reserved addresses whose scripts are associated with the active wallet_id derived from paymentMethod.AccountDerivation. It adds error handling for missing NBXplorer connectivity. A Playwright test CanHideReservedAddressesFromReplacedWallet confirms that replacing a wallet hides old reserved addresses and restoring the original seed brings them back.
Changed components
BTCPayServer/Controllers/UIWalletsController.csBTCPayServer.Tests/WalletTests.csWallet receive/reserved addresses UINBXplorer integrationInspect captured patch +142 / −0
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index a8bcd69..f6dbd4b 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -513,6 +513,89 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
});
}
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanHideReservedAddressesFromReplacedWallet()
+ {
+ 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;
+ var originalMnemonic = (await s.GenerateWallet()).ToString();
+
+ await s.GoToWallet(walletId, WalletsNavPages.Receive);
+
+ List<string> oldAddresses = [];
+ var currentAddress = await s.Page.GetAttributeAsync("#Address", "data-text") ?? string.Empty;
+ Assert.False(string.IsNullOrEmpty(currentAddress));
+ oldAddresses.Add(currentAddress);
+
+ for (var i = 0; i < 2; i++)
+ {
+ 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);
+ });
+
+ currentAddress = await s.Page.GetAttributeAsync("#Address", "data-text") ?? string.Empty;
+ Assert.False(string.IsNullOrEmpty(currentAddress));
+ oldAddresses.Add(currentAddress);
+ }
+
+ 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);
+ await s.Page.FillAsync(labelInputSelector, "old-wallet-label");
+ await s.Page.Keyboard.PressAsync("Enter");
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ var text = await s.Page.InnerTextAsync("#reserved-addresses table tbody");
+ Assert.Contains("old-wallet-label", text);
+ });
+ var oldReservedAddressesPage = await s.Page.ContentAsync();
+ foreach (var oldAddress in oldAddresses)
+ {
+ Assert.Contains(oldAddress, oldReservedAddressesPage);
+ }
+
+ await s.GenerateWallet(seed: "melody lizard phrase voice unique car opinion merge degree evil swift cargo");
+ await s.GoToWallet(walletId, WalletsNavPages.Receive);
+
+ var newAddress = await s.Page.GetAttributeAsync("#Address", "data-text") ?? string.Empty;
+ Assert.False(string.IsNullOrEmpty(newAddress));
+ Assert.DoesNotContain(newAddress, oldAddresses);
+
+ await s.Page.ClickAsync("#reserved-addresses-button");
+ await s.Page.WaitForSelectorAsync("#reserved-addresses");
+
+ var replacedWalletReservedAddressesPage = await s.Page.ContentAsync();
+ Assert.Contains(newAddress, replacedWalletReservedAddressesPage);
+ Assert.DoesNotContain("old-wallet-label", replacedWalletReservedAddressesPage);
+ foreach (var oldAddress in oldAddresses)
+ {
+ Assert.DoesNotContain(oldAddress, replacedWalletReservedAddressesPage);
+ }
+
+ await s.GenerateWallet(seed: originalMnemonic);
+ await s.GoToWallet(walletId, WalletsNavPages.Receive);
+ await s.Page.ClickAsync("#reserved-addresses-button");
+ await s.Page.WaitForSelectorAsync("#reserved-addresses");
+
+ var restoredWalletReservedAddressesPage = await s.Page.ContentAsync();
+ Assert.DoesNotContain(newAddress, restoredWalletReservedAddressesPage);
+ Assert.Contains("old-wallet-label", restoredWalletReservedAddressesPage);
+ foreach (var oldAddress in oldAddresses)
+ {
+ Assert.Contains(oldAddress, restoredWalletReservedAddressesPage);
+ }
+ }
+
[Fact]
[Trait("Playwright", "Playwright-2")]
public async Task CanUseBumpFee()
diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs
index ca6425c..11c6e1c 100644
--- a/BTCPayServer/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Controllers/UIWalletsController.cs
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
+using System.Data.Common;
using System.Globalization;
using System.Linq;
using System.Net.Mime;
@@ -804,6 +805,25 @@ namespace BTCPayServer.Controllers
return NotFound();
var labeledAddresses = await WalletRepository.GetReservedAddressesWithDetails(walletId);
+ if (labeledAddresses.Count != 0)
+ {
+ var connectionFactory = ServiceProvider.GetRequiredService<NBXplorerConnectionFactory>();
+ if (!connectionFactory.Available)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Reserved Addresses requires access to the NBXplorer database."].Value;
+ return RedirectToAction(nameof(WalletReceive), new { walletId });
+ }
+
+ try
+ {
+ labeledAddresses = await FilterReservedAddressesToCurrentDerivation(connectionFactory, walletId, paymentMethod, labeledAddresses);
+ }
+ catch (DbException)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Reserved Addresses is temporarily unavailable because the NBXplorer database cannot be reached."].Value;
+ return RedirectToAction(nameof(WalletReceive), new { walletId });
+ }
+ }
var vm = new ReservedAddressesViewModel
{
@@ -815,6 +835,45 @@ namespace BTCPayServer.Controllers
return View(vm);
}
+ private async Task<List<ReservedAddress>> FilterReservedAddressesToCurrentDerivation(
+ NBXplorerConnectionFactory connectionFactory,
+ WalletId walletId,
+ DerivationSchemeSettings paymentMethod,
+ List<ReservedAddress> labeledAddresses)
+ {
+ if (labeledAddresses.Count == 0 || paymentMethod.AccountDerivation is null)
+ return labeledAddresses;
+
+ var addresses = labeledAddresses.Select(a => a.Address).ToArray();
+ var currentWalletId = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(
+ walletId.CryptoCode,
+ paymentMethod.AccountDerivation.ToString());
+
+ await using var conn = await connectionFactory.OpenConnection();
+ var activeAddresses = (await conn.QueryAsync<string>(
+ """
+ SELECT DISTINCT searched.addr
+ FROM unnest(@addresses) AS searched(addr)
+ JOIN scripts s
+ ON s.code = @code
+ AND s.addr = searched.addr
+ JOIN wallets_scripts ws
+ ON ws.code = s.code
+ AND ws.script = s.script
+ WHERE ws.wallet_id = @walletId
+ """,
+ new
+ {
+ addresses,
+ code = walletId.CryptoCode,
+ walletId = currentWalletId
+ })).ToHashSet(StringComparer.Ordinal);
+
+ return labeledAddresses
+ .Where(address => activeAddresses.Contains(address.Address))
+ .ToList();
+ }
+
private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod)
{
var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
Why this scored 59/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.