using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Web; using BTCPayServer.Abstractions.Models; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Payments; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Wallets; using BTCPayServer.Views.Wallets; using Microsoft.Playwright; using NBitcoin; using NBitcoin.Payment; using NBXplorer.Models; using Xunit; using static Microsoft.Playwright.Assertions; namespace BTCPayServer.Tests; public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper) { [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanUseCoinSelectionFilters() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(true); (_, string storeId) = await s.CreateNewStore(); await s.GenerateWallet("BTC", "", false); var walletId = new WalletId(storeId, "BTC"); await s.GoToWallet(walletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.GenerateAsync(1); const decimal AmountTiny = 0.001m; const decimal AmountSmall = 0.005m; const decimal AmountMedium = 0.009m; const decimal AmountLarge = 0.02m; List txs = [ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountTiny)), await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountSmall)), await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountMedium)), await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountLarge)) ]; await s.Server.ExplorerNode.GenerateAsync(1); await s.GoToWallet(walletId, WalletsNavPages.Send); await s.Page.ClickAsync("#toggleInputSelection"); var input = s.Page.Locator("input[placeholder^='Filter']"); await input.WaitForAsync(); Assert.NotNull(input); // Test amountmin await input.ClearAsync(); await input.FillAsync("amountmin:0.01"); await TestUtils.EventuallyAsync(async () => { Assert.Single(await s.Page.Locator("li.list-group-item").AllAsync()); }); // Test amountmax await input.ClearAsync(); await input.FillAsync("amountmax:0.002"); await TestUtils.EventuallyAsync(async () => { Assert.Single(await s.Page.Locator("li.list-group-item").AllAsync()); }); // Test general text (txid) await input.ClearAsync(); await input.FillAsync(txs[2].ToString()[..8]); await TestUtils.EventuallyAsync(async () => { Assert.Single(await s.Page.Locator("li.list-group-item").AllAsync()); }); // Test timestamp before/after await input.ClearAsync(); await input.FillAsync("after:2099-01-01"); await TestUtils.EventuallyAsync(async () => { Assert.Empty(await s.Page.Locator("li.list-group-item").AllAsync()); }); await input.ClearAsync(); await input.FillAsync("before:2099-01-01"); await TestUtils.EventuallyAsync(async () => { Assert.True((await s.Page.Locator("li.list-group-item").AllAsync()).Count >= 4); }); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanImportMnemonic() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(true); foreach (var isHotwallet in new[] { false, true }) { var cryptoCode = "BTC"; await s.CreateNewStore(); await s.GenerateWallet(cryptoCode, "melody lizard phrase voice unique car opinion merge degree evil swift cargo", isHotWallet: isHotwallet); await s.GoToWalletSettings(cryptoCode); if (isHotwallet) { await s.Page.ClickAsync("#ActionsDropdownToggle"); Assert.True(await s.Page.Locator("#ViewSeed").IsVisibleAsync()); } else { Assert.False(await s.Page.Locator("#ViewSeed").IsVisibleAsync()); } } } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanImportWallet() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(true); await s.CreateNewStore(); const string cryptoCode = "BTC"; var mnemonic = await s.GenerateWallet(cryptoCode, "click chunk owner kingdom faint steak safe evidence bicycle repeat bulb wheel"); // Make sure wallet info is correct await s.GoToWalletSettings(cryptoCode); Assert.Contains(mnemonic.DeriveExtKey().GetPublicKey().GetHDFingerPrint().ToString(), await s.Page.GetAttributeAsync("#AccountKeys_0__MasterFingerprint", "value")); Assert.Contains("m/84'/1'/0'", await s.Page.GetAttributeAsync("#AccountKeys_0__AccountKeyPath", "value")); // Transactions list is empty await s.GoToWallet(); await s.Page.WaitForSelectorAsync("#WalletTransactions[data-loaded='true']"); Assert.Contains("There are no transactions yet", await s.Page.Locator("#WalletTransactions").TextContentAsync()); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanManageWallet() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(true); var (_, storeId) = await s.CreateNewStore(); const string cryptoCode = "BTC"; // ReSharper disable once GrammarMistakeInComment // In this test, we try to spend from a manual seed. We import the xpub 49'/0'/0', // then try to use the seed to sign the transaction await s.GenerateWallet(); //let's quickly test the wallet send page await s.GoToWallet(navPages: WalletsNavPages.Send); //you cannot use the Sign with NBX option without saving private keys when generating the wallet. Assert.DoesNotContain("nbx-seed", await s.Page.ContentAsync()); Assert.Equal(0, await s.Page.Locator("#GoBack").CountAsync()); await s.Page.ClickAsync("#SignTransaction"); await s.Page.WaitForSelectorAsync("text=Destination Address field is required"); Assert.Equal(0, await s.Page.Locator("#GoBack").CountAsync()); await s.Page.ClickAsync("#CancelWizard"); await s.GoToWallet(navPages: WalletsNavPages.Receive); //generate a receiving address await s.Page.WaitForSelectorAsync("#address-tab .qr-container"); Assert.True(await s.Page.Locator("#address-tab .qr-container").IsVisibleAsync()); // no previous page in the wizard, hence no back button Assert.Equal(0, await s.Page.Locator("#GoBack").CountAsync()); var receiveAddr = await s.Page.Locator("#Address").GetAttributeAsync("data-text"); // Can add a label? await TestUtils.EventuallyAsync(async () => { await s.Page.ClickAsync("div.label-manager input"); await Task.Delay(500); await s.Page.FillAsync("div.label-manager input", "test-label"); await s.Page.Keyboard.PressAsync("Enter"); await Task.Delay(500); await s.Page.FillAsync("div.label-manager input", "label2"); await s.Page.Keyboard.PressAsync("Enter"); await Task.Delay(500); }); await TestUtils.EventuallyAsync(async () => { await s.Page.ReloadAsync(); await s.Page.WaitForSelectorAsync("[data-value='test-label']"); }); Assert.True(await s.Page.Locator("#address-tab .qr-container").IsVisibleAsync()); Assert.Equal(receiveAddr, await s.Page.Locator("#Address").GetAttributeAsync("data-text")); await TestUtils.EventuallyAsync(async () => { var content = await s.Page.ContentAsync(); Assert.Contains("test-label", content); }); // Remove a label await s.Page.WaitForSelectorAsync("[data-value='test-label']"); await s.Page.ClickAsync("[data-value='test-label']"); await Task.Delay(500); await s.Page.EvaluateAsync(@"() => { const l = document.querySelector('[data-value=""test-label""]'); l.click(); l.nextSibling.dispatchEvent(new KeyboardEvent('keydown', {'key': 'Delete', keyCode: 8})); }"); await Task.Delay(500); await s.Page.ReloadAsync(); Assert.DoesNotContain("test-label", await s.Page.ContentAsync()); Assert.Equal(0, await s.Page.Locator("#GoBack").CountAsync()); //send money to addr and ensure it changed var sess = await s.Server.ExplorerClient.CreateWebsocketNotificationSessionAsync(); await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(receiveAddr!, Network.RegTest), Money.Parse("0.1")); await sess.WaitNext(e => e.Outputs.FirstOrDefault()?.Address.ToString() == receiveAddr); await Task.Delay(200); await s.Page.ReloadAsync(); await s.Page.ClickAsync("button[value=generate-new-address]"); Assert.NotEqual(receiveAddr, await s.Page.Locator("#Address").GetAttributeAsync("data-text")); receiveAddr = await s.Page.Locator("#Address").GetAttributeAsync("data-text"); await s.Page.ClickAsync("#CancelWizard"); // Check the label is applied to the tx var wt = s.InWalletTransactions(); await wt.AssertHasLabels("label2"); //change the wallet and ensure old address is not there and generating a new one does not result in the prev one await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(null, WalletsNavPages.Receive); await s.Page.ClickAsync("button[value=generate-new-address]"); var newAddr = await s.Page.Locator("#Address").GetAttributeAsync("data-text"); Assert.NotEqual(receiveAddr, newAddr); var invoiceId = await s.CreateInvoice(storeId); var invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId); var btc = PaymentTypes.CHAIN.GetPaymentMethodId("BTC"); var address = invoice.GetPaymentPrompt(btc)!.Destination; // wallet should have been imported to bitcoin core wallet var result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest)); Assert.False(result.IsWatchOnly); await s.GoToStore(storeId); var mnemonic = await s.GenerateWallet(cryptoCode, "", true); //let's import and save private keys invoiceId = await s.CreateInvoice(storeId); invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId); address = invoice.GetPaymentPrompt(btc)!.Destination; result = await s.Server.ExplorerNode.GetAddressInfoAsync( BitcoinAddress.Create(address, Network.RegTest)); //spendable from bitcoin core wallet! Assert.False(result.IsWatchOnly); var tx = await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest), Money.Coins(3.0m)); await s.Server.ExplorerNode.GenerateAsync(1); await s.GoToStore(storeId); await s.GoToWalletSettings(); var url = s.Page.Url; await s.ClickOnAllSectionLinks("#Nav-Wallets"); // Make sure wallet info is correct await s.GoToUrl(url); await s.Page.WaitForSelectorAsync("#AccountKeys_0__MasterFingerprint"); Assert.Equal(mnemonic.DeriveExtKey().GetPublicKey().GetHDFingerPrint().ToString(), await s.Page.Locator("#AccountKeys_0__MasterFingerprint").GetAttributeAsync("value")); Assert.Equal("m/84'/1'/0'", await s.Page.Locator("#AccountKeys_0__AccountKeyPath").GetAttributeAsync("value")); // Make sure we can rescan, because we are admin! await s.Page.ClickAsync("#ActionsDropdownToggle"); await s.Page.ClickAsync("#Rescan"); await s.Page.GetByText("The batch size make sure").WaitForAsync(); // // Check the tx sent earlier arrived wt = await s.GoToWalletTransactions(); await wt.WaitTransactionsLoaded(); await s.Page.Locator($"[data-text='{tx}']").WaitForAsync(); var walletTransactionUri = new Uri(s.Page.Url); // Send to bob var ws = await s.GoToWalletSend(); var bob = new Key().PubKey.Hash.GetAddress(Network.RegTest); await ws.FillAddress(bob); await ws.FillAmount(1); // Add labels to the transaction output await TestUtils.EventuallyAsync(async () => { await s.Page.ClickAsync("div.label-manager input"); await s.Page.FillAsync("div.label-manager input", "tx-label"); await s.Page.Keyboard.PressAsync("Enter"); await s.Page.WaitForSelectorAsync("[data-value='tx-label']"); }); await ws.Sign(); // The back button should lead back to the previous page inside the send wizard var backUrl = await s.Page.Locator("#GoBack").GetAttributeAsync("href"); Assert.EndsWith($"/send?returnUrl={Uri.EscapeDataString(walletTransactionUri.AbsolutePath)}", backUrl); // The cancel button should lead to the page that referred to the send wizard var cancelUrl = await s.Page.Locator("#CancelWizard").GetAttributeAsync("href"); Assert.EndsWith(walletTransactionUri.AbsolutePath, cancelUrl); // Broadcast var wb = s.InBroadcast(); await wb.AssertSending(bob, 1.0m); await wb.Broadcast(); Assert.Equal(walletTransactionUri.ToString(), s.Page.Url); // Assert that the added label is associated with the transaction await wt.AssertHasLabels("tx-label"); await s.GoToWallet(navPages: WalletsNavPages.Send); var jack = new Key().PubKey.Hash.GetAddress(Network.RegTest); await ws.FillAddress(jack); await ws.FillAmount(0.01m); await ws.Sign(); await wb.AssertSending(jack, 0.01m); Assert.EndsWith("psbt/ready", s.Page.Url); await wb.Broadcast(); await s.FindAlertMessage(); var bip21 = invoice .EntityToDTO(s.Server.PayTester.GetService>(), s.Server.PayTester.GetService()).CryptoInfo.First().PaymentUrls.BIP21; //let's make bip21 more interesting bip21 += "&label=Solid Snake&message=Snake? Snake? SNAAAAKE!"; var parsedBip21 = new BitcoinUrlBuilder(bip21, Network.RegTest); await s.GoToWalletSend(); // ReSharper disable once AsyncVoidMethod async void PasteBIP21(object sender, IDialog e) { await e.AcceptAsync(bip21); } s.Page.Dialog += PasteBIP21; await s.Page.ClickAsync("#bip21parse"); s.Page.Dialog -= PasteBIP21; await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info); Assert.Equal(parsedBip21.Amount!.ToString(false), await s.Page.Locator("#Outputs_0__Amount").GetAttributeAsync("value")); Assert.Equal(parsedBip21.Address!.ToString(), await s.Page.Locator("#Outputs_0__DestinationAddress").GetAttributeAsync("value")); await s.Page.ClickAsync("#CancelWizard"); await s.GoToWalletSettings(); var settingsUri = new Uri(s.Page.Url); await s.Page.ClickAsync("#ActionsDropdownToggle"); await s.Page.ClickAsync("#ViewSeed"); // Seed backup page var recoveryPhrase = await s.Page.Locator("#RecoveryPhrase").First.GetAttributeAsync("data-mnemonic"); Assert.Equal(mnemonic.ToString(), recoveryPhrase); Assert.Contains("The recovery phrase will also be stored on the server as a hot wallet.", await s.Page.ContentAsync()); // No confirmation, just a link to return to the wallet Assert.Equal(0, await s.Page.Locator("#confirm").CountAsync()); await s.Page.ClickAsync("#proceed"); Assert.Equal(settingsUri.ToString(), s.Page.Url); // Once more, test the cancel link of the wallet send page leads back to the previous page await s.GoToWallet(navPages: WalletsNavPages.Send); cancelUrl = await s.Page.Locator("#CancelWizard").GetAttributeAsync("href"); Assert.EndsWith(settingsUri.AbsolutePath, cancelUrl); // no previous page in the wizard, hence no back button Assert.Equal(0, await s.Page.Locator("#GoBack").CountAsync()); await s.Page.ClickAsync("#CancelWizard"); Assert.Equal(settingsUri.ToString(), s.Page.Url); // Transactions list contains export, ensure functions are present. await s.GoToWalletTransactions(); await s.Page.ClickAsync(".mass-action-select-all"); await s.Page.Locator("#BumpFee").WaitForAsync(); // JSON export await s.Page.ClickAsync("#ExportDropdownToggle"); var opening = s.Page.Context.WaitForPageAsync(); await s.Page.ClickAsync("#ExportJSON"); await using (_ = await s.SwitchPage(opening)) { await s.Page.WaitForLoadStateAsync(); var exportUri = new Uri(s.Page.Url); Assert.Contains(s.WalletId.ToString(), s.Page.Url); Assert.EndsWith("/export", exportUri.AbsolutePath); Assert.Contains("format=json", exportUri.Query); Assert.Contains("\"Amount\": \"3.00000000\"", await s.Page.ContentAsync()); } // CSV export await s.Page.ClickAsync("#ExportDropdownToggle"); var download = await s.Page.RunAndWaitForDownloadAsync(async () => { await s.Page.ClickAsync("#ExportCSV"); }); Assert.Contains(tx.ToString(), await File.ReadAllTextAsync(await download.PathAsync())); // BIP-329 export await s.Page.ClickAsync("#ExportDropdownToggle"); download = await s.Page.RunAndWaitForDownloadAsync(async () => { await s.Page.ClickAsync("#ExportBIP329"); }); Assert.Contains(tx.ToString(), await File.ReadAllTextAsync(await download.PathAsync())); } [Fact] [Trait("Playwright", "Playwright-2")] 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); }); } [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 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() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await CreateInvoices(s); var client = await s.AsTestAccount().CreateClient(); var txs = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Select(t => t.TransactionHash).ToArray(); Assert.Equal(3, txs.Length); var w = await s.GoToWalletTransactions(s.WalletId); await w.BumpFee(txs[0]); // Because a single transaction is selected, we should be able to select CPFP only (Because no change are available, we can't do RBF) await s.Page.Locator("[name='txId']").WaitForAsync(); Assert.Equal("disabled", await s.Page.GetAttributeAsync("#BumpMethod", "disabled")); Assert.Equal("CPFP", await s.Page.Locator("#BumpMethod").InnerTextAsync()); await s.ClickCancel(); // Same but using mass action await w.Select(txs[0]); await s.Page.ClickAsync("#BumpFee"); await s.Page.Locator("[name='txId']").WaitForAsync(); await s.ClickCancel(); // Because two transactions are select we can only mass bump on CPFP await w.Select(txs[0], txs[1]); await s.Page.ClickAsync("#BumpFee"); Assert.False(await s.Page.Locator("[name='txId']").IsVisibleAsync()); Assert.Equal("disabled", await s.Page.GetAttributeAsync("#BumpMethod", "disabled")); Assert.Equal("CPFP", await s.Page.Locator("#BumpMethod").InnerTextAsync()); var newExpectedEffectiveFeeRate = decimal.Parse(await s.Page.GetAttributeAsync("[name='FeeSatoshiPerByte']", "value") ?? string.Empty, CultureInfo.InvariantCulture); await s.ClickPagePrimary(); await s.Page.ClickAsync("#BroadcastTransaction"); await s.FindAlertMessage(partialText: "Transaction broadcasted successfully"); // The CPFP tag should be applied to the new tx var cpfpTx = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Select(t => t.TransactionHash).ToArray()[0]; await w.AssertHasLabels(cpfpTx, "CPFP"); // The CPFP should be RBF-able Assert.DoesNotContain(cpfpTx, txs); await w.BumpFee(cpfpTx); Assert.Null(await s.Page.GetAttributeAsync("#BumpMethod", "disabled")); Assert.Equal("RBF", await s.Page.Locator("#BumpMethod option:checked").InnerTextAsync()); var currentEffectiveFeeRate = decimal.Parse( await s.Page.GetAttributeAsync("[name='CurrentFeeSatoshiPerByte']", "value") ?? string.Empty, CultureInfo.InvariantCulture); // We CPFP'd two transactions with a newExpectedEffectiveFeeRate of 20.0 // When we want to RBF the previous CPFP, the currentEffectiveFeeRate should be coherent with our ealier choice Assert.Equal(newExpectedEffectiveFeeRate, currentEffectiveFeeRate, 0); await s.ClickPagePrimary(); await s.Page.ClickAsync("#BroadcastTransaction"); await s.FindAlertMessage(); await s.Page.ReloadAsync(); var rbfTx = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Select(t => t.TransactionHash).ToArray()[0]; // CPFP has been replaced, so it should not be found await w.AssertNotFound(cpfpTx); // However, the new transaction should have copied the CPFP tag from the transaction it replaced, and have an RBF label as well. await w.AssertHasLabels(rbfTx, "CPFP"); await w.AssertHasLabels(rbfTx, "RBF"); // Now, we sweep all the UTXOs to a single destination. This should be RBF-able. (Fee deducted on the lone UTXO) var send = await s.GoToWalletSend(); await send.FillAddress(new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest)); await send.SweepBalance(); await send.SetFeeRate(20m); await send.Sign(); await s.Page.ClickAsync("button[value=broadcast]"); // Now we RBF the sweep await w.BumpFee(); Assert.Equal("RBF", await s.Page.Locator("#BumpMethod").InnerTextAsync()); await s.ClickPagePrimary(); await s.Page.ClickAsync("#BroadcastTransaction"); await w.AssertHasLabels("RBF"); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanSearchLabelFilterInWalletTransactions() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); const int txCount = 22; const int distinctLabelCount = 21; for (var i = 0; i < txCount; i++) { await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m + i * 0.0001m)); } await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); await TestUtils.EventuallyAsync(async () => { var txs = await client.ShowOnChainWalletTransactions(s.StoreId, "BTC"); Assert.True(txs.Count() >= txCount); }); const string targetLabel = "zz-smoke-popular-target"; var labels = Enumerable.Range(0, distinctLabelCount - 1) .Select(i => $"smoke-alpha-{i:00}") .ToArray(); var transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(txCount).ToArray(); for (var i = 0; i < transactions.Length; i++) { var label = i < 2 ? targetLabel : labels[i - 2]; await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", transactions[i].TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { label } }); } await s.GoToWalletTransactions(s.WalletId); await s.SearchFilters.LabelSelectorToggle.ClickAsync(); await s.Page.Locator("#LabelSelectorMenu").WaitForAsync(); await s.Page.Locator("#LabelSearch").WaitForAsync(); await TestUtils.EventuallyAsync(async () => { Assert.Equal(20, await s.Page.Locator("#LabelSelectorMenu .label-filter-item").CountAsync()); Assert.True(await s.Page.Locator($"#LabelSelectorMenu .label-filter-item span:has-text('{targetLabel}')").IsVisibleAsync()); var targetItem = s.Page .Locator("#LabelSelectorMenu .label-filter-item") .Filter(new() { Has = s.Page.Locator($".label-filter-text:text-is('{targetLabel}')") }); Assert.Equal("2", (await targetItem.Locator(".label-filter-count").InnerTextAsync()).Trim()); var singleUseLabel = labels[0]; var singleUseItem = s.Page .Locator("#LabelSelectorMenu .label-filter-item") .Filter(new() { Has = s.Page.Locator($".label-filter-text:text-is('{singleUseLabel}')") }); Assert.Equal("1", (await singleUseItem.Locator(".label-filter-count").InnerTextAsync()).Trim()); }); await s.Page.FillAsync("#LabelSearch", "target"); await TestUtils.EventuallyAsync(async () => { var items = await s.Page.Locator("#LabelSelectorMenu .label-filter-item").CountAsync(); Assert.Equal(1, items); Assert.Equal("2", (await s.Page.Locator("#LabelSelectorMenu .label-filter-item .label-filter-count").InnerTextAsync()).Trim()); }); await s.Page.ClickAsync($"#LabelSelectorMenu .label-filter-item span:has-text('{targetLabel}')"); await TestUtils.EventuallyAsync(() => { Assert.Contains($"label:{targetLabel}", Uri.UnescapeDataString(s.Page.Url)); return Task.CompletedTask; }); await s.InWalletTransactions().AssertHasLabels(targetLabel); // Import BIP-329 labels from the transactions page toolbar (#6663) var walletId = new WalletId(s.StoreId, "BTC"); const string toolbarLabel = "bip329-toolbar"; var toolbarTxId = transactions[1].TransactionHash.ToString(); var toolbarFile = Path.Combine(Path.GetTempPath(), $"bip329-{Guid.NewGuid():N}.jsonl"); await File.WriteAllTextAsync(toolbarFile, $"{{\"type\":\"tx\",\"ref\":\"{toolbarTxId}\",\"label\":\"{toolbarLabel}\"}}\n"); await s.Page.SetInputFilesAsync("#Dropdowns .wallet-labels__import-file", toolbarFile); await s.Page.WaitForURLAsync(s.ServerUri + $"wallets/{walletId}/labels"); await s.FindAlertMessage(partialText: "Imported 1 label(s)."); var toolbarTx = await client.GetOnChainWalletTransaction(s.StoreId, "BTC", toolbarTxId); Assert.Contains(toolbarLabel, toolbarTx.Labels.Keys); await s.GoToWalletTransactions(s.WalletId); // The label dropdown exposes a "Manage Labels" link that navigates to the wallet labels page (#7252) await s.SearchFilters.LabelSelectorToggle.ClickAsync(); await s.Page.ClickAsync("#LabelSelectorMenu a:has-text('Manage Labels')"); await s.Page.WaitForURLAsync(s.ServerUri + $"wallets/{walletId}/labels"); // Import BIP-329 labels from the wallet labels page (#6663) const string importedLabel = "bip329-imported"; var importedTxId = transactions[0].TransactionHash.ToString(); var importFile = Path.Combine(Path.GetTempPath(), $"bip329-{Guid.NewGuid():N}.jsonl"); await File.WriteAllTextAsync(importFile, $"{{\"type\":\"tx\",\"ref\":\"{importedTxId}\",\"label\":\"{importedLabel}\"}}\n" + "not json\n"); await s.Page.SetInputFilesAsync(".wallet-labels__import-file", importFile); await s.Page.ClickAsync(".wallet-labels__import-button"); await s.FindAlertMessage(partialText: "Imported 1 label(s), skipped 1 line(s)."); Assert.True(await s.Page.Locator($".transaction-label:has-text('{importedLabel}')").IsVisibleAsync()); var importedTx = await client.GetOnChainWalletTransaction(s.StoreId, "BTC", importedTxId); Assert.Contains(importedLabel, importedTx.Labels.Keys); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanPreserveSearchTextWhenApplyingWalletLabelFilter() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m)); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.002m)); await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); var transactions = Array.Empty(); await TestUtils.EventuallyAsync(async () => { transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray(); Assert.Equal(2, transactions.Length); }); const string targetLabel = "preserve-search-label"; const string otherLabel = "different-wallet-label"; var targetSearchText = transactions[0].TransactionHash.ToString()[..12]; await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", transactions[0].TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { targetLabel } }); await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", transactions[1].TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { otherLabel } }); await s.GoToWalletTransactions(s.WalletId); await s.SearchFilters.FillSearchText(targetSearchText); await TestUtils.EventuallyAsync(async () => { await s.SearchFilters.AssertSearchText(targetSearchText); var urlAfterSearch = new Uri(s.Page.Url); var qsAfterSearch = HttpUtility.ParseQueryString(urlAfterSearch.Query); Assert.Equal(targetSearchText, qsAfterSearch["SearchText"]); Assert.True(string.IsNullOrEmpty(qsAfterSearch["SearchTerm"])); }); await s.SearchFilters.SelectLabel(targetLabel); await s.SearchFilters.AssertSearchText(targetSearchText); Assert.Contains($"label:{targetLabel}", await s.SearchFilters.SearchTermValue()); await Expect(s.SearchFilters.LabelSelectorToggle).ToContainTextAsync(targetLabel); var urlAfterLabelFilter = new Uri(s.Page.Url); var qsAfterLabelFilter = HttpUtility.ParseQueryString(urlAfterLabelFilter.Query); Assert.Equal(targetSearchText, qsAfterLabelFilter["SearchText"]); Assert.Contains($"label:{targetLabel}", Uri.UnescapeDataString(qsAfterLabelFilter["SearchTerm"] ?? string.Empty)); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanPreserveSearchTextWhenApplyingWalletDatePresetFilter() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m)); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.002m)); await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); var transactions = Array.Empty(); await TestUtils.EventuallyAsync(async () => { transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray(); Assert.Equal(2, transactions.Length); }); var targetSearchText = transactions[0].TransactionHash.ToString()[..12]; await s.GoToWalletTransactions(s.WalletId); await s.SearchFilters.FillSearchText(targetSearchText); await TestUtils.EventuallyAsync(async () => { await s.SearchFilters.AssertSearchText(targetSearchText); var urlAfterSearch = new Uri(s.Page.Url); var qsAfterSearch = HttpUtility.ParseQueryString(urlAfterSearch.Query); Assert.Equal(targetSearchText, qsAfterSearch["SearchText"]); Assert.True(string.IsNullOrEmpty(qsAfterSearch["SearchTerm"])); }); await s.SearchFilters.SelectDateRangePreset("This month"); await s.SearchFilters.AssertSearchText(targetSearchText); await Expect(s.SearchFilters.DateRangeSelector).ToHaveTextAsync("This month"); Assert.Contains("daterange:thismonth", await s.SearchFilters.SearchTermValue()); var urlAfterPreset = new Uri(s.Page.Url); var qsAfterPreset = HttpUtility.ParseQueryString(urlAfterPreset.Query); Assert.Equal(targetSearchText, qsAfterPreset["SearchText"]); Assert.Contains("daterange:thismonth", Uri.UnescapeDataString(qsAfterPreset["SearchTerm"] ?? string.Empty)); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanFilterWalletTransactionsByNoLabel() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m)); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.002m)); await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); var transactions = Array.Empty(); await TestUtils.EventuallyAsync(async () => { transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray(); Assert.Equal(2, transactions.Length); }); var labeledTx = transactions[0]; var unlabeledTx = transactions[1]; const string targetLabel = "no-label-excluded"; await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", labeledTx.TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { targetLabel } }); await s.GoToWalletTransactions(s.WalletId); await s.SearchFilters.SelectNoLabel(); await Expect(s.SearchFilters.LabelSelectorToggle).ToContainTextAsync("No Label"); Assert.Contains("nolabel:true", await s.SearchFilters.SearchTermValue()); var urlAfterNoLabelFilter = new Uri(s.Page.Url); var qsAfterNoLabelFilter = HttpUtility.ParseQueryString(urlAfterNoLabelFilter.Query); Assert.Contains("nolabel:true", Uri.UnescapeDataString(qsAfterNoLabelFilter["SearchTerm"] ?? string.Empty)); await Expect(s.Page.Locator($".transaction-row[data-value='{unlabeledTx.TransactionHash}']")).ToBeVisibleAsync(); await Expect(s.Page.Locator($".transaction-row[data-value='{labeledTx.TransactionHash}']")).ToBeHiddenAsync(); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanCombineWalletDirectionAndLabelFilters() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m)); await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync(); var outgoingTx = await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest { Destinations = [ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination { Destination = nodeAddress.ToString(), Amount = 0.1m } ], FeeRate = new FeeRate(5m) }); await s.Server.ExplorerNode.GenerateAsync(1); var transactions = Array.Empty(); await TestUtils.EventuallyAsync(async () => { transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray(); Assert.Contains(transactions, tx => tx.TransactionHash == outgoingTx.TransactionHash); }); var incomingTx = transactions.Single(tx => tx.TransactionHash != outgoingTx.TransactionHash); const string targetLabel = "combined-wallet-filter-target"; await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", incomingTx.TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { targetLabel } }); await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", outgoingTx.TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { targetLabel } }); await s.GoToWalletTransactions(s.WalletId); await SelectWalletDirection(s, "Outgoing"); await s.SearchFilters.SelectLabel(targetLabel); await s.Page.WaitForLoadStateAsync(); await AssertDirectionToggle(s, "Outgoing"); await Expect(s.SearchFilters.LabelSelectorToggle).ToContainTextAsync(targetLabel); var hiddenSearchTerm = await s.SearchFilters.SearchTermValue(); Assert.Contains("direction:out", hiddenSearchTerm); Assert.Contains($"label:{targetLabel}", hiddenSearchTerm); var urlAfterCombinedFilter = new Uri(s.Page.Url); var qsAfterCombinedFilter = HttpUtility.ParseQueryString(urlAfterCombinedFilter.Query); var searchTerm = Uri.UnescapeDataString(qsAfterCombinedFilter["SearchTerm"] ?? string.Empty); Assert.Contains("direction:out", searchTerm); Assert.Contains($"label:{targetLabel}", searchTerm); await Expect(s.Page.Locator($".transaction-row[data-value='{outgoingTx.TransactionHash}']")).ToBeVisibleAsync(); await Expect(s.Page.Locator($".transaction-row[data-value='{incomingTx.TransactionHash}']")).ToBeHiddenAsync(); await Expect(s.Page.Locator(".transaction-row")).ToHaveCountAsync(1); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanFilterWalletTransactionsByDirectionWithoutPollutingSearchText() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m)); await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync(); await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest { Destinations = [ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination { Destination = nodeAddress.ToString(), Amount = 0.1m } ], FeeRate = new FeeRate(5m) }); await s.Server.ExplorerNode.GenerateAsync(1); await TestUtils.EventuallyAsync(async () => { Assert.True((await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Count() >= 2); }); await s.GoToWalletTransactions(s.WalletId); await s.SearchFilters.AssertSearchText(string.Empty); await SelectWalletDirection(s, "Outgoing"); await AssertDirectionToggle(s, "Outgoing"); Assert.Contains("direction:out", await s.SearchFilters.SearchTermValue()); await s.SearchFilters.AssertSearchText(string.Empty); await Expect(s.Page.Locator(".transaction-row .amount-col .text-danger").First).ToBeVisibleAsync(); await Expect(s.Page.Locator(".transaction-row .amount-col .text-success")).ToHaveCountAsync(0); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanClearAllWalletTransactionFiltersAndSearchText() { await using var s = CreatePlaywrightTester(); async Task AssertDateRangeTimeZone(string timeZone) { await s.SearchFilters.OpenDateRange(); await Expect(s.SearchFilters.DateRangeTimeZone).ToHaveValueAsync(timeZone); } await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m)); await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync(); var outgoingTx = await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest { Destinations = [ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination { Destination = nodeAddress.ToString(), Amount = 0.1m } ], FeeRate = new FeeRate(5m) }); await s.Server.ExplorerNode.GenerateAsync(1); await TestUtils.EventuallyAsync(async () => { Assert.True((await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Count() >= 2); }); const string targetLabel = "clear-all-target"; await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", outgoingTx.TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { targetLabel } }); await s.GoToWalletTransactions(s.WalletId); await s.SearchFilters.OpenDateRange(); var browserTimeZone = await s.Page.EvaluateAsync("() => Intl.DateTimeFormat().resolvedOptions().timeZone"); await Expect(s.SearchFilters.DateRangeTimeZone).ToHaveValueAsync(browserTimeZone + " (Default)"); await s.SearchFilters.DateRangeTimeZone.ClickAsync(); await Expect(s.SearchFilters.DateRangeTimeZone).ToHaveValueAsync(string.Empty); await Expect(s.SearchFilters.DateRangeTimeZone).ToHaveAttributeAsync("placeholder", browserTimeZone + " (Default)"); await s.SearchFilters.DateRangeTimeZone.PressAsync("Tab"); await Expect(s.SearchFilters.DateRangeTimeZone).ToHaveValueAsync(browserTimeZone + " (Default)"); const string selectedTimeZone = "America/New_York"; await s.SearchFilters.SelectTimeZone(selectedTimeZone); await AssertDateRangeTimeZone(selectedTimeZone); await s.SearchFilters.FillSearchText(targetLabel); await SelectWalletDirection(s, "Outgoing"); await s.SearchFilters.AssertSearchText(targetLabel); var searchTermWithFilters = await s.SearchFilters.SearchTermValue(); Assert.Contains("direction:out", searchTermWithFilters); Assert.Contains($"timezone:{selectedTimeZone}", searchTermWithFilters); await AssertDirectionToggle(s, "Outgoing"); await Expect(s.Page.Locator(".transaction-row")).ToHaveCountAsync(1); await Expect(s.SearchFilters.ClearAllFiltersButton).ToHaveCountAsync(1); await s.SearchFilters.ClearAllFilters(); await s.SearchFilters.AssertSearchText(string.Empty); await Expect(s.SearchFilters.SearchTerm).ToHaveValueAsync($"timezone:{selectedTimeZone}"); await AssertDateRangeTimeZone(selectedTimeZone); await AssertDirectionToggle(s, "All Directions"); await Expect(s.SearchFilters.ClearAllFiltersButton).ToHaveCountAsync(0); Assert.True(await s.Page.Locator(".transaction-row").CountAsync() >= 2); await Expect(s.Page.Locator(".transaction-row .amount-col .text-danger").First).ToBeVisibleAsync(); await Expect(s.Page.Locator(".transaction-row .amount-col .text-success").First).ToBeVisibleAsync(); var urlAfterClearAll = new Uri(s.Page.Url); var qsAfterClearAll = HttpUtility.ParseQueryString(urlAfterClearAll.Query); Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchText"])); Assert.Equal($"timezone:{selectedTimeZone}", qsAfterClearAll["SearchTerm"]); await s.GoToInvoices(s.StoreId); await AssertDateRangeTimeZone(selectedTimeZone); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanPreserveWalletFiltersInExportLinks() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.Server.ExplorerNode.GenerateAsync(1); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.GoToWallet(s.WalletId, WalletsNavPages.Receive); var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m)); await s.Server.ExplorerNode.GenerateAsync(1); var client = await s.AsTestAccount().CreateClient(); var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync(); var outgoingTx = await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest { Destinations = [ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination { Destination = nodeAddress.ToString(), Amount = 0.1m } ], FeeRate = new FeeRate(5m) }); await s.Server.ExplorerNode.GenerateAsync(1); var transactions = Array.Empty(); await TestUtils.EventuallyAsync(async () => { transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray(); Assert.Contains(transactions, tx => tx.TransactionHash == outgoingTx.TransactionHash); }); var incomingTx = transactions.Single(tx => tx.TransactionHash != outgoingTx.TransactionHash); const string targetLabel = "export-wallet-filter-target"; const string otherLabel = "export-wallet-filter-other"; var targetSearchText = outgoingTx.TransactionHash.ToString()[..12]; await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", outgoingTx.TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { targetLabel } }); await client.PatchOnChainWalletTransaction( s.StoreId, "BTC", incomingTx.TransactionHash.ToString(), new PatchOnChainTransactionRequest { Labels = new List { otherLabel } }); await s.GoToWalletTransactions(s.WalletId); await s.SearchFilters.FillSearchText(targetSearchText); await SelectWalletDirection(s, "Outgoing"); await s.SearchFilters.SelectLabel(targetLabel); await s.SearchFilters.SelectCustomStartDate("2026-03-01T12:34:56"); await s.SearchFilters.AssertSearchText(targetSearchText); var hiddenSearchTerm = await s.SearchFilters.SearchTermValue(); Assert.Contains("direction:out", hiddenSearchTerm); Assert.Contains($"label:{targetLabel}", hiddenSearchTerm); Assert.Contains("startdate:2026-03-01T12:34:56", hiddenSearchTerm); await Expect(s.Page.Locator("#Export input[name='searchText']")).ToHaveValueAsync(targetSearchText); var exportSearchTerm = await s.Page.InputValueAsync("#Export input[name='searchTerm']"); Assert.Contains("direction:out", exportSearchTerm); Assert.Contains($"label:{targetLabel}", exportSearchTerm); Assert.Contains("startdate:2026-03-01T12:34:56", exportSearchTerm); Assert.DoesNotContain(otherLabel, exportSearchTerm); } private async Task CreateInvoices(PlaywrightTester tester) { var client = await tester.AsTestAccount().CreateClient(); var creating = Enumerable.Range(0, 3) .Select(_ => client.CreateInvoice(tester.StoreId, new() { Amount = 10m })); foreach (var c in creating) { var created = await c; await tester.GoToUrl($"i/{created.Id}"); await tester.PayInvoice(); } } private static Task AssertDirectionToggle(PlaywrightTester tester, string text) => Expect(tester.Page.Locator(".wallet-transactions__direction-toggle")).ToContainTextAsync(text); private static async Task SelectWalletDirection(PlaywrightTester tester, string direction) { await tester.Page.ClickAsync(".wallet-transactions__direction"); await tester.Page.ClickAsync($".wallet-transactions__direction-option:has-text('{direction}')"); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanUseCoinSelection() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(true); var (_, storeId) = await s.CreateNewStore(); await s.GenerateWallet("BTC", "", true); var walletId = new WalletId(storeId, "BTC"); await s.GoToWallet(walletId, WalletsNavPages.Receive); var addressStr = await s.Page.Locator("#Address").GetAttributeAsync("data-text"); var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork); await s.Server.ExplorerNode.GenerateAsync(1); for (var i = 0; i < 6; i++) { await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m)); } var handlers = s.Server.PayTester.GetService(); var targetTx = await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.2m)); var tx = await s.Server.ExplorerNode.GetRawTransactionAsync(targetTx); var spentOutpoint = new OutPoint(targetTx, tx.Outputs.FindIndex(txout => txout.Value == Money.Coins(1.2m))); var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(walletId.CryptoCode); await TestUtils.EventuallyAsync(async () => { var store = await s.Server.PayTester.StoreRepository.FindStore(storeId); var x = store!.GetPaymentMethodConfig(pmi, handlers); var wallet = s.Server.PayTester.GetService().GetWallet(walletId.CryptoCode); wallet.InvalidateCache(x!.AccountDerivation); Assert.Contains( await wallet.GetUnspentCoins(x.AccountDerivation), coin => coin.OutPoint == spentOutpoint); }); await s.Server.ExplorerNode.GenerateAsync(1); await s.GoToWallet(walletId, WalletsNavPages.Send); await s.Page.Locator("#toggleInputSelection").ClickAsync(); await s.Page.Locator($"[id='{spentOutpoint}']").WaitForAsync(); Assert.Equal("true", (await s.Page.Locator("[name='InputSelection']").InputValueAsync()).ToLowerInvariant()); // Select All test await s.Page.Locator("#select-all-checkbox").ClickAsync(); var selectedOptions = await s.Page.Locator("[name='SelectedInputs'] option[selected]").AllAsync(); var listItems = await s.Page.Locator("li.list-group-item").AllAsync(); Assert.Equal(listItems.Count, selectedOptions.Count); await s.Page.Locator("#select-all-checkbox").ClickAsync(); selectedOptions = await s.Page.Locator("[name='SelectedInputs'] option[selected]").AllAsync(); Assert.Empty(selectedOptions); await s.Page.Locator($"[id='{spentOutpoint}']").ClickAsync(); selectedOptions = await s.Page.Locator("[name='SelectedInputs'] option[selected]").AllAsync(); Assert.Single(selectedOptions); var bob = new Key().PubKey.Hash.GetAddress(Network.RegTest); await s.Page.Locator("[name='Outputs[0].DestinationAddress']").FillAsync(bob.ToString()); var amountInput = s.Page.Locator("[name='Outputs[0].Amount']"); await amountInput.FillAsync("0.3"); var checkboxElement = s.Page.Locator("input[type='checkbox'][name='Outputs[0].SubtractFeesFromOutput']"); if (!await checkboxElement.IsCheckedAsync()) { await checkboxElement.ClickAsync(); } await s.Page.Locator("#SignTransaction").ClickAsync(); await s.Page.Locator("button[value='broadcast']").ClickAsync(); var happyElement = await s.FindAlertMessage(); var happyText = await happyElement.InnerTextAsync(); var txid = System.Text.RegularExpressions.Regex.Match(happyText, @"\((.*)\)").Groups[1].Value; tx = await s.Server.ExplorerNode.GetRawTransactionAsync(new uint256(txid)); Assert.Single(tx.Inputs); Assert.Equal(spentOutpoint, tx.Inputs[0].PrevOut); } [Fact] [Trait("Playwright", "Playwright-2")] public async Task CanUseCPFP() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(true); await s.CreateNewStore(); await s.GenerateWallet(isHotWallet: true); await s.FundStoreWallet(); await CreateInvoices(s); // Let's CPFP from the invoices page await s.GoToInvoices(s.StoreId); await s.Page.SetCheckedAsync(".mass-action-select-all", true); await s.Page.ClickAsync("#BumpFee"); await s.ClickPagePrimary(); await s.Page.ClickAsync("#BroadcastTransaction"); await s.FindAlertMessage(); Assert.Contains($"/stores/{s.StoreId}/invoices", s.Page.Url); // CPFP again should fail because all invoices got bumped await s.GoToInvoices(); await s.Page.SetCheckedAsync(".mass-action-select-all", true); await s.Page.ClickAsync("#BumpFee"); Assert.Contains($"/stores/{s.StoreId}/invoices", s.Page.Url); await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error, partialText: "No UTXOs available"); for (var i = 0; i < 5; i++) { var txs = await s.GoToWalletTransactions(s.WalletId); await txs.SelectAll(); await txs.BumpFeeSelected(); await s.ClickPagePrimary(); await s.Page.ClickAsync("#BroadcastTransaction"); Assert.Contains($"/wallets/{s.WalletId}", s.Page.Url); await s.FindAlertMessage(partialText: "Transaction broadcasted successfully"); // The CPFP tag should be applied to the new tx await txs.AssertHasLabels("CPFP"); } } }