using System; using System.Collections.Generic; using System.Diagnostics.Metrics; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.BIP78.Sender; using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Lightning; using BTCPayServer.Lightning.CLightning; using BTCPayServer.Models.AccountViewModels; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.PayJoin.Sender; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using BTCPayServer.Tests.Logging; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis.Operations; using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitcoin.DataEncoders; using NBitcoin.Payment; using NBitpayClient; using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Npgsql; using Xunit; using Xunit.Sdk; namespace BTCPayServer.Tests { public class TestAccount { readonly ServerTester parent; public string LNAddress; public TestAccount(ServerTester parent) { this.parent = parent; BitPay = new Bitpay(new Key(), parent.PayTester.ServerUri); } public void GrantAccess(bool isAdmin = false) { GrantAccessAsync(isAdmin).GetAwaiter().GetResult(); } public async Task MakeAdmin(bool isAdmin = true) { var userManager = parent.PayTester.GetService>(); var u = await userManager.FindByIdAsync(UserId); if (isAdmin) await userManager.AddToRoleAsync(u, Roles.ServerAdmin); else await userManager.RemoveFromRoleAsync(u, Roles.ServerAdmin); IsAdmin = true; } public Task CreateClient() { return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email, RegisterDetails.Password)); } public async Task CreateClient(params string[] permissions) { var manageController = parent.PayTester.GetController(UserId, StoreId, IsAdmin); Assert.IsType(await manageController.AddApiKey( new UIManageController.AddApiKeyViewModel() { PermissionValues = permissions.Select(s => { Permission.TryParse(s, out var p); return p; }).GroupBy(permission => permission.Policy).Select(p => { var stores = p.Where(permission => !string.IsNullOrEmpty(permission.Scope)) .Select(permission => permission.Scope).ToList(); return new UIManageController.AddApiKeyViewModel.PermissionValueItem() { Permission = p.Key, Forbidden = false, StoreMode = stores.Any() ? UIManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific : UIManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores, SpecificStores = stores, Value = true }; }).ToList() })); var statusMessage = manageController.TempData.GetStatusMessageModel(); Assert.NotNull(statusMessage); var str = ""; var apiKey = statusMessage.Html.Substring(statusMessage.Html.IndexOf(str) + str.Length); apiKey = apiKey.Substring(0, apiKey.IndexOf("")); return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey); } public void Register(bool isAdmin = false) { RegisterAsync(isAdmin).GetAwaiter().GetResult(); } public async Task GrantAccessAsync(bool isAdmin = false) { await RegisterAsync(isAdmin); await CreateStoreAsync(); var store = GetController(); var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant); Assert.IsType(await store.RequestPairing(pairingCode.ToString())); await store.Pair(pairingCode.ToString(), StoreId); } public BTCPayServerClient CreateClientFromAPIKey(string apiKey) { return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey); } public void CreateStore() { CreateStoreAsync().GetAwaiter().GetResult(); } public async Task SetNetworkFeeMode(NetworkFeeMode mode) { await ModifyPayment(payment => { payment.NetworkFeeMode = mode; }); } public async Task ModifyPayment(Action modify) { var storeController = GetController(); var response = await storeController.GeneralSettings(StoreId); GeneralSettingsViewModel settings = (GeneralSettingsViewModel)((ViewResult)response).Model!; modify(settings); await storeController.GeneralSettings(settings); } public async Task ModifyGeneralSettings(Action modify) { var storeController = GetController(); var response = await storeController.GeneralSettings(StoreId); GeneralSettingsViewModel settings = (GeneralSettingsViewModel)((ViewResult)response).Model!; modify(settings); storeController.GeneralSettings(settings).GetAwaiter().GetResult(); } public async Task ModifyOnchainPaymentSettings(Action modify) { var storeController = GetController(); var response = await storeController.WalletSettings(StoreId, "BTC"); WalletSettingsViewModel walletSettings = (WalletSettingsViewModel)((ViewResult)response).Model; modify(walletSettings); storeController.UpdateWalletSettings(walletSettings).GetAwaiter().GetResult(); } public T GetController(bool setImplicitStore = true) where T : Controller { var controller = parent.PayTester.GetController(UserId, setImplicitStore ? StoreId : null, IsAdmin); return controller; } public async Task CreateStoreAsync() { if (UserId is null) { await RegisterAsync(); } var store = GetController(); await store.CreateStore(new CreateStoreViewModel { Name = "Test Store", PreferredExchange = "coingecko" }); StoreId = store.CreatedStoreId; parent.Stores.Add(StoreId); } public BTCPayNetwork SupportedNetwork { get; set; } public WalletId RegisterDerivationScheme(string crytoCode, ScriptPubKeyType segwit = ScriptPubKeyType.Legacy, bool importKeysToNBX = false) { return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult(); } public async Task RegisterDerivationSchemeAsync(string cryptoCode, ScriptPubKeyType segwit = ScriptPubKeyType.Legacy, bool importKeysToNBX = false, bool importsKeysToBitcoinCore = false) { if (StoreId is null) await CreateStoreAsync(); SupportedNetwork = parent.NetworkProvider.GetNetwork(cryptoCode); var store = parent.PayTester.GetController(UserId, StoreId, true); var generateRequest = new WalletSetupRequest { ScriptPubKeyType = segwit, SavePrivateKeys = importKeysToNBX, ImportKeysToRPC = importsKeysToBitcoinCore }; await store.GenerateWallet(StoreId, cryptoCode, WalletSetupMethod.HotWallet, generateRequest); Assert.NotNull(store.GenerateWalletResponse); GenerateWalletResponseV = store.GenerateWalletResponse; return new WalletId(StoreId, cryptoCode); } public GenerateWalletResponse GenerateWalletResponseV { get; set; } public DerivationStrategyBase DerivationScheme { get => GenerateWalletResponseV.DerivationScheme; } public void SetLNUrl(string cryptoCode, bool activated) { var lnSettingsVm = GetController().LightningSettings(StoreId, cryptoCode).AssertViewModel(); lnSettingsVm.LNURLEnabled = activated; Assert.IsType(GetController().LightningSettings(lnSettingsVm).Result); } private async Task RegisterAsync(bool isAdmin = false) { var account = parent.PayTester.GetController(); RegisterDetails = new RegisterViewModel() { Email = Utils.GenerateEmail(), ConfirmPassword = Password, Password = Password, IsAdmin = isAdmin }; await account.Register(RegisterDetails); //this addresses an obscure issue where LockSubscription is unintentionally set to "true", //resulting in a large number of tests failing. if (account.RegisteredUserId == null) { var settings = parent.PayTester.GetService(); var policies = await settings.GetSettingAsync() ?? new PoliciesSettings(); policies.LockSubscription = false; await account.Register(RegisterDetails); } TestLogs.LogInformation($"UserId: {account.RegisteredUserId} Password: {Password}"); UserId = account.RegisteredUserId; Email = RegisterDetails.Email; IsAdmin = account.RegisteredAdmin; } public string Password { get; set; } = "Kitten0@"; public RegisterViewModel RegisterDetails { get; set; } public Bitpay BitPay { get; set; } public string UserId { get; set; } public string Email { get; set; } public string StoreId { get; set; } public bool IsAdmin { get; internal set; } public void RegisterLightningNode(string cryptoCode, string connectionType = null, bool isMerchant = true) { RegisterLightningNodeAsync(cryptoCode, connectionType, isMerchant).GetAwaiter().GetResult(); } public Task RegisterLightningNodeAsync(string cryptoCode, bool isMerchant = true, string storeId = null) { return RegisterLightningNodeAsync(cryptoCode, null, isMerchant, storeId); } public async Task RegisterLightningNodeAsync(string cryptoCode, string connectionType, bool isMerchant = true, string storeId = null) { var storeController = GetController(); var connectionString = parent.GetLightningConnectionString(connectionType, isMerchant); var nodeType = connectionString == LightningPaymentMethodConfig.InternalNode ? LightningNodeType.Internal : LightningNodeType.Custom; var vm = new LightningNodeViewModel { ConnectionString = connectionString, LightningNodeType = nodeType, SkipPortTest = true }; await storeController.SetupLightningNode(storeId ?? StoreId, vm, "save", cryptoCode); if (storeController.ModelState.ErrorCount != 0) Assert.Fail(storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage); } public async Task RegisterInternalLightningNodeAsync(string cryptoCode, string storeId = null) { var storeController = GetController(); var vm = new LightningNodeViewModel { ConnectionString = "", LightningNodeType = LightningNodeType.Internal, SkipPortTest = true }; await storeController.SetupLightningNode(storeId ?? StoreId, vm, "save", cryptoCode); if (storeController.ModelState.ErrorCount != 0) Assert.Fail(storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage); } public async Task ReceiveUTXO(Money value, BTCPayNetwork network = null) { network ??= SupportedNetwork; var cashCow = parent.ExplorerNode; var btcPayWallet = parent.PayTester.GetService().GetWallet(network); var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address; await parent.WaitForEvent(async () => { await cashCow.SendToAddressAsync(address, value); }); int i = 0; while (i < 30) { var result = (await btcPayWallet.GetUnspentCoins(DerivationScheme)) .FirstOrDefault(c => c.ScriptPubKey == address.ScriptPubKey)?.Coin; if (result != null) { return result; } await Task.Delay(1000); i++; } Assert.False(true); return null; } public async Task GetNewAddress(BTCPayNetwork network) { var btcPayWallet = parent.PayTester.GetService().GetWallet(network); var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address; return address; } public async Task Sign(PSBT psbt) { parent.PayTester.GetService() .GetWallet(psbt.Network.NetworkSet.CryptoCode); var explorerClient = parent.PayTester.GetService() .GetExplorerClient(psbt.Network.NetworkSet.CryptoCode); psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest() { DerivationScheme = DerivationScheme, PSBT = psbt })).PSBT; return psbt.SignAll(this.DerivationScheme, GenerateWalletResponseV.AccountHDKey, GenerateWalletResponseV.AccountKeyPath); } Logging.ILog TestLogs => this.parent.TestLogs; public async Task SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null, bool senderError = false) { var endpoint = GetPayjoinBitcoinUrl(invoice, psbt.Network); if (endpoint == null) { throw new InvalidOperationException("No payjoin endpoint for the invoice"); } var pjClient = parent.PayTester.GetService(); var storeRepository = parent.PayTester.GetService(); var store = await storeRepository.FindStore(StoreId); var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(psbt.Network.NetworkSet.CryptoCode); var handlers = parent.PayTester.GetService(); var settings = store.GetPaymentMethodConfig(pmi, handlers); TestLogs.LogInformation($"Proposing {psbt.GetGlobalTransaction().GetHash()}"); if (expectedError is null && !senderError) { var proposed = await pjClient.RequestPayjoin(endpoint, new PayjoinWallet(settings), psbt, default); TestLogs.LogInformation($"Proposed payjoin is {proposed.GetGlobalTransaction().GetHash()}"); Assert.NotNull(proposed); return proposed; } else { if (senderError) { await Assert.ThrowsAsync(async () => await pjClient.RequestPayjoin(endpoint, new PayjoinWallet(settings), psbt, default)); } else { var ex = await Assert.ThrowsAsync(async () => await pjClient.RequestPayjoin(endpoint, new PayjoinWallet(settings), psbt, default)); var split = expectedError.Split('|'); Assert.Equal(split[0], ex.ErrorCode); if (split.Length > 1) Assert.Contains(split[1], ex.ReceiverMessage); } return null; } } public async Task SubmitPayjoin(Invoice invoice, Transaction transaction, BTCPayNetwork network, string expectedError = null) { var response = await SubmitPayjoinCore(transaction.ToHex(), invoice, network.NBitcoinNetwork, expectedError); if (response == null) return null; var signed = Transaction.Parse(await response.Content.ReadAsStringAsync(), network.NBitcoinNetwork); return signed; } async Task SubmitPayjoinCore(string content, Invoice invoice, Network network, string expectedError) { var bip21 = GetPayjoinBitcoinUrl(invoice, network); bip21.TryGetPayjoinEndpoint(out var endpoint); var response = await parent.PayTester.HttpClient.PostAsync(endpoint, new StringContent(content, Encoding.UTF8, "text/plain")); if (expectedError != null) { var split = expectedError.Split('|'); Assert.False(response.IsSuccessStatusCode); var error = JObject.Parse(await response.Content.ReadAsStringAsync()); if (split.Length > 0) Assert.Equal(split[0], error["errorCode"].Value()); if (split.Length > 1) Assert.Contains(split[1], error["message"].Value()); return null; } else { if (!response.IsSuccessStatusCode) { var error = JObject.Parse(await response.Content.ReadAsStringAsync()); Assert.Fail($"Error: {error["errorCode"].Value()}: {error["message"].Value()}"); } } return response; } public static BitcoinUrlBuilder GetPayjoinBitcoinUrl(Invoice invoice, Network network) { var parsedBip21 = new BitcoinUrlBuilder( invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21, network); if (!parsedBip21.TryGetPayjoinEndpoint(out _)) return null; return parsedBip21; } class WebhookListener : IDisposable { private Client.Models.StoreWebhookData _wh; private FakeServer _server; private readonly List _webhookEvents; private CancellationTokenSource _cts; public WebhookListener(Client.Models.StoreWebhookData wh, FakeServer server, List webhookEvents) { _wh = wh; _server = server; _webhookEvents = webhookEvents; _cts = new CancellationTokenSource(); _ = Listen(_cts.Token); } async Task Listen(CancellationToken cancellation) { while (!cancellation.IsCancellationRequested) { var req = await _server.GetNextRequest(cancellation); var bytes = await req.Request.Body.ReadBytesAsync((int)req.Request.Headers.ContentLength); var callback = Encoding.UTF8.GetString(bytes); lock (_webhookEvents) { _webhookEvents.Add(JsonConvert.DeserializeObject(callback)); } req.Response.StatusCode = 200; _server.Done(); } } public void Dispose() { _cts.Cancel(); _server.Dispose(); } } public class DummyStoreWebhookEvent : StoreWebhookEvent { } public List WebhookEvents { get; set; } = new List(); public async Task AssertHasWebhookEvent(string eventType, Action assert) where TEvent : class { int retry = 0; retry: lock (WebhookEvents) { foreach (var evt in WebhookEvents) { if (evt.Type == eventType) { var typedEvt = evt.ReadAs(); try { assert(typedEvt); return typedEvt; } catch (XunitException) { } } } } if (retry < 3) { await Task.Delay(1000); retry++; goto retry; } Assert.Fail("No webhook event match the assertion"); return null; } public async Task SetupWebhook() { var server = new FakeServer(); await server.Start(); var client = await CreateClient(Policies.CanModifyWebhooks); var wh = await client.CreateWebhook(StoreId, new CreateStoreWebhookRequest() { AutomaticRedelivery = false, Url = server.ServerUri.AbsoluteUri }); parent.Resources.Add(new WebhookListener(wh, server, WebhookEvents)); } public async Task PayInvoice(string invoiceId) { var inv = await BitPay.GetInvoiceAsync(invoiceId); var net = parent.ExplorerNode.Network; await parent.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(inv.BitcoinAddress, net), inv.BtcDue); await TestUtils.EventuallyAsync(async () => { var localInvoice = await BitPay.GetInvoiceAsync(invoiceId, Facade.Merchant); Assert.Equal("paid", localInvoice.Status); }); } public async Task AddGuest(string userId) { var repo = parent.PayTester.GetService(); await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Guest); } public async Task AddOwner(string userId) { var repo = parent.PayTester.GetService(); await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Owner); } public async Task AddManager(string userId) { var repo = parent.PayTester.GetService(); await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Manager); } public async Task AddEmployee(string userId) { var repo = parent.PayTester.GetService(); await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Employee); } public async Task PayOnChain(string invoiceId) { var cryptoCode = "BTC"; var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode); var client = await CreateClient(); var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId); var method = methods.First(m => m.PaymentMethodId == pmi.ToString()); var address = method.Destination; var tx = await client.CreateOnChainTransaction(StoreId, cryptoCode, new CreateOnChainTransactionRequest() { Destinations = new List() { new () { Destination = address, Amount = method.Due } }, FeeRate = new FeeRate(1.0m) }); await WaitInvoicePaid(invoiceId); return tx.TransactionHash; } public async Task PayOnBOLT11(string invoiceId) { var cryptoCode = "BTC"; var client = await CreateClient(); var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId); var method = methods.First(m => m.PaymentMethodId == $"{cryptoCode}-LN"); var bolt11 = method.Destination; await parent.CustomerLightningD.Pay(bolt11); await WaitInvoicePaid(invoiceId); } public async Task PayOnLNUrl(string invoiceId) { var cryptoCode = "BTC"; var network = SupportedNetwork.NBitcoinNetwork; var client = await CreateClient(); var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId); var method = methods.First(m => m.PaymentMethodId == $"{cryptoCode}-LNURL"); var lnurL = LNURL.LNURL.Parse(method.PaymentLink, out var tag); var http = new HttpClient(); var payreq = (LNURL.LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurL, tag, http); var resp = await payreq.SendRequest(payreq.MinSendable, network, http); var bolt11 = resp.Pr; await parent.CustomerLightningD.Pay(bolt11); await WaitInvoicePaid(invoiceId); } public Task WaitInvoicePaid(string invoiceId) { return TestUtils.EventuallyAsync(async () => { var client = await CreateClient(); var invoice = await client.GetInvoice(StoreId, invoiceId); if (invoice.Status == InvoiceStatus.Settled) return; Assert.Equal(InvoiceStatus.Processing, invoice.Status); }); } public async Task PayOnLNAddress(string lnAddrUser = null) { lnAddrUser ??= LNAddress; var network = SupportedNetwork.NBitcoinNetwork; var payReqStr = await (await parent.PayTester.HttpClient.GetAsync($".well-known/lnurlp/{lnAddrUser}")).Content.ReadAsStringAsync(); var payreq = JsonConvert.DeserializeObject(payReqStr); var resp = await payreq.SendRequest(payreq.MinSendable, network, parent.PayTester.HttpClient); var bolt11 = resp.Pr; await parent.CustomerLightningD.Pay(bolt11); } public async Task CreateLNAddress() { var lnAddrUser = Guid.NewGuid().ToString(); var ctx = parent.PayTester.GetService().CreateContext(); ctx.LightningAddresses.Add(new() { StoreDataId = StoreId, Username = lnAddrUser }); await ctx.SaveChangesAsync(); LNAddress = lnAddrUser; return lnAddrUser; } public async Task ImportOldInvoices(string storeId = null) { storeId ??= StoreId; var oldInvoices = File.ReadAllLines(TestUtils.GetTestDataFullPath("OldInvoices.csv")); var oldPayments = File.ReadAllLines(TestUtils.GetTestDataFullPath("OldPayments.csv")); var dbContext = this.parent.PayTester.GetService().CreateContext(); var db = (NpgsqlConnection)dbContext.Database.GetDbConnection(); await db.OpenAsync(); bool isHeader = true; using (var writer = db.BeginTextImport("COPY \"Invoices\" (\"Id\",\"Blob\",\"Created\",\"ExceptionStatus\",\"Status\",\"StoreDataId\",\"Archived\",\"Blob2\") FROM STDIN DELIMITER ',' CSV HEADER")) { foreach (var invoice in oldInvoices) { if (isHeader) { isHeader = false; await writer.WriteLineAsync(invoice); } else { var localInvoice = invoice.Replace("3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd", storeId); var fields = localInvoice.Split(','); var blob1 = ZipUtils.Unzip(Encoders.Hex.DecodeData(fields[1].Substring(2))); var matched = Regex.Match(blob1, "xpub[^\\\"-]*"); if (matched.Success) { var xpub = (BitcoinExtPubKey)Network.Main.Parse(matched.Value); var xpubTestnet = xpub.ExtPubKey.GetWif(Network.RegTest).ToString(); blob1 = blob1.Replace(xpub.ToString(), xpubTestnet.ToString()); fields[1] = $"\\x{Encoders.Hex.EncodeData(ZipUtils.Zip(blob1))}"; localInvoice = string.Join(',', fields); } await writer.WriteLineAsync(localInvoice); } } await writer.FlushAsync(); } isHeader = true; using (var writer = db.BeginTextImport("COPY \"Payments\" (\"Id\",\"Blob\",\"InvoiceDataId\",\"Accounted\",\"Blob2\",\"PaymentMethodId\") FROM STDIN DELIMITER ',' CSV HEADER")) { foreach (var invoice in oldPayments) { var localPayment = invoice.Replace("3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd", storeId); // Old data could have Type to null. localPayment += "UNKNOWN"; await writer.WriteLineAsync(localPayment); } await writer.FlushAsync(); } } } }