Tests: Move tests to PullPaymentTests.cs
What changed, and why it matters
This commit is purely a test-code reorganization. It moves several Playwright UI tests from one test file to another and creates a new dedicated WalletTests.cs file. No production application code was changed, so it cannot introduce a security vulnerability in the software users run.
No security action needed; this is a test refactoring. Reviewers may verify that the moved tests still pass in CI.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff shows only changes under BTCPayServer.Tests. Methods CanUsePullPaymentsViaUI, CanManageWallet, CanUseReservedAddressesView, CanImportMnemonic, CanImportWallet, and CanUseCoinSelectionFilters are relocated from PlaywrightTests.cs into PullPaymentsTests.cs and WalletTests.cs. Minor formatting and using-statement adjustments accompany the move. There are no changes to controllers, services, data access, or any runtime code path.
Changed components
BTCPayServer.Tests/PlaywrightTests.csBTCPayServer.Tests/PullPaymentsTests.csBTCPayServer.Tests/WalletTests.csInspect captured patch +1016 / −971
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 51e5039..e1369f7 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
-using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
@@ -19,7 +18,6 @@ using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
-using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
@@ -35,9 +33,7 @@ using Microsoft.Playwright;
using static Microsoft.Playwright.Assertions;
using NBitcoin;
using NBitcoin.DataEncoders;
-using NBitcoin.Payment;
using NBXplorer;
-using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -67,480 +63,6 @@ namespace BTCPayServer.Tests
Assert.Contains("Starting listening NBXplorer", await s.Page.ContentAsync());
}
- [Fact]
- [Trait("Playwright", "Playwright")]
- [Trait("Lightning", "Lightning")]
- public async Task CanUsePullPaymentsViaUI()
- {
- await using var s = CreatePlaywrightTester();
- s.Server.DeleteStore = false;
- s.Server.ActivateLightning(LightningConnectionType.LndREST);
- await s.StartAsync();
- await s.Server.EnsureChannelsSetup();
- await s.RegisterNewUser(true);
- await s.CreateNewStore();
- await s.GenerateWallet("BTC", "", true, true);
-
- await s.Server.ExplorerNode.GenerateAsync(1);
- await s.FundStoreWallet(denomination: 50.0m);
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
- await s.ClickPagePrimary();
- await s.Page.FillAsync("#Name", "PP1");
- await s.Page.FillAsync("#Amount", "99.0");
- await s.ClickPagePrimary();
-
- await using (_ = await s.SwitchPage(async () =>
- {
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- }))
- {
- await Expect(s.Page.Locator("body")).ToContainTextAsync("PP1");
- }
-
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
-
- await s.ClickPagePrimary();
- await s.Page.FillAsync("#Name", "PP2");
- await s.Page.FillAsync("#Amount", "100.0");
- await s.ClickPagePrimary();
-
- string viewPullPaymentUrl;
- // This should select the first View, ie, the last one PP2
- await using (await s.SwitchPage(async () =>
- {
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- }))
- {
- var address = await s.Server.ExplorerNode.GetNewAddressAsync();
- await s.Page.FillAsync("#Destination", address.ToString());
- await s.Page.FillAsync("#ClaimedAmount", "15");
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
- await s.FindAlertMessage();
-
- // We should not be able to use an address already used
- await s.Page.FillAsync("#Destination", address.ToString());
- await s.Page.FillAsync("#ClaimedAmount", "20");
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
- await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error);
-
- address = await s.Server.ExplorerNode.GetNewAddressAsync();
- await s.Page.FillAsync("#Destination", address.ToString());
- await s.Page.FillAsync("#ClaimedAmount", "20");
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
- await s.FindAlertMessage();
- await Expect(s.Page.Locator("body")).ToContainTextAsync("Awaiting Approval");
-
- viewPullPaymentUrl = s.Page.Url;
- }
-
- // This one should have nothing
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
- var payouts = s.Page.Locator(".pp-payout");
- await Expect(payouts).ToHaveCountAsync(2);
- await payouts.Nth(1).ClickAsync();
- await Expect(s.Page.Locator(".payout")).ToHaveCountAsync(0);
- // PP2 should have payouts
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
- payouts = s.Page.Locator(".pp-payout");
- await payouts.First.ClickAsync();
-
- await TestUtils.EventuallyAsync(async () =>
- {
- var count = await s.Page.Locator(".payout").CountAsync();
- Assert.True(count > 0);
- });
- await s.Page.CheckAsync(".mass-action-select-all");
- await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve-pay");
-
- await s.Page.ClickAsync("#SignTransaction");
- await s.Page.ClickAsync("button[value='broadcast']");
- await s.FindAlertMessage();
-
- var pmo = await s.GoToWalletTransactions();
- await Expect(s.Page.Locator(".transaction-label")).ToHaveCountAsync(2);
- await pmo.AssertHasLabels("payout");
- await pmo.AssertHasLabels("pull-payment");
-
- await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
- await s.Page.ClickAsync($"#{PayoutState.InProgress}-view");
-
- await Expect(s.Page.Locator(".transaction-link")).ToHaveCountAsync(2);
-
- await s.GoToUrl(viewPullPaymentUrl);
- await Expect(s.Page.Locator(".transaction-link")).ToHaveCountAsync(2);
- await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.InProgress.GetStateString());
-
- await s.Server.ExplorerNode.GenerateAsync(1);
-
- await TestUtils.EventuallyAsync(async () =>
- {
- await s.Page.ReloadAsync();
- Assert.Contains(PayoutState.Completed.GetStateString(), await s.Page.ContentAsync());
- });
- await s.Server.ExplorerNode.GenerateAsync(10);
- var pullPaymentId = viewPullPaymentUrl.Split('/').Last();
-
- await TestUtils.EventuallyAsync(async () =>
- {
- await using var ctx = s.Server.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
- var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync();
- Assert.True(payoutsData.All(p => p.State == PayoutState.Completed));
- });
- await s.GoToHome();
- //offline/external payout test
-
- await s.CreateNewStore();
- await s.GenerateWallet("BTC", "", true, true);
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
-
- await s.ClickPagePrimary();
- await s.Page.FillAsync("#Name", "External Test");
- await s.Page.FillAsync("#Amount", "0.001");
- await s.Page.FillAsync("#Currency", "BTC");
- await s.ClickPagePrimary();
-
- await using (await s.SwitchPage(async () =>
- {
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- }))
- {
- var address = await s.Server.ExplorerNode.GetNewAddressAsync();
- await s.Page.FillAsync("#Destination", address.ToString());
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
- await s.FindAlertMessage();
-
- await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingApproval.GetStateString());
- await s.Page.Context.Pages.First().BringToFrontAsync();
- }
-
- await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
- await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-view");
- await s.Page.CheckAsync(".mass-action-select-all");
- await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve");
- await s.FindAlertMessage();
- var onchainAddress = await s.Server.ExplorerNode.GetNewAddressAsync();
- var tx = await s.Server.ExplorerNode.SendToAddressAsync(onchainAddress, Money.FromUnit(0.001m, MoneyUnit.BTC));
- await s.Page.Context.Pages.First().BringToFrontAsync();
-
- await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
-
- await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-view");
- await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingPayment.GetStateString());
- await s.Page.CheckAsync(".mass-action-select-all");
- await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-mark-paid");
- await s.FindAlertMessage();
-
- await s.Page.ClickAsync($"#{PayoutState.InProgress}-view");
- await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
- await Expect(s.Page.Locator(".payout")).ToHaveCountAsync(0);
- await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
-
- await TestUtils.EventuallyAsync(async () =>
- {
- await s.Page.ReloadAsync();
- var rows = s.Page.Locator(".payout");
- var count = await rows.CountAsync();
- Assert.True(count > 0);
- // Basic sanity: source column shows the pull payment name
- var rowText = await rows.First.InnerTextAsync();
- Assert.Contains("External Test", rowText);
- });
-
- // lightning tests
- // Since the merchant is sending on lightning, it needs some liquidity from the client
- var payoutAmount = LightMoney.Satoshis(1000);
- var minimumReserve = LightMoney.Satoshis(167773m);
- var inv = await s.Server.MerchantLnd.Client.CreateInvoice(minimumReserve + payoutAmount, "Donation to merchant", TimeSpan.FromHours(1), default);
- var resp = await s.Server.CustomerLightningD.Pay(inv.BOLT11);
- Assert.Equal(PayResult.Ok, resp.Result);
-
- var newStore = await s.CreateNewStore();
- await s.AddLightningNode();
-
- //Currently an onchain wallet is required to use the Lightning payouts feature..
- await s.GenerateWallet("BTC", "", true, true);
- await s.GoToStore(newStore.storeId, StoreNavPages.PullPayments);
- await s.ClickPagePrimary();
-
- var paymentMethodOptions = s.Page.Locator("input[name='PayoutMethods']");
- await Expect(paymentMethodOptions).ToHaveCountAsync(2);
-
- await s.Page.FillAsync("#Name", "Lightning Test");
- await s.Page.FillAsync("#Amount", payoutAmount.ToString());
- await s.Page.FillAsync("#Currency", "BTC");
- await s.ClickPagePrimary();
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- string bolt;
- await using (await s.SwitchPage(async () =>
- {
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- }))
- {
- // Bitcoin-only, SelectedPaymentMethod should not be displayed
- await Expect(s.Page.Locator("#SelectedPayoutMethod")).ToHaveCountAsync(0);
-
- bolt = (await s.Server.CustomerLightningD.CreateInvoice(
- payoutAmount,
- $"LN payout test {DateTime.UtcNow.Ticks}",
- TimeSpan.FromHours(1), CancellationToken.None)).BOLT11;
- await s.Page.FillAsync("#Destination", bolt);
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
- //we do not allow short-life bolts.
- await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error);
-
- bolt = (await s.Server.CustomerLightningD.CreateInvoice(
- payoutAmount,
- $"LN payout test {DateTime.UtcNow.Ticks}",
- TimeSpan.FromDays(31), CancellationToken.None)).BOLT11;
- await s.Page.FillAsync("#Destination", bolt);
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
- await s.FindAlertMessage();
-
- await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingApproval.GetStateString());
- }
-
- await s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
- await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
- await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-view");
- await s.Page.CheckAsync(".mass-action-select-all");
- await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve-pay");
- await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
- await Expect(s.Page.Locator("body")).ToContainTextAsync($"{payoutAmount} BTC");
- await s.Page.Locator("#pay-invoices-form").EvaluateAsync("form => form.submit()");
-
- await s.FindAlertMessage();
- await s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
- await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
-
- await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
- if (!(await s.Page.ContentAsync()).Contains(bolt))
- {
- await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-view");
- await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
-
- await s.Page.CheckAsync(".mass-action-select-all");
- await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-mark-paid");
- await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
-
- await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
- await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
- }
-
- //auto-approve pull payments
- await s.GoToStore(StoreNavPages.PullPayments);
- await s.ClickPagePrimary();
- await s.Page.FillAsync("#Name", "PP1");
- await s.Page.CheckAsync("#AutoApproveClaims");
- await s.Page.FillAsync("#Amount", "99.0");
- await s.Page.PressAsync("#Amount", "Enter");
- await s.FindAlertMessage();
-
- string lnurlStr = null;
- await using (await s.SwitchPage(async () =>
- {
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- }))
- {
- var address = await s.Server.ExplorerNode.GetNewAddressAsync();
- await s.Page.FillAsync("#Destination", address.ToString());
- await s.Page.FillAsync("#ClaimedAmount", "20");
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
- await s.FindAlertMessage();
-
- await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingPayment.GetStateString());
- }
-
- // LNURL Withdraw support check with BTC denomination
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
- await s.ClickPagePrimary();
- await s.Page.FillAsync("#Name", "PP1");
- await s.Page.CheckAsync("#AutoApproveClaims");
- await s.Page.FillAsync("#Amount", "0.0000001");
- await s.Page.FillAsync("#Currency", "BTC");
- await s.Page.PressAsync("#Currency", "Enter");
- await s.FindAlertMessage();
-
- await using (await s.SwitchPage(async () =>
- {
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- }))
- {
- await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
- await s.Page.ClickAsync("#lnurlwithdraw-button");
- await s.Page.WaitForFunctionAsync("() => document.querySelector('#qr-code-data-input')?.value?.length > 0");
-
- // Try to use lnurlw via the QR Code
- lnurlStr = await s.Page.Locator("#qr-code-data-input").InputValueAsync();
- var lnurl = new Uri(LNURL.LNURL.Parse(lnurlStr, out _).ToString().Replace("https", "http"));
- await s.Page.ClickAsync("button[data-bs-dismiss='modal']");
- var info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(lnurl, s.Server.PayTester.HttpClient));
- Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
- Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
- info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient));
- Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
- Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
-
- var bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
- new LightMoney(0.00000005m, LightMoneyUnit.BTC),
- $"LNurl w payout test {DateTime.UtcNow.Ticks}",
- TimeSpan.FromHours(1), CancellationToken.None));
- var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
- // Oops!
- Assert.Equal("The request has been approved. The sender needs to send the payment manually. (Or activate the lightning automated payment processor)", response.Reason);
- var account = await s.AsTestAccount().CreateClient();
- await account.UpdateStoreLightningAutomatedPayoutProcessors(s.StoreId, "BTC-LN", new()
- {
- ProcessNewPayoutsInstantly = true,
- IntervalSeconds = TimeSpan.FromSeconds(60)
- });
- // Now it should process to complete
- await TestUtils.EventuallyAsync(async () =>
- {
- await s.Page.ReloadAsync();
- var content = await s.Page.ContentAsync();
- Assert.Contains(bolt2.BOLT11, content);
- Assert.Contains(PayoutState.Completed.GetStateString(), content);
- Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status);
- });
- }
-
- // Simulate a boltcard
- Assert.False(string.IsNullOrEmpty(lnurlStr), "LNURL string should have been captured from the previous flow");
- {
- var db = s.Server.PayTester.GetService<ApplicationDbContextFactory>();
- var ppid = new Uri(LNURL.LNURL.Parse(lnurlStr, out _).ToString().Replace("https", "http")).AbsoluteUri.Split('/').Last();
- var issuerKey = new IssuerKey(SettingsRepositoryExtensions.FixedKey());
- var uid = RandomNumberGenerator.GetBytes(7);
- var cardKey = issuerKey.CreatePullPaymentCardKey(uid, 0, ppid);
- var keys = cardKey.DeriveBoltcardKeys(issuerKey);
- await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
- var piccData = new byte[] { 0xc7 }.Concat(uid).Concat(new byte[] { 1, 0, 0, 0, 0, 0, 0, 0 }).ToArray();
- var p = keys.EncryptionKey.Encrypt(piccData);
- var c = keys.AuthenticationKey.GetSunMac(uid, 1);
- var boltcardUrl = new Uri(s.Server.PayTester.ServerUri.AbsoluteUri + $"boltcard?p={Encoders.Hex.EncodeData(p).ToUpperInvariant()}&c={Encoders.Hex.EncodeData(c).ToUpperInvariant()}");
- var info2 = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
- info2 = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
- var fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "p=([A-F0-9]{32})", $"p={RandomBytes(16)}"));
- await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
- fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "c=([A-F0-9]{16})", $"c={RandomBytes(8)}"));
- await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
-
- var bolt3 = (await s.Server.CustomerLightningD.CreateInvoice(
- new LightMoney(0.00000005m, LightMoneyUnit.BTC),
- $"LNurl w payout test2 {DateTime.UtcNow.Ticks}",
- TimeSpan.FromHours(1), CancellationToken.None));
- var response2 = await info2.SendRequest(bolt3.BOLT11, s.Server.PayTester.HttpClient, null, null);
- Assert.Equal("OK", response2.Status);
- await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient));
- response2 = await info2.SendRequest(bolt3.BOLT11, s.Server.PayTester.HttpClient, null, null);
- Assert.Equal("ERROR", response2.Status);
- Assert.Contains("Replayed", response2.Reason);
-
- var reg = await db.GetBoltcardRegistration(issuerKey, uid);
- Assert.Equal((ppid, 1, 0), (reg.PullPaymentId, reg.Counter, reg.Version));
- await db.SetBoltcardResetState(issuerKey, uid);
- reg = await db.GetBoltcardRegistration(issuerKey, uid);
- Assert.Equal((null, 0, 0), (reg.PullPaymentId, reg.Counter, reg.Version));
- await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
- reg = await db.GetBoltcardRegistration(issuerKey, uid);
- Assert.Equal((ppid, 0, 1), (reg.PullPaymentId, reg.Counter, reg.Version));
-
- await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
- reg = await db.GetBoltcardRegistration(issuerKey, uid);
- Assert.Equal((ppid, 0, 2), (reg.PullPaymentId, reg.Counter, reg.Version));
- }
-
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
- await s.ClickPagePrimary();
- await s.Page.FillAsync("#Name", "PP1");
- await s.Page.UncheckAsync("#AutoApproveClaims");
- await s.Page.FillAsync("#Amount", "0.0000001");
- await s.Page.FillAsync("#Currency", "BTC");
- await s.Page.PressAsync("#Currency", "Enter");
- await s.FindAlertMessage();
-
- var newPage7 = s.Page.Context.WaitForPageAsync();
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- await using (await s.SwitchPage(await newPage7))
- {
- await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
- await s.Page.ClickAsync("#lnurlwithdraw-button");
- await s.Page.WaitForFunctionAsync("() => document.querySelector('#qr-code-data-input')?.value?.length > 0");
- var lnurlStr2 = await s.Page.Locator("#qr-code-data-input").InputValueAsync();
- await s.Page.ClickAsync("button[data-bs-dismiss='modal']");
- var info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(new Uri(LNURL.LNURL.Parse(lnurlStr2, out _).ToString().Replace("https", "http")), s.Server.PayTester.HttpClient));
- Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
- Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
- info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient));
- Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
- Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
-
- var bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
- new LightMoney(0.0000001m, LightMoneyUnit.BTC),
- $"LNurl w payout test {DateTime.UtcNow.Ticks}",
- TimeSpan.FromHours(1), CancellationToken.None));
- var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
- // Nope, you need to approve the claim automatically
- Assert.Equal("The request has been recorded, but still need to be approved before execution.", response.Reason);
- await TestUtils.EventuallyAsync(async () =>
- {
- await s.Page.ReloadAsync();
- var content = await s.Page.ContentAsync();
- Assert.Contains(bolt2.BOLT11, content);
- Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), content);
- });
- }
- await s.Page.Context.Pages.First().BringToFrontAsync();
-
- // LNURL Withdraw support check with SATS denomination
- await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
- await s.ClickPagePrimary();
- await s.Page.FillAsync("#Name", "PP SATS");
- await s.Page.CheckAsync("#AutoApproveClaims");
- await s.Page.FillAsync("#Amount", "21021");
- await s.Page.FillAsync("#Currency", "SATS");
- await s.Page.PressAsync("#Currency", "Enter");
- await s.FindAlertMessage();
-
- var newPage8 = s.Page.Context.WaitForPageAsync();
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- await using (await s.SwitchPage(await newPage8))
- {
- await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
- await s.Page.ClickAsync("#lnurlwithdraw-button");
- await s.Page.WaitForFunctionAsync("() => document.querySelector('#qr-code-data-input')?.value?.length > 0");
- var lnurlStr3 = await s.Page.Locator("#qr-code-data-input").InputValueAsync();
- await s.Page.ClickAsync("button[data-bs-dismiss='modal']");
- var amount = new LightMoney(21021, LightMoneyUnit.Satoshi);
- var info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(new Uri(LNURL.LNURL.Parse(lnurlStr3, out _).ToString().Replace("https", "http")), s.Server.PayTester.HttpClient));
- Assert.Equal(amount, info.MaxWithdrawable);
- Assert.Equal(amount, info.CurrentBalance);
- info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient));
- Assert.Equal(amount, info.MaxWithdrawable);
- Assert.Equal(amount, info.CurrentBalance);
-
- var bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
- amount,
- $"LNurl w payout test {DateTime.UtcNow.Ticks}",
- TimeSpan.FromHours(1), CancellationToken.None));
- var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
- await TestUtils.EventuallyAsync(async () =>
- {
- await s.Page.ReloadAsync();
- var content = await s.Page.ContentAsync();
- Assert.Contains(bolt2.BOLT11, content);
- Assert.Contains(PayoutState.Completed.GetStateString(), content);
- Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status);
- });
- }
-
- static string RandomBytes(int count)
- {
- var c = RandomNumberGenerator.GetBytes(count);
- return Encoders.Hex.EncodeData(c);
- }
- }
-
[Fact]
public async Task CanUseForms()
{
@@ -1745,363 +1267,6 @@ namespace BTCPayServer.Tests
}
}
-
- [Fact]
- 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";
-
- // 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(cryptoCode, "", true);
-
- //let's test quickly 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<NewTransactionEvent>(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(cryptoCode, "", 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 in watch only mode.
- var result =
- await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
- Assert.True(result.IsWatchOnly);
- await s.GoToStore(storeId);
- var mnemonic = await s.GenerateWallet(cryptoCode, "", true, true);
-
- //lets 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();
- // 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);
- // 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<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(), s.Server.PayTester.GetService<CurrencyNameTable>()).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();
- Assert.Contains(s.WalletId.ToString(), s.Page.Url);
- Assert.EndsWith("export?format=json", s.Page.Url);
- 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]
- 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]
public async Task CanUsePaymentRequest()
{
@@ -2555,30 +1720,6 @@ namespace BTCPayServer.Tests
Assert.Equal("Settled", (await s.Page.Locator("[data-invoice-state-badge]").TextContentAsync())?.Trim());
}
- [Fact]
- 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]
public async Task CanSetupStoreViaGuide()
{
@@ -2607,29 +1748,6 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("To start accepting payments, set up a store.", await s.Page.ContentAsync());
}
- [Fact]
- 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("Lightning", "Lightning")]
public async Task CanUseLndSeedBackup()
@@ -2721,80 +1839,6 @@ namespace BTCPayServer.Tests
});
}
- [Fact]
- 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, true);
- 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<uint256> 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")]
[Trait("Lightning", "Lightning")]
diff --git a/BTCPayServer.Tests/PullPaymentsTests.cs b/BTCPayServer.Tests/PullPaymentsTests.cs
index af7a089..c21c825 100644
--- a/BTCPayServer.Tests/PullPaymentsTests.cs
+++ b/BTCPayServer.Tests/PullPaymentsTests.cs
@@ -1,16 +1,23 @@
using System;
using System.Linq;
using System.Security.Cryptography;
+using System.Text.RegularExpressions;
+using System.Threading;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
+using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.NTag424;
+using BTCPayServer.Payments;
using BTCPayServer.Views.Stores;
using Dapper;
+using LNURL;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Playwright;
using NBitcoin;
using NBitcoin.DataEncoders;
using Xunit;
@@ -22,6 +29,489 @@ namespace BTCPayServer.Tests;
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ [Trait("Lightning", "Lightning")]
+ public async Task CanUsePullPaymentsViaUI()
+ {
+ await using var s = CreatePlaywrightTester();
+ s.Server.DeleteStore = false;
+ s.Server.ActivateLightning(LightningConnectionType.LndREST);
+ await s.StartAsync();
+ await s.Server.EnsureChannelsSetup();
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet("BTC", "", true, true);
+
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.FundStoreWallet(denomination: 50.0m);
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+ await s.ClickPagePrimary();
+ await s.Page.FillAsync("#Name", "PP1");
+ await s.Page.FillAsync("#Amount", "99.0");
+ await s.ClickPagePrimary();
+
+ await using (_ = await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
+ {
+ await Expect(s.Page.Locator("body")).ToContainTextAsync("PP1");
+ }
+
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+
+ await s.ClickPagePrimary();
+ await s.Page.FillAsync("#Name", "PP2");
+ await s.Page.FillAsync("#Amount", "100.0");
+ await s.ClickPagePrimary();
+
+ string viewPullPaymentUrl;
+ // This should select the first View, ie, the last one PP2
+ await using (await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
+ {
+ var address = await s.Server.ExplorerNode.GetNewAddressAsync();
+ await s.Page.FillAsync("#Destination", address.ToString());
+ await s.Page.FillAsync("#ClaimedAmount", "15");
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await s.FindAlertMessage();
+
+ // We should not be able to use an address already used
+ await s.Page.FillAsync("#Destination", address.ToString());
+ await s.Page.FillAsync("#ClaimedAmount", "20");
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error);
+
+ address = await s.Server.ExplorerNode.GetNewAddressAsync();
+ await s.Page.FillAsync("#Destination", address.ToString());
+ await s.Page.FillAsync("#ClaimedAmount", "20");
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await s.FindAlertMessage();
+ await Expect(s.Page.Locator("body")).ToContainTextAsync("Awaiting Approval");
+
+ viewPullPaymentUrl = s.Page.Url;
+ }
+
+ // This one should have nothing
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+ var payouts = s.Page.Locator(".pp-payout");
+ await Expect(payouts).ToHaveCountAsync(2);
+ await payouts.Nth(1).ClickAsync();
+ await Expect(s.Page.Locator(".payout")).ToHaveCountAsync(0);
+ // PP2 should have payouts
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+ payouts = s.Page.Locator(".pp-payout");
+ await payouts.First.ClickAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ var count = await s.Page.Locator(".payout").CountAsync();
+ Assert.True(count > 0);
+ });
+ await s.Page.CheckAsync(".mass-action-select-all");
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve-pay");
+
+ await s.Page.ClickAsync("#SignTransaction");
+ await s.Page.ClickAsync("button[value='broadcast']");
+ await s.FindAlertMessage();
+
+ var pmo = await s.GoToWalletTransactions();
+ await Expect(s.Page.Locator(".transaction-label")).ToHaveCountAsync(2);
+ await pmo.AssertHasLabels("payout");
+ await pmo.AssertHasLabels("pull-payment");
+
+ await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
+ await s.Page.ClickAsync($"#{PayoutState.InProgress}-view");
+
+ await Expect(s.Page.Locator(".transaction-link")).ToHaveCountAsync(2);
+
+ await s.GoToUrl(viewPullPaymentUrl);
+ await Expect(s.Page.Locator(".transaction-link")).ToHaveCountAsync(2);
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.InProgress.GetStateString());
+
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ await s.Page.ReloadAsync();
+ Assert.Contains(PayoutState.Completed.GetStateString(), await s.Page.ContentAsync());
+ });
+ await s.Server.ExplorerNode.GenerateAsync(10);
+ var pullPaymentId = viewPullPaymentUrl.Split('/').Last();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ await using var ctx = s.Server.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
+ var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync();
+ Assert.True(payoutsData.All(p => p.State == PayoutState.Completed));
+ });
+ await s.GoToHome();
+ //offline/external payout test
+
+ await s.CreateNewStore();
+ await s.GenerateWallet("BTC", "", true, true);
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+
+ await s.ClickPagePrimary();
+ await s.Page.FillAsync("#Name", "External Test");
+ await s.Page.FillAsync("#Amount", "0.001");
+ await s.Page.FillAsync("#Currency", "BTC");
+ await s.ClickPagePrimary();
+
+ await using (await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
+ {
+ var address = await s.Server.ExplorerNode.GetNewAddressAsync();
+ await s.Page.FillAsync("#Destination", address.ToString());
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await s.FindAlertMessage();
+
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingApproval.GetStateString());
+ await s.Page.Context.Pages.First().BringToFrontAsync();
+ }
+
+ await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-view");
+ await s.Page.CheckAsync(".mass-action-select-all");
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve");
+ await s.FindAlertMessage();
+ var onchainAddress = await s.Server.ExplorerNode.GetNewAddressAsync();
+ await s.Server.ExplorerNode.SendToAddressAsync(onchainAddress, Money.FromUnit(0.001m, MoneyUnit.BTC));
+ await s.Page.Context.Pages.First().BringToFrontAsync();
+
+ await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
+
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-view");
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingPayment.GetStateString());
+ await s.Page.CheckAsync(".mass-action-select-all");
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-mark-paid");
+ await s.FindAlertMessage();
+
+ await s.Page.ClickAsync($"#{PayoutState.InProgress}-view");
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ await Expect(s.Page.Locator(".payout")).ToHaveCountAsync(0);
+ await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ await s.Page.ReloadAsync();
+ var rows = s.Page.Locator(".payout");
+ var count = await rows.CountAsync();
+ Assert.True(count > 0);
+ // Basic sanity: source column shows the pull payment name
+ var rowText = await rows.First.InnerTextAsync();
+ Assert.Contains("External Test", rowText);
+ });
+
+ // lightning tests
+ // Since the merchant is sending on lightning, it needs some liquidity from the client
+ var payoutAmount = LightMoney.Satoshis(1000);
+ var minimumReserve = LightMoney.Satoshis(167773m);
+ var inv = await s.Server.MerchantLnd.Client.CreateInvoice(minimumReserve + payoutAmount, "Donation to merchant", TimeSpan.FromHours(1),
+ CancellationToken.None);
+ var resp = await s.Server.CustomerLightningD.Pay(inv.BOLT11);
+ Assert.Equal(PayResult.Ok, resp.Result);
+
+ var newStore = await s.CreateNewStore();
+ await s.AddLightningNode();
+
+ //Currently an onchain wallet is required to use the Lightning payouts feature…
+ await s.GenerateWallet("BTC", "", true, true);
+ await s.GoToStore(newStore.storeId, StoreNavPages.PullPayments);
+ await s.ClickPagePrimary();
+
+ var paymentMethodOptions = s.Page.Locator("input[name='PayoutMethods']");
+ await Expect(paymentMethodOptions).ToHaveCountAsync(2);
+
+ await s.Page.FillAsync("#Name", "Lightning Test");
+ await s.Page.FillAsync("#Amount", payoutAmount.ToString());
+ await s.Page.FillAsync("#Currency", "BTC");
+ await s.ClickPagePrimary();
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ string bolt;
+ await using (await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
+ {
+ // Bitcoin-only, SelectedPaymentMethod should not be displayed
+ await Expect(s.Page.Locator("#SelectedPayoutMethod")).ToHaveCountAsync(0);
+
+ bolt = (await s.Server.CustomerLightningD.CreateInvoice(
+ payoutAmount,
+ $"LN payout test {DateTime.UtcNow.Ticks}",
+ TimeSpan.FromHours(1), CancellationToken.None)).BOLT11;
+ await s.Page.FillAsync("#Destination", bolt);
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ //we do not allow short-life bolts.
+ await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error);
+
+ bolt = (await s.Server.CustomerLightningD.CreateInvoice(
+ payoutAmount,
+ $"LN payout test {DateTime.UtcNow.Ticks}",
+ TimeSpan.FromDays(31), CancellationToken.None)).BOLT11;
+ await s.Page.FillAsync("#Destination", bolt);
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await s.FindAlertMessage();
+
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingApproval.GetStateString());
+ }
+
+ await s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
+ await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-view");
+ await s.Page.CheckAsync(".mass-action-select-all");
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve-pay");
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
+ await Expect(s.Page.Locator("body")).ToContainTextAsync($"{payoutAmount} BTC");
+ await s.Page.Locator("#pay-invoices-form").EvaluateAsync("form => form.submit()");
+
+ await s.FindAlertMessage();
+ await s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
+ await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
+
+ await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
+ if (!(await s.Page.ContentAsync()).Contains(bolt))
+ {
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-view");
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
+
+ await s.Page.CheckAsync(".mass-action-select-all");
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-mark-paid");
+ await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
+
+ await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
+ }
+
+ //auto-approve pull payments
+ await s.GoToStore(StoreNavPages.PullPayments);
+ await s.ClickPagePrimary();
+ await s.Page.FillAsync("#Name", "PP1");
+ await s.Page.CheckAsync("#AutoApproveClaims");
+ await s.Page.FillAsync("#Amount", "99.0");
+ await s.Page.PressAsync("#Amount", "Enter");
+ await s.FindAlertMessage();
+
+ string lnurlStr;
+ await using (await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
+ {
+ var address = await s.Server.ExplorerNode.GetNewAddressAsync();
+ await s.Page.FillAsync("#Destination", address.ToString());
+ await s.Page.FillAsync("#ClaimedAmount", "20");
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await s.FindAlertMessage();
+
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingPayment.GetStateString());
+ }
+
+ // LNURL Withdraw support check with BTC denomination
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+ await s.ClickPagePrimary();
+ await s.Page.FillAsync("#Name", "PP1");
+ await s.Page.CheckAsync("#AutoApproveClaims");
+ await s.Page.FillAsync("#Amount", "0.0000001");
+ await s.Page.FillAsync("#Currency", "BTC");
+ await s.Page.PressAsync("#Currency", "Enter");
+ await s.FindAlertMessage();
+
+ await using (await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
+ {
+ await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
+ await s.Page.ClickAsync("#lnurlwithdraw-button");
+ await s.Page.WaitForFunctionAsync("() => document.querySelector('#qr-code-data-input')?.value?.length > 0");
+
+ // Try to use lnurlw via the QR Code
+ lnurlStr = await s.Page.Locator("#qr-code-data-input").InputValueAsync();
+ var lnurl = new Uri(LNURL.LNURL.Parse(lnurlStr, out _).ToString().Replace("https", "http"));
+ await s.Page.ClickAsync("button[data-bs-dismiss='modal']");
+ var info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(lnurl, s.Server.PayTester.HttpClient));
+ Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+ Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+ info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient));
+ Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+ Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+
+ var bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
+ new LightMoney(0.00000005m, LightMoneyUnit.BTC),
+ $"LNurl w payout test {DateTime.UtcNow.Ticks}",
+ TimeSpan.FromHours(1), CancellationToken.None));
+ var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
+ // Oops!
+ Assert.Equal(
+ "The request has been approved. The sender needs to send the payment manually. (Or activate the lightning automated payment processor)",
+ response.Reason);
+ var account = await s.AsTestAccount().CreateClient();
+ await account.UpdateStoreLightningAutomatedPayoutProcessors(s.StoreId, "BTC-LN", new()
+ {
+ ProcessNewPayoutsInstantly = true,
+ IntervalSeconds = TimeSpan.FromSeconds(60)
+ });
+ // Now it should process to complete
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ await s.Page.ReloadAsync();
+ var content = await s.Page.ContentAsync();
+ Assert.Contains(bolt2.BOLT11, content);
+ Assert.Contains(PayoutState.Completed.GetStateString(), content);
+ Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status);
+ });
+ }
+
+ // Simulate a boltcard
+ Assert.False(string.IsNullOrEmpty(lnurlStr), "LNURL string should have been captured from the previous flow");
+ {
+ var db = s.Server.PayTester.GetService<ApplicationDbContextFactory>();
+ var ppid = new Uri(LNURL.LNURL.Parse(lnurlStr, out _).ToString().Replace("https", "http")).AbsoluteUri.Split('/').Last();
+ var issuerKey = new IssuerKey(SettingsRepositoryExtensions.FixedKey());
+ var uid = RandomNumberGenerator.GetBytes(7);
+ var cardKey = issuerKey.CreatePullPaymentCardKey(uid, 0, ppid);
+ var keys = cardKey.DeriveBoltcardKeys(issuerKey);
+ await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
+ var piccData = new byte[] { 0xc7 }.Concat(uid).Concat(new byte[] { 1, 0, 0, 0, 0, 0, 0, 0 }).ToArray();
+ var p = keys.EncryptionKey.Encrypt(piccData);
+ var c = keys.AuthenticationKey.GetSunMac(uid, 1);
+ var boltcardUrl = new Uri(s.Server.PayTester.ServerUri.AbsoluteUri +
+ $"boltcard?p={Encoders.Hex.EncodeData(p).ToUpperInvariant()}&c={Encoders.Hex.EncodeData(c).ToUpperInvariant()}");
+ await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
+ var info2 = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
+ var fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "p=([A-F0-9]{32})", $"p={RandomBytes(16)}"));
+ await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
+ fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "c=([A-F0-9]{16})", $"c={RandomBytes(8)}"));
+ await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
+
+ var bolt3 = (await s.Server.CustomerLightningD.CreateInvoice(
+ new LightMoney(0.00000005m, LightMoneyUnit.BTC),
+ $"LNurl w payout test2 {DateTime.UtcNow.Ticks}",
+ TimeSpan.FromHours(1), CancellationToken.None));
+ var response2 = await info2.SendRequest(bolt3.BOLT11, s.Server.PayTester.HttpClient, null, null);
+ Assert.Equal("OK", response2.Status);
+ await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient));
+ response2 = await info2.SendRequest(bolt3.BOLT11, s.Server.PayTester.HttpClient, null, null);
+ Assert.Equal("ERROR", response2.Status);
+ Assert.Contains("Replayed", response2.Reason);
+
+ var reg = await db.GetBoltcardRegistration(issuerKey, uid);
+ Assert.Equal((ppid, 1, 0), (reg!.PullPaymentId, reg.Counter, reg.Version));
+ await db.SetBoltcardResetState(issuerKey, uid);
+ reg = await db.GetBoltcardRegistration(issuerKey, uid);
+ Assert.Equal((null, 0, 0), (reg!.PullPaymentId, reg.Counter, reg.Version));
+ await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
+ reg = await db.GetBoltcardRegistration(issuerKey, uid);
+ Assert.Equal((ppid, 0, 1), (reg!.PullPaymentId, reg.Counter, reg.Version));
+
+ await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
+ reg = await db.GetBoltcardRegistration(issuerKey, uid);
+ Assert.Equal((ppid, 0, 2), (reg!.PullPaymentId, reg.Counter, reg.Version));
+ }
+
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+ await s.ClickPagePrimary();
+ await s.Page.FillAsync("#Name", "PP1");
+ await s.Page.UncheckAsync("#AutoApproveClaims");
+ await s.Page.FillAsync("#Amount", "0.0000001");
+ await s.Page.FillAsync("#Currency", "BTC");
+ await s.Page.PressAsync("#Currency", "Enter");
+ await s.FindAlertMessage();
+
+ var newPage7 = s.Page.Context.WaitForPageAsync();
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ await using (await s.SwitchPage(await newPage7))
+ {
+ await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
+ await s.Page.ClickAsync("#lnurlwithdraw-button");
+ await s.Page.WaitForFunctionAsync("() => document.querySelector('#qr-code-data-input')?.value?.length > 0");
+ var lnurlStr2 = await s.Page.Locator("#qr-code-data-input").InputValueAsync();
+ await s.Page.ClickAsync("button[data-bs-dismiss='modal']");
+ var info = Assert.IsType<LNURLWithdrawRequest>(
+ await LNURL.LNURL.FetchInformation(new Uri(LNURL.LNURL.Parse(lnurlStr2, out _).ToString().Replace("https", "http")),
+ s.Server.PayTester.HttpClient));
+ Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+ Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+ info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient));
+ Assert.Equal(info.MaxWithdrawable, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+ Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
+
+ var bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
+ new LightMoney(0.0000001m, LightMoneyUnit.BTC),
+ $"LNurl w payout test {DateTime.UtcNow.Ticks}",
+ TimeSpan.FromHours(1), CancellationToken.None));
+ var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
+ // Nope, you need to approve the claim automatically
+ Assert.Equal("The request has been recorded, but still need to be approved before execution.", response.Reason);
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ await s.Page.ReloadAsync();
+ var content = await s.Page.ContentAsync();
+ Assert.Contains(bolt2.BOLT11, content);
+ Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), content);
+ });
+ }
+
+ await s.Page.Context.Pages.First().BringToFrontAsync();
+
+ // LNURL Withdraw support check with SATS denomination
+ await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
+ await s.ClickPagePrimary();
+ await s.Page.FillAsync("#Name", "PP SATS");
+ await s.Page.CheckAsync("#AutoApproveClaims");
+ await s.Page.FillAsync("#Amount", "21021");
+ await s.Page.FillAsync("#Currency", "SATS");
+ await s.Page.PressAsync("#Currency", "Enter");
+ await s.FindAlertMessage();
+
+ var newPage8 = s.Page.Context.WaitForPageAsync();
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ await using (await s.SwitchPage(await newPage8))
+ {
+ await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
+ await s.Page.ClickAsync("#lnurlwithdraw-button");
+ await s.Page.WaitForFunctionAsync("() => document.querySelector('#qr-code-data-input')?.value?.length > 0");
+ var lnurlStr3 = await s.Page.Locator("#qr-code-data-input").InputValueAsync();
+ await s.Page.ClickAsync("button[data-bs-dismiss='modal']");
+ var amount = new LightMoney(21021, LightMoneyUnit.Satoshi);
+ var info = Assert.IsType<LNURLWithdrawRequest>(
+ await LNURL.LNURL.FetchInformation(new Uri(LNURL.LNURL.Parse(lnurlStr3, out _).ToString().Replace("https", "http")),
+ s.Server.PayTester.HttpClient));
+ Assert.Equal(amount, info.MaxWithdrawable);
+ Assert.Equal(amount, info.CurrentBalance);
+ info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient));
+ Assert.Equal(amount, info.MaxWithdrawable);
+ Assert.Equal(amount, info.CurrentBalance);
+
+ var bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
+ amount,
+ $"LNurl w payout test {DateTime.UtcNow.Ticks}",
+ TimeSpan.FromHours(1), CancellationToken.None));
+ await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ await s.Page.ReloadAsync();
+ var content = await s.Page.ContentAsync();
+ Assert.Contains(bolt2.BOLT11, content);
+ Assert.Contains(PayoutState.Completed.GetStateString(), content);
+ Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status);
+ });
+ }
+
+ static string RandomBytes(int count)
+ {
+ var c = RandomNumberGenerator.GetBytes(count);
+ return Encoders.Hex.EncodeData(c);
+ }
+ }
+
[Fact]
[Trait("Integration", "Integration")]
public async Task CanTopUpPullPayment()
@@ -36,7 +526,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
Currency = "BTC",
Amount = 1.0m,
- PayoutMethods = [ "BTC-CHAIN" ]
+ PayoutMethods = ["BTC-CHAIN"]
});
var controller = user.GetController<UIInvoiceController>();
var invoice = await controller.CreateInvoiceCoreRaw(new()
@@ -57,6 +547,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
}
[Fact]
+ [Trait("Integration", "Integration")]
public async Task CanMigratePayoutsAndPullPayments()
{
var tester = CreateDBTester();
@@ -69,9 +560,11 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
Id = "pp1",
StoreId = "store1",
- Blob = "{\"Name\": \"CoinLottery\", \"View\": {\"Email\": null, \"Title\": \"\", \"Description\": \"\", \"EmbeddedCSS\": null, \"CustomCSSLink\": null}, \"Limit\": \"10.00\", \"Period\": null, \"Currency\": \"GBP\", \"Description\": \"\", \"Divisibility\": 0, \"MinimumClaim\": \"0\", \"AutoApproveClaims\": false, \"SupportedPaymentMethods\": [\"BTC\", \"BTC_LightningLike\"]}"
+ Blob =
+ "{\"Name\": \"CoinLottery\", \"View\": {\"Email\": null, \"Title\": \"\", \"Description\": \"\", \"EmbeddedCSS\": null, \"CustomCSSLink\": null}, \"Limit\": \"10.00\", \"Period\": null, \"Currency\": \"GBP\", \"Description\": \"\", \"Divisibility\": 0, \"MinimumClaim\": \"0\", \"AutoApproveClaims\": false, \"SupportedPaymentMethods\": [\"BTC\", \"BTC_LightningLike\"]}"
};
- await conn.ExecuteAsync("INSERT INTO \"PullPayments\"(\"Id\", \"StoreId\", \"Blob\", \"StartDate\", \"Archived\") VALUES (@Id, @StoreId, @Blob::JSONB, NOW(), 'f')", param);
+ await conn.ExecuteAsync(
+ "INSERT INTO \"PullPayments\"(\"Id\", \"StoreId\", \"Blob\", \"StartDate\", \"Archived\") VALUES (@Id, @StoreId, @Blob::JSONB, NOW(), 'f')", param);
var parameters = new[]
{
new
@@ -107,22 +600,29 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
Blob = "{\"Amount\": \"-10.0\", \"Revision\": 0, \"Destination\": \"address\", \"CryptoAmount\": null, \"MinimumConfirmation\": 1}"
}
};
- await conn.ExecuteAsync("INSERT INTO \"Payouts\"(\"Id\", \"StoreDataId\", \"PullPaymentDataId\", \"PaymentMethodId\", \"Blob\", \"State\", \"Date\") VALUES (@Id, @StoreId, @PullPaymentDataId, @PaymentMethodId, @Blob::JSONB, 'state', NOW())", parameters);
+ await conn.ExecuteAsync(
+ "INSERT INTO \"Payouts\"(\"Id\", \"StoreDataId\", \"PullPaymentDataId\", \"PaymentMethodId\", \"Blob\", \"State\", \"Date\") VALUES (@Id, @StoreId, @PullPaymentDataId, @PaymentMethodId, @Blob::JSONB, 'state', NOW())",
+ parameters);
await tester.CompleteMigrations();
- var migrated = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"PullPayments\" WHERE \"Id\"='pp1' AND \"Limit\"=10.0 AND \"Currency\"='GBP' AND \"Blob\"->>'SupportedPayoutMethods'='[\"BTC-CHAIN\", \"BTC-LN\"]'");
+ var migrated = await conn.ExecuteScalarAsync<bool>(
+ "SELECT 't'::BOOLEAN FROM \"PullPayments\" WHERE \"Id\"='pp1' AND \"Limit\"=10.0 AND \"Currency\"='GBP' AND \"Blob\"->>'SupportedPayoutMethods'='[\"BTC-CHAIN\", \"BTC-LN\"]'");
Assert.True(migrated);
- migrated = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p1' AND \"Amount\"= 0.00012225 AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='GBP' AND \"PayoutMethodId\"='BTC-CHAIN'");
+ migrated = await conn.ExecuteScalarAsync<bool>(
+ "SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p1' AND \"Amount\"= 0.00012225 AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='GBP' AND \"PayoutMethodId\"='BTC-CHAIN'");
Assert.True(migrated);
- migrated = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p2' AND \"Amount\" IS NULL AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='GBP' AND \"PayoutMethodId\"='BTC-LN'");
+ migrated = await conn.ExecuteScalarAsync<bool>(
+ "SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p2' AND \"Amount\" IS NULL AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='GBP' AND \"PayoutMethodId\"='BTC-LN'");
Assert.True(migrated);
- migrated = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p3' AND \"Amount\" IS NULL AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='BTC'");
+ migrated = await conn.ExecuteScalarAsync<bool>(
+ "SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p3' AND \"Amount\" IS NULL AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='BTC'");
Assert.True(migrated);
- migrated = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p4' AND \"Amount\" IS NULL AND \"OriginalAmount\"=-10.0 AND \"OriginalCurrency\"='BTC' AND \"PayoutMethodId\"='TOPUP'");
+ migrated = await conn.ExecuteScalarAsync<bool>(
+ "SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p4' AND \"Amount\" IS NULL AND \"OriginalAmount\"=-10.0 AND \"OriginalCurrency\"='BTC' AND \"PayoutMethodId\"='TOPUP'");
Assert.True(migrated);
}
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index 9652845..00c91fe 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -1,21 +1,519 @@
-using System.Globalization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.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 Xunit.Abstractions;
namespace BTCPayServer.Tests;
+
public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
[Fact]
- [Trait("Playwright", "Playwright")]
+ [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, true);
+ 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<uint256> 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(cryptoCode, "", true);
+
+ //let's test quickly 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<NewTransactionEvent>(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(cryptoCode, "", 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 in watch only mode.
+ var result =
+ await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
+ Assert.True(result.IsWatchOnly);
+ await s.GoToStore(storeId);
+ var mnemonic = await s.GenerateWallet(cryptoCode, "", true, 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();
+ // 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);
+ // 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<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(),
+ s.Server.PayTester.GetService<CurrencyNameTable>()).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();
+ Assert.Contains(s.WalletId.ToString(), s.Page.Url);
+ Assert.EndsWith("export?format=json", s.Page.Url);
+ 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 CanUseBumpFee()
{
await using var s = CreatePlaywrightTester();
@@ -52,7 +550,8 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
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);
+ var newExpectedEffectiveFeeRate = decimal.Parse(await s.Page.GetAttributeAsync("[name='FeeSatoshiPerByte']", "value") ?? string.Empty,
+ CultureInfo.InvariantCulture);
await s.ClickPagePrimary();
await s.Page.ClickAsync("#BroadcastTransaction");
@@ -87,7 +586,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
// 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 a RBF label as well.
+ // 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");
@@ -119,8 +618,8 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
}
}
- [Fact]
- [Trait("Playwright", "Playwright-2")]
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
public async Task CanUseCoinSelection()
{
await using var s = CreatePlaywrightTester();
@@ -137,6 +636,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m));
}
+
var handlers = s.Server.PayTester.GetService<PaymentMethodHandlerDictionary>();
var targetTx = await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.2m));
var tx = await s.Server.ExplorerNode.GetRawTransactionAsync(targetTx);
@@ -180,6 +680,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
await checkboxElement.ClickAsync();
}
+
await s.Page.Locator("#SignTransaction").ClickAsync();
await s.Page.Locator("button[value='broadcast']").ClickAsync();
var happyElement = await s.FindAlertMessage();
Why this scored 15/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.