From 89da4184ff916f30ee079eb648e711401682d7ae Mon Sep 17 00:00:00 2001 From: Kukks Date: Mon, 6 Jan 2020 13:57:32 +0100 Subject: [PATCH 01/21] BIP79 Support --- BTCPayServer.Tests/PayJoinTests.cs | 411 ++++++++++++++++++ BTCPayServer.Tests/TestAccount.cs | 29 +- BTCPayServer/BTCPayServer.csproj | 1 + BTCPayServer/Controllers/StoresController.cs | 2 + .../Controllers/WalletsController.PSBT.cs | 79 +++- BTCPayServer/Controllers/WalletsController.cs | 46 +- BTCPayServer/Data/StoreBlob.cs | 1 + BTCPayServer/Hosting/BTCPayServerServices.cs | 4 + .../CheckoutExperienceViewModel.cs | 3 + .../WalletViewModels/WalletSendModel.cs | 3 +- .../BitcoinLikeOnChainPaymentMethod.cs | 16 + .../Bitcoin/BitcoinLikePaymentData.cs | 9 +- .../Bitcoin/BitcoinLikePaymentHandler.cs | 10 +- .../Payments/Bitcoin/NBXplorerListener.cs | 66 ++- .../PayJoin/PayJoinEndpointController.cs | 401 +++++++++++++++++ .../Payments/PayJoin/PayJoinExtensions.cs | 13 + BTCPayServer/Payments/PayJoin/PayJoinState.cs | 142 ++++++ .../Payments/PayJoin/PayJoinStateProvider.cs | 121 ++++++ .../PayJoin/PayJoinStateRecordedItem.cs | 25 ++ .../PayJoin/PayJoinTransactionBroadcaster.cs | 92 ++++ BTCPayServer/Payments/PaymentTypes.Bitcoin.cs | 4 +- .../Services/Invoices/InvoiceEntity.cs | 13 +- .../Services/Invoices/InvoiceRepository.cs | 15 + BTCPayServer/Services/SocketFactory.cs | 27 +- BTCPayServer/Services/Wallets/BTCPayWallet.cs | 5 +- .../Shared/ViewBitcoinLikePaymentData.cshtml | 2 +- .../Views/Stores/CheckoutExperience.cshtml | 5 + BTCPayServer/Views/Wallets/WalletSend.cshtml | 8 + BTCPayServer/ZoneLimits.cs | 1 + 29 files changed, 1511 insertions(+), 43 deletions(-) create mode 100644 BTCPayServer.Tests/PayJoinTests.cs create mode 100644 BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs create mode 100644 BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs create mode 100644 BTCPayServer/Payments/PayJoin/PayJoinState.cs create mode 100644 BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs create mode 100644 BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs create mode 100644 BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs new file mode 100644 index 000000000..5b7d84bb9 --- /dev/null +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -0,0 +1,411 @@ +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Controllers; +using BTCPayServer.Data; +using BTCPayServer.Models; +using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Payments.PayJoin; +using BTCPayServer.Services.Wallets; +using BTCPayServer.Tests.Logging; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBitcoin.Payment; +using NBitpayClient; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests +{ + public class PayJoinTests + { + public const int TestTimeout = 60_000; + + public PayJoinTests(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.LogProvider = new XUnitLogProvider(helper); + } + + [Fact] + // [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanUseBIP79() + { + using (var tester = ServerTester.Create()) + { + await tester.StartAsync(); + var btcPayNetwork = tester.NetworkProvider.GetNetwork("BTC"); + var btcPayWallet = tester.PayTester.GetService().GetWallet(btcPayNetwork); + var cashCow = tester.ExplorerNode; + cashCow.Generate(2); // get some money in case + + var senderUser = tester.NewAccount(); + senderUser.GrantAccess(true); + senderUser.RegisterDerivationScheme("BTC", true, true); + + var invoice = senderUser.BitPay.CreateInvoice( + new Invoice() {Price = 100, Currency = "USD", FullNotifications = true}); + //payjoin is not enabled by default. + Assert.DoesNotContain("bpu", invoice.CryptoInfo.First().PaymentUrls.BIP21); + cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network), + Money.Coins(0.06m)); + + var receiverUser = tester.NewAccount(); + receiverUser.GrantAccess(true); + receiverUser.RegisterDerivationScheme("BTC", true, true); + + await receiverUser.EnablePayJoin(); + // payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer + invoice = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + + //check that the BIP21 has an endpoint + var bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21; + Assert.Contains("bpu", bip21); + var parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork); + var endpoint = parsedBip21.UnknowParameters["bpu"]; + + + //see if the btcpay send wallet supports BIP21 properly and also the payjoin endpoint + var receiverWalletId = new WalletId(receiverUser.StoreId, "BTC"); + var senderWalletId = new WalletId(senderUser.StoreId, "BTC"); + var senderWallerController = senderUser.GetController(); + var senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId) + .AssertViewModelAsync(); + senderWalletSendVM = await senderWallerController + .WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21) + .AssertViewModelAsync(); + + Assert.Single(senderWalletSendVM.Outputs); + Assert.Equal(endpoint, senderWalletSendVM.PayJoinEndpointUrl); + Assert.Equal(parsedBip21.Address.ToString(), senderWalletSendVM.Outputs.First().DestinationAddress); + Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC), senderWalletSendVM.Outputs.First().Amount); + + //the nbx wallet option should also be available + Assert.True(senderWalletSendVM.NBXSeedAvailable); + + //pay the invoice with the nbx seed wallet option + also the invoice + var postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId, + senderWalletSendVM, "nbx-seed", CancellationToken.None) + .AssertViewModelAsync(); + var redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value; + var psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork); + var senderWalletSendPSBTResult = new WalletPSBTReadyViewModel() + { + PSBT = redirectedPSBT, + SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value, + SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value + }; + //While the endpoint was set, the receiver had no utxos. The payment should fall back to original payment terms instead + Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC).ToString(), + psbt.Outputs.Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value); + + Assert.Equal("WalletTransactions", + Assert.IsType( + await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult, + "broadcast")) + .ActionName); + + //we used the bip21 link straight away to pay the invoice so it should be paid straight away. + TestUtils.Eventually(() => + { + invoice = receiverUser.BitPay.GetInvoice(invoice.Id); + Assert.Equal(Invoice.STATUS_PAID, invoice.Status); + }); + + //now that there is a utxo, let's do it again + + invoice = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21; + parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork); + senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId) + .AssertViewModelAsync(); + senderWalletSendVM = await senderWallerController + .WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21) + .AssertViewModelAsync(); + postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId, + senderWalletSendVM, "nbx-seed", CancellationToken.None) + .AssertViewModelAsync(); + redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value; + psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork); + senderWalletSendPSBTResult = new WalletPSBTReadyViewModel() + { + PSBT = redirectedPSBT, + SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value, + SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value + }; + //the payjoin should make the amount being paid to the address higher + Assert.True(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC) < psbt.Outputs + .Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value + .ToDecimal(MoneyUnit.BTC)); + + var payJoinStateProvider = tester.PayTester.GetService(); + //the state should now hold that there is an ongoing utxo + var state = payJoinStateProvider.Get(receiverWalletId); + Assert.NotNull(state); + Assert.Single(state.GetRecords()); + Assert.Equal(0.02m, state.GetRecords().First().ContributedAmount); + Assert.Single(state.GetRecords().First().CoinsExposed); + Assert.Equal(psbt.Finalize().ExtractTransaction().GetHash(), + state.GetRecords().First().ProposedTransactionHash); + + Assert.Equal("WalletTransactions", + Assert.IsType( + await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult, + "broadcast")) + .ActionName); + + TestUtils.Eventually(() => + { + invoice = receiverUser.BitPay.GetInvoice(invoice.Id); + + Assert.Equal(Invoice.STATUS_PAID, invoice.Status); + Assert.Equal(Invoice.EXSTATUS_FALSE, invoice.ExceptionStatus.ToString().ToLowerInvariant()); + }); + + //verify that we have a record that it was a payjoin + var receiverController = receiverUser.GetController(); + var invoiceVM = + await receiverController.Invoice(invoice.Id).AssertViewModelAsync(); + Assert.Single(invoiceVM.Payments); + Assert.True(Assert.IsType(invoiceVM.Payments.First().GetCryptoPaymentData()) + .PayJoinSelfContributedAmount > 0); + + //check that the state has cleared that ongoing tx + state = payJoinStateProvider.Get(receiverWalletId); + Assert.NotNull(state); + Assert.Empty(state.GetRecords()); + Assert.Empty(state.GetExposedCoins()); + + //Cool, so the payjoin works! + //The cool thing with payjoin is that your utxos don't grow + Assert.Single(await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme)); + + //Let's be as malicious as CSW + + //give the cow some cash + await cashCow.GenerateAsync(1); + //let's get some more utxos first + Assert.NotNull(await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.011m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.012m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.013m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.021m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.022m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.023m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.024m, MoneyUnit.BTC))); + + await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.014m, MoneyUnit.BTC)); + var senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme); + + var senderChange = (await btcPayWallet.GetChangeAddressAsync(senderUser.DerivationScheme)).Item1; + + //Let's start the harassment + invoice = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + + parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + endpoint = parsedBip21.UnknowParameters["bpu"]; + + var invoice2 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var endpoint2 = secondInvoiceParsedBip21.UnknowParameters["bpu"]; + + var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId); + var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike); + var derivationSchemeSettings = senderStore.GetSupportedPaymentMethods(tester.NetworkProvider) + .OfType().SingleOrDefault(settings => + settings.PaymentId == paymentMethodId); + var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m); + var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m); + var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m); + var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m); + + var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); + signingKeySettings.RootFingerprint = + senderUser.GenerateWalletResponseV.MasterHDKey.GetPublicKey().GetHDFingerPrint(); + + var extKey = + senderUser.GenerateWalletResponseV.MasterHDKey.Derive(signingKeySettings.GetRootedKeyPath() + .KeyPath); + + + var n = tester.ExplorerClient.Network.NBitcoinNetwork; + var Invoice1Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(parsedBip21.Address, parsedBip21.Amount) + .AddCoins(coin.Coin) + .AddKeys(extKey.Derive(coin.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice1Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(parsedBip21.Address, parsedBip21.Amount) + .AddCoins(coin2.Coin) + .AddKeys(extKey.Derive(coin2.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice2Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount) + .AddCoins(coin.Coin) + .AddKeys(extKey.Derive(coin.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice2Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount) + .AddCoins(coin2.Coin) + .AddKeys(extKey.Derive(coin2.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + //Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all + //Result: reject + Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint, + new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); + + //Attempt 2: Create two transactions using different inputs and send them to the same invoice. + //Result: Second Tx should be rejected. + var Invoice1Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint, + new StringContent(Invoice1Coin1.ToHex(), Encoding.UTF8, "text/plain")); + + var Invoice1Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint, + new StringContent(Invoice1Coin2.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.True(Invoice1Coin1Response.IsSuccessStatusCode); + Assert.False(Invoice1Coin2Response.IsSuccessStatusCode); + var Invoice1Coin1ResponseTx = + Transaction.Parse(await Invoice1Coin1Response.Content.ReadAsStringAsync(), n); + var contributedInputsInvoice1Coin1ResponseTx = + Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut); + Assert.Single(contributedInputsInvoice1Coin1ResponseTx); + + //Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted + //Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1 + + var Invoice2Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint2, + new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain")); + + var Invoice2Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint2, + new StringContent(Invoice2Coin2.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.False(Invoice2Coin1Response.IsSuccessStatusCode); + Assert.True(Invoice2Coin2Response.IsSuccessStatusCode); + + var Invoice2Coin2ResponseTx = + Transaction.Parse(await Invoice2Coin2Response.Content.ReadAsStringAsync(), n); + var contributedInputsInvoice2Coin2ResponseTx = + Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut); + Assert.Single(contributedInputsInvoice2Coin2ResponseTx); + + //Attempt 4: Make tx that pays invoice 3 and 4 and submit to both + //Result: reject on 4: the protocol should not worry about this complexity + + var invoice3 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice3Endpoint = invoice3ParsedBip21.UnknowParameters["bpu"]; + + + var invoice4 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice4Endpoint = invoice4ParsedBip21.UnknowParameters["bpu"]; + + + var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice3ParsedBip21.Address, invoice3ParsedBip21.Amount) + .Send(invoice4ParsedBip21.Address, invoice4ParsedBip21.Amount) + .AddCoins(coin3.Coin) + .AddKeys(extKey.Derive(coin3.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice3Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice3Endpoint, + new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain")); + + var Invoice4Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice4Endpoint, + new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.True(Invoice3Coin3Response.IsSuccessStatusCode); + Assert.False(Invoice4Coin3Response.IsSuccessStatusCode); + + //Attempt 5: Make tx that pays invoice 5 with 2 outputs + //Result: proposed tx consolidates the outputs + + var invoice5 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"]; + + var Invoice5Coin4 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) + .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) + .AddCoins(coin4.Coin) + .AddKeys(extKey.Derive(coin4.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.True(Invoice5Coin4Response.IsSuccessStatusCode); + var Invoice5Coin4ResponseTx = + Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n); + Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address)); + + //Attempt 6: submit the same tx over and over in the hopes of getting new utxos + //Result: same tx gets sent back + for (int i = 0; i < 5; i++) + { + var Invoice5Coin4Response2 = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); + if (!Invoice5Coin4Response2.IsSuccessStatusCode) + { + Logs.Tester.LogInformation( + $"Failed on try {i + 1} with {await Invoice5Coin4Response2.Content.ReadAsStringAsync()}"); + } + + Assert.True(Invoice5Coin4Response2.IsSuccessStatusCode); + var Invoice5Coin4Response2Tx = + Transaction.Parse(await Invoice5Coin4Response2.Content.ReadAsStringAsync(), n); + Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), Invoice5Coin4Response2Tx.GetHash()); + } + } + } + } +} diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 78d0d7208..fddf8e1bb 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -33,9 +33,9 @@ namespace BTCPayServer.Tests BitPay = new Bitpay(new Key(), parent.PayTester.ServerUri); } - public void GrantAccess() + public void GrantAccess(bool isAdmin = false) { - GrantAccessAsync().GetAwaiter().GetResult(); + GrantAccessAsync(isAdmin).GetAwaiter().GetResult(); } public async Task MakeAdmin(bool isAdmin = true) @@ -74,13 +74,13 @@ namespace BTCPayServer.Tests return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey); } - public void Register() + public void Register(bool isAdmin = false) { - RegisterAsync().GetAwaiter().GetResult(); + RegisterAsync(isAdmin).GetAwaiter().GetResult(); } - public async Task GrantAccessAsync() + public async Task GrantAccessAsync(bool isAdmin = false) { - await RegisterAsync(); + await RegisterAsync(isAdmin); await CreateStoreAsync(); var store = this.GetController(); var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant); @@ -160,6 +160,20 @@ namespace BTCPayServer.Tests return new WalletId(StoreId, cryptoCode); } + public async Task EnablePayJoin() + { + var storeController = parent.PayTester.GetController(UserId, StoreId); + var checkoutExperienceVM = + Assert.IsType(Assert + .IsType(storeController.CheckoutExperience()).Model); + + checkoutExperienceVM.PayJoinEnabled = true; + + Assert.Equal(nameof(storeController.CheckoutExperience), + Assert.IsType( + await storeController.CheckoutExperience(checkoutExperienceVM)).ActionName); + } + public GenerateWalletResponse GenerateWalletResponseV { get; set; } public DerivationStrategyBase DerivationScheme @@ -170,7 +184,7 @@ namespace BTCPayServer.Tests } } - private async Task RegisterAsync() + private async Task RegisterAsync(bool isAdmin = false) { var account = parent.PayTester.GetController(); RegisterDetails = new RegisterViewModel() @@ -178,6 +192,7 @@ namespace BTCPayServer.Tests Email = Guid.NewGuid() + "@toto.com", ConfirmPassword = "Kitten0@", Password = "Kitten0@", + IsAdmin = isAdmin }; await account.Register(RegisterDetails); UserId = account.RegisteredUserId; diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 47a0c18c3..cd2f54f3a 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -51,6 +51,7 @@ + all diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 9a8a3c2c1..e29e84d21 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -381,6 +381,7 @@ namespace BTCPayServer.Controllers vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? ""; vm.LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi; vm.RedirectAutomatically = storeBlob.RedirectAutomatically; + vm.PayJoinEnabled = storeBlob.PayJoinEnabled; return View(vm); } void SetCryptoCurrencies(CheckoutExperienceViewModel vm, Data.StoreData storeData) @@ -441,6 +442,7 @@ namespace BTCPayServer.Controllers blob.LightningMaxValue = lightningMaxValue; blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi; blob.RedirectAutomatically = model.RedirectAutomatically; + blob.PayJoinEnabled = model.PayJoinEnabled; if (CurrentStore.SetStoreBlob(blob)) { needUpdate = true; diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 781488129..85d9029d2 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.ModelBinders; +using BTCPayServer.Models; using BTCPayServer.Models.WalletViewModels; using Microsoft.AspNetCore.Mvc; using NBitcoin; @@ -121,7 +124,7 @@ namespace BTCPayServer.Controllers .GetMetadataAsync(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey); - return SignWithSeed(walletId, + return await SignWithSeed(walletId, new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64()}); } @@ -153,6 +156,80 @@ namespace BTCPayServer.Controllers return result.PSBT; } + private async Task TryGetBPProposedTX(PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork) + { + + if (TempData.TryGetValue( "bpu", out var bpu) && !string.IsNullOrEmpty(bpu?.ToString()) && Uri.TryCreate(bpu.ToString(), UriKind.Absolute, out var endpoint)) + { + TempData.Remove("bpu"); + HttpClient httpClient; + if (endpoint.IsOnion() && _socketFactory.SocksClient!= null) + { + if ( _socketFactory.SocksClient == null) + { + return null; + } + httpClient = _socketFactory.SocksClient; + } + else + { + httpClient = _httpClientFactory.CreateClient("bpu"); + } + + var cloned = psbt.Clone(); + + if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors)) + { + return null; + } + + var bpuresponse = await httpClient.PostAsync(bpu.ToString(), new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain")); + if (bpuresponse.IsSuccessStatusCode) + { + var hex = await bpuresponse.Content.ReadAsStringAsync(); + if (PSBT.TryParse(hex, btcPayNetwork.NBitcoinNetwork, out var newPSBT)) + { + //check that all the inputs we provided are still there and that there is at least one new(signed) input. + bool valid = false; + var existingInputs = psbt.Inputs.Select(input => input.PrevOut).ToList(); + foreach (var input in newPSBT.Inputs) + { + var existingInput = existingInputs.SingleOrDefault(point => point == input.PrevOut); + if (existingInput != null) + { + existingInputs.Remove(existingInput); + continue; + } + + if (!input.TryFinalizeInput(out _)) + { + valid = false; + break; + } + // a new signed input was provided + valid = true; + } + + if (!valid || existingInputs.Any()) + { + return null; + } + + newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Info, + AllowDismiss = false, + Message = "This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" + }); + return newPSBT; + } + } + } + + return null; + } + [HttpGet] [Route("{walletId}/psbt/ready")] public async Task WalletPSBTReady( diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index f429bde91..78850b3a6 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net.Http; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -49,6 +50,8 @@ namespace BTCPayServer.Controllers private readonly WalletReceiveStateService _WalletReceiveStateService; private readonly EventAggregator _EventAggregator; private readonly SettingsRepository _settingsRepository; + private readonly SocketFactory _socketFactory; + private readonly IHttpClientFactory _httpClientFactory; public RateFetcher RateFetcher { get; } CurrencyNameTable _currencyTable; @@ -66,7 +69,9 @@ namespace BTCPayServer.Controllers BTCPayWalletProvider walletProvider, WalletReceiveStateService walletReceiveStateService, EventAggregator eventAggregator, - SettingsRepository settingsRepository) + SettingsRepository settingsRepository, + SocketFactory socketFactory, + IHttpClientFactory httpClientFactory) { _currencyTable = currencyTable; Repository = repo; @@ -83,6 +88,8 @@ namespace BTCPayServer.Controllers _WalletReceiveStateService = walletReceiveStateService; _EventAggregator = eventAggregator; _settingsRepository = settingsRepository; + _socketFactory = socketFactory; + _httpClientFactory = httpClientFactory; } // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md @@ -456,7 +463,9 @@ namespace BTCPayServer.Controllers if (network == null || network.ReadonlyWallet) return NotFound(); vm.SupportRBF = network.SupportRBF; - + vm.NBXSeedAvailable = await CanUseHotWallet() && !string.IsNullOrEmpty(await ExplorerClientProvider.GetExplorerClient(network) + .GetMetadataAsync(GetDerivationSchemeSettings(walletId).AccountDerivation, + WellknownMetadataKeys.MasterHDKey, cancellation)); if (!string.IsNullOrEmpty(bip21)) { LoadFromBIP21(vm, bip21, network); @@ -594,7 +603,7 @@ namespace BTCPayServer.Controllers return View(vm); } derivationScheme.RebaseKeyPaths(psbt.PSBT); - + TempData.AddOrReplace("bpu", vm.PayJoinEndpointUrl); switch (command) { case "vault": @@ -603,7 +612,7 @@ namespace BTCPayServer.Controllers var extKey = await ExplorerClientProvider.GetExplorerClient(network) .GetMetadataAsync(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey, cancellation); - return SignWithSeed(walletId, new SignWithSeedViewModel() + return await SignWithSeed(walletId, new SignWithSeedViewModel() { SeedOrKey = extKey, PSBT = psbt.PSBT.ToBase64() @@ -650,6 +659,8 @@ namespace BTCPayServer.Controllers $"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}" }); } + uriBuilder.UnknowParameters.TryGetValue("bpu", out var vmPayJoinEndpointUrl); + vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl; } catch (Exception) { @@ -675,9 +686,17 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/vault")] - public IActionResult SubmitVault([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SubmitVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendVaultModel model) { + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + var newPSBT = await TryGetBPProposedTX(PSBT.Parse(model.PSBT, network.NBitcoinNetwork), GetDerivationSchemeSettings(walletId), network); + if (newPSBT != null) + { + model.PSBT = newPSBT.ToBase64(); + return View("WalletSendVault", model); + } + return RedirectToWalletPSBTReady(model.PSBT); } private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null) @@ -747,9 +766,16 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/ledger")] - public IActionResult SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendLedgerModel model) { + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + var newPSBT = await TryGetBPProposedTX(PSBT.Parse(model.PSBT,network.NBitcoinNetwork ), GetDerivationSchemeSettings(walletId), network); + if (newPSBT != null) + { + model.PSBT = newPSBT.ToBase64(); + return View("WalletSendLedger", model); + } return RedirectToWalletPSBTReady(model.PSBT); } @@ -764,7 +790,7 @@ namespace BTCPayServer.Controllers } [HttpPost("{walletId}/psbt/seed")] - public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, SignWithSeedViewModel viewModel) { if (!ModelState.IsValid) @@ -826,6 +852,12 @@ namespace BTCPayServer.Controllers return View(viewModel); } ModelState.Remove(nameof(viewModel.PSBT)); + var newPSBT = await TryGetBPProposedTX(psbt, GetDerivationSchemeSettings(walletId), network); + if (newPSBT != null) + { + viewModel.PSBT = newPSBT.ToBase64(); + return await SignWithSeed(walletId, viewModel); + } return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString()); } diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index d7782d264..99ee6e4e7 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -173,6 +173,7 @@ namespace BTCPayServer.Data public EmailSettings EmailSettings { get; set; } public bool RedirectAutomatically { get; set; } + public bool PayJoinEnabled { get; set; } public IPaymentFilter GetExcludedPaymentMethods() { diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 74f967690..041496c63 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -31,6 +31,7 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Changelly; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Payments.PayJoin; using BTCPayServer.Security; using BTCPayServer.Services.PaymentRequests; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -62,6 +63,7 @@ namespace BTCPayServer.Hosting { httpClient.Timeout = Timeout.InfiniteTimeSpan; }); + services.AddPayJoinServices(); services.AddMoneroLike(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -257,11 +259,13 @@ namespace BTCPayServer.Hosting { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=1000r/min burst=100 nodelay"); } else { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst= nodelay"); } return rateLimits; }); diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs index f9763be47..88857000f 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs @@ -57,6 +57,9 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Redirect invoice to redirect url automatically after paid")] public bool RedirectAutomatically { get; set; } + + [Display(Name = "Enable BIP79 Payjoin/P2EP")] + public bool PayJoinEnabled { get; set; } public void SetLanguages(LanguageService langService, string defaultLang) { diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs index 5cc12c361..f28c5a606 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs @@ -10,7 +10,6 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletSendModel { - public List Outputs { get; set; } = new List(); public class TransactionOutput @@ -49,6 +48,8 @@ namespace BTCPayServer.Models.WalletViewModels public bool DisableRBF { get; set; } public bool NBXSeedAvailable { get; set; } + [Display(Name = "PayJoin Endpoint Url (BIP79)")] + public string PayJoinEndpointUrl { get; set; } public bool InputSelection { get; set; } public InputSelectionOption[] InputsAvailable { get; set; } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs index baed24724..11dd8eae3 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Wallets; using NBitcoin; using NBXplorer.JsonConverters; using Newtonsoft.Json; @@ -56,10 +57,25 @@ namespace BTCPayServer.Payments.Bitcoin public Money NextNetworkFee { get; set; } [JsonIgnore] public String DepositAddress { get; set; } + + public PayJoinPaymentState PayJoin { get; set; } = new PayJoinPaymentState(); + + + public BitcoinAddress GetDepositAddress(Network network) { return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network); } /////////////////////////////////////////////////////////////////////////////////////// } + + public class PayJoinPaymentState + { + public bool Enabled { get; set; } = false; + public uint256 ProposedTransactionHash { get; set; } + public List CoinsExposed { get; set; } + public decimal TotalOutputAmount { get; set; } + public decimal ContributedAmount { get; set; } + public uint256 OriginalTransactionHash { get; set; } + } } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs index c57ce7729..c3d52fcd2 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs @@ -21,13 +21,14 @@ namespace BTCPayServer.Payments.Bitcoin } - public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf) + public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf, decimal payJoinSelfContributedAmount) { Address = address; Value = value; Outpoint = outpoint; ConfirmationCount = 0; RBF = rbf; + PayJoinSelfContributedAmount = payJoinSelfContributedAmount; } [JsonIgnore] public BTCPayNetworkBase Network { get; set; } @@ -40,7 +41,8 @@ namespace BTCPayServer.Payments.Bitcoin public decimal NetworkFee { get; set; } public BitcoinAddress Address { get; set; } public IMoney Value { get; set; } - + public decimal PayJoinSelfContributedAmount { get; set; } = 0; + [JsonIgnore] public Script ScriptPubKey { @@ -67,7 +69,8 @@ namespace BTCPayServer.Payments.Bitcoin public decimal GetValue() { - return Value?.GetValue(Network as BTCPayNetwork)??Output.Value.ToDecimal(MoneyUnit.BTC); + return (Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC)) - + PayJoinSelfContributedAmount; } public bool PaymentCompleted(PaymentEntity entity) diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs index 7aa3091d8..ba99a47a3 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs @@ -125,7 +125,8 @@ namespace BTCPayServer.Payments.Bitcoin var prepare = (Prepare)preparePaymentObject; Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod onchainMethod = new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod(); - onchainMethod.NetworkFeeMode = store.GetStoreBlob().NetworkFeeMode; + var blob = store.GetStoreBlob(); + onchainMethod.NetworkFeeMode = blob.NetworkFeeMode; onchainMethod.FeeRate = await prepare.GetFeeRate; switch (onchainMethod.NetworkFeeMode) { @@ -142,7 +143,14 @@ namespace BTCPayServer.Payments.Bitcoin onchainMethod.NextNetworkFee = Money.Zero; break; } + onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString(); + onchainMethod.PayJoin = new PayJoinPaymentState() + { + Enabled = blob.PayJoinEnabled && + supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() != + ScriptPubKeyType.Legacy + }; return onchainMethod; } } diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index 674f29810..f8d91460b 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using NBXplorer; using System.Collections.Concurrent; +using BTCPayServer.Controllers; using NBXplorer.DerivationStrategy; using BTCPayServer.Events; using BTCPayServer.Services; @@ -18,6 +19,7 @@ using NBitcoin; using NBXplorer.Models; using BTCPayServer.Payments; using BTCPayServer.HostedServices; +using BTCPayServer.Payments.PayJoin; using NBitcoin.Altcoins.Elements; using NBitcoin.RPC; @@ -29,6 +31,7 @@ namespace BTCPayServer.Payments.Bitcoin public class NBXplorerListener : IHostedService { EventAggregator _Aggregator; + private readonly PayJoinStateProvider _payJoinStateProvider; ExplorerClientProvider _ExplorerClients; IHostApplicationLifetime _Lifetime; InvoiceRepository _InvoiceRepository; @@ -39,7 +42,8 @@ namespace BTCPayServer.Payments.Bitcoin public NBXplorerListener(ExplorerClientProvider explorerClients, BTCPayWalletProvider wallets, InvoiceRepository invoiceRepository, - EventAggregator aggregator, + EventAggregator aggregator, + PayJoinStateProvider payJoinStateProvider, IHostApplicationLifetime lifetime) { PollInterval = TimeSpan.FromMinutes(1.0); @@ -47,6 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin _InvoiceRepository = invoiceRepository; _ExplorerClients = explorerClients; _Aggregator = aggregator; + _payJoinStateProvider = payJoinStateProvider; _Lifetime = lifetime; } @@ -138,7 +143,6 @@ namespace BTCPayServer.Payments.Bitcoin switch (newEvent) { case NBXplorer.Models.NewBlockEvent evt: - await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices()) .Select(invoiceId => UpdatePaymentStates(wallet, invoiceId)) .ToArray()); @@ -146,11 +150,7 @@ namespace BTCPayServer.Payments.Bitcoin break; case NBXplorer.Models.NewTransactionEvent evt: wallet.InvalidateCache(evt.DerivationStrategy); - _Aggregator.Publish(new NewOnChainTransactionEvent() - { - CryptoCode = wallet.Network.CryptoCode, - NewTransactionEvent = evt - }); + foreach (var output in network.GetValidOutputs(evt)) { var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant(); @@ -159,7 +159,16 @@ namespace BTCPayServer.Payments.Bitcoin { var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy, output.Item1.KeyPath, output.Item1.ScriptPubKey); - var paymentData = new BitcoinLikePaymentData(address, output.matchedOutput.Value, output.outPoint, evt.TransactionData.Transaction.RBF); + + var payJoinSelfContributedAmount = GetPayJoinContributedAmount( + new WalletId(invoice.StoreId, network.CryptoCode), + output.matchedOutput.Value.GetValue(network), + evt.TransactionData.TransactionHash); + + var paymentData = new BitcoinLikePaymentData(address, + output.matchedOutput.Value, output.outPoint, + evt.TransactionData.Transaction.RBF, payJoinSelfContributedAmount); + var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any(); if (!alreadyExist) { @@ -174,6 +183,12 @@ namespace BTCPayServer.Payments.Bitcoin } } + _Aggregator.Publish(new NewOnChainTransactionEvent() + { + CryptoCode = wallet.Network.CryptoCode, + NewTransactionEvent = evt + }); + break; default: Logs.PayServer.LogWarning("Received unknown message from NBXplorer"); @@ -297,6 +312,8 @@ namespace BTCPayServer.Payments.Bitcoin if (strategy == null) continue; var cryptoId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); + var paymentMethod = invoice.GetPaymentMethod(cryptoId).GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod; + if (!invoice.Support(cryptoId)) continue; var coins = (await wallet.GetUnspentCoins(strategy)) @@ -307,9 +324,12 @@ namespace BTCPayServer.Payments.Bitcoin var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash); var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey); - var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint, - transaction.Transaction.RBF); + var payJoinSelfContributedAmount = + GetPayJoinContributedAmount(paymentMethod, coin.Value.GetValue(network), transaction.TransactionHash); + var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint, + transaction.Transaction.RBF, payJoinSelfContributedAmount); + var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false); alreadyAccounted.Add(coin.OutPoint); if (payment != null) @@ -324,6 +344,32 @@ namespace BTCPayServer.Payments.Bitcoin return totalPayment; } + private decimal GetPayJoinContributedAmount(BitcoinLikeOnChainPaymentMethod paymentMethod, decimal amount, uint256 transactionHash) + { + if (paymentMethod.PayJoin.Enabled && + paymentMethod.PayJoin.ProposedTransactionHash == transactionHash && + paymentMethod.PayJoin.TotalOutputAmount == amount) + { + //this is the payjoin output! + return paymentMethod.PayJoin.ContributedAmount; + } + + return 0; + } + private decimal GetPayJoinContributedAmount(WalletId walletId, decimal amount, uint256 transactionHash) + { + var payJoinState = + _payJoinStateProvider.Get(walletId); + + if (payJoinState == null || !payJoinState.TryGetWithProposedHash(transactionHash, out var record) || + record.TotalOutputAmount != amount) return 0; + + //this is the payjoin output! + payJoinState.RemoveRecord(transactionHash); + return record.ContributedAmount; + + } + private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network) { return invoice.GetSupportedPaymentMethod(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike)) diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs new file mode 100644 index 000000000..c394cf93f --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Filters; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBitcoin.DataEncoders; +using NBXplorer; +using NBXplorer.Models; +using NicolasDorier.RateLimits; + +namespace BTCPayServer.Payments.PayJoin +{ + [Route("{cryptoCode}/bpu")] + public class PayJoinEndpointController : ControllerBase + { + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly InvoiceRepository _invoiceRepository; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly StoreRepository _storeRepository; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + private readonly PayJoinStateProvider _payJoinStateProvider; + + public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider, + InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider, + StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider, + PayJoinStateProvider payJoinStateProvider) + { + _btcPayNetworkProvider = btcPayNetworkProvider; + _invoiceRepository = invoiceRepository; + _explorerClientProvider = explorerClientProvider; + _storeRepository = storeRepository; + _btcPayWalletProvider = btcPayWalletProvider; + _payJoinStateProvider = payJoinStateProvider; + } + + [HttpPost("{invoice}")] + [IgnoreAntiforgeryToken] + [EnableCors(CorsPolicies.All)] + [MediaTypeConstraint("text/plain")] + [RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)] + public async Task Submit(string cryptoCode, string invoice) + { + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network == null) + { + return UnprocessableEntity("Incorrect network"); + } + + string rawBody; + using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8)) + { + rawBody = await reader.ReadToEndAsync(); + } + + if (string.IsNullOrEmpty(rawBody)) + { + return UnprocessableEntity("raw tx not provided"); + } + + PSBT psbt = null; + if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var transaction) && + !PSBT.TryParse(rawBody, network.NBitcoinNetwork, out psbt)) + { + return UnprocessableEntity("invalid raw transaction or psbt"); + } + + if (psbt != null) + { + transaction = psbt.ExtractTransaction(); + } + + if (transaction.Check() != TransactionCheckResult.Success) + { + return UnprocessableEntity($"invalid tx: {transaction.Check()}"); + } + + if (transaction.Inputs.Any(txin => txin.ScriptSig == null || txin.WitScript == null)) + { + return UnprocessableEntity($"all inputs must be segwit and signed"); + } + + var explorerClient = _explorerClientProvider.GetExplorerClient(network); + var mempool = await explorerClient.BroadcastAsync(transaction, true); + if (!mempool.Success) + { + return UnprocessableEntity($"provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"); + } + + var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); + + //multiple outs could mean a payment being done to multiple invoices to multiple stores in one payjoin tx which makes life unbearable + //UNLESS the request specified an invoice Id, which is mandatory :) + var matchingInvoice = await _invoiceRepository.GetInvoice(invoice); + if (matchingInvoice == null) + { + return UnprocessableEntity($"invalid invoice"); + } + + if (matchingInvoice.IsExpired() || matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId); + //get outs to our current invoice address + var currentPaymentMethodDetails = + invoicePaymentMethod.GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod; + + if (!currentPaymentMethodDetails.PayJoin.Enabled) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null && + currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash()) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var address = currentPaymentMethodDetails.GetDepositAddress(network.NBitcoinNetwork); + var matchingTXOuts = transaction.Outputs.Where(txout => txout.IsTo(address)); + var nonMatchingTXOuts = transaction.Outputs.Where(txout => !txout.IsTo(address)); + if (!matchingTXOuts.Any()) + { + return UnprocessableEntity($"tx does not pay invoice"); + } + + var store = await _storeRepository.FindStore(matchingInvoice.StoreId); + + //check if store is enabled + var derivationSchemeSettings = store.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType().SingleOrDefault(settings => + settings.PaymentId == paymentMethodId && store.GetEnabledPaymentIds(_btcPayNetworkProvider) + .Contains(settings.PaymentId)); + if (derivationSchemeSettings == null) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var state = _payJoinStateProvider.GetOrAdd(new WalletId(matchingInvoice.StoreId, cryptoCode), + derivationSchemeSettings.AccountDerivation); + + //check if any of the inputs have been spotted in other txs sent our way..Reject anything but the original + //also reject if the invoice being payjoined to already has a record + if (!state.CheckIfTransactionValid(transaction, invoice, out var alreadyExists)) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //check if wallet of store is configured to be hot wallet + var extKeyStr = await explorerClient.GetMetadataAsync( + derivationSchemeSettings.AccountDerivation, + WellknownMetadataKeys.MasterHDKey); + if (extKeyStr == null) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var extKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork); + + var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); + if (signingKeySettings.RootFingerprint is null) + signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint(); + + RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath(); + if (rootedKeyPath == null) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + // The master fingerprint and/or account key path of your seed are not set in the wallet settings + } + + // The user gave the root key, let's try to rebase the PSBT, and derive the account private key + if (rootedKeyPath.MasterFingerprint == extKey.GetPublicKey().GetHDFingerPrint()) + { + extKey = extKey.Derive(rootedKeyPath.KeyPath); + } + + //check if the store uses segwit -- mixing inputs of different types is suspicious + if (derivationSchemeSettings.AccountDerivation.ScriptPubKeyType() == ScriptPubKeyType.Legacy) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //get previous payments so that we can check if their address is also used in the txouts) + var previousPayments = matchingInvoice.GetPayments(network) + .Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData); + + if (transaction.Outputs.Any( + txout => previousPayments.Any(data => txout.IsTo(data.GetDestination())))) + { + //Meh, address reuse from the customer would be happening with this tx, skip + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //get any utxos we exposed already that match any of the inputs sent to us. + var utxosToContributeToThisPayment = state.GetExposed(transaction); + + var invoicePaymentMethodAccounting = invoicePaymentMethod.Calculate(); + if (invoicePaymentMethodAccounting.Due != matchingTXOuts.Sum(txout => txout.Value) && + !utxosToContributeToThisPayment.Any()) + { + //the invoice would be under/overpaid with this tx and we have not exposed utxos so no worries + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //if we have not exposed any utxos to any of the inputs + if (!utxosToContributeToThisPayment.Any()) + { + var wallet = _btcPayWalletProvider.GetWallet(network); + //get all utxos we have so far exposed + var coins = state.GetRecords().SelectMany(list => + list.CoinsExposed.Select(coin => coin.OutPoint.Hash)); + + //get all utxos we have NOT so far exposed + var availableUtxos = (await wallet.GetUnspentCoins(derivationSchemeSettings.AccountDerivation)).Where( + coin => + !coins.Contains(coin.OutPoint.Hash)); + if (availableUtxos.Any()) + { + //clean up the state by removing utxos from the exposed list that we no longer have + state.PruneExposedButSpentCoins(availableUtxos); + //if we have coins that were exposed before but were not spent, prioritize them + var exposedAlready = state.GetExposedCoins(); + if (exposedAlready.Any()) + { + utxosToContributeToThisPayment = SelectCoins(network, exposedAlready, + invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC), + nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC))); + state.PruneExposedBySpentCoins(utxosToContributeToThisPayment.Select(coin => coin.OutPoint)); + } + else + { + utxosToContributeToThisPayment = SelectCoins(network, availableUtxos, + invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC), + nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC))); + } + } + } + + //we don't have any utxos to provide to this tx + if (!utxosToContributeToThisPayment.Any()) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //we rebuild the tx using 1 output to the invoice designed address + var cjOutputContributedAmount = utxosToContributeToThisPayment.Sum(coin => coin.Value.GetValue(network)); + var cjOutputSum = matchingTXOuts.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)) + + cjOutputContributedAmount; + + var newTx = transaction.Clone(); + + + if (matchingTXOuts.Count() > 1) + { + //if there are more than 1 outputs to our address, consolidate them to 1 + coinjoined amount to avoid unnecessary utxos + newTx.Outputs.Clear(); + newTx.Outputs.Add(new Money(cjOutputSum, MoneyUnit.BTC), address.ScriptPubKey); + foreach (var nonmatchingTxOut in nonMatchingTXOuts) + { + newTx.Outputs.Add(nonmatchingTxOut.Value, nonmatchingTxOut.ScriptPubKey); + } + } + else + { + //set the value of the out to our address to the sum of the coinjoined amount + foreach (var txOutput in newTx.Outputs.Where(txOutput => + txOutput.Value == matchingTXOuts.First().Value && + txOutput.ScriptPubKey == matchingTXOuts.First().ScriptPubKey)) + { + txOutput.Value = new Money(cjOutputSum, MoneyUnit.BTC); + break; + } + } + + newTx.Inputs.AddRange(utxosToContributeToThisPayment.Select(coin => + new TxIn(coin.OutPoint) {Sequence = newTx.Inputs.First().Sequence})); + + if (psbt != null) + { + psbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork); + + psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest() + { + DerivationScheme = derivationSchemeSettings.AccountDerivation, + PSBT = psbt, + RebaseKeyPaths = derivationSchemeSettings.GetPSBTRebaseKeyRules().ToList() + })).PSBT; + + + psbt = psbt.SignWithKeys(utxosToContributeToThisPayment + .Select(coin => extKey.Derive(coin.KeyPath).PrivateKey) + .ToArray()); + + if (!alreadyExists) + { + await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, + cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, + invoicePaymentMethod); + } + + return Ok(HexEncoder.IsWellFormed(rawBody) ? psbt.ToHex() : psbt.ToBase64()); + } + else + { + // Since we're going to modify the transaction, we're going invalidate all signatures + foreach (TxIn newTxInput in newTx.Inputs) + { + newTxInput.WitScript = WitScript.Empty; + } + + newTx.Sign( + utxosToContributeToThisPayment.Select(coin => + extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)), + utxosToContributeToThisPayment.Select(coin => coin.Coin)); + + if (!alreadyExists) + { + await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, + cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, + invoicePaymentMethod); + } + + return Ok(newTx.ToHex()); + } + } + + private async Task AddRecord(string invoice, PayJoinState joinState, Transaction transaction, + List utxosToContributeToThisPayment, decimal cjOutputContributedAmount, decimal cjOutputSum, + Transaction newTx, + BitcoinLikeOnChainPaymentMethod currentPaymentMethodDetails, PaymentMethod invoicePaymentMethod) + { + //keep a record of the tx and check if we have seen the tx before or any of its inputs + //on a timer service: if x amount of times passes, broadcast this tx + joinState.AddRecord(new PayJoinStateRecordedItem() + { + Timestamp = DateTimeOffset.Now, + Transaction = transaction, + OriginalTransactionHash = transaction.GetHash(), + CoinsExposed = utxosToContributeToThisPayment, + ContributedAmount = cjOutputContributedAmount, + TotalOutputAmount = cjOutputSum, + ProposedTransactionHash = newTx.GetHash(), + InvoiceId = invoice + }); + //we also store a record in the payment method details of the invoice, + //Tn case the server is shut down and a payjoin payment is made before it is turned back on. + //Otherwise we would end up marking the invoice as overPaid with our own inputs! + currentPaymentMethodDetails.PayJoin = new PayJoinPaymentState() + { + Enabled = true, + CoinsExposed = utxosToContributeToThisPayment, + ContributedAmount = cjOutputContributedAmount, + TotalOutputAmount = cjOutputSum, + ProposedTransactionHash = newTx.GetHash(), + OriginalTransactionHash = transaction.GetHash(), + }; + invoicePaymentMethod.SetPaymentMethodDetails(currentPaymentMethodDetails); + await _invoiceRepository.UpdateInvoicePaymentMethod(invoice, invoicePaymentMethod); + } + + private List SelectCoins(BTCPayNetwork network, IEnumerable availableUtxos, + decimal paymentAmount, IEnumerable otherOutputs) + { + // UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario". + // + // "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output. + // + // "UIH2": one input is larger than any output. This heuristically implies that no output is a payment, or, to say it better, it implies that this is not a normal wallet-created payment, it's something strange/exotic. + //src: https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2796539 + + foreach (var availableUtxo in availableUtxos) + { + //we can only check against our input as we dont know the value of the rest. + var input = availableUtxo.Value.GetValue(network); + var paymentAmountSum = input + paymentAmount; + if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output)) + { + //UIH 1 & 2 + continue; + } + + return new List {availableUtxo}; + } + + //For now we just grab a utxo "at random" + Random r = new Random(); + return new List() {availableUtxos.ElementAt(r.Next(0, availableUtxos.Count()))}; + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs new file mode 100644 index 000000000..377fa2304 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace BTCPayServer.Payments.PayJoin +{ + public static class PayJoinExtensions + { + public static void AddPayJoinServices(this IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinState.cs b/BTCPayServer/Payments/PayJoin/PayJoinState.cs new file mode 100644 index 000000000..9f73c8807 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinState.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using BTCPayServer.Services.Wallets; +using NBitcoin; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinState + { + //keep track of all transactions sent to us via this protocol + private readonly ConcurrentDictionary RecordedTransactions = + new ConcurrentDictionary(); + + //utxos that have been exposed but the original tx was broadcasted instead. + private readonly ConcurrentDictionary ExposedCoins; + + public PayJoinState(ConcurrentDictionary exposedCoins = null) + { + ExposedCoins = exposedCoins ?? new ConcurrentDictionary(); + } + + public IEnumerable GetRecords() + { + return RecordedTransactions.Values; + } + + public IEnumerable GetStaleRecords(TimeSpan cutoff) + { + return GetRecords().Where(pair => + DateTimeOffset.Now.Subtract(pair.Timestamp).TotalMilliseconds >= + cutoff.TotalMilliseconds); + } + + public bool CheckIfTransactionValid(Transaction transaction, string invoiceId, out bool alreadyExists) + { + if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}")) + { + alreadyExists = true; + return true; + } + + alreadyExists = false; + var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString()); + return !RecordedTransactions.Any(record => + record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) || + record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase) || + record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString()))); + } + + public void AddRecord(PayJoinStateRecordedItem recordedItem) + { + RecordedTransactions.TryAdd(recordedItem.ToString(), recordedItem); + foreach (var receivedCoin in recordedItem.CoinsExposed) + { + ExposedCoins.TryRemove(receivedCoin.OutPoint.ToString(), out _); + } + } + + public void RemoveRecord(PayJoinStateRecordedItem item, bool keepExposed) + { + if (keepExposed) + { + foreach (var receivedCoin in item.CoinsExposed) + { + ExposedCoins.AddOrReplace(receivedCoin.OutPoint.ToString(), receivedCoin); + } + } + + RecordedTransactions.TryRemove(item.ToString(), out _); + } + + public void RemoveRecord(uint256 proposedTxHash) + { + var id = RecordedTransactions.Single(pair => + pair.Value.ProposedTransactionHash == proposedTxHash || + pair.Value.OriginalTransactionHash == proposedTxHash).Key; + RecordedTransactions.TryRemove(id, out _); + } + + public List GetExposed(Transaction transaction) + { + return RecordedTransactions.Values + .Where(pair => + pair.Transaction.Inputs.Any(txIn => + transaction.Inputs.Any(txIn2 => txIn.PrevOut == txIn2.PrevOut))) + .SelectMany(pair => pair.CoinsExposed).ToList(); + } + + public bool TryGetWithProposedHash(uint256 hash, out PayJoinStateRecordedItem item) + { + item = + RecordedTransactions.Values.SingleOrDefault( + recordedItem => recordedItem.ProposedTransactionHash == hash); + return item != null; + } + + public IEnumerable GetExposedCoins(bool includeOnesInOngoingBPUs = false) + { + var result = ExposedCoins.Values; + return includeOnesInOngoingBPUs + ? result.Concat(RecordedTransactions.Values.SelectMany(item => item.CoinsExposed)) + : result; + } + + public void PruneExposedButSpentCoins(IEnumerable stillAvailable) + { + var keys = stillAvailable.Select(coin => coin.OutPoint.ToString()); + var keysToRemove = ExposedCoins.Keys.Where(s => !keys.Contains(s)); + foreach (var key in keysToRemove) + { + ExposedCoins.TryRemove(key, out _); + } + } + + + public void PruneExposedBySpentCoins(IEnumerable taken) + { + var keys = taken.Select(coin => coin.ToString()); + var keysToRemove = ExposedCoins.Keys.Where(s => keys.Contains(s)); + foreach (var key in keysToRemove) + { + ExposedCoins.TryRemove(key, out _); + } + } + + public void PruneRecordsOfUsedInputs(TxInList transactionInputs) + { + foreach (PayJoinStateRecordedItem payJoinStateRecordedItem in RecordedTransactions.Values) + { + if (payJoinStateRecordedItem.CoinsExposed.Any(coin => + transactionInputs.Any(txin => txin.PrevOut == coin.OutPoint))) + { + RemoveRecord(payJoinStateRecordedItem, true); + } + } + + PruneExposedBySpentCoins(transactionInputs.Select(coin => coin.PrevOut)); + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs b/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs new file mode 100644 index 000000000..fac5b4765 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using NBitcoin; +using NBXplorer.DerivationStrategy; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinStateProvider + { + private readonly SettingsRepository _settingsRepository; + private readonly StoreRepository _storeRepository; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + + private MultiValueDictionary Lookup = + new MultiValueDictionary(); + + private ConcurrentDictionary States = + new ConcurrentDictionary(); + + public PayJoinStateProvider(SettingsRepository settingsRepository, StoreRepository storeRepository, + BTCPayNetworkProvider btcPayNetworkProvider, BTCPayWalletProvider btcPayWalletProvider) + { + _settingsRepository = settingsRepository; + _storeRepository = storeRepository; + _btcPayNetworkProvider = btcPayNetworkProvider; + _btcPayWalletProvider = btcPayWalletProvider; + } + + public IEnumerable Get(string cryptoCode, DerivationStrategyBase derivationStrategyBase) + { + if (Lookup.TryGetValue(derivationStrategyBase, out var walletIds)) + { + var matchedWalletKeys = walletIds.Where(id => + id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)); + + return matchedWalletKeys.Select(id => States.TryGet(id)); + } + + return Array.Empty(); + } + + public PayJoinState Get(WalletId walletId) + { + return States.TryGet(walletId); + } + + public ConcurrentDictionary GetAll() + { + return States; + } + + public PayJoinState GetOrAdd(WalletId key, DerivationStrategyBase derivationStrategyBase, + IEnumerable exposedCoins = null) + { + return States.GetOrAdd(key, id => + { + Lookup.Add(derivationStrategyBase, id); + return new PayJoinState(exposedCoins == null + ? null + : new ConcurrentDictionary(exposedCoins.Select(coin => + new KeyValuePair(coin.OutPoint.ToString(), coin)))); + }); + } + + public void RemoveState(WalletId walletId) + { + States.TryRemove(walletId, out _); + } + + public async Task SaveCoins() + { + Dictionary> saved = + new Dictionary>(); + foreach (var payState in GetAll()) + { + saved.Add(payState.Key.ToString(), + payState.Value.GetExposedCoins(true).Select(coin => coin.OutPoint)); + } + + await _settingsRepository.UpdateSetting(saved, "bpu-state"); + } + + public async Task LoadCoins() + { + Dictionary> saved = + await _settingsRepository.GetSettingAsync>>("bpu-state"); + if (saved == null) + { + return; + } + + foreach (KeyValuePair> keyValuePair in saved) + { + var walletId = WalletId.Parse(keyValuePair.Key); + var store = await _storeRepository.FindStore(walletId.StoreId); + var derivationSchemeSettings = store?.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType().SingleOrDefault(settings => + settings.PaymentId.CryptoCode.Equals(walletId.CryptoCode, + StringComparison.InvariantCultureIgnoreCase)); + if (derivationSchemeSettings == null) + { + continue; + } + + var utxos = await _btcPayWalletProvider.GetWallet(walletId.CryptoCode) + .GetUnspentCoins(derivationSchemeSettings.AccountDerivation); + + _ = GetOrAdd(walletId, derivationSchemeSettings.AccountDerivation, + utxos.Where(coin => keyValuePair.Value.Contains(coin.OutPoint))); + } + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs b/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs new file mode 100644 index 000000000..0e480fcb5 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using BTCPayServer.Services.Wallets; +using NBitcoin; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinStateRecordedItem + { + public Transaction Transaction { get; set; } + public DateTimeOffset Timestamp { get; set; } + public uint256 ProposedTransactionHash { get; set; } + public List CoinsExposed { get; set; } + public decimal TotalOutputAmount { get; set; } + public decimal ContributedAmount { get; set; } + public uint256 OriginalTransactionHash { get; set; } + + public string InvoiceId { get; set; } + + public override string ToString() + { + return $"{InvoiceId}_{OriginalTransactionHash}"; + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs b/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs new file mode 100644 index 000000000..778e8bdb6 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Events; +using Microsoft.Extensions.Hosting; +using NBXplorer; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinTransactionBroadcaster : IHostedService + { + private readonly TimeSpan + BroadcastAfter = + TimeSpan.FromMinutes( + 5); // The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok. + + private readonly EventAggregator _eventAggregator; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly PayJoinStateProvider _payJoinStateProvider; + + private CompositeDisposable leases = new CompositeDisposable(); + + public PayJoinTransactionBroadcaster( + EventAggregator eventAggregator, + ExplorerClientProvider explorerClientProvider, + PayJoinStateProvider payJoinStateProvider) + { + _eventAggregator = eventAggregator; + _explorerClientProvider = explorerClientProvider; + _payJoinStateProvider = payJoinStateProvider; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var loadCoins = _payJoinStateProvider.LoadCoins(); + //if the wallet was updated, we need to remove the state as the utxos no longer fit + leases.Add(_eventAggregator.Subscribe(evt => + _payJoinStateProvider.RemoveState(evt.WalletId))); + + leases.Add(_eventAggregator.Subscribe(txEvent => + { + if (!txEvent.NewTransactionEvent.Outputs.Any()) + { + return; + } + + var relevantStates = + _payJoinStateProvider.Get(txEvent.CryptoCode, txEvent.NewTransactionEvent.DerivationStrategy); + + foreach (var relevantState in relevantStates) + { + //if any of the exposed inputs where spent, remove them from our state + relevantState.PruneRecordsOfUsedInputs(txEvent.NewTransactionEvent.TransactionData.Transaction + .Inputs); + } + })); + _ = BroadcastTransactionsPeriodically(cancellationToken); + await loadCoins; + } + + private async Task BroadcastTransactionsPeriodically(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var tasks = new List(); + + foreach (var state in _payJoinStateProvider.GetAll()) + { + var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode); + //broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time. + //This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible. + var staleTxs = state.Value.GetStaleRecords(BroadcastAfter); + + tasks.AddRange(staleTxs.Select(staleTx => explorerClient + .BroadcastAsync(staleTx.Transaction, cancellationToken) + .ContinueWith(task => { state.Value.RemoveRecord(staleTx, true); }, TaskScheduler.Default))); + } + + await Task.WhenAll(tasks); + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _payJoinStateProvider.SaveCoins(); + leases.Dispose(); + } + } +} diff --git a/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs index 461d189e7..3e6f7b4b1 100644 --- a/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs +++ b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs @@ -33,12 +33,12 @@ namespace BTCPayServer.Payments public override IPaymentMethodDetails DeserializePaymentMethodDetails(BTCPayNetworkBase network, string str) { - return JsonConvert.DeserializeObject(str); + return ((BTCPayNetwork) network).ToObject(str); } public override string SerializePaymentMethodDetails(BTCPayNetworkBase network, IPaymentMethodDetails details) { - return JsonConvert.SerializeObject(details); + return ((BTCPayNetwork) network).ToString((BitcoinLikeOnChainPaymentMethod)details); } public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value) diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 1b219f2d1..d3728e38b 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -411,7 +411,8 @@ namespace BTCPayServer.Services.Invoices var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo(); var subtotalPrice = accounting.TotalDue - accounting.NetworkFee; var cryptoCode = info.GetId().CryptoCode; - var address = info.GetPaymentMethodDetails()?.GetPaymentDestination(); + var details = info.GetPaymentMethodDetails(); + var address = details?.GetPaymentDestination(); var exrates = new Dictionary { { ProductInformation.Currency, cryptoInfo.Rate } @@ -463,12 +464,18 @@ namespace BTCPayServer.Services.Invoices { var minerInfo = new MinerFeeInfo(); minerInfo.TotalFee = accounting.NetworkFee.Satoshi; - minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)info.GetPaymentMethodDetails()).FeeRate + minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)details).FeeRate .GetFee(1).Satoshi; dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo); + var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due); + + if (((details as BitcoinLikeOnChainPaymentMethod)?.PayJoin?.Enabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase)) + { + bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu/{Id}"; + } cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() { - BIP21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due), + BIP21 = bip21, }; #pragma warning disable 618 diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 59f5d52b5..6f6f8dafe 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -292,6 +292,21 @@ retry: } } + public async Task UpdateInvoicePaymentMethod(string invoiceId, PaymentMethod paymentMethod) + { + using (var context = _ContextFactory.CreateContext()) + { + var invoice = await context.Invoices.FindAsync(invoiceId); + if (invoice == null) + return; + var network = paymentMethod.Network; + var invoiceEntity = ToObject(invoice.Blob); + invoiceEntity.SetPaymentMethod(paymentMethod); + invoice.Blob = ToBytes(invoiceEntity, network); + await context.SaveChangesAsync(); + } + } + public async Task AddPendingInvoiceIfNotPresent(string invoiceId) { using (var context = _ContextFactory.CreateContext()) diff --git a/BTCPayServer/Services/SocketFactory.cs b/BTCPayServer/Services/SocketFactory.cs index e604c1b06..ed2da78b5 100644 --- a/BTCPayServer/Services/SocketFactory.cs +++ b/BTCPayServer/Services/SocketFactory.cs @@ -1,13 +1,12 @@ -using System; -using NBitcoin; -using System.Collections.Generic; -using System.Linq; +using NBitcoin; using System.Net; +using System.Net.Http; using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Configuration; +using com.LandonKey.SocksWebProxy; +using com.LandonKey.SocksWebProxy.Proxy; using NBitcoin.Protocol.Connectors; using NBitcoin.Protocol; @@ -16,9 +15,11 @@ namespace BTCPayServer.Services public class SocketFactory { private readonly BTCPayServerOptions _options; + public readonly HttpClient SocksClient; public SocketFactory(BTCPayServerOptions options) { _options = options; + SocksClient = CreateHttpClientUsingSocks(); } public async Task ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken) { @@ -60,5 +61,21 @@ namespace BTCPayServer.Services { } } + + private HttpClient CreateHttpClientUsingSocks() + { + if (_options.SocksEndpoint == null) + return null; + return new HttpClient(new HttpClientHandler + { + Proxy = new SocksWebProxy(new ProxyConfig() + { + Version = ProxyConfig.SocksVersion.Five, + SocksAddress = _options.SocksEndpoint.AsOnionCatIPEndpoint().Address, + SocksPort = _options.SocksEndpoint.AsOnionCatIPEndpoint().Port, + }), + UseProxy = true + }); + } } } diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index da13c7890..26604e53b 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -23,7 +23,7 @@ namespace BTCPayServer.Services.Wallets public DateTimeOffset Timestamp { get; set; } public KeyPath KeyPath { get; set; } public IMoney Value { get; set; } - + public Coin Coin { get; set; } } public class NetworkCoins { @@ -180,7 +180,8 @@ namespace BTCPayServer.Services.Wallets Value = c.Value, Timestamp = c.Timestamp, OutPoint = c.Outpoint, - ScriptPubKey = c.ScriptPubKey + ScriptPubKey = c.ScriptPubKey, + Coin = c.AsCoin(derivationStrategy) }).ToArray(); } diff --git a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml index d30391173..9efdba957 100644 --- a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml +++ b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml @@ -52,7 +52,7 @@ @payment.Crypto @payment.DepositAddress - @payment.CryptoPaymentData.GetValue() + @payment.CryptoPaymentData.GetValue() @(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"(+ Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount })") +
+ + + +
} + @if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl)) + { +
+ + + +
+ }
diff --git a/BTCPayServer/ZoneLimits.cs b/BTCPayServer/ZoneLimits.cs index 08e722cec..85a424967 100644 --- a/BTCPayServer/ZoneLimits.cs +++ b/BTCPayServer/ZoneLimits.cs @@ -9,5 +9,6 @@ namespace BTCPayServer { public const string Login = "btcpaylogin"; public const string Register = "btcpayregister"; + public const string PayJoin = "PayJoin"; } } From 2e3a0706ee1053e588880357be8da6cc4f1f4115 Mon Sep 17 00:00:00 2001 From: Kukks Date: Thu, 5 Mar 2020 19:04:08 +0100 Subject: [PATCH 02/21] RBF Protection & Handling --- BTCPayServer.Data/Data/PaymentData.cs | 2 +- BTCPayServer.Tests/PayJoinTests.cs | 224 ++++++++++++++++-- BTCPayServer.Tests/ServerTester.cs | 12 + .../Payments/Bitcoin/NBXplorerListener.cs | 13 +- .../PayJoin/PayJoinEndpointController.cs | 39 ++- BTCPayServer/Payments/PayJoin/PayJoinState.cs | 50 +++- .../PayJoin/PayJoinStateRecordedItem.cs | 3 +- .../PayJoin/PayJoinTransactionBroadcaster.cs | 80 +++++-- 8 files changed, 357 insertions(+), 66 deletions(-) diff --git a/BTCPayServer.Data/Data/PaymentData.cs b/BTCPayServer.Data/Data/PaymentData.cs index fb7c98784..2361ba097 100644 --- a/BTCPayServer.Data/Data/PaymentData.cs +++ b/BTCPayServer.Data/Data/PaymentData.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 5b7d84bb9..ae9911184 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Net.Http; using System.Text; @@ -5,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using BTCPayServer.Controllers; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Models; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.WalletViewModels; @@ -14,6 +16,8 @@ using BTCPayServer.Payments.PayJoin; using BTCPayServer.Services.Wallets; using BTCPayServer.Tests.Logging; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using NBitcoin; using NBitcoin.Payment; using NBitpayClient; @@ -118,6 +122,12 @@ namespace BTCPayServer.Tests invoice = receiverUser.BitPay.GetInvoice(invoice.Id); Assert.Equal(Invoice.STATUS_PAID, invoice.Status); }); + //verify that there is nothing in the payment state + + var payJoinStateProvider = tester.PayTester.GetService(); + var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); + Assert.NotNull(receiverWalletPayJoinState); + Assert.Empty(receiverWalletPayJoinState.GetRecords()); //now that there is a utxo, let's do it again @@ -146,15 +156,13 @@ namespace BTCPayServer.Tests .Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value .ToDecimal(MoneyUnit.BTC)); - var payJoinStateProvider = tester.PayTester.GetService(); //the state should now hold that there is an ongoing utxo - var state = payJoinStateProvider.Get(receiverWalletId); - Assert.NotNull(state); - Assert.Single(state.GetRecords()); - Assert.Equal(0.02m, state.GetRecords().First().ContributedAmount); - Assert.Single(state.GetRecords().First().CoinsExposed); + Assert.Single(receiverWalletPayJoinState.GetRecords()); + Assert.Equal(0.02m, receiverWalletPayJoinState.GetRecords().First().ContributedAmount); + Assert.Single(receiverWalletPayJoinState.GetRecords().First().CoinsExposed); + Assert.False(receiverWalletPayJoinState.GetRecords().First().TxSeen); Assert.Equal(psbt.Finalize().ExtractTransaction().GetHash(), - state.GetRecords().First().ProposedTransactionHash); + receiverWalletPayJoinState.GetRecords().First().ProposedTransactionHash); Assert.Equal("WalletTransactions", Assert.IsType( @@ -178,11 +186,31 @@ namespace BTCPayServer.Tests Assert.True(Assert.IsType(invoiceVM.Payments.First().GetCryptoPaymentData()) .PayJoinSelfContributedAmount > 0); + + //we dont remove the payjoin tx state even if we detect it, for cases of RBF + Assert.NotEmpty(receiverWalletPayJoinState.GetRecords()); + Assert.Single(receiverWalletPayJoinState.GetRecords()); + Assert.True(receiverWalletPayJoinState.GetRecords().First().TxSeen); + + var debugData = new + { + StoreId = receiverWalletId.StoreId, + InvoiceId = receiverWalletPayJoinState.GetRecords().First().InvoiceId, + PayJoinTx = receiverWalletPayJoinState.GetRecords().First().ProposedTransactionHash + }; + for (int i = 0; i < 6; i++) + { + await tester.WaitForEvent(async () => + { + await cashCow.GenerateAsync(1); + }); + } + //check that the state has cleared that ongoing tx - state = payJoinStateProvider.Get(receiverWalletId); - Assert.NotNull(state); - Assert.Empty(state.GetRecords()); - Assert.Empty(state.GetExposedCoins()); + receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); + Assert.NotNull(receiverWalletPayJoinState); + Assert.Empty(receiverWalletPayJoinState.GetRecords()); + Assert.Empty(receiverWalletPayJoinState.GetExposedCoins()); //Cool, so the payjoin works! //The cool thing with payjoin is that your utxos don't grow @@ -196,24 +224,30 @@ namespace BTCPayServer.Tests Assert.NotNull(await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, new Money(0.011m, MoneyUnit.BTC))); - Assert.NotNull( await cashCow.SendToAddressAsync( + Assert.NotNull(await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, new Money(0.012m, MoneyUnit.BTC))); - Assert.NotNull( await cashCow.SendToAddressAsync( + Assert.NotNull(await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, new Money(0.013m, MoneyUnit.BTC))); - Assert.NotNull( await cashCow.SendToAddressAsync( + Assert.NotNull(await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, new Money(0.021m, MoneyUnit.BTC))); - Assert.NotNull( await cashCow.SendToAddressAsync( + Assert.NotNull(await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, new Money(0.022m, MoneyUnit.BTC))); - Assert.NotNull( await cashCow.SendToAddressAsync( + Assert.NotNull(await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, new Money(0.023m, MoneyUnit.BTC))); - Assert.NotNull( await cashCow.SendToAddressAsync( + Assert.NotNull(await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, new Money(0.024m, MoneyUnit.BTC))); + Assert.NotNull(await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.025m, MoneyUnit.BTC))); + Assert.NotNull(await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.026m, MoneyUnit.BTC))); await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, @@ -245,6 +279,8 @@ namespace BTCPayServer.Tests var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m); var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m); var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m); + var coin5 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.025m); + var coin6 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.026m); var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); signingKeySettings.RootFingerprint = @@ -371,14 +407,15 @@ namespace BTCPayServer.Tests tester.ExplorerClient.Network.NBitcoinNetwork); var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"]; - var Invoice5Coin4 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() .SetChange(senderChange) .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) .AddCoins(coin4.Coin) .AddKeys(extKey.Derive(coin4.KeyPath)) - .SendEstimatedFees(new FeeRate(100m)) - .BuildTransaction(true); + .SendEstimatedFees(new FeeRate(100m)); + + var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true); var Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); @@ -405,6 +442,153 @@ namespace BTCPayServer.Tests Transaction.Parse(await Invoice5Coin4Response2.Content.ReadAsStringAsync(), n); Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), Invoice5Coin4Response2Tx.GetHash()); } + + //Attempt 7: send the payjoin porposed tx to the endpoint + //Result: get same tx sent back as is + Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); + Assert.True(Invoice5Coin4Response.IsSuccessStatusCode); + Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), + Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash()); + + //Attempt 8: sign the payjoin and send it back to the endpoint + //Result: get same tx sent back as is + var Invoice5Coin4ResponseTxSigned = Invoice5Coin4TxBuilder.SignTransaction(Invoice5Coin4ResponseTx); + Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); + Assert.True(Invoice5Coin4Response.IsSuccessStatusCode); + Assert.Equal(Invoice5Coin4ResponseTxSigned.GetHash(), + Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash()); + + //Attempt 9: broadcast a payjoin tx, then try to submit both original tx and the payjoin itself again + //Result: fails + await tester.ExplorerClient.BroadcastAsync(Invoice5Coin4ResponseTxSigned); + + Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); + + Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); + + //Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again + //Result: same tx gets sent back + + //give the receiver some more utxos + Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.1m, MoneyUnit.BTC))); + + var invoice6 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice6Endpoint = invoice6ParsedBip21.UnknowParameters["bpu"]; + + var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice6ParsedBip21.Address, invoice6ParsedBip21.Amount) + .AddCoins(coin5.Coin) + .AddKeys(extKey.Derive(coin5.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .SetLockTime(0); + + var invoice6Coin5 = invoice6Coin5TxBuilder + .BuildTransaction(true); + + var Invoice6Coin5Response1 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint, + new StringContent(invoice6Coin5.ToHex(), Encoding.UTF8, "text/plain")); + Assert.True(Invoice6Coin5Response1.IsSuccessStatusCode); + var Invoice6Coin5Response1Tx = + Transaction.Parse(await Invoice6Coin5Response1.Content.ReadAsStringAsync(), n); + var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx); + //broadcast the first payjoin + await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned); + + invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m)); + var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder + .BuildTransaction(true); + + var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint, + new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain")); + Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode); + var Invoice6Coin5Response3Tx = + Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n); + Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin => + Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut))); + + //Attempt 11: + //send tx with rbt, broadcast payjoin, + //create tx spending the original tx inputs with rbf to self, + //Result: the exposed utxos are priorized in the next p2ep + + //give the receiver some more utxos + Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.1m, MoneyUnit.BTC))); + + var invoice7 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice7Endpoint = invoice7ParsedBip21.UnknowParameters["bpu"]; + + var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice7ParsedBip21.Address, invoice7ParsedBip21.Amount) + .AddCoins(coin6.Coin) + .AddKeys(extKey.Derive(coin6.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .SetLockTime(0); + + var invoice7Coin6Tx = invoice7Coin6TxBuilder + .BuildTransaction(true); + + var invoice7Coin6Response1 = await tester.PayTester.HttpClient.PostAsync(invoice7Endpoint, + new StringContent(invoice7Coin6Tx.ToHex(), Encoding.UTF8, "text/plain")); + Assert.True(invoice7Coin6Response1.IsSuccessStatusCode); + var invoice7Coin6Response1Tx = + Transaction.Parse(await invoice7Coin6Response1.Content.ReadAsStringAsync(), n); + var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx); + var contributedInputsInvoice7Coin6Response1TxSigned = + Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut); + Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id); + //broadcast the payjoin + await tester.WaitForEvent(async () => + { + var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned)); + Assert.True(res.Success); + }); + + Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); + + var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .AddCoins(coin6.Coin) + .SendAll(senderChange) + .SubtractFees() + .AddKeys(extKey.Derive(coin6.KeyPath)) + .SendEstimatedFees(new FeeRate(200m)) + .SetLockTime(0) + .BuildTransaction(true); + + //broadcast the "rbf cancel" tx + await tester.WaitForEvent(async () => + { + var res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2)); + Assert.True(res.Success); + }); + //btcpay does not know of replaced txs where the outputs do not pay it(double spends using RBF to "cancel" a payment) + Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); + + //hijack our automated payjoin original broadcaster and force it to broadcast all, now + var payJoinTransactionBroadcaster = tester.PayTester.ServiceProvider.GetServices() + .OfType().First(); + await payJoinTransactionBroadcaster.BroadcastStaleTransactions(TimeSpan.Zero, CancellationToken.None); + + Assert.DoesNotContain(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id); + //all our failed payjoins are clear and any exposed utxo has been moved to the prioritized list + Assert.Contains(receiverWalletPayJoinState.GetExposedCoins(), receivedCoin => + receivedCoin.OutPoint == contributedInputsInvoice7Coin6Response1TxSigned.PrevOut); } } } diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 714ff14d5..409a1cf26 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -144,6 +144,18 @@ namespace BTCPayServer.Tests await CustomerLightningD.Pay(bolt11); } + public async Task WaitForEvent(Func action) + { + var tcs = new TaskCompletionSource(); + var sub = PayTester.GetService().Subscribe(evt => + { + tcs.SetResult(true); + }); + await action.Invoke(); + await tcs.Task; + sub.Dispose(); + } + public ILightningClient CustomerLightningD { get; set; } public ILightningClient MerchantLightningD { get; private set; } diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index f8d91460b..b7d36fa63 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -232,6 +232,7 @@ namespace BTCPayServer.Payments.Bitcoin var transactions = await wallet.GetTransactions(GetAllBitcoinPaymentData(invoice) .Select(p => p.Outpoint.Hash) .ToArray()); + var payJoinState = _payJoinStateProvider.Get(new WalletId(invoice.StoreId, wallet.Network.CryptoCode)); foreach (var payment in invoice.GetPayments(wallet.Network)) { if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike) @@ -267,7 +268,7 @@ namespace BTCPayServer.Payments.Bitcoin } } - + bool updated = false; if (accounted != payment.Accounted) { @@ -284,7 +285,12 @@ namespace BTCPayServer.Payments.Bitcoin updated = true; } } - + + // we keep the state of the payjoin tx until it is confirmed in case of rbf situations where the tx is cancelled + if (paymentData.PayJoinSelfContributedAmount> 0 && accounted && paymentData.PaymentConfirmed(payment, invoice.SpeedPolicy)) + { + payJoinState?.RemoveRecord(paymentData.Outpoint.Hash); + } // if needed add invoice back to pending to track number of confirmations if (paymentData.ConfirmationCount < wallet.Network.MaxTrackedConfirmation) await _InvoiceRepository.AddPendingInvoiceIfNotPresent(invoice.Id); @@ -363,9 +369,8 @@ namespace BTCPayServer.Payments.Bitcoin if (payJoinState == null || !payJoinState.TryGetWithProposedHash(transactionHash, out var record) || record.TotalOutputAmount != amount) return 0; - + record.TxSeen = true; //this is the payjoin output! - payJoinState.RemoveRecord(transactionHash); return record.ContributedAmount; } diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index c394cf93f..0828bdb4a 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -105,12 +105,7 @@ namespace BTCPayServer.Payments.PayJoin { return UnprocessableEntity($"invalid invoice"); } - - if (matchingInvoice.IsExpired() || matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - + var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId); //get outs to our current invoice address var currentPaymentMethodDetails = @@ -121,8 +116,19 @@ namespace BTCPayServer.Payments.PayJoin return UnprocessableEntity($"cannot handle payjoin tx"); } + //the invoice must be active, and the status must be new OR paid if + if (matchingInvoice.IsExpired() || + ((matchingInvoice.GetInvoiceState().Status == InvoiceStatus.Paid && + currentPaymentMethodDetails.PayJoin.OriginalTransactionHash == null) || + matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New)) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null && - currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash()) + currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash() && + !transaction.RBF) { return UnprocessableEntity($"cannot handle payjoin tx"); } @@ -152,7 +158,8 @@ namespace BTCPayServer.Payments.PayJoin //check if any of the inputs have been spotted in other txs sent our way..Reject anything but the original //also reject if the invoice being payjoined to already has a record - if (!state.CheckIfTransactionValid(transaction, invoice, out var alreadyExists)) + var validity = state.CheckIfTransactionValid(transaction, invoice); + if (validity == PayJoinState.TransactionValidityResult.Invalid_Inputs_Seen || validity == PayJoinState.TransactionValidityResult.Invalid_PartialMatch) { return UnprocessableEntity($"cannot handle payjoin tx"); } @@ -196,7 +203,7 @@ namespace BTCPayServer.Payments.PayJoin .Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData); if (transaction.Outputs.Any( - txout => previousPayments.Any(data => txout.IsTo(data.GetDestination())))) + txout => previousPayments.Any(data => !txout.IsTo(address) && txout.IsTo(data.GetDestination())))) { //Meh, address reuse from the customer would be happening with this tx, skip return UnprocessableEntity($"cannot handle payjoin tx"); @@ -302,7 +309,12 @@ namespace BTCPayServer.Payments.PayJoin .Select(coin => extKey.Derive(coin.KeyPath).PrivateKey) .ToArray()); - if (!alreadyExists) + if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs) + { + //if the invoice was rbfed, remove the current record and replace it with the new one + state.RemoveRecord(invoice); + } + if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch) { await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, @@ -324,7 +336,12 @@ namespace BTCPayServer.Payments.PayJoin extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)), utxosToContributeToThisPayment.Select(coin => coin.Coin)); - if (!alreadyExists) + if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs) + { + //if the invoice was rbfed, remove the current record and replace it with the new one + state.RemoveRecord(invoice); + } + if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch) { await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, diff --git a/BTCPayServer/Payments/PayJoin/PayJoinState.cs b/BTCPayServer/Payments/PayJoin/PayJoinState.cs index 9f73c8807..7a564cce6 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinState.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinState.cs @@ -33,20 +33,44 @@ namespace BTCPayServer.Payments.PayJoin cutoff.TotalMilliseconds); } - public bool CheckIfTransactionValid(Transaction transaction, string invoiceId, out bool alreadyExists) + public enum TransactionValidityResult + { + Valid_ExactMatch, + Invalid_PartialMatch, + Valid_NoMatch, + Invalid_Inputs_Seen, + Valid_SameInputs + } + + public TransactionValidityResult CheckIfTransactionValid(Transaction transaction, string invoiceId) { if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}")) { - alreadyExists = true; - return true; + return TransactionValidityResult.Valid_ExactMatch; } - alreadyExists = false; var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString()); - return !RecordedTransactions.Any(record => + + var matches = RecordedTransactions.Where(record => record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) || - record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase) || - record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString()))); + record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase)); + + if (matches.Any()) + { + if(matches.Any(record => + record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) && + record.Value.Transaction.RBF && + record.Value.Transaction.Inputs.All(recordTxIn => hashes.Contains(recordTxIn.PrevOut.ToString())))) + { + return TransactionValidityResult.Valid_SameInputs; + } + + return TransactionValidityResult.Invalid_PartialMatch; + } + + return RecordedTransactions.Any(record => + record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString()))) + ? TransactionValidityResult.Invalid_Inputs_Seen: TransactionValidityResult.Valid_NoMatch; } public void AddRecord(PayJoinStateRecordedItem recordedItem) @@ -73,9 +97,19 @@ namespace BTCPayServer.Payments.PayJoin public void RemoveRecord(uint256 proposedTxHash) { - var id = RecordedTransactions.Single(pair => + var id = RecordedTransactions.SingleOrDefault(pair => pair.Value.ProposedTransactionHash == proposedTxHash || pair.Value.OriginalTransactionHash == proposedTxHash).Key; + if (id != null) + { + RecordedTransactions.TryRemove(id, out _); + } + } + + public void RemoveRecord(string invoiceId) + { + var id = RecordedTransactions.Single(pair => + pair.Value.InvoiceId == invoiceId).Key; RecordedTransactions.TryRemove(id, out _); } diff --git a/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs b/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs index 0e480fcb5..b155308b7 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs @@ -12,10 +12,11 @@ namespace BTCPayServer.Payments.PayJoin public uint256 ProposedTransactionHash { get; set; } public List CoinsExposed { get; set; } public decimal TotalOutputAmount { get; set; } - public decimal ContributedAmount { get; set; } + public decimal ContributedAmount { get; set ; } public uint256 OriginalTransactionHash { get; set; } public string InvoiceId { get; set; } + public bool TxSeen { get; set; } public override string ToString() { diff --git a/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs b/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs index 778e8bdb6..50486cdf0 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs @@ -3,18 +3,21 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Data; using BTCPayServer.Events; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; using Microsoft.Extensions.Hosting; +using NBitcoin.RPC; using NBXplorer; namespace BTCPayServer.Payments.PayJoin { public class PayJoinTransactionBroadcaster : IHostedService { - private readonly TimeSpan - BroadcastAfter = - TimeSpan.FromMinutes( - 5); // The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok. + // The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok. + private static readonly TimeSpan BroadcastAfter = TimeSpan.FromMinutes(5); private readonly EventAggregator _eventAggregator; private readonly ExplorerClientProvider _explorerClientProvider; @@ -41,7 +44,9 @@ namespace BTCPayServer.Payments.PayJoin leases.Add(_eventAggregator.Subscribe(txEvent => { - if (!txEvent.NewTransactionEvent.Outputs.Any()) + if (!txEvent.NewTransactionEvent.Outputs.Any() || + (txEvent.NewTransactionEvent.TransactionData.Transaction.RBF && + txEvent.NewTransactionEvent.TransactionData.Confirmations == 0)) { return; } @@ -51,7 +56,7 @@ namespace BTCPayServer.Payments.PayJoin foreach (var relevantState in relevantStates) { - //if any of the exposed inputs where spent, remove them from our state + //if any of the exposed inputs were spent, remove them from our state relevantState.PruneRecordsOfUsedInputs(txEvent.NewTransactionEvent.TransactionData.Transaction .Inputs); } @@ -64,25 +69,58 @@ namespace BTCPayServer.Payments.PayJoin { while (!cancellationToken.IsCancellationRequested) { - var tasks = new List(); - - foreach (var state in _payJoinStateProvider.GetAll()) - { - var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode); - //broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time. - //This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible. - var staleTxs = state.Value.GetStaleRecords(BroadcastAfter); - - tasks.AddRange(staleTxs.Select(staleTx => explorerClient - .BroadcastAsync(staleTx.Transaction, cancellationToken) - .ContinueWith(task => { state.Value.RemoveRecord(staleTx, true); }, TaskScheduler.Default))); - } - - await Task.WhenAll(tasks); + await BroadcastStaleTransactions(BroadcastAfter, cancellationToken); await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); } } + public async Task BroadcastStaleTransactions(TimeSpan broadcastAfter, CancellationToken cancellationToken) + { + List tasks = new List(); + foreach (var state in _payJoinStateProvider.GetAll()) + { + var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode); + //broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time. + //This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible. + var staleTxs = state.Value.GetStaleRecords(broadcastAfter) + .Where(item => !item.TxSeen || item.Transaction.RBF); + + tasks.AddRange(staleTxs.Select(async staleTx => + { + //if the transaction signals RBF and was broadcasted, check if it was rbfed out + if (staleTx.TxSeen && staleTx.Transaction.RBF) + { + var proposedTransaction = await explorerClient.GetTransactionAsync(staleTx.ProposedTransactionHash, cancellationToken); + var result = await explorerClient.BroadcastAsync(proposedTransaction.Transaction, cancellationToken); + var accounted = result.Success || + result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN || + !( + // Happen if a blocks mined a replacement + // Or if the tx is a double spend of something already in the mempool without rbf + result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR || + // Happen if RBF is on and fee insufficient + result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED); + + if (accounted) + { + //if it wasn't replaced just yet, do not attempt to move the exposed coins to the priority list + return; + } + } + else + { + await explorerClient + .BroadcastAsync(staleTx.Transaction, cancellationToken); + } + + + state.Value.RemoveRecord(staleTx, true); + })); + } + + await Task.WhenAll(tasks); + } + public async Task StopAsync(CancellationToken cancellationToken) { await _payJoinStateProvider.SaveCoins(); From f1821636db187f50f86ed1880b261213d6fc4587 Mon Sep 17 00:00:00 2001 From: Kukks Date: Fri, 13 Mar 2020 15:10:26 +0100 Subject: [PATCH 03/21] fix tor client creator --- .../Controllers/WalletsController.PSBT.cs | 6 +-- BTCPayServer/Properties/launchSettings.json | 3 +- BTCPayServer/Services/SocketFactory.cs | 54 ++++++++++++++----- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 85d9029d2..3ec39b5cd 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -163,13 +163,13 @@ namespace BTCPayServer.Controllers { TempData.Remove("bpu"); HttpClient httpClient; - if (endpoint.IsOnion() && _socketFactory.SocksClient!= null) + if (endpoint.IsOnion() ) { - if ( _socketFactory.SocksClient == null) + httpClient = await _socketFactory.SocksClient; + if (httpClient == null) { return null; } - httpClient = _socketFactory.SocksClient; } else { diff --git a/BTCPayServer/Properties/launchSettings.json b/BTCPayServer/Properties/launchSettings.json index 2f3db0eaa..988d32240 100644 --- a/BTCPayServer/Properties/launchSettings.json +++ b/BTCPayServer/Properties/launchSettings.json @@ -51,7 +51,8 @@ "BTCPAY_SSHCONNECTION": "root@127.0.0.1:21622", "BTCPAY_SSHPASSWORD": "opD3i2282D", "BTCPAY_DEBUGLOG": "debug.log", - "BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc" + "BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc", + "BTCPAY_SOCKSENDPOINT": "tor:9050" }, "applicationUrl": "https://localhost:14142/" } diff --git a/BTCPayServer/Services/SocketFactory.cs b/BTCPayServer/Services/SocketFactory.cs index ed2da78b5..00988d5ae 100644 --- a/BTCPayServer/Services/SocketFactory.cs +++ b/BTCPayServer/Services/SocketFactory.cs @@ -1,4 +1,6 @@ -using NBitcoin; +using System; +using System.Linq; +using NBitcoin; using System.Net; using System.Net.Http; using System.Net.Sockets; @@ -15,12 +17,14 @@ namespace BTCPayServer.Services public class SocketFactory { private readonly BTCPayServerOptions _options; - public readonly HttpClient SocksClient; + public readonly Task SocksClient; + public SocketFactory(BTCPayServerOptions options) { _options = options; SocksClient = CreateHttpClientUsingSocks(); } + public async Task ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken) { DefaultEndpointConnector connector = new DefaultEndpointConnector(); @@ -32,6 +36,7 @@ namespace BTCPayServer.Services SocksEndpoint = _options.SocksEndpoint }); } + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); try { @@ -41,6 +46,7 @@ namespace BTCPayServer.Services { SafeCloseSocket(socket); } + return socket; } @@ -53,6 +59,7 @@ namespace BTCPayServer.Services catch { } + try { socket.Dispose(); @@ -62,19 +69,42 @@ namespace BTCPayServer.Services } } - private HttpClient CreateHttpClientUsingSocks() + private Task CreateHttpClientUsingSocks() { - if (_options.SocksEndpoint == null) - return null; - return new HttpClient(new HttpClientHandler + return Task.Run(() => { - Proxy = new SocksWebProxy(new ProxyConfig() + var proxyConfig = new ProxyConfig() {Version = ProxyConfig.SocksVersion.Five}; + switch (_options.SocksEndpoint) { - Version = ProxyConfig.SocksVersion.Five, - SocksAddress = _options.SocksEndpoint.AsOnionCatIPEndpoint().Address, - SocksPort = _options.SocksEndpoint.AsOnionCatIPEndpoint().Port, - }), - UseProxy = true + case null: + return null; + case IPEndPoint ipEndPoint: + proxyConfig.SocksPort = ipEndPoint.Port; + proxyConfig.SocksAddress = ipEndPoint.Address; + break; + case DnsEndPoint dnsEndPoint: + try + { + proxyConfig.SocksPort = dnsEndPoint.Port; + var ip = Dns.GetHostEntry(dnsEndPoint.Host).AddressList + .SingleOrDefault(address => address.AddressFamily == AddressFamily.InterNetwork); + if (ip == null) + { + return null; + } + + proxyConfig.SocksAddress = ip; + break; + } + catch (Exception e) + { + return null; + } + default: + return null; + } + + return new HttpClient(new HttpClientHandler {Proxy = new SocksWebProxy(proxyConfig), UseProxy = true}); }); } } From 56d5e6f99f6ad12e4778313336ec07e23f76f921 Mon Sep 17 00:00:00 2001 From: Kukks Date: Fri, 13 Mar 2020 16:52:50 +0100 Subject: [PATCH 04/21] fixes --- .../Controllers/WalletsController.PSBT.cs | 2 +- BTCPayServer/Hosting/BTCPayServerServices.cs | 2 +- BTCPayServer/Services/SocketFactory.cs | 49 +++++++++++-------- .../Shared/ViewBitcoinLikePaymentData.cshtml | 2 +- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 3ec39b5cd..0a3410607 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -220,7 +220,7 @@ namespace BTCPayServer.Controllers { Severity = StatusMessageModel.StatusSeverity.Info, AllowDismiss = false, - Message = "This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" + Html = "This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" }); return newPSBT; } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 041496c63..8c5875980 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -265,7 +265,7 @@ namespace BTCPayServer.Hosting { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay"); - rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst= nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst=3 nodelay"); } return rateLimits; }); diff --git a/BTCPayServer/Services/SocketFactory.cs b/BTCPayServer/Services/SocketFactory.cs index 00988d5ae..6bcab0c28 100644 --- a/BTCPayServer/Services/SocketFactory.cs +++ b/BTCPayServer/Services/SocketFactory.cs @@ -9,6 +9,8 @@ using System.Threading.Tasks; using BTCPayServer.Configuration; using com.LandonKey.SocksWebProxy; using com.LandonKey.SocksWebProxy.Proxy; +using Microsoft.Extensions.Logging; +using NBitcoin.Logging; using NBitcoin.Protocol.Connectors; using NBitcoin.Protocol; @@ -73,38 +75,45 @@ namespace BTCPayServer.Services { return Task.Run(() => { - var proxyConfig = new ProxyConfig() {Version = ProxyConfig.SocksVersion.Five}; - switch (_options.SocksEndpoint) + try { - case null: - return null; - case IPEndPoint ipEndPoint: - proxyConfig.SocksPort = ipEndPoint.Port; - proxyConfig.SocksAddress = ipEndPoint.Address; - break; - case DnsEndPoint dnsEndPoint: - try - { + var proxyConfig = new ProxyConfig() {Version = ProxyConfig.SocksVersion.Five}; + switch (_options.SocksEndpoint) + { + case null: + return null; + case IPEndPoint ipEndPoint: + proxyConfig.SocksPort = ipEndPoint.Port; + proxyConfig.SocksAddress = ipEndPoint.Address; + break; + case DnsEndPoint dnsEndPoint: + proxyConfig.SocksPort = dnsEndPoint.Port; var ip = Dns.GetHostEntry(dnsEndPoint.Host).AddressList .SingleOrDefault(address => address.AddressFamily == AddressFamily.InterNetwork); if (ip == null) { + Logs.Utils.LogWarning( $"Could not find ip for {dnsEndPoint.Host}"); return null; } proxyConfig.SocksAddress = ip; break; - } - catch (Exception e) - { - return null; - } - default: - return null; - } - return new HttpClient(new HttpClientHandler {Proxy = new SocksWebProxy(proxyConfig), UseProxy = true}); + default: + return null; + } + Logs.Utils.LogWarning( $"Created socks proxied http client!"); + return new HttpClient(new HttpClientHandler + { + Proxy = new SocksWebProxy(proxyConfig), UseProxy = true + }); + } + catch (Exception e) + { + Logs.Utils.LogError(e, "Could not create Tor client"); + return null; + } }); } } diff --git a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml index 9efdba957..99a484b57 100644 --- a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml +++ b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml @@ -52,7 +52,7 @@ @payment.Crypto @payment.DepositAddress - @payment.CryptoPaymentData.GetValue() @(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"(+ Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount })") + @payment.CryptoPaymentData.GetValue() @(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"
(+ Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount })")
From 1a62ee92603304fe204edfb665b7038a0be8a1d1 Mon Sep 17 00:00:00 2001 From: Kukks Date: Sat, 14 Mar 2020 12:21:12 +0100 Subject: [PATCH 05/21] try fix tor socks5 connection --- BTCPayServer.Tests/docker-compose.yml | 17 ++++ BTCPayServer/BTCPayServer.csproj | 2 +- .../Controllers/WalletsController.PSBT.cs | 16 ++- BTCPayServer/Controllers/WalletsController.cs | 6 +- BTCPayServer/Hosting/BTCPayServerServices.cs | 1 + BTCPayServer/Properties/launchSettings.json | 2 +- BTCPayServer/Services/SocketFactory.cs | 98 ++++++++----------- .../Shared/ViewBitcoinLikePaymentData.cshtml | 2 +- 8 files changed, 74 insertions(+), 70 deletions(-) diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index 1018b60fe..c98aa3278 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -51,6 +51,7 @@ services: - customer_lnd - merchant_lnd - sshd + - tor sshd: build: @@ -318,6 +319,19 @@ services: - "bitcoin_datadir:/deps/.bitcoin" links: - bitcoind + tor: + restart: unless-stopped + image: btcpayserver/tor:0.4.1.5 + container_name: tor + environment: + TOR_PASSWORD: btcpayserver + ports: + - "9050" # SOCKS + - "9051" # Tor Control + volumes: + - "tor_datadir:/home/tor/.tor" + - "torrcdir:/usr/local/etc/tor" + - "tor_servicesdir:/var/lib/tor/hidden_services" volumes: sshd_datadir: @@ -328,3 +342,6 @@ volumes: lightning_charge_datadir: customer_lnd_datadir: merchant_lnd_datadir: + tor_datadir: + torrcdir: + tor_servicesdir: diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index cd2f54f3a..1dc1a659b 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -35,6 +35,7 @@ + @@ -51,7 +52,6 @@ - all diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 0a3410607..8e774bd35 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -156,26 +156,24 @@ namespace BTCPayServer.Controllers return result.PSBT; } + + private async Task TryGetBPProposedTX(PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork) { if (TempData.TryGetValue( "bpu", out var bpu) && !string.IsNullOrEmpty(bpu?.ToString()) && Uri.TryCreate(bpu.ToString(), UriKind.Absolute, out var endpoint)) { TempData.Remove("bpu"); - HttpClient httpClient; - if (endpoint.IsOnion() ) + var httpClient = _socks5HttpClientFactory.CreateClient("payjoin"); + if (endpoint.IsOnion() && httpClient == null) { - httpClient = await _socketFactory.SocksClient; - if (httpClient == null) - { - return null; - } + return null; } else { - httpClient = _httpClientFactory.CreateClient("bpu"); + httpClient = _httpClientFactory.CreateClient("payjoin"); } - + var cloned = psbt.Clone(); if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors)) diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 78850b3a6..87951d4a0 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -50,7 +50,7 @@ namespace BTCPayServer.Controllers private readonly WalletReceiveStateService _WalletReceiveStateService; private readonly EventAggregator _EventAggregator; private readonly SettingsRepository _settingsRepository; - private readonly SocketFactory _socketFactory; + private readonly Socks5HttpClientFactory _socks5HttpClientFactory; private readonly IHttpClientFactory _httpClientFactory; public RateFetcher RateFetcher { get; } @@ -70,7 +70,7 @@ namespace BTCPayServer.Controllers WalletReceiveStateService walletReceiveStateService, EventAggregator eventAggregator, SettingsRepository settingsRepository, - SocketFactory socketFactory, + Socks5HttpClientFactory socks5HttpClientFactory, IHttpClientFactory httpClientFactory) { _currencyTable = currencyTable; @@ -88,7 +88,7 @@ namespace BTCPayServer.Controllers _WalletReceiveStateService = walletReceiveStateService; _EventAggregator = eventAggregator; _settingsRepository = settingsRepository; - _socketFactory = socketFactory; + _socks5HttpClientFactory = socks5HttpClientFactory; _httpClientFactory = httpClientFactory; } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 8c5875980..340375149 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -68,6 +68,7 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => diff --git a/BTCPayServer/Properties/launchSettings.json b/BTCPayServer/Properties/launchSettings.json index 988d32240..c78e85659 100644 --- a/BTCPayServer/Properties/launchSettings.json +++ b/BTCPayServer/Properties/launchSettings.json @@ -52,7 +52,7 @@ "BTCPAY_SSHPASSWORD": "opD3i2282D", "BTCPAY_DEBUGLOG": "debug.log", "BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc", - "BTCPAY_SOCKSENDPOINT": "tor:9050" + "BTCPAY_SOCKSENDPOINT": "localhost:9050" }, "applicationUrl": "https://localhost:14142/" } diff --git a/BTCPayServer/Services/SocketFactory.cs b/BTCPayServer/Services/SocketFactory.cs index 6bcab0c28..a8aa32f9b 100644 --- a/BTCPayServer/Services/SocketFactory.cs +++ b/BTCPayServer/Services/SocketFactory.cs @@ -1,30 +1,64 @@ -using System; -using System.Linq; -using NBitcoin; +using System.Collections.Concurrent; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Configuration; -using com.LandonKey.SocksWebProxy; -using com.LandonKey.SocksWebProxy.Proxy; -using Microsoft.Extensions.Logging; -using NBitcoin.Logging; +using MihaZupan; using NBitcoin.Protocol.Connectors; using NBitcoin.Protocol; namespace BTCPayServer.Services { + public class Socks5HttpClientFactory : IHttpClientFactory + { + private readonly BTCPayServerOptions _options; + + public Socks5HttpClientFactory(BTCPayServerOptions options) + { + _options = options; + } + + private static (string Host, int Port)? ToParts(EndPoint endpoint) + { + switch (endpoint) + { + case DnsEndPoint dns: + return (dns.Host, dns.Port); + case IPEndPoint ipEndPoint: + return (ipEndPoint.Address.ToString(), ipEndPoint.Port); + } + + return null; + } + + private ConcurrentDictionary cachedClients = new ConcurrentDictionary(); + public HttpClient CreateClient(string name) + { + return cachedClients.GetOrAdd(name, s => + { + var parts = ToParts(_options.SocksEndpoint); + if (!parts.HasValue) + { + return null; + } + + var proxy = new HttpToSocks5Proxy(parts.Value.Host, parts.Value.Port); + return new HttpClient( + new HttpClientHandler {Proxy = proxy, }, + true); + }); + } + } + public class SocketFactory { private readonly BTCPayServerOptions _options; - public readonly Task SocksClient; public SocketFactory(BTCPayServerOptions options) { _options = options; - SocksClient = CreateHttpClientUsingSocks(); } public async Task ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken) @@ -70,51 +104,5 @@ namespace BTCPayServer.Services { } } - - private Task CreateHttpClientUsingSocks() - { - return Task.Run(() => - { - try - { - var proxyConfig = new ProxyConfig() {Version = ProxyConfig.SocksVersion.Five}; - switch (_options.SocksEndpoint) - { - case null: - return null; - case IPEndPoint ipEndPoint: - proxyConfig.SocksPort = ipEndPoint.Port; - proxyConfig.SocksAddress = ipEndPoint.Address; - break; - case DnsEndPoint dnsEndPoint: - - proxyConfig.SocksPort = dnsEndPoint.Port; - var ip = Dns.GetHostEntry(dnsEndPoint.Host).AddressList - .SingleOrDefault(address => address.AddressFamily == AddressFamily.InterNetwork); - if (ip == null) - { - Logs.Utils.LogWarning( $"Could not find ip for {dnsEndPoint.Host}"); - return null; - } - - proxyConfig.SocksAddress = ip; - break; - - default: - return null; - } - Logs.Utils.LogWarning( $"Created socks proxied http client!"); - return new HttpClient(new HttpClientHandler - { - Proxy = new SocksWebProxy(proxyConfig), UseProxy = true - }); - } - catch (Exception e) - { - Logs.Utils.LogError(e, "Could not create Tor client"); - return null; - } - }); - } } } diff --git a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml index 99a484b57..2ebe9c0c1 100644 --- a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml +++ b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml @@ -52,7 +52,7 @@ @payment.Crypto @payment.DepositAddress - @payment.CryptoPaymentData.GetValue() @(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"
(+ Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount })") + @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"
(Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount})")
From e4cb1a875b67dca53138c9b0cbbb1e8f11e8ce18 Mon Sep 17 00:00:00 2001 From: Kukks Date: Tue, 17 Mar 2020 07:53:20 +0100 Subject: [PATCH 06/21] Remove tor client factory --- BTCPayServer/BTCPayServer.csproj | 1 - .../Controllers/WalletsController.PSBT.cs | 10 +--- BTCPayServer/Controllers/WalletsController.cs | 3 -- BTCPayServer/Hosting/BTCPayServerServices.cs | 1 - BTCPayServer/Services/SocketFactory.cs | 46 +------------------ 5 files changed, 2 insertions(+), 59 deletions(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 1dc1a659b..47a0c18c3 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -35,7 +35,6 @@ - diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 8e774bd35..3ef04b66c 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -164,15 +164,7 @@ namespace BTCPayServer.Controllers if (TempData.TryGetValue( "bpu", out var bpu) && !string.IsNullOrEmpty(bpu?.ToString()) && Uri.TryCreate(bpu.ToString(), UriKind.Absolute, out var endpoint)) { TempData.Remove("bpu"); - var httpClient = _socks5HttpClientFactory.CreateClient("payjoin"); - if (endpoint.IsOnion() && httpClient == null) - { - return null; - } - else - { - httpClient = _httpClientFactory.CreateClient("payjoin"); - } + var httpClient = _httpClientFactory.CreateClient("payjoin"); var cloned = psbt.Clone(); diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 87951d4a0..f98588e4b 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -50,7 +50,6 @@ namespace BTCPayServer.Controllers private readonly WalletReceiveStateService _WalletReceiveStateService; private readonly EventAggregator _EventAggregator; private readonly SettingsRepository _settingsRepository; - private readonly Socks5HttpClientFactory _socks5HttpClientFactory; private readonly IHttpClientFactory _httpClientFactory; public RateFetcher RateFetcher { get; } @@ -70,7 +69,6 @@ namespace BTCPayServer.Controllers WalletReceiveStateService walletReceiveStateService, EventAggregator eventAggregator, SettingsRepository settingsRepository, - Socks5HttpClientFactory socks5HttpClientFactory, IHttpClientFactory httpClientFactory) { _currencyTable = currencyTable; @@ -88,7 +86,6 @@ namespace BTCPayServer.Controllers _WalletReceiveStateService = walletReceiveStateService; _EventAggregator = eventAggregator; _settingsRepository = settingsRepository; - _socks5HttpClientFactory = socks5HttpClientFactory; _httpClientFactory = httpClientFactory; } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 340375149..8c5875980 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -68,7 +68,6 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => diff --git a/BTCPayServer/Services/SocketFactory.cs b/BTCPayServer/Services/SocketFactory.cs index a8aa32f9b..e508b452c 100644 --- a/BTCPayServer/Services/SocketFactory.cs +++ b/BTCPayServer/Services/SocketFactory.cs @@ -1,57 +1,13 @@ -using System.Collections.Concurrent; -using System.Net; -using System.Net.Http; +using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Configuration; -using MihaZupan; using NBitcoin.Protocol.Connectors; using NBitcoin.Protocol; namespace BTCPayServer.Services { - public class Socks5HttpClientFactory : IHttpClientFactory - { - private readonly BTCPayServerOptions _options; - - public Socks5HttpClientFactory(BTCPayServerOptions options) - { - _options = options; - } - - private static (string Host, int Port)? ToParts(EndPoint endpoint) - { - switch (endpoint) - { - case DnsEndPoint dns: - return (dns.Host, dns.Port); - case IPEndPoint ipEndPoint: - return (ipEndPoint.Address.ToString(), ipEndPoint.Port); - } - - return null; - } - - private ConcurrentDictionary cachedClients = new ConcurrentDictionary(); - public HttpClient CreateClient(string name) - { - return cachedClients.GetOrAdd(name, s => - { - var parts = ToParts(_options.SocksEndpoint); - if (!parts.HasValue) - { - return null; - } - - var proxy = new HttpToSocks5Proxy(parts.Value.Host, parts.Value.Port); - return new HttpClient( - new HttpClientHandler {Proxy = proxy, }, - true); - }); - } - } - public class SocketFactory { private readonly BTCPayServerOptions _options; From fc88a867fa6890a0ff9d1fd157f8517576e77f3d Mon Sep 17 00:00:00 2001 From: Kukks Date: Tue, 17 Mar 2020 08:36:27 +0100 Subject: [PATCH 07/21] try fix test --- BTCPayServer.Tests/PayJoinTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index ae9911184..9b9416e3e 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -252,7 +252,7 @@ namespace BTCPayServer.Tests await cashCow.SendToAddressAsync( (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, new Money(0.014m, MoneyUnit.BTC)); - var senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme); + var senderChange = (await btcPayWallet.GetChangeAddressAsync(senderUser.DerivationScheme)).Item1; @@ -275,6 +275,13 @@ namespace BTCPayServer.Tests var derivationSchemeSettings = senderStore.GetSupportedPaymentMethods(tester.NetworkProvider) .OfType().SingleOrDefault(settings => settings.PaymentId == paymentMethodId); + + ReceivedCoin[] senderCoins = null; + await TestUtils.EventuallyAsync(async () => + { + senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme); + Assert.Contains(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.026m); + }); var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m); var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m); var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m); From d90ffb2254b61d739790e8e72e6c7ad62f247061 Mon Sep 17 00:00:00 2001 From: Kukks Date: Tue, 17 Mar 2020 08:43:42 +0100 Subject: [PATCH 08/21] move payjoin settings to store settings from checkout experience --- BTCPayServer.Tests/TestAccount.cs | 12 ++++++------ BTCPayServer/Controllers/StoresController.cs | 5 ++--- .../StoreViewModels/CheckoutExperienceViewModel.cs | 3 --- .../Models/StoreViewModels/StoreViewModel.cs | 3 +++ BTCPayServer/Views/Stores/CheckoutExperience.cshtml | 5 ----- BTCPayServer/Views/Stores/UpdateStore.cshtml | 5 +++++ 6 files changed, 16 insertions(+), 17 deletions(-) diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index fddf8e1bb..a659d4bcd 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -163,15 +163,15 @@ namespace BTCPayServer.Tests public async Task EnablePayJoin() { var storeController = parent.PayTester.GetController(UserId, StoreId); - var checkoutExperienceVM = - Assert.IsType(Assert - .IsType(storeController.CheckoutExperience()).Model); + var storeVM = + Assert.IsType(Assert + .IsType(storeController.UpdateStore()).Model); - checkoutExperienceVM.PayJoinEnabled = true; + storeVM.PayJoinEnabled = true; - Assert.Equal(nameof(storeController.CheckoutExperience), + Assert.Equal(nameof(storeController.UpdateStore), Assert.IsType( - await storeController.CheckoutExperience(checkoutExperienceVM)).ActionName); + await storeController.UpdateStore(storeVM)).ActionName); } public GenerateWalletResponse GenerateWalletResponseV { get; set; } diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index e29e84d21..e157d2352 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -381,7 +381,6 @@ namespace BTCPayServer.Controllers vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? ""; vm.LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi; vm.RedirectAutomatically = storeBlob.RedirectAutomatically; - vm.PayJoinEnabled = storeBlob.PayJoinEnabled; return View(vm); } void SetCryptoCurrencies(CheckoutExperienceViewModel vm, Data.StoreData storeData) @@ -442,7 +441,6 @@ namespace BTCPayServer.Controllers blob.LightningMaxValue = lightningMaxValue; blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi; blob.RedirectAutomatically = model.RedirectAutomatically; - blob.PayJoinEnabled = model.PayJoinEnabled; if (CurrentStore.SetStoreBlob(blob)) { needUpdate = true; @@ -481,6 +479,7 @@ namespace BTCPayServer.Controllers vm.InvoiceExpiration = storeBlob.InvoiceExpiration; vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; vm.PaymentTolerance = storeBlob.PaymentTolerance; + vm.PayJoinEnabled = storeBlob.PayJoinEnabled; return View(vm); } @@ -575,7 +574,7 @@ namespace BTCPayServer.Controllers blob.InvoiceExpiration = model.InvoiceExpiration; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; blob.PaymentTolerance = model.PaymentTolerance; - + blob.PayJoinEnabled = model.PayJoinEnabled; if (CurrentStore.SetStoreBlob(blob)) { needUpdate = true; diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs index 88857000f..f9763be47 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs @@ -57,9 +57,6 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Redirect invoice to redirect url automatically after paid")] public bool RedirectAutomatically { get; set; } - - [Display(Name = "Enable BIP79 Payjoin/P2EP")] - public bool PayJoinEnabled { get; set; } public void SetLanguages(LanguageService langService, string defaultLang) { diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index 6475b4d15..b300405ea 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -86,6 +86,9 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Description template of the lightning invoice")] public string LightningDescriptionTemplate { get; set; } + + [Display(Name = "Enable BIP79 Payjoin/P2EP")] + public bool PayJoinEnabled { get; set; } public class LightningNode { diff --git a/BTCPayServer/Views/Stores/CheckoutExperience.cshtml b/BTCPayServer/Views/Stores/CheckoutExperience.cshtml index 1b7e79930..a207c5b94 100644 --- a/BTCPayServer/Views/Stores/CheckoutExperience.cshtml +++ b/BTCPayServer/Views/Stores/CheckoutExperience.cshtml @@ -68,11 +68,6 @@
-
- - - -
diff --git a/BTCPayServer/Views/Stores/UpdateStore.cshtml b/BTCPayServer/Views/Stores/UpdateStore.cshtml index f7f91ba78..aa1b7ba5a 100644 --- a/BTCPayServer/Views/Stores/UpdateStore.cshtml +++ b/BTCPayServer/Views/Stores/UpdateStore.cshtml @@ -81,6 +81,11 @@
+
+ + + +
Derivation Scheme
The DerivationScheme represents the destination of the funds received by your invoice on chain. From 2b11b43d6df78b0df9e2b01b0a6f5d24eb051062 Mon Sep 17 00:00:00 2001 From: Kukks Date: Thu, 26 Mar 2020 12:36:28 +0100 Subject: [PATCH 09/21] NRE fix --- BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index b7d36fa63..b4db9eb23 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -352,7 +352,7 @@ namespace BTCPayServer.Payments.Bitcoin private decimal GetPayJoinContributedAmount(BitcoinLikeOnChainPaymentMethod paymentMethod, decimal amount, uint256 transactionHash) { - if (paymentMethod.PayJoin.Enabled && + if (paymentMethod.PayJoin?.Enabled is true && paymentMethod.PayJoin.ProposedTransactionHash == transactionHash && paymentMethod.PayJoin.TotalOutputAmount == amount) { From 886510c2e11a0933672af7d5fd4c98af3af8e94c Mon Sep 17 00:00:00 2001 From: Kukks Date: Thu, 26 Mar 2020 12:36:33 +0100 Subject: [PATCH 10/21] remove tor for now --- BTCPayServer.Tests/docker-compose.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index c98aa3278..50478dbcb 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -319,20 +319,6 @@ services: - "bitcoin_datadir:/deps/.bitcoin" links: - bitcoind - tor: - restart: unless-stopped - image: btcpayserver/tor:0.4.1.5 - container_name: tor - environment: - TOR_PASSWORD: btcpayserver - ports: - - "9050" # SOCKS - - "9051" # Tor Control - volumes: - - "tor_datadir:/home/tor/.tor" - - "torrcdir:/usr/local/etc/tor" - - "tor_servicesdir:/var/lib/tor/hidden_services" - volumes: sshd_datadir: bitcoin_datadir: @@ -342,6 +328,3 @@ volumes: lightning_charge_datadir: customer_lnd_datadir: merchant_lnd_datadir: - tor_datadir: - torrcdir: - tor_servicesdir: From 23b2f55b47add43e17a74e720224fb1e12275272 Mon Sep 17 00:00:00 2001 From: Kukks Date: Thu, 26 Mar 2020 12:39:19 +0100 Subject: [PATCH 11/21] use nameof --- BTCPayServer/Controllers/WalletsController.cs | 6 +++--- BTCPayServer/Views/Wallets/WalletSendVault.cshtml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index f98588e4b..929cb8735 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -673,7 +673,7 @@ namespace BTCPayServer.Controllers private IActionResult ViewVault(WalletId walletId, PSBT psbt) { - return View("WalletSendVault", new WalletSendVaultModel() + return View(nameof(WalletSendVault), new WalletSendVaultModel() { WalletId = walletId.ToString(), PSBT = psbt.ToBase64(), @@ -683,7 +683,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/vault")] - public async Task SubmitVault([ModelBinder(typeof(WalletIdModelBinder))] + public async Task WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendVaultModel model) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); @@ -691,7 +691,7 @@ namespace BTCPayServer.Controllers if (newPSBT != null) { model.PSBT = newPSBT.ToBase64(); - return View("WalletSendVault", model); + return View(nameof(WalletSendVault), model); } return RedirectToWalletPSBTReady(model.PSBT); diff --git a/BTCPayServer/Views/Wallets/WalletSendVault.cshtml b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml index fa72b664e..91952c655 100644 --- a/BTCPayServer/Views/Wallets/WalletSendVault.cshtml +++ b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml @@ -20,7 +20,7 @@
-
diff --git a/BTCPayServer/Views/Wallets/WalletPSBT.cshtml b/BTCPayServer/Views/Wallets/WalletPSBT.cshtml index b678237c9..dd720e1fc 100644 --- a/BTCPayServer/Views/Wallets/WalletPSBT.cshtml +++ b/BTCPayServer/Views/Wallets/WalletPSBT.cshtml @@ -30,6 +30,7 @@
+ diff --git a/BTCPayServer/Views/Wallets/WalletSendVault.cshtml b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml index 91952c655..a820605c3 100644 --- a/BTCPayServer/Views/Wallets/WalletSendVault.cshtml +++ b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml @@ -24,6 +24,7 @@ +
From 065be9be6455f0f9ad299065ad05b0bcd3fc9a4a Mon Sep 17 00:00:00 2001 From: Kukks Date: Thu, 26 Mar 2020 15:42:54 +0100 Subject: [PATCH 16/21] Revert back to original transaction if payjoined tx does not work --- .../Controllers/WalletsController.PSBT.cs | 37 ++++++++++++++----- BTCPayServer/Controllers/WalletsController.cs | 9 +++-- .../WalletViewModels/SignWithSeedViewModel.cs | 1 + .../WalletPSBTReadyViewModel.cs | 1 + .../WalletViewModels/WalletSendVaultModel.cs | 1 + .../Views/Wallets/SignWithSeed.cshtml | 1 + .../Views/Wallets/WalletPSBTReady.cshtml | 8 +++- .../Views/Wallets/WalletSendVault.cshtml | 1 + 8 files changed, 46 insertions(+), 13 deletions(-) diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index d5ad35168..66506d97b 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -205,13 +205,14 @@ namespace BTCPayServer.Controllers } newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork); - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Severity = StatusMessageModel.StatusSeverity.Info, - AllowDismiss = false, - Html = "This transaction has been coordinated between the receiver and you to create a
payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" - }); - return newPSBT; + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Info, + AllowDismiss = false, + Html = + $"This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" + }); + return newPSBT; } } } @@ -225,12 +226,14 @@ namespace BTCPayServer.Controllers [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string psbt = null, string signingKey = null, - string signingKeyPath = null) + string signingKeyPath = null, + string originalPsbt = null) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var vm = new WalletPSBTReadyViewModel() { PSBT = psbt }; vm.SigningKey = signingKey; vm.SigningKeyPath = signingKeyPath; + vm.OriginalPSBT = originalPsbt; var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); @@ -351,7 +354,7 @@ namespace BTCPayServer.Controllers WalletId walletId, WalletPSBTReadyViewModel vm, string command = null) { if (command == null) - return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath); + return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT); PSBT psbt = null; var network = NetworkProvider.GetNetwork(walletId.CryptoCode); try @@ -367,6 +370,11 @@ namespace BTCPayServer.Controllers vm.GlobalError = "Invalid PSBT"; return View(nameof(WalletPSBTReady),vm); } + + if (command == "use-original") + { + return await WalletPSBTReady(walletId, vm.OriginalPSBT, vm.SigningKey, vm.SigningKeyPath); + } if (command == "broadcast") { if (!psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors)) @@ -380,6 +388,17 @@ namespace BTCPayServer.Controllers var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction); if (!broadcastResult.Success) { + if (!string.IsNullOrEmpty(vm.OriginalPSBT)) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + AllowDismiss = false, + Html = $"The payjoin transaction could not be broadcast.
({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).
The transaction has been reverted back to its original format and is ready to be broadcast." + }); + return await WalletPSBTReady(walletId, vm.OriginalPSBT, vm.SigningKey, vm.SigningKeyPath); + } + vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"; return View(nameof(WalletPSBTReady),vm); } diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 3616886f7..5759c0838 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -692,13 +692,14 @@ namespace BTCPayServer.Controllers model.PayJoinEndpointUrl = null; if (newPSBT != null) { + model.OriginalPSBT = model.PSBT; model.PSBT = newPSBT.ToBase64(); return View(nameof(WalletSendVault), model); } - return RedirectToWalletPSBTReady(model.PSBT); + return RedirectToWalletPSBTReady(model.PSBT, originalPsbt: model.OriginalPSBT); } - private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null) + private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null, string originalPsbt = null) { var vm = new PostRedirectViewModel() { @@ -707,6 +708,7 @@ namespace BTCPayServer.Controllers Parameters = { new KeyValuePair("psbt", psbt), + new KeyValuePair("originalPsbt", originalPsbt), new KeyValuePair("SigningKey", signingKey), new KeyValuePair("SigningKeyPath", signingKeyPath) } @@ -851,10 +853,11 @@ namespace BTCPayServer.Controllers viewModel.PayJoinEndpointUrl = null; if (newPSBT != null) { + viewModel.OriginalPSBT = psbt.ToBase64(); viewModel.PSBT = newPSBT.ToBase64(); return await SignWithSeed(walletId, viewModel); } - return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString()); + return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT); } private bool PSBTChanged(PSBT psbt, Action act) diff --git a/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs b/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs index 254b67212..d0e5074b3 100644 --- a/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs @@ -7,6 +7,7 @@ namespace BTCPayServer.Models.WalletViewModels { public class SignWithSeedViewModel { + public string OriginalPSBT { get; set; } public string PayJoinEndpointUrl { get; set; } [Required] public string PSBT { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs index 985ee8ba8..411a22430 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs @@ -8,6 +8,7 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletPSBTReadyViewModel { + public string OriginalPSBT { get; set; } public string PSBT { get; set; } public string SigningKey { get; set; } public string SigningKeyPath { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs index 252e511a0..bf60de537 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs @@ -7,6 +7,7 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletSendVaultModel { + public string OriginalPSBT { get; set; } public string WalletId { get; set; } public string PSBT { get; set; } public string WebsocketPath { get; set; } diff --git a/BTCPayServer/Views/Wallets/SignWithSeed.cshtml b/BTCPayServer/Views/Wallets/SignWithSeed.cshtml index d7fe04569..0abad6fb2 100644 --- a/BTCPayServer/Views/Wallets/SignWithSeed.cshtml +++ b/BTCPayServer/Views/Wallets/SignWithSeed.cshtml @@ -27,6 +27,7 @@
+
diff --git a/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml b/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml index ee491ac41..45c69a9f5 100644 --- a/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml +++ b/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml @@ -137,7 +137,8 @@
- + + @if (!Model.HasErrors) @@ -146,6 +147,11 @@ or } + @if (!string.IsNullOrEmpty(Model.OriginalPSBT)) + { + or + + }
diff --git a/BTCPayServer/Views/Wallets/WalletSendVault.cshtml b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml index a820605c3..ad42522ce 100644 --- a/BTCPayServer/Views/Wallets/WalletSendVault.cshtml +++ b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml @@ -23,6 +23,7 @@ From 64717328f6859da359e8c90cde935ebff2e017ea Mon Sep 17 00:00:00 2001 From: Kukks Date: Fri, 27 Mar 2020 09:53:08 +0100 Subject: [PATCH 17/21] check output sum of proposed payjoin --- BTCPayServer/Controllers/WalletsController.PSBT.cs | 14 +++++++++++++- BTCPayServer/Controllers/WalletsController.cs | 4 ++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 66506d97b..d3f8ac069 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -159,7 +159,7 @@ namespace BTCPayServer.Controllers - private async Task TryGetBPProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork) + private async Task TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork) { if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint)) { @@ -192,6 +192,7 @@ namespace BTCPayServer.Controllers if (!input.TryFinalizeInput(out _)) { + //a new input was provided but was invalid. valid = false; break; } @@ -204,6 +205,17 @@ namespace BTCPayServer.Controllers return null; } + //check if output sum to self is the same. + var signingAccountKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); + var newOutputSumToSelfSum = newPSBT.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccountKeySettings.AccountKey, + signingAccountKeySettings.GetRootedKeyPath()).Sum(output => output.Value); + + var originalOutputSumToSelf = psbt.Outputs.Sum(output => output.Value); + if (originalOutputSumToSelf < newOutputSumToSelfSum) + { + return null; + } + newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork); TempData.SetStatusMessageModel(new StatusMessageModel() { diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 5759c0838..86d9a3b6e 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -688,7 +688,7 @@ namespace BTCPayServer.Controllers WalletId walletId, WalletSendVaultModel model) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); - var newPSBT = await TryGetBPProposedTX(model.PayJoinEndpointUrl, PSBT.Parse(model.PSBT, network.NBitcoinNetwork), GetDerivationSchemeSettings(walletId), network); + var newPSBT = await TryGetPayjoinProposedTX(model.PayJoinEndpointUrl, PSBT.Parse(model.PSBT, network.NBitcoinNetwork), GetDerivationSchemeSettings(walletId), network); model.PayJoinEndpointUrl = null; if (newPSBT != null) { @@ -849,7 +849,7 @@ namespace BTCPayServer.Controllers return View(viewModel); } ModelState.Remove(nameof(viewModel.PSBT)); - var newPSBT = await TryGetBPProposedTX(viewModel.PayJoinEndpointUrl,psbt, GetDerivationSchemeSettings(walletId), network); + var newPSBT = await TryGetPayjoinProposedTX(viewModel.PayJoinEndpointUrl,psbt, GetDerivationSchemeSettings(walletId), network); viewModel.PayJoinEndpointUrl = null; if (newPSBT != null) { From b56d026fdb3d17877a6359c1e2c2904ef0c32e07 Mon Sep 17 00:00:00 2001 From: Kukks Date: Fri, 27 Mar 2020 14:58:01 +0100 Subject: [PATCH 18/21] small changes --- .../Payments/PayJoin/PayJoinEndpointController.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index 0828bdb4a..6a9fdf118 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -76,7 +76,14 @@ namespace BTCPayServer.Payments.PayJoin if (psbt != null) { - transaction = psbt.ExtractTransaction(); + try + { + transaction = psbt.ExtractTransaction(); + } + catch (Exception e) + { + return UnprocessableEntity("invalid psbt"); + } } if (transaction.Check() != TransactionCheckResult.Success) @@ -109,9 +116,9 @@ namespace BTCPayServer.Payments.PayJoin var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId); //get outs to our current invoice address var currentPaymentMethodDetails = - invoicePaymentMethod.GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod; + (BitcoinLikeOnChainPaymentMethod) invoicePaymentMethod.GetPaymentMethodDetails(); - if (!currentPaymentMethodDetails.PayJoin.Enabled) + if (!currentPaymentMethodDetails.PayJoin?.Enabled is true) { return UnprocessableEntity($"cannot handle payjoin tx"); } From 4d2e59e1a15c56b985c2f9fd326e2c21f973959a Mon Sep 17 00:00:00 2001 From: Kukks Date: Mon, 30 Mar 2020 08:31:30 +0200 Subject: [PATCH 19/21] Change flow of payjoin in wallet + fix tests --- BTCPayServer.Tests/PayJoinTests.cs | 263 ++++++++---------- BTCPayServer.Tests/SeleniumTester.cs | 31 +++ .../Controllers/WalletsController.PSBT.cs | 131 ++++++--- BTCPayServer/Controllers/WalletsController.cs | 40 ++- .../WalletPSBTReadyViewModel.cs | 1 + .../Shared/ViewBitcoinLikePaymentData.cshtml | 2 +- .../Views/Wallets/WalletPSBTReady.cshtml | 11 +- 7 files changed, 255 insertions(+), 224 deletions(-) diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 9b9416e3e..47bc46c1e 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -13,6 +13,7 @@ using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.PayJoin; +using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Wallets; using BTCPayServer.Tests.Logging; using Microsoft.AspNetCore.Mvc; @@ -21,6 +22,7 @@ using Microsoft.Extensions.Hosting; using NBitcoin; using NBitcoin.Payment; using NBitpayClient; +using OpenQA.Selenium; using Xunit; using Xunit.Abstractions; @@ -36,6 +38,108 @@ namespace BTCPayServer.Tests Logs.LogProvider = new XUnitLogProvider(helper); } + [Fact] + [Trait("Selenium", "Selenium")] + public async Task CanUseBIP79Client() + { + using (var s = SeleniumTester.Create()) + { + await s.StartAsync(); + s.RegisterNewUser(true); + var receiver = s.CreateNewStore(); + var receiverSeed = s.GenerateWallet("BTC", "", true, true); + var receiverWalletId = new WalletId(receiver.storeId, "BTC"); + var payJoinStateProvider = s.Server.PayTester.GetService(); + //payjoin is not enabled by default. + var invoiceId = s.CreateInvoice(receiver.storeId); + s.GoToInvoiceCheckout(invoiceId); + var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.DoesNotContain("bpu", bip21); + + s.GoToHome(); + s.GoToStore(receiver.storeId); + //payjoin is not enabled by default. + Assert.False(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected); + s.SetCheckbox(s,"PayJoinEnabled", true); + s.Driver.FindElement(By.Id("Save")).Click(); + Assert.True(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected); + var sender = s.CreateNewStore(); + var senderSeed = s.GenerateWallet("BTC", "", true, true); + var senderWalletId = new WalletId(sender.storeId, "BTC"); + await s.Server.ExplorerNode.GenerateAsync(1); + await s.FundStoreWallet(senderWalletId); + + invoiceId = s.CreateInvoice(receiver.storeId); + s.GoToInvoiceCheckout(invoiceId); + bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.Contains("bpu", bip21); + + s.GoToWalletSend(senderWalletId); + s.Driver.FindElement(By.Id("bip21parse")).Click(); + s.Driver.SwitchTo().Alert().SendKeys(bip21); + s.Driver.SwitchTo().Alert().Accept(); + Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); + s.Driver.ScrollTo(By.Id("SendMenu")); + s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); + s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); + await s.Server.WaitForEvent(async () => + { + s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick(); + }); + //no funds in receiver wallet to do payjoin + s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning); + await TestUtils.EventuallyAsync(async () => + { + var invoice = await s.Server.PayTester.GetService().GetInvoice(invoiceId); + Assert.Equal(InvoiceStatus.Paid, invoice.Status); + }); + + s.GoToInvoices(); + var paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value")); + Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase)); + + //let's do it all again, except now the receiver has funds and is able to payjoin + invoiceId = s.CreateInvoice(receiver.storeId); + s.GoToInvoiceCheckout(invoiceId); + bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.Contains("bpu", bip21); + + s.GoToWalletSend(senderWalletId); + s.Driver.FindElement(By.Id("bip21parse")).Click(); + s.Driver.SwitchTo().Alert().SendKeys(bip21); + s.Driver.SwitchTo().Alert().Accept(); + Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); + s.Driver.ScrollTo(By.Id("SendMenu")); + s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); + s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); + await s.Server.WaitForEvent(async () => + { + s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick(); + }); + s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Success); + await TestUtils.EventuallyAsync(async () => + { + var invoice = await s.Server.PayTester.GetService().GetInvoice(invoiceId); + Assert.Equal(InvoiceStatus.Paid, invoice.Status); + }); + s.GoToInvoices(); + paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value")); + Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase)); + + + //the state should now hold that there is an ongoing utxo + var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); + Assert.NotNull(receiverWalletPayJoinState); + Assert.Single(receiverWalletPayJoinState.GetRecords()); + Assert.Equal(0.02m, receiverWalletPayJoinState.GetRecords().First().ContributedAmount); + Assert.Single(receiverWalletPayJoinState.GetRecords().First().CoinsExposed); + + } + } + [Fact] // [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] @@ -44,6 +148,8 @@ namespace BTCPayServer.Tests using (var tester = ServerTester.Create()) { await tester.StartAsync(); + + var payJoinStateProvider = tester.PayTester.GetService(); var btcPayNetwork = tester.NetworkProvider.GetNetwork("BTC"); var btcPayWallet = tester.PayTester.GetService().GetWallet(btcPayNetwork); var cashCow = tester.ExplorerNode; @@ -68,155 +174,9 @@ namespace BTCPayServer.Tests // payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer invoice = receiverUser.BitPay.CreateInvoice( new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); - - //check that the BIP21 has an endpoint - var bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21; - Assert.Contains("bpu", bip21); - var parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork); - var endpoint = parsedBip21.UnknowParameters["bpu"]; - - - //see if the btcpay send wallet supports BIP21 properly and also the payjoin endpoint + cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network), + Money.Coins(0.06m)); var receiverWalletId = new WalletId(receiverUser.StoreId, "BTC"); - var senderWalletId = new WalletId(senderUser.StoreId, "BTC"); - var senderWallerController = senderUser.GetController(); - var senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId) - .AssertViewModelAsync(); - senderWalletSendVM = await senderWallerController - .WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21) - .AssertViewModelAsync(); - - Assert.Single(senderWalletSendVM.Outputs); - Assert.Equal(endpoint, senderWalletSendVM.PayJoinEndpointUrl); - Assert.Equal(parsedBip21.Address.ToString(), senderWalletSendVM.Outputs.First().DestinationAddress); - Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC), senderWalletSendVM.Outputs.First().Amount); - - //the nbx wallet option should also be available - Assert.True(senderWalletSendVM.NBXSeedAvailable); - - //pay the invoice with the nbx seed wallet option + also the invoice - var postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId, - senderWalletSendVM, "nbx-seed", CancellationToken.None) - .AssertViewModelAsync(); - var redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value; - var psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork); - var senderWalletSendPSBTResult = new WalletPSBTReadyViewModel() - { - PSBT = redirectedPSBT, - SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value, - SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value - }; - //While the endpoint was set, the receiver had no utxos. The payment should fall back to original payment terms instead - Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC).ToString(), - psbt.Outputs.Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value); - - Assert.Equal("WalletTransactions", - Assert.IsType( - await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult, - "broadcast")) - .ActionName); - - //we used the bip21 link straight away to pay the invoice so it should be paid straight away. - TestUtils.Eventually(() => - { - invoice = receiverUser.BitPay.GetInvoice(invoice.Id); - Assert.Equal(Invoice.STATUS_PAID, invoice.Status); - }); - //verify that there is nothing in the payment state - - var payJoinStateProvider = tester.PayTester.GetService(); - var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); - Assert.NotNull(receiverWalletPayJoinState); - Assert.Empty(receiverWalletPayJoinState.GetRecords()); - - //now that there is a utxo, let's do it again - - invoice = receiverUser.BitPay.CreateInvoice( - new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); - bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21; - parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork); - senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId) - .AssertViewModelAsync(); - senderWalletSendVM = await senderWallerController - .WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21) - .AssertViewModelAsync(); - postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId, - senderWalletSendVM, "nbx-seed", CancellationToken.None) - .AssertViewModelAsync(); - redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value; - psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork); - senderWalletSendPSBTResult = new WalletPSBTReadyViewModel() - { - PSBT = redirectedPSBT, - SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value, - SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value - }; - //the payjoin should make the amount being paid to the address higher - Assert.True(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC) < psbt.Outputs - .Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value - .ToDecimal(MoneyUnit.BTC)); - - //the state should now hold that there is an ongoing utxo - Assert.Single(receiverWalletPayJoinState.GetRecords()); - Assert.Equal(0.02m, receiverWalletPayJoinState.GetRecords().First().ContributedAmount); - Assert.Single(receiverWalletPayJoinState.GetRecords().First().CoinsExposed); - Assert.False(receiverWalletPayJoinState.GetRecords().First().TxSeen); - Assert.Equal(psbt.Finalize().ExtractTransaction().GetHash(), - receiverWalletPayJoinState.GetRecords().First().ProposedTransactionHash); - - Assert.Equal("WalletTransactions", - Assert.IsType( - await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult, - "broadcast")) - .ActionName); - - TestUtils.Eventually(() => - { - invoice = receiverUser.BitPay.GetInvoice(invoice.Id); - - Assert.Equal(Invoice.STATUS_PAID, invoice.Status); - Assert.Equal(Invoice.EXSTATUS_FALSE, invoice.ExceptionStatus.ToString().ToLowerInvariant()); - }); - - //verify that we have a record that it was a payjoin - var receiverController = receiverUser.GetController(); - var invoiceVM = - await receiverController.Invoice(invoice.Id).AssertViewModelAsync(); - Assert.Single(invoiceVM.Payments); - Assert.True(Assert.IsType(invoiceVM.Payments.First().GetCryptoPaymentData()) - .PayJoinSelfContributedAmount > 0); - - - //we dont remove the payjoin tx state even if we detect it, for cases of RBF - Assert.NotEmpty(receiverWalletPayJoinState.GetRecords()); - Assert.Single(receiverWalletPayJoinState.GetRecords()); - Assert.True(receiverWalletPayJoinState.GetRecords().First().TxSeen); - - var debugData = new - { - StoreId = receiverWalletId.StoreId, - InvoiceId = receiverWalletPayJoinState.GetRecords().First().InvoiceId, - PayJoinTx = receiverWalletPayJoinState.GetRecords().First().ProposedTransactionHash - }; - for (int i = 0; i < 6; i++) - { - await tester.WaitForEvent(async () => - { - await cashCow.GenerateAsync(1); - }); - } - - //check that the state has cleared that ongoing tx - receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); - Assert.NotNull(receiverWalletPayJoinState); - Assert.Empty(receiverWalletPayJoinState.GetRecords()); - Assert.Empty(receiverWalletPayJoinState.GetExposedCoins()); - - //Cool, so the payjoin works! - //The cool thing with payjoin is that your utxos don't grow - Assert.Single(await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme)); - - //Let's be as malicious as CSW //give the cow some cash await cashCow.GenerateAsync(1); @@ -260,9 +220,9 @@ namespace BTCPayServer.Tests invoice = receiverUser.BitPay.CreateInvoice( new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); - parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21, + var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - endpoint = parsedBip21.UnknowParameters["bpu"]; + var endpoint = parsedBip21.UnknowParameters["bpu"]; var invoice2 = receiverUser.BitPay.CreateInvoice( new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); @@ -558,6 +518,9 @@ namespace BTCPayServer.Tests var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx); var contributedInputsInvoice7Coin6Response1TxSigned = Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut); + + + var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id); //broadcast the payjoin await tester.WaitForEvent(async () => diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 259e8a417..9a070f83f 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -120,6 +120,7 @@ namespace BTCPayServer.Tests return (usr, Driver.FindElement(By.Id("Id")).GetAttribute("value")); } + public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false) { @@ -314,8 +315,38 @@ namespace BTCPayServer.Tests return id; } + public async Task FundStoreWallet(WalletId walletId, int coins = 1, decimal denomination = 1m) + { + GoToWalletReceive(walletId); + Driver.FindElement(By.Id("generateButton")).Click(); + var addressStr = Driver.FindElement(By.Id("vue-address")).GetProperty("value"); + var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork); + for (int i = 0; i < coins; i++) + { + await Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(denomination)); + } + } + + public void PayInvoice(WalletId walletId, string invoiceId) + { + GoToInvoiceCheckout(invoiceId); + var bip21 = Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.Contains("bpu", bip21); + + GoToWalletSend(walletId); + Driver.FindElement(By.Id("bip21parse")).Click(); + Driver.SwitchTo().Alert().SendKeys(bip21); + Driver.SwitchTo().Alert().Accept(); + Driver.ScrollTo(By.Id("SendMenu")); + Driver.FindElement(By.Id("SendMenu")).ForceClick(); + Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); + Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick(); + } + + private void CheckForJSErrors() { //wait for seleniun update: https://stackoverflow.com/questions/57520296/selenium-webdriver-3-141-0-driver-manage-logs-availablelogtypes-throwing-syste diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index d3f8ac069..6753483f9 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -217,13 +217,6 @@ namespace BTCPayServer.Controllers } newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork); - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Severity = StatusMessageModel.StatusSeverity.Info, - AllowDismiss = false, - Html = - $"This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" - }); return newPSBT; } } @@ -239,13 +232,15 @@ namespace BTCPayServer.Controllers WalletId walletId, string psbt = null, string signingKey = null, string signingKeyPath = null, - string originalPsbt = null) + string originalPsbt = null, + string payJoinEndpointUrl = null) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var vm = new WalletPSBTReadyViewModel() { PSBT = psbt }; vm.SigningKey = signingKey; vm.SigningKeyPath = signingKeyPath; vm.OriginalPSBT = originalPsbt; + vm.PayJoinEndpointUrl = payJoinEndpointUrl; var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); @@ -366,13 +361,14 @@ namespace BTCPayServer.Controllers WalletId walletId, WalletPSBTReadyViewModel vm, string command = null) { if (command == null) - return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT); + return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl); PSBT psbt = null; var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + DerivationSchemeSettings derivationSchemeSettings = null; try { psbt = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork); - var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); + derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); await FetchTransactionDetails(derivationSchemeSettings, vm, network); @@ -382,54 +378,99 @@ namespace BTCPayServer.Controllers vm.GlobalError = "Invalid PSBT"; return View(nameof(WalletPSBTReady),vm); } - - if (command == "use-original") + + switch (command) { - return await WalletPSBTReady(walletId, vm.OriginalPSBT, vm.SigningKey, vm.SigningKeyPath); - } - if (command == "broadcast") - { - if (!psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors)) - { - vm.SetErrors(errors); - return View(nameof(WalletPSBTReady),vm); - } - var transaction = psbt.ExtractTransaction(); - try - { - var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction); - if (!broadcastResult.Success) + case "payjoin": + var proposedPayjoin =await + TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network); + if (proposedPayjoin == null) { - if (!string.IsNullOrEmpty(vm.OriginalPSBT)) + //we possibly exposed the tx to the receiver, so we need to broadcast straight away + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Warning, + AllowDismiss = false, + Html = $"The payjoin transaction could not be created. The original transaction was broadcast instead." + }); + return await WalletPSBTReady(walletId, vm, "broadcast"); + } + else + { + + try + { + var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork); + var payjoinSigned = PSBTChanged(proposedPayjoin, + () => proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation, + extKey, + RootedKeyPath.Parse(vm.SigningKeyPath))); + if (!payjoinSigned) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Warning, + AllowDismiss = false, + Html = $"The payjoin transaction could not be signed. The original transaction was broadcast instead." + }); + return await WalletPSBTReady(walletId, vm, "broadcast"); + } + vm.PSBT = proposedPayjoin.ToBase64(); + vm.OriginalPSBT = psbt.ToBase64(); + return await WalletPSBTReady(walletId, vm, "broadcast"); + } + catch (Exception e) { TempData.SetStatusMessageModel(new StatusMessageModel() { - Severity = StatusMessageModel.StatusSeverity.Error, + Severity = StatusMessageModel.StatusSeverity.Info, AllowDismiss = false, - Html = $"The payjoin transaction could not be broadcast.
({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).
The transaction has been reverted back to its original format and is ready to be broadcast." + Html = + $"This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" }); - return await WalletPSBTReady(walletId, vm.OriginalPSBT, vm.SigningKey, vm.SigningKeyPath); + return ViewVault(walletId, proposedPayjoin, vm.PayJoinEndpointUrl, psbt); } + } + case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors): + vm.SetErrors(errors); + return View(nameof(WalletPSBTReady),vm); + case "broadcast": + { + var transaction = psbt.ExtractTransaction(); + try + { + var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction); + if (!broadcastResult.Success) + { + if (!string.IsNullOrEmpty(vm.OriginalPSBT)) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Warning, + AllowDismiss = false, + Html = $"The payjoin transaction could not be broadcast.
({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).
The transaction has been reverted back to its original format and has been broadcast." + }); + vm.PSBT = vm.OriginalPSBT; + vm.OriginalPSBT = null; + return await WalletPSBTReady(walletId, vm, "broadcast"); + } - vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"; + vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"; + return View(nameof(WalletPSBTReady),vm); + } + } + catch (Exception ex) + { + vm.GlobalError = "Error while broadcasting: " + ex.Message; return View(nameof(WalletPSBTReady),vm); } + return RedirectToWalletTransaction(walletId, transaction); } - catch (Exception ex) - { - vm.GlobalError = "Error while broadcasting: " + ex.Message; + case "analyze-psbt": + return RedirectToWalletPSBT(psbt); + default: + vm.GlobalError = "Unknown command"; return View(nameof(WalletPSBTReady),vm); - } - return RedirectToWalletTransaction(walletId, transaction); - } - else if (command == "analyze-psbt") - { - return RedirectToWalletPSBT(psbt); - } - else - { - vm.GlobalError = "Unknown command"; - return View(nameof(WalletPSBTReady),vm); } } diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 86d9a3b6e..174aeedea 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -671,12 +671,13 @@ namespace BTCPayServer.Controllers ModelState.Clear(); } - private IActionResult ViewVault(WalletId walletId, PSBT psbt, string payJoinEndpointUrl) + private IActionResult ViewVault(WalletId walletId, PSBT psbt, string payJoinEndpointUrl, PSBT originalPSBT = null) { return View(nameof(WalletSendVault), new WalletSendVaultModel() { PayJoinEndpointUrl = payJoinEndpointUrl, WalletId = walletId.ToString(), + OriginalPSBT = originalPSBT?.ToBase64(), PSBT = psbt.ToBase64(), WebsocketPath = this.Url.Action(nameof(VaultController.VaultBridgeConnection), "Vault", new { walletId = walletId.ToString() }) }); @@ -687,19 +688,9 @@ namespace BTCPayServer.Controllers public async Task WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendVaultModel model) { - var network = NetworkProvider.GetNetwork(walletId.CryptoCode); - var newPSBT = await TryGetPayjoinProposedTX(model.PayJoinEndpointUrl, PSBT.Parse(model.PSBT, network.NBitcoinNetwork), GetDerivationSchemeSettings(walletId), network); - model.PayJoinEndpointUrl = null; - if (newPSBT != null) - { - model.OriginalPSBT = model.PSBT; - model.PSBT = newPSBT.ToBase64(); - return View(nameof(WalletSendVault), model); - } - - return RedirectToWalletPSBTReady(model.PSBT, originalPsbt: model.OriginalPSBT); + return RedirectToWalletPSBTReady(model.PSBT, originalPsbt: model.OriginalPSBT, payJoinEndpointUrl: model.PayJoinEndpointUrl); } - private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null, string originalPsbt = null) + private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null, string originalPsbt = null, string payJoinEndpointUrl = null) { var vm = new PostRedirectViewModel() { @@ -709,6 +700,7 @@ namespace BTCPayServer.Controllers { new KeyValuePair("psbt", psbt), new KeyValuePair("originalPsbt", originalPsbt), + new KeyValuePair("payJoinEndpointUrl", payJoinEndpointUrl), new KeyValuePair("SigningKey", signingKey), new KeyValuePair("SigningKeyPath", signingKeyPath) } @@ -849,15 +841,7 @@ namespace BTCPayServer.Controllers return View(viewModel); } ModelState.Remove(nameof(viewModel.PSBT)); - var newPSBT = await TryGetPayjoinProposedTX(viewModel.PayJoinEndpointUrl,psbt, GetDerivationSchemeSettings(walletId), network); - viewModel.PayJoinEndpointUrl = null; - if (newPSBT != null) - { - viewModel.OriginalPSBT = psbt.ToBase64(); - viewModel.PSBT = newPSBT.ToBase64(); - return await SignWithSeed(walletId, viewModel); - } - return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT); + return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl); } private bool PSBTChanged(PSBT psbt, Action act) @@ -881,7 +865,17 @@ namespace BTCPayServer.Controllers var wallet = _walletProvider.GetWallet(network); var derivationSettings = GetDerivationSchemeSettings(walletId); wallet.InvalidateCache(derivationSettings.AccountDerivation); - TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash().ToString()})"; + if (TempData.GetStatusMessageModel() == null) + { + TempData[WellKnownTempData.SuccessMessage] = + $"Transaction broadcasted successfully ({transaction.GetHash()})"; + } + else + { + var statusMessageModel = TempData.GetStatusMessageModel(); + statusMessageModel.Message += $" ({transaction.GetHash()})"; + TempData.SetStatusMessageModel(statusMessageModel); + } } return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } diff --git a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs index 411a22430..daed6978e 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs @@ -8,6 +8,7 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletPSBTReadyViewModel { + public string PayJoinEndpointUrl { get; set; } public string OriginalPSBT { get; set; } public string PSBT { get; set; } public string SigningKey { get; set; } diff --git a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml index 2ebe9c0c1..b0f756cca 100644 --- a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml +++ b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml @@ -52,7 +52,7 @@ @payment.Crypto @payment.DepositAddress - @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"
(Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount})") + @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"
(Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount})")
From 9e1ae29600f2d514238e9e0c33682c92a034b91c Mon Sep 17 00:00:00 2001 From: Kukks Date: Tue, 31 Mar 2020 14:57:24 +0200 Subject: [PATCH 20/21] change broadcast button style --- .../Views/Wallets/WalletPSBTReady.cshtml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml b/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml index c0db86da7..3c74e2210 100644 --- a/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml +++ b/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml @@ -144,15 +144,21 @@ @if (!Model.HasErrors) { - + @if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl)) + { + + or + + } + else + { + + } + or } - @if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl)) - { - or - - } +
From fd026a97332a7797f4b1ca43350316cd1659bfa6 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Mar 2020 00:28:22 +0900 Subject: [PATCH 21/21] Refactor server-side --- .../BTCPayServer.Client.csproj | 4 +- .../Altcoins/BTCPayNetworkProvider.Bitcoin.cs | 1 + BTCPayServer.Common/BTCPayNetwork.cs | 2 + .../BTCPayServer.Common.csproj | 2 +- BTCPayServer.Tests/PayJoinTests.cs | 421 ++++++----- BTCPayServer.Tests/PaymentHandlerTest.cs | 6 +- BTCPayServer.Tests/ServerTester.cs | 9 +- BTCPayServer.Tests/TestAccount.cs | 181 ++++- BTCPayServer.Tests/UnitTest1.cs | 6 +- BTCPayServer.Tests/docker-compose.yml | 2 +- BTCPayServer/Controllers/InvoiceController.cs | 2 +- .../Controllers/WalletsController.PSBT.cs | 104 +-- BTCPayServer/Controllers/WalletsController.cs | 12 +- BTCPayServer/Extensions.cs | 27 +- ...ayedTransactionBroadcasterHostedService.cs | 40 ++ .../BitcoinLikeOnChainPaymentMethod.cs | 16 +- .../Bitcoin/BitcoinLikePaymentData.cs | 25 +- .../Bitcoin/BitcoinLikePaymentHandler.cs | 49 +- .../Payments/Bitcoin/NBXplorerListener.cs | 111 ++- .../Payments/IPaymentMethodHandler.cs | 10 +- .../Lightning/LightningLikePaymentHandler.cs | 2 + .../PayJoin/PayJoinEndpointController.cs | 676 ++++++++++-------- .../Payments/PayJoin/PayJoinExtensions.cs | 11 +- .../Payments/PayJoin/PayJoinRepository.cs | 48 ++ BTCPayServer/Payments/PayJoin/PayJoinState.cs | 176 ----- .../Payments/PayJoin/PayJoinStateProvider.cs | 121 ---- .../PayJoin/PayJoinStateRecordedItem.cs | 26 - .../PayJoin/PayJoinTransactionBroadcaster.cs | 130 ---- .../MoneroLikePaymentMethodHandler.cs | 3 +- .../Services/DelayedTransactionBroadcaster.cs | 105 +++ .../Services/Invoices/InvoiceEntity.cs | 4 +- .../Services/Invoices/InvoiceRepository.cs | 10 +- BTCPayServer/Services/PayjoinClient.cs | 208 ++++++ BTCPayServer/Services/Wallets/BTCPayWallet.cs | 32 +- BTCPayServer/Views/Server/_Nav.cshtml | 1 - .../Shared/ViewBitcoinLikePaymentData.cshtml | 2 +- 36 files changed, 1381 insertions(+), 1204 deletions(-) create mode 100644 BTCPayServer/HostedServices/DelayedTransactionBroadcasterHostedService.cs create mode 100644 BTCPayServer/Payments/PayJoin/PayJoinRepository.cs delete mode 100644 BTCPayServer/Payments/PayJoin/PayJoinState.cs delete mode 100644 BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs delete mode 100644 BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs delete mode 100644 BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs create mode 100644 BTCPayServer/Services/DelayedTransactionBroadcaster.cs create mode 100644 BTCPayServer/Services/PayjoinClient.cs diff --git a/BTCPayServer.Client/BTCPayServer.Client.csproj b/BTCPayServer.Client/BTCPayServer.Client.csproj index 4acef7f94..3ba96e1ad 100644 --- a/BTCPayServer.Client/BTCPayServer.Client.csproj +++ b/BTCPayServer.Client/BTCPayServer.Client.csproj @@ -1,11 +1,11 @@ - + netstandard2.1 - + diff --git a/BTCPayServer.Common/Altcoins/BTCPayNetworkProvider.Bitcoin.cs b/BTCPayServer.Common/Altcoins/BTCPayNetworkProvider.Bitcoin.cs index f03652ceb..489362f72 100644 --- a/BTCPayServer.Common/Altcoins/BTCPayNetworkProvider.Bitcoin.cs +++ b/BTCPayServer.Common/Altcoins/BTCPayNetworkProvider.Bitcoin.cs @@ -24,6 +24,7 @@ namespace BTCPayServer DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType), CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("0'") : new KeyPath("1'"), SupportRBF = true, + SupportPayJoin = true, //https://github.com/spesmilo/electrum/blob/11733d6bc271646a00b69ff07657119598874da4/electrum/constants.py ElectrumMapping = NetworkType == NetworkType.Mainnet ? new Dictionary() diff --git a/BTCPayServer.Common/BTCPayNetwork.cs b/BTCPayServer.Common/BTCPayNetwork.cs index c95b58b3c..18daa1e91 100644 --- a/BTCPayServer.Common/BTCPayNetwork.cs +++ b/BTCPayServer.Common/BTCPayNetwork.cs @@ -61,6 +61,8 @@ namespace BTCPayServer public int MaxTrackedConfirmation { get; internal set; } = 6; public string UriScheme { get; internal set; } + public bool SupportPayJoin { get; set; } = false; + public KeyPath GetRootKeyPath(DerivationType type) { KeyPath baseKey; diff --git a/BTCPayServer.Common/BTCPayServer.Common.csproj b/BTCPayServer.Common/BTCPayServer.Common.csproj index 6fd66c12c..0384cba21 100644 --- a/BTCPayServer.Common/BTCPayServer.Common.csproj +++ b/BTCPayServer.Common/BTCPayServer.Common.csproj @@ -4,6 +4,6 @@ - + diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 47bc46c1e..e027166e3 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -13,12 +13,14 @@ using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.PayJoin; +using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Wallets; using BTCPayServer.Tests.Logging; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel; using NBitcoin; using NBitcoin.Payment; using NBitpayClient; @@ -45,17 +47,20 @@ namespace BTCPayServer.Tests using (var s = SeleniumTester.Create()) { await s.StartAsync(); + var invoiceRepository = s.Server.PayTester.GetService(); + // var payjoinRepository = s.Server.PayTester.GetService(); + // var broadcaster = s.Server.PayTester.GetService(); s.RegisterNewUser(true); var receiver = s.CreateNewStore(); var receiverSeed = s.GenerateWallet("BTC", "", true, true); var receiverWalletId = new WalletId(receiver.storeId, "BTC"); - var payJoinStateProvider = s.Server.PayTester.GetService(); + //payjoin is not enabled by default. var invoiceId = s.CreateInvoice(receiver.storeId); s.GoToInvoiceCheckout(invoiceId); var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) .GetAttribute("href"); - Assert.DoesNotContain("bpu", bip21); + Assert.DoesNotContain("bpu=", bip21); s.GoToHome(); s.GoToStore(receiver.storeId); @@ -74,19 +79,20 @@ namespace BTCPayServer.Tests s.GoToInvoiceCheckout(invoiceId); bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) .GetAttribute("href"); - Assert.Contains("bpu", bip21); - + Assert.Contains("bpu=", bip21); + s.GoToWalletSend(senderWalletId); s.Driver.FindElement(By.Id("bip21parse")).Click(); s.Driver.SwitchTo().Alert().SendKeys(bip21); s.Driver.SwitchTo().Alert().Accept(); - Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); + Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); - await s.Server.WaitForEvent(async () => + await s.Server.WaitForEvent(() => { s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick(); + return Task.CompletedTask; }); //no funds in receiver wallet to do payjoin s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning); @@ -95,48 +101,197 @@ namespace BTCPayServer.Tests var invoice = await s.Server.PayTester.GetService().GetInvoice(invoiceId); Assert.Equal(InvoiceStatus.Paid, invoice.Status); }); - + s.GoToInvoices(); var paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value")); Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase)); - + //let's do it all again, except now the receiver has funds and is able to payjoin invoiceId = s.CreateInvoice(receiver.storeId); s.GoToInvoiceCheckout(invoiceId); bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) .GetAttribute("href"); Assert.Contains("bpu", bip21); - + s.GoToWalletSend(senderWalletId); s.Driver.FindElement(By.Id("bip21parse")).Click(); s.Driver.SwitchTo().Alert().SendKeys(bip21); s.Driver.SwitchTo().Alert().Accept(); - Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); + Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); - await s.Server.WaitForEvent(async () => + await s.Server.WaitForEvent(() => { s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick(); + return Task.CompletedTask; }); s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Success); + await TestUtils.EventuallyAsync(async () => + { + var invoice = await invoiceRepository.GetInvoice(invoiceId); + var payments = invoice.GetPayments().ToArray(); + var originalPayment = payments + .Single(p => + p.GetCryptoPaymentData() is BitcoinLikePaymentData pd && + pd.PayjoinInformation?.Type is PayjoinTransactionType.Original); + var coinjoinPayment = payments + .Single(p => + p.GetCryptoPaymentData() is BitcoinLikePaymentData pd && + pd.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin); + Assert.Equal(-1, ((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).ConfirmationCount); + Assert.Equal(0, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).ConfirmationCount); + Assert.False(originalPayment.Accounted); + Assert.True(coinjoinPayment.Accounted); + Assert.Equal(((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).Value, + ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).Value); + }); + await TestUtils.EventuallyAsync(async () => { var invoice = await s.Server.PayTester.GetService().GetInvoice(invoiceId); + var dto = invoice.EntityToDTO(); Assert.Equal(InvoiceStatus.Paid, invoice.Status); }); s.GoToInvoices(); paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value")); Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase)); + } + } + [Fact] + [Trait("Integration", "Integration")] + public async Task CanUseBIP79FeeCornerCase() + { + using (var tester = ServerTester.Create()) + { + await tester.StartAsync(); + var broadcaster = tester.PayTester.GetService(); + var payjoinRepository = tester.PayTester.GetService(); + broadcaster.Disable(); + var network = tester.NetworkProvider.GetNetwork("BTC"); + var btcPayWallet = tester.PayTester.GetService().GetWallet(network); + var cashCow = tester.ExplorerNode; + cashCow.Generate(2); // get some money in case + + var senderUser = tester.NewAccount(); + senderUser.GrantAccess(true); + senderUser.RegisterDerivationScheme("BTC", true); + + var receiverUser = tester.NewAccount(); + receiverUser.GrantAccess(true); + receiverUser.RegisterDerivationScheme("BTC", true, true); + await receiverUser.EnablePayJoin(); + var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network); + string lastInvoiceId = null; + + var vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "not-enough-money"); + async Task RunVector() + { + var coin = await senderUser.ReceiveUTXO(vector.SpentCoin, network); + var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() {Price = vector.InvoiceAmount.ToDecimal(MoneyUnit.BTC), Currency = "BTC", FullNotifications = true}); + lastInvoiceId = invoice.Id; + var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network); + var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder(); + txBuilder.AddCoins(coin); + txBuilder.Send(invoiceAddress, vector.Paid); + txBuilder.SendFees(vector.Fee); + txBuilder.SetChange(await senderUser.GetNewAddress(network)); + var psbt = txBuilder.BuildPSBT(false); + psbt = await senderUser.Sign(psbt); + var pj = await senderUser.SubmitPayjoin(invoice, psbt, vector.ExpectedError); + if (vector.ExpectLocked) + { + Assert.True(await payjoinRepository.TryUnlock(receiverCoin.Outpoint)); + } + else + { + Assert.False(await payjoinRepository.TryUnlock(receiverCoin.Outpoint)); + } + return pj; + } + + Logs.Tester.LogInformation("Here we send exactly the right amount. This should fails as\n" + + "there is not enough to pay the additional payjoin input. (going below the min relay fee"); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "not-enough-money"); + await RunVector(); - //the state should now hold that there is an ongoing utxo - var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); - Assert.NotNull(receiverWalletPayJoinState); - Assert.Single(receiverWalletPayJoinState.GetRecords()); - Assert.Equal(0.02m, receiverWalletPayJoinState.GetRecords().First().ContributedAmount); - Assert.Single(receiverWalletPayJoinState.GetRecords().First().CoinsExposed); + Logs.Tester.LogInformation("We don't pay enough"); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "invoice-not-fully-paid"); + await RunVector(); + + Logs.Tester.LogInformation("We pay correctly"); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), ExpectLocked: true, ExpectedError: null as string); + await RunVector(); + Logs.Tester.LogInformation("We pay correctly, but no utxo\n" + + "However, this has the side effect of having the receiver broadcasting the original tx"); + await payjoinRepository.TryLock(receiverCoin.Outpoint); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), ExpectLocked: true, ExpectedError: "out-of-utxos"); + await RunVector(); + await TestUtils.EventuallyAsync(async () => + { + var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme); + Assert.Equal(2, coins.Length); + var newCoin = coins.First(c => (Money)c.Value == Money.Satoshis(500)); + await payjoinRepository.TryLock(newCoin.OutPoint); + }); + var originalSenderUser = senderUser; + retry: + // Additional fee is 96 , minrelaytx is 294 + // We pay correctly, fees partially taken from what is overpaid + // We paid 510, the receiver pay 10 sat + // The send pay remaining 86 sat from his pocket + // So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back) + Logs.Tester.LogInformation($"Check if we can take fee on overpaid utxo{(senderUser == receiverUser ? " (to self)" : "")}"); + vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), ExpectLocked: true, ExpectedError: null as string); + var proposedPSBT = await RunVector(); + Assert.Equal(2, proposedPSBT.Outputs.Count); + Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(500) + receiverCoin.Amount); + Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(294)); + proposedPSBT = await senderUser.Sign(proposedPSBT); + proposedPSBT = proposedPSBT.Finalize(); + var explorerClient = tester.PayTester.GetService().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode); + var result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction()); + Assert.True(result.Success); + Logs.Tester.LogInformation($"We broadcasted the payjoin {proposedPSBT.ExtractTransaction().GetHash()}"); + Logs.Tester.LogInformation($"Let's make sure that the coinjoin is not over paying, since the 10 overpaid sats have gone to fee"); + await TestUtils.EventuallyAsync(async () => + { + var invoice = await tester.PayTester.GetService().GetInvoice(lastInvoiceId); + Assert.Equal(InvoiceStatus.Paid, invoice.Status); + Assert.Equal(InvoiceExceptionStatus.None, invoice.ExceptionStatus); + var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme); + foreach (var coin in coins) + await payjoinRepository.TryLock(coin.OutPoint); + }); + tester.ExplorerNode.Generate(1); + receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network); + + if (senderUser != receiverUser) + { + Logs.Tester.LogInformation("Let's do the same, this time paying to ourselves"); + senderUser = receiverUser; + goto retry; + } + else + { + senderUser = originalSenderUser; + } + + + // Same as above. Except the sender send one satoshi less, so the change + // output get below dust and should be removed completely. + vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), ExpectLocked: true, ExpectedError: null as string); + proposedPSBT = await RunVector(); + var output = Assert.Single(proposedPSBT.Outputs); + // With the output removed, the user should have largely pay all the needed fee + Assert.Equal(Money.Satoshis(510) + receiverCoin.Amount, output.Value); + proposedPSBT = await senderUser.Sign(proposedPSBT); + proposedPSBT = proposedPSBT.Finalize(); + explorerClient = tester.PayTester.GetService().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode); + result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction(), true); + Assert.True(result.Success); } } @@ -149,7 +304,7 @@ namespace BTCPayServer.Tests { await tester.StartAsync(); - var payJoinStateProvider = tester.PayTester.GetService(); + ////var payJoinStateProvider = tester.PayTester.GetService(); var btcPayNetwork = tester.NetworkProvider.GetNetwork("BTC"); var btcPayWallet = tester.PayTester.GetService().GetWallet(btcPayNetwork); var cashCow = tester.ExplorerNode; @@ -181,40 +336,17 @@ namespace BTCPayServer.Tests //give the cow some cash await cashCow.GenerateAsync(1); //let's get some more utxos first - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, - new Money(0.011m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, - new Money(0.012m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, - new Money(0.013m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, - new Money(0.021m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, - new Money(0.022m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, - new Money(0.023m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, - new Money(0.024m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, - new Money(0.025m, MoneyUnit.BTC))); - Assert.NotNull(await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, - new Money(0.026m, MoneyUnit.BTC))); - - await cashCow.SendToAddressAsync( - (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, - new Money(0.014m, MoneyUnit.BTC)); - - - var senderChange = (await btcPayWallet.GetChangeAddressAsync(senderUser.DerivationScheme)).Item1; + await receiverUser.ReceiveUTXO(Money.Coins(0.011m), btcPayNetwork); + await receiverUser.ReceiveUTXO(Money.Coins(0.012m), btcPayNetwork); + await receiverUser.ReceiveUTXO(Money.Coins(0.013m), btcPayNetwork); + await receiverUser.ReceiveUTXO(Money.Coins(0.014m), btcPayNetwork); + await senderUser.ReceiveUTXO(Money.Coins(0.021m), btcPayNetwork); + await senderUser.ReceiveUTXO(Money.Coins(0.022m), btcPayNetwork); + await senderUser.ReceiveUTXO(Money.Coins(0.023m), btcPayNetwork); + await senderUser.ReceiveUTXO(Money.Coins(0.024m), btcPayNetwork); + await senderUser.ReceiveUTXO(Money.Coins(0.025m), btcPayNetwork); + await senderUser.ReceiveUTXO(Money.Coins(0.026m), btcPayNetwork); + var senderChange = await senderUser.GetNewAddress(btcPayNetwork); //Let's start the harassment invoice = receiverUser.BitPay.CreateInvoice( @@ -222,13 +354,11 @@ namespace BTCPayServer.Tests var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - var endpoint = parsedBip21.UnknowParameters["bpu"]; var invoice2 = receiverUser.BitPay.CreateInvoice( new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - var endpoint2 = secondInvoiceParsedBip21.UnknowParameters["bpu"]; var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId); var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike); @@ -293,39 +423,22 @@ namespace BTCPayServer.Tests //Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all //Result: reject - Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint, - new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); + // Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint, + // new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); //Attempt 2: Create two transactions using different inputs and send them to the same invoice. //Result: Second Tx should be rejected. - var Invoice1Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint, - new StringContent(Invoice1Coin1.ToHex(), Encoding.UTF8, "text/plain")); - - var Invoice1Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint, - new StringContent(Invoice1Coin2.ToHex(), Encoding.UTF8, "text/plain")); - - Assert.True(Invoice1Coin1Response.IsSuccessStatusCode); - Assert.False(Invoice1Coin2Response.IsSuccessStatusCode); - var Invoice1Coin1ResponseTx = - Transaction.Parse(await Invoice1Coin1Response.Content.ReadAsStringAsync(), n); + var Invoice1Coin1ResponseTx = await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork); + await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork, "already-paid"); var contributedInputsInvoice1Coin1ResponseTx = Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut); Assert.Single(contributedInputsInvoice1Coin1ResponseTx); //Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted //Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1 - - var Invoice2Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint2, - new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain")); - - var Invoice2Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint2, - new StringContent(Invoice2Coin2.ToHex(), Encoding.UTF8, "text/plain")); - - Assert.False(Invoice2Coin1Response.IsSuccessStatusCode); - Assert.True(Invoice2Coin2Response.IsSuccessStatusCode); - - var Invoice2Coin2ResponseTx = - Transaction.Parse(await Invoice2Coin2Response.Content.ReadAsStringAsync(), n); + await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, "inputs-already-used"); + var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork); + var contributedInputsInvoice2Coin2ResponseTx = Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut); Assert.Single(contributedInputsInvoice2Coin2ResponseTx); @@ -337,14 +450,12 @@ namespace BTCPayServer.Tests new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - var invoice3Endpoint = invoice3ParsedBip21.UnknowParameters["bpu"]; var invoice4 = receiverUser.BitPay.CreateInvoice( new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - var invoice4Endpoint = invoice4ParsedBip21.UnknowParameters["bpu"]; var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() @@ -356,14 +467,8 @@ namespace BTCPayServer.Tests .SendEstimatedFees(new FeeRate(100m)) .BuildTransaction(true); - var Invoice3Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice3Endpoint, - new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain")); - - var Invoice4Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice4Endpoint, - new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain")); - - Assert.True(Invoice3Coin3Response.IsSuccessStatusCode); - Assert.False(Invoice4Coin3Response.IsSuccessStatusCode); + await senderUser.SubmitPayjoin(invoice3, Invoice3AndInvoice4Coin3, btcPayNetwork); + await senderUser.SubmitPayjoin(invoice4, Invoice3AndInvoice4Coin3, btcPayNetwork, "already-paid"); //Attempt 5: Make tx that pays invoice 5 with 2 outputs //Result: proposed tx consolidates the outputs @@ -372,7 +477,6 @@ namespace BTCPayServer.Tests new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"]; var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() .SetChange(senderChange) @@ -383,60 +487,9 @@ namespace BTCPayServer.Tests .SendEstimatedFees(new FeeRate(100m)); var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true); - - var Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, - new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); - - Assert.True(Invoice5Coin4Response.IsSuccessStatusCode); - var Invoice5Coin4ResponseTx = - Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n); + var Invoice5Coin4ResponseTx = await senderUser.SubmitPayjoin(invoice5, Invoice5Coin4, btcPayNetwork); Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address)); - //Attempt 6: submit the same tx over and over in the hopes of getting new utxos - //Result: same tx gets sent back - for (int i = 0; i < 5; i++) - { - var Invoice5Coin4Response2 = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, - new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); - if (!Invoice5Coin4Response2.IsSuccessStatusCode) - { - Logs.Tester.LogInformation( - $"Failed on try {i + 1} with {await Invoice5Coin4Response2.Content.ReadAsStringAsync()}"); - } - - Assert.True(Invoice5Coin4Response2.IsSuccessStatusCode); - var Invoice5Coin4Response2Tx = - Transaction.Parse(await Invoice5Coin4Response2.Content.ReadAsStringAsync(), n); - Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), Invoice5Coin4Response2Tx.GetHash()); - } - - //Attempt 7: send the payjoin porposed tx to the endpoint - //Result: get same tx sent back as is - Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, - new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); - Assert.True(Invoice5Coin4Response.IsSuccessStatusCode); - Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), - Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash()); - - //Attempt 8: sign the payjoin and send it back to the endpoint - //Result: get same tx sent back as is - var Invoice5Coin4ResponseTxSigned = Invoice5Coin4TxBuilder.SignTransaction(Invoice5Coin4ResponseTx); - Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, - new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); - Assert.True(Invoice5Coin4Response.IsSuccessStatusCode); - Assert.Equal(Invoice5Coin4ResponseTxSigned.GetHash(), - Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash()); - - //Attempt 9: broadcast a payjoin tx, then try to submit both original tx and the payjoin itself again - //Result: fails - await tester.ExplorerClient.BroadcastAsync(Invoice5Coin4ResponseTxSigned); - - Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, - new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); - - Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, - new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); - //Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again //Result: same tx gets sent back @@ -449,7 +502,6 @@ namespace BTCPayServer.Tests new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - var invoice6Endpoint = invoice6ParsedBip21.UnknowParameters["bpu"]; var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() .SetChange(senderChange) @@ -462,26 +514,22 @@ namespace BTCPayServer.Tests var invoice6Coin5 = invoice6Coin5TxBuilder .BuildTransaction(true); - var Invoice6Coin5Response1 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint, - new StringContent(invoice6Coin5.ToHex(), Encoding.UTF8, "text/plain")); - Assert.True(Invoice6Coin5Response1.IsSuccessStatusCode); - var Invoice6Coin5Response1Tx = - Transaction.Parse(await Invoice6Coin5Response1.Content.ReadAsStringAsync(), n); + var Invoice6Coin5Response1Tx =await senderUser.SubmitPayjoin(invoice6, invoice6Coin5, btcPayNetwork); var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx); //broadcast the first payjoin await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned); - invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m)); - var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder - .BuildTransaction(true); - - var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint, - new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain")); - Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode); - var Invoice6Coin5Response3Tx = - Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n); - Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin => - Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut))); + // invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m)); + // var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder + // .BuildTransaction(true); + // + // var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint, + // new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain")); + // Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode); + // var Invoice6Coin5Response3Tx = + // Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n); + // Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin => + // Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut))); //Attempt 11: //send tx with rbt, broadcast payjoin, @@ -497,7 +545,6 @@ namespace BTCPayServer.Tests new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21, tester.ExplorerClient.Network.NBitcoinNetwork); - var invoice7Endpoint = invoice7ParsedBip21.UnknowParameters["bpu"]; var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() .SetChange(senderChange) @@ -510,26 +557,26 @@ namespace BTCPayServer.Tests var invoice7Coin6Tx = invoice7Coin6TxBuilder .BuildTransaction(true); - var invoice7Coin6Response1 = await tester.PayTester.HttpClient.PostAsync(invoice7Endpoint, - new StringContent(invoice7Coin6Tx.ToHex(), Encoding.UTF8, "text/plain")); - Assert.True(invoice7Coin6Response1.IsSuccessStatusCode); - var invoice7Coin6Response1Tx = - Transaction.Parse(await invoice7Coin6Response1.Content.ReadAsStringAsync(), n); + var invoice7Coin6Response1Tx = await senderUser.SubmitPayjoin(invoice7, invoice7Coin6Tx, btcPayNetwork); var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx); var contributedInputsInvoice7Coin6Response1TxSigned = Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut); - var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); - Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id); + ////var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); + ////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id); //broadcast the payjoin - await tester.WaitForEvent(async () => + var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned)); + Assert.True(res.Success); + + // Paid with coinjoin + await TestUtils.EventuallyAsync(async () => { - var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned)); - Assert.True(res.Success); + var invoiceEntity = await tester.PayTester.GetService().GetInvoice(invoice7.Id); + Assert.Equal(InvoiceStatus.Paid, invoiceEntity.Status); + Assert.Contains(invoiceEntity.GetPayments(), p => p.Accounted && ((BitcoinLikePaymentData)p.GetCryptoPaymentData()).PayjoinInformation.Type is PayjoinTransactionType.Coinjoin); }); - - Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); + ////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() .SetChange(senderChange) @@ -542,23 +589,25 @@ namespace BTCPayServer.Tests .BuildTransaction(true); //broadcast the "rbf cancel" tx - await tester.WaitForEvent(async () => + res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2)); + Assert.True(res.Success); + + // Make a block, this should put back the invoice to new + var blockhash = tester.ExplorerNode.Generate(1)[0]; + Assert.NotNull(await tester.ExplorerNode.GetRawTransactionAsync(invoice7Coin6Tx2.GetHash(), blockhash)); + Assert.Null(await tester.ExplorerNode.GetRawTransactionAsync(Invoice7Coin6Response1TxSigned.GetHash(), blockhash, false)); + // Now we should return to New + OutPoint ourOutpoint = null; + await TestUtils.EventuallyAsync(async () => { - var res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2)); - Assert.True(res.Success); + var invoiceEntity = await tester.PayTester.GetService().GetInvoice(invoice7.Id); + Assert.Equal(InvoiceStatus.New, invoiceEntity.Status); + Assert.True(invoiceEntity.GetPayments().All(p => !p.Accounted)); + ourOutpoint = invoiceEntity.GetAllBitcoinPaymentData().First().PayjoinInformation.ContributedOutPoints[0]; }); - //btcpay does not know of replaced txs where the outputs do not pay it(double spends using RBF to "cancel" a payment) - Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); - - //hijack our automated payjoin original broadcaster and force it to broadcast all, now - var payJoinTransactionBroadcaster = tester.PayTester.ServiceProvider.GetServices() - .OfType().First(); - await payJoinTransactionBroadcaster.BroadcastStaleTransactions(TimeSpan.Zero, CancellationToken.None); - - Assert.DoesNotContain(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id); - //all our failed payjoins are clear and any exposed utxo has been moved to the prioritized list - Assert.Contains(receiverWalletPayJoinState.GetExposedCoins(), receivedCoin => - receivedCoin.OutPoint == contributedInputsInvoice7Coin6Response1TxSigned.PrevOut); + var payjoinRepository = tester.PayTester.GetService(); + // The outpoint should now be available for next pj selection + Assert.False(await payjoinRepository.TryUnlock(ourOutpoint)); } } } diff --git a/BTCPayServer.Tests/PaymentHandlerTest.cs b/BTCPayServer.Tests/PaymentHandlerTest.cs index 17060fc7d..6d471ddbd 100644 --- a/BTCPayServer.Tests/PaymentHandlerTest.cs +++ b/BTCPayServer.Tests/PaymentHandlerTest.cs @@ -6,10 +6,12 @@ using BTCPayServer.Data; using BTCPayServer.Services.Rates; using System.Collections.Generic; using System.Threading.Tasks; +using BTCPayServer.Logging; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Lightning; using BTCPayServer.Rating; +using Logs = BTCPayServer.Tests.Logging.Logs; namespace BTCPayServer.Tests { @@ -41,8 +43,8 @@ namespace BTCPayServer.Tests currencyPairRateResult.Add(new CurrencyPair("USD", "BTC"), Task.FromResult(rateResultUSDBTC)); currencyPairRateResult.Add(new CurrencyPair("BTC", "USD"), Task.FromResult(rateResultBTCUSD)); - - handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null); + InvoiceLogs logs = new InvoiceLogs(); + handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null, null); handlerLN = new LightningLikePaymentHandler(null, null, networkProvider, null); #pragma warning restore CS0618 diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 409a1cf26..a01680499 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -144,16 +144,17 @@ namespace BTCPayServer.Tests await CustomerLightningD.Pay(bolt11); } - public async Task WaitForEvent(Func action) + public async Task WaitForEvent(Func action) { - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var sub = PayTester.GetService().Subscribe(evt => { - tcs.SetResult(true); + tcs.TrySetResult(evt); }); await action.Invoke(); - await tcs.Task; + var result = await tcs.Task; sub.Dispose(); + return result; } public ILightningClient CustomerLightningD { get; set; } diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index a659d4bcd..c6f8586e9 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -8,8 +8,10 @@ using NBitcoin; using NBitpayClient; using System; using System.Collections.Generic; +using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Amazon.S3.Model; using Xunit; using NBXplorer.DerivationStrategy; using BTCPayServer.Payments; @@ -21,12 +23,18 @@ using BTCPayServer.Data; using Microsoft.AspNetCore.Identity; using NBXplorer.Models; using BTCPayServer.Client; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using NBitcoin.Payment; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Tests { public class TestAccount { ServerTester parent; + public TestAccount(ServerTester parent) { this.parent = parent; @@ -51,7 +59,8 @@ namespace BTCPayServer.Tests public Task CreateClient() { - return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email, RegisterDetails.Password)); + return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email, + RegisterDetails.Password)); } public async Task CreateClient(params string[] permissions) @@ -60,11 +69,12 @@ namespace BTCPayServer.Tests var x = Assert.IsType(await manageController.AddApiKey( new ManageController.AddApiKeyViewModel() { - PermissionValues = permissions.Select(s => new ManageController.AddApiKeyViewModel.PermissionValueItem() - { - Permission = s, - Value = true - }).ToList(), + PermissionValues = + permissions.Select(s => + new ManageController.AddApiKeyViewModel.PermissionValueItem() + { + Permission = s, Value = true + }).ToList(), StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores })); var statusMessage = manageController.TempData.GetStatusMessageModel(); @@ -78,6 +88,7 @@ namespace BTCPayServer.Tests { RegisterAsync(isAdmin).GetAwaiter().GetResult(); } + public async Task GrantAccessAsync(bool isAdmin = false) { await RegisterAsync(isAdmin); @@ -105,6 +116,7 @@ namespace BTCPayServer.Tests store.NetworkFeeMode = mode; }); } + public void ModifyStore(Action modify) { var storeController = GetController(); @@ -122,7 +134,7 @@ namespace BTCPayServer.Tests public async Task CreateStoreAsync() { var store = this.GetController(); - await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" }); + await store.CreateStore(new CreateStoreViewModel() {Name = "Test Store"}); StoreId = store.CreatedStoreId; parent.Stores.Add(StoreId); } @@ -133,7 +145,9 @@ namespace BTCPayServer.Tests { return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult(); } - public async Task RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false, bool importKeysToNBX = false) + + public async Task RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false, + bool importKeysToNBX = false) { SupportedNetwork = parent.NetworkProvider.GetNetwork(cryptoCode); var store = parent.PayTester.GetController(UserId, StoreId); @@ -143,20 +157,21 @@ namespace BTCPayServer.Tests SavePrivateKeys = importKeysToNBX }); - await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel() - { - Enabled = true, - CryptoCode = cryptoCode, - Network = SupportedNetwork, - RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(), - RootKeyPath = SupportedNetwork.GetRootKeyPath(), - Source = "NBXplorer", - AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(), - DerivationSchemeFormat = "BTCPay", - KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(), - DerivationScheme = DerivationScheme.ToString(), - Confirmation = true - }, cryptoCode); + await store.AddDerivationScheme(StoreId, + new DerivationSchemeViewModel() + { + Enabled = true, + CryptoCode = cryptoCode, + Network = SupportedNetwork, + RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(), + RootKeyPath = SupportedNetwork.GetRootKeyPath(), + Source = "NBXplorer", + AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(), + DerivationSchemeFormat = "BTCPay", + KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(), + DerivationScheme = DerivationScheme.ToString(), + Confirmation = true + }, cryptoCode); return new WalletId(StoreId, cryptoCode); } @@ -199,21 +214,26 @@ namespace BTCPayServer.Tests IsAdmin = account.RegisteredAdmin; } - public RegisterViewModel RegisterDetails{ get; set; } + public RegisterViewModel RegisterDetails { get; set; } public Bitpay BitPay { - get; set; + get; + set; } + public string UserId { - get; set; + get; + set; } public string StoreId { - get; set; + get; + set; } + public bool IsAdmin { get; internal set; } public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType) @@ -229,19 +249,116 @@ namespace BTCPayServer.Tests if (connectionType == LightningConnectionType.Charge) connectionString = "type=charge;server=" + parent.MerchantCharge.Client.Uri.AbsoluteUri; else if (connectionType == LightningConnectionType.CLightning) - connectionString = "type=clightning;server=" + ((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri; + connectionString = "type=clightning;server=" + + ((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri; else if (connectionType == LightningConnectionType.LndREST) connectionString = $"type=lnd-rest;server={parent.MerchantLnd.Swagger.BaseUrl};allowinsecure=true"; else throw new NotSupportedException(connectionType.ToString()); - await storeController.AddLightningNode(StoreId, new LightningNodeViewModel() - { - ConnectionString = connectionString, - SkipPortTest = true - }, "save", "BTC"); + await storeController.AddLightningNode(StoreId, + new LightningNodeViewModel() {ConnectionString = connectionString, SkipPortTest = true}, "save", "BTC"); if (storeController.ModelState.ErrorCount != 0) Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage); } + + public async Task ReceiveUTXO(Money value, BTCPayNetwork network) + { + var cashCow = parent.ExplorerNode; + var btcPayWallet = parent.PayTester.GetService().GetWallet(network); + var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address; + var txid = await cashCow.SendToAddressAsync(address, value); + var tx = await cashCow.GetRawTransactionAsync(txid); + return tx.Outputs.AsCoins().First(c => c.ScriptPubKey == address.ScriptPubKey); + } + + public async Task GetNewAddress(BTCPayNetwork network) + { + var cashCow = parent.ExplorerNode; + var btcPayWallet = parent.PayTester.GetService().GetWallet(network); + var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address; + return address; + } + + public async Task Sign(PSBT psbt) + { + var btcPayWallet = 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); + } + + public async Task SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null) + { + var endpoint = GetPayjoinEndpoint(invoice, psbt.Network); + var pjClient = parent.PayTester.GetService(); + var storeRepository = parent.PayTester.GetService(); + var store = await storeRepository.FindStore(StoreId); + var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType() + .First(); + if (expectedError is null) + { + var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default); + Assert.NotNull(proposed); + return proposed; + } + else + { + var ex = await Assert.ThrowsAsync(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default)); + Assert.Equal(expectedError, ex.ErrorCode); + 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 endpoint = GetPayjoinEndpoint(invoice, network); + var response = await parent.PayTester.HttpClient.PostAsync(endpoint, + new StringContent(content, Encoding.UTF8, "text/plain")); + if (expectedError != null) + { + Assert.False(response.IsSuccessStatusCode); + var error = JObject.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(expectedError, error["errorCode"].Value()); + return null; + } + else + { + if (!response.IsSuccessStatusCode) + { + var error = JObject.Parse(await response.Content.ReadAsStringAsync()); + Assert.True(false, + $"Error: {error["errorCode"].Value()}: {error["message"].Value()}"); + } + } + + return response; + } + + private static Uri GetPayjoinEndpoint(Invoice invoice, Network network) + { + var parsedBip21 = new BitcoinUrlBuilder( + invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21, + network); + return new Uri(parsedBip21.UnknowParameters["bpu"], UriKind.Absolute); + } } } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index da77ee6e7..ccd50ff5a 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -173,7 +173,7 @@ namespace BTCPayServer.Tests var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest); var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[] { - new BitcoinLikePaymentHandler(null, networkProvider, null, null), + new BitcoinLikePaymentHandler(null, networkProvider, null, null, null), new LightningLikePaymentHandler(null, null, networkProvider, null), }); var networkBTC = networkProvider.GetNetwork("BTC"); @@ -288,7 +288,7 @@ namespace BTCPayServer.Tests var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest); var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[] { - new BitcoinLikePaymentHandler(null, networkProvider, null, null), + new BitcoinLikePaymentHandler(null, networkProvider, null, null, null), new LightningLikePaymentHandler(null, null, networkProvider, null), }); var entity = new InvoiceEntity(); @@ -477,7 +477,7 @@ namespace BTCPayServer.Tests var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest); var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[] { - new BitcoinLikePaymentHandler(null, networkProvider, null, null), + new BitcoinLikePaymentHandler(null, networkProvider, null, null, null), new LightningLikePaymentHandler(null, null, networkProvider, null), }); var entity = new InvoiceEntity(); diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index f0b105bb4..46442dbea 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -76,7 +76,7 @@ services: - customer_lnd - merchant_lnd nbxplorer: - image: nicolasdorier/nbxplorer:2.1.14 + image: nicolasdorier/nbxplorer:2.1.21 restart: unless-stopped ports: - "32838:32838" diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index effb7cafa..46568e6f5 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -259,7 +259,7 @@ namespace BTCPayServer.Controllers using (logs.Measure($"{logPrefix} Payment method details creation")) { - var paymentDetails = await handler.CreatePaymentMethodDetails(supportedPaymentMethod, paymentMethod, store, network, preparePayment); + var paymentDetails = await handler.CreatePaymentMethodDetails(logs, supportedPaymentMethod, paymentMethod, store, network, preparePayment); paymentMethod.SetPaymentMethodDetails(paymentDetails); } diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 6753483f9..449d8eeb2 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -107,7 +107,7 @@ namespace BTCPayServer.Controllers return ViewWalletSendLedger(walletId, psbt); case "update": var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); - psbt = await UpdatePSBT(derivationSchemeSettings, psbt, network); + psbt = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbt); if (psbt == null) { ModelState.AddModelError(nameof(vm.PSBT), "You need to update your version of NBXplorer"); @@ -144,84 +144,23 @@ namespace BTCPayServer.Controllers } } - private async Task UpdatePSBT(DerivationSchemeSettings derivationSchemeSettings, PSBT psbt, BTCPayNetwork network) - { - var result = await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(new UpdatePSBTRequest() - { - PSBT = psbt, - DerivationScheme = derivationSchemeSettings.AccountDerivation, - }); - if (result == null) - return null; - derivationSchemeSettings.RebaseKeyPaths(result.PSBT); - return result.PSBT; - } - - - - private async Task TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork) + private async Task TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint)) { - var httpClient = _httpClientFactory.CreateClient("payjoin"); - var cloned = psbt.Clone(); - - if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors)) + cloned = cloned.Finalize(); + await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1.0), cloned.ExtractTransaction(), btcPayNetwork); + try + { + return await _payjoinClient.RequestPayjoin(endpoint, derivationSchemeSettings, cloned, cancellationToken); + } + catch (Exception) { return null; } - var bpuresponse = await httpClient.PostAsync(endpoint, new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain")); - if (bpuresponse.IsSuccessStatusCode) - { - var hex = await bpuresponse.Content.ReadAsStringAsync(); - if (PSBT.TryParse(hex, btcPayNetwork.NBitcoinNetwork, out var newPSBT)) - { - //check that all the inputs we provided are still there and that there is at least one new(signed) input. - bool valid = false; - var existingInputs = psbt.Inputs.Select(input => input.PrevOut).ToHashSet(); - foreach (var input in newPSBT.Inputs) - { - var existingInput = existingInputs.SingleOrDefault(point => point == input.PrevOut); - if (existingInput != null) - { - existingInputs.Remove(existingInput); - continue; - } - - if (!input.TryFinalizeInput(out _)) - { - //a new input was provided but was invalid. - valid = false; - break; - } - // a new signed input was provided - valid = true; - } - - if (!valid || existingInputs.Any()) - { - return null; - } - - //check if output sum to self is the same. - var signingAccountKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); - var newOutputSumToSelfSum = newPSBT.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccountKeySettings.AccountKey, - signingAccountKeySettings.GetRootedKeyPath()).Sum(output => output.Value); - - var originalOutputSumToSelf = psbt.Outputs.Sum(output => output.Value); - if (originalOutputSumToSelf < newOutputSumToSelfSum) - { - return null; - } - - newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork); - return newPSBT; - } - } } - return null; } @@ -256,7 +195,7 @@ namespace BTCPayServer.Controllers { var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork); if (!psbtObject.IsAllFinalized()) - psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject; + psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject; IHDKey signingKey = null; RootedKeyPath signingKeyPath = null; try @@ -358,7 +297,7 @@ namespace BTCPayServer.Controllers [Route("{walletId}/psbt/ready")] public async Task WalletPSBTReady( [ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, WalletPSBTReadyViewModel vm, string command = null) + WalletId walletId, WalletPSBTReadyViewModel vm, string command = null, CancellationToken cancellationToken = default) { if (command == null) return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl); @@ -383,7 +322,7 @@ namespace BTCPayServer.Controllers { case "payjoin": var proposedPayjoin =await - TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network); + TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network, cancellationToken); if (proposedPayjoin == null) { //we possibly exposed the tx to the receiver, so we need to broadcast straight away @@ -401,25 +340,14 @@ namespace BTCPayServer.Controllers try { var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork); - var payjoinSigned = PSBTChanged(proposedPayjoin, - () => proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation, - extKey, - RootedKeyPath.Parse(vm.SigningKeyPath))); - if (!payjoinSigned) - { - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Severity = StatusMessageModel.StatusSeverity.Warning, - AllowDismiss = false, - Html = $"The payjoin transaction could not be signed. The original transaction was broadcast instead." - }); - return await WalletPSBTReady(walletId, vm, "broadcast"); - } + proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation, + extKey, + RootedKeyPath.Parse(vm.SigningKeyPath)); vm.PSBT = proposedPayjoin.ToBase64(); vm.OriginalPSBT = psbt.ToBase64(); return await WalletPSBTReady(walletId, vm, "broadcast"); } - catch (Exception e) + catch (Exception) { TempData.SetStatusMessageModel(new StatusMessageModel() { diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 174aeedea..016ba7992 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -50,7 +50,8 @@ namespace BTCPayServer.Controllers private readonly WalletReceiveStateService _WalletReceiveStateService; private readonly EventAggregator _EventAggregator; private readonly SettingsRepository _settingsRepository; - private readonly IHttpClientFactory _httpClientFactory; + private readonly DelayedTransactionBroadcaster _broadcaster; + private readonly PayjoinClient _payjoinClient; public RateFetcher RateFetcher { get; } CurrencyNameTable _currencyTable; @@ -69,7 +70,8 @@ namespace BTCPayServer.Controllers WalletReceiveStateService walletReceiveStateService, EventAggregator eventAggregator, SettingsRepository settingsRepository, - IHttpClientFactory httpClientFactory) + DelayedTransactionBroadcaster broadcaster, + PayjoinClient payjoinClient) { _currencyTable = currencyTable; Repository = repo; @@ -86,7 +88,8 @@ namespace BTCPayServer.Controllers _WalletReceiveStateService = walletReceiveStateService; _EventAggregator = eventAggregator; _settingsRepository = settingsRepository; - _httpClientFactory = httpClientFactory; + _broadcaster = broadcaster; + _payjoinClient = payjoinClient; } // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md @@ -843,7 +846,8 @@ namespace BTCPayServer.Controllers ModelState.Remove(nameof(viewModel.PSBT)); return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl); } - + + private bool PSBTChanged(PSBT psbt, Action act) { var before = psbt.ToBase64(); diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 90e4305f1..ecbff263f 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -36,6 +36,7 @@ using System.Net; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Newtonsoft.Json.Linq; +using BTCPayServer.Payments.Bitcoin; namespace BTCPayServer { @@ -136,15 +137,37 @@ namespace BTCPayServer catch { } finally { try { webSocket.Dispose(); } catch { } } } - public static async Task> GetTransactions(this BTCPayWallet client, uint256[] hashes, CancellationToken cts = default(CancellationToken)) + + public static IEnumerable GetAllBitcoinPaymentData(this InvoiceEntity invoice) + { + return invoice.GetPayments() + .Where(p => p.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike) + .Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData()); + } + + public static async Task> GetTransactions(this BTCPayWallet client, uint256[] hashes, bool includeOffchain = false, CancellationToken cts = default(CancellationToken)) { hashes = hashes.Distinct().ToArray(); var transactions = hashes - .Select(async o => await client.GetTransactionAsync(o, cts)) + .Select(async o => await client.GetTransactionAsync(o, includeOffchain, cts)) .ToArray(); await Task.WhenAll(transactions).ConfigureAwait(false); return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash()); } + + public static async Task UpdatePSBT(this ExplorerClientProvider explorerClientProvider, DerivationSchemeSettings derivationSchemeSettings, PSBT psbt) + { + var result = await explorerClientProvider.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode).UpdatePSBTAsync(new UpdatePSBTRequest() + { + PSBT = psbt, + DerivationScheme = derivationSchemeSettings.AccountDerivation + }); + if (result == null) + return null; + derivationSchemeSettings.RebaseKeyPaths(result.PSBT); + return result.PSBT; + } + public static string WithTrailingSlash(this string str) { if (str.EndsWith("/", StringComparison.InvariantCulture)) diff --git a/BTCPayServer/HostedServices/DelayedTransactionBroadcasterHostedService.cs b/BTCPayServer/HostedServices/DelayedTransactionBroadcasterHostedService.cs new file mode 100644 index 000000000..0dec92937 --- /dev/null +++ b/BTCPayServer/HostedServices/DelayedTransactionBroadcasterHostedService.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Logging; +using BTCPayServer.Services; + +namespace BTCPayServer.HostedServices +{ + public class DelayedTransactionBroadcasterHostedService : BaseAsyncService + { + private readonly DelayedTransactionBroadcaster _transactionBroadcaster; + + public DelayedTransactionBroadcasterHostedService(DelayedTransactionBroadcaster transactionBroadcaster) + { + _transactionBroadcaster = transactionBroadcaster; + } + + internal override Task[] InitializeTasks() + { + return new Task[] + { + CreateLoopTask(Rebroadcast) + }; + } + + public TimeSpan PollInternal { get; set; } = TimeSpan.FromMinutes(1.0); + + async Task Rebroadcast() + { + while (true) + { + await _transactionBroadcaster.ProcessAll(Cancellation); + await Task.Delay(PollInternal, Cancellation); + } + } + } +} diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs index 11dd8eae3..53506071c 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs @@ -49,7 +49,7 @@ namespace BTCPayServer.Payments.Bitcoin _NetworkFeeRate = value; } } - + public bool PayjoinEnabled { get; set; } // Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason [JsonIgnore] public FeeRate FeeRate { get; set; } @@ -58,24 +58,10 @@ namespace BTCPayServer.Payments.Bitcoin [JsonIgnore] public String DepositAddress { get; set; } - public PayJoinPaymentState PayJoin { get; set; } = new PayJoinPaymentState(); - - - public BitcoinAddress GetDepositAddress(Network network) { return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network); } /////////////////////////////////////////////////////////////////////////////////////// } - - public class PayJoinPaymentState - { - public bool Enabled { get; set; } = false; - public uint256 ProposedTransactionHash { get; set; } - public List CoinsExposed { get; set; } - public decimal TotalOutputAmount { get; set; } - public decimal ContributedAmount { get; set; } - public uint256 OriginalTransactionHash { get; set; } - } } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs index c3d52fcd2..bbbd7799c 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs @@ -21,14 +21,13 @@ namespace BTCPayServer.Payments.Bitcoin } - public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf, decimal payJoinSelfContributedAmount) + public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf) { Address = address; Value = value; Outpoint = outpoint; ConfirmationCount = 0; RBF = rbf; - PayJoinSelfContributedAmount = payJoinSelfContributedAmount; } [JsonIgnore] public BTCPayNetworkBase Network { get; set; } @@ -38,11 +37,11 @@ namespace BTCPayServer.Payments.Bitcoin public TxOut Output { get; set; } public int ConfirmationCount { get; set; } public bool RBF { get; set; } - public decimal NetworkFee { get; set; } public BitcoinAddress Address { get; set; } public IMoney Value { get; set; } - public decimal PayJoinSelfContributedAmount { get; set; } = 0; - + + public PayjoinInformation PayjoinInformation { get; set; } + [JsonIgnore] public Script ScriptPubKey { @@ -69,8 +68,7 @@ namespace BTCPayServer.Payments.Bitcoin public decimal GetValue() { - return (Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC)) - - PayJoinSelfContributedAmount; + return Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC); } public bool PaymentCompleted(PaymentEntity entity) @@ -109,4 +107,17 @@ namespace BTCPayServer.Payments.Bitcoin return GetDestination().ToString(); } } + + + public class PayjoinInformation + { + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public PayjoinTransactionType Type { get; set; } + public OutPoint[] ContributedOutPoints { get; set; } + } + public enum PayjoinTransactionType + { + Original, + Coinjoin + } } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs index ba99a47a3..4b7b3ba8c 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.Logging; using BTCPayServer.Models; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Rating; @@ -19,16 +21,19 @@ namespace BTCPayServer.Payments.Bitcoin ExplorerClientProvider _ExplorerProvider; private readonly BTCPayNetworkProvider _networkProvider; private IFeeProviderFactory _FeeRateProviderFactory; + private readonly NBXplorerDashboard _dashboard; private Services.Wallets.BTCPayWalletProvider _WalletProvider; public BitcoinLikePaymentHandler(ExplorerClientProvider provider, BTCPayNetworkProvider networkProvider, IFeeProviderFactory feeRateProviderFactory, + NBXplorerDashboard dashboard, Services.Wallets.BTCPayWalletProvider walletProvider) { _ExplorerProvider = provider; _networkProvider = networkProvider; _FeeRateProviderFactory = feeRateProviderFactory; + _dashboard = dashboard; _WalletProvider = walletProvider; } @@ -74,16 +79,19 @@ namespace BTCPayServer.Payments.Bitcoin { if (storeBlob.OnChainMinValue != null) { - var currentRateToCrypto = await rate[new CurrencyPair(paymentMethodId.CryptoCode, storeBlob.OnChainMinValue.Currency)]; + var currentRateToCrypto = + await rate[new CurrencyPair(paymentMethodId.CryptoCode, storeBlob.OnChainMinValue.Currency)]; if (currentRateToCrypto?.BidAsk != null) { - var limitValueCrypto = Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid); + var limitValueCrypto = + Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid); if (amount < limitValueCrypto) { return "The amount of the invoice is too low to be paid on chain"; } } } + return string.Empty; } @@ -106,9 +114,12 @@ namespace BTCPayServer.Payments.Bitcoin var storeBlob = store.GetStoreBlob(); return new Prepare() { - GetFeeRate = _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget), - GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never ? null - : _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(), + GetFeeRate = + _FeeRateProviderFactory.CreateFeeProvider(network) + .GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget), + GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never + ? null + : _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(), ReserveAddress = _WalletProvider.GetWallet(network) .ReserveAddressAsync(supportedPaymentMethod.AccountDerivation) }; @@ -117,6 +128,7 @@ namespace BTCPayServer.Payments.Bitcoin public override PaymentType PaymentType => PaymentTypes.BTCLike; public override async Task CreatePaymentMethodDetails( + InvoiceLogs logs, DerivationSchemeSettings supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, BTCPayNetwork network, object preparePaymentObject) { @@ -132,7 +144,8 @@ namespace BTCPayServer.Payments.Bitcoin { case NetworkFeeMode.Always: onchainMethod.NetworkFeeRate = (await prepare.GetNetworkFeeRate); - onchainMethod.NextNetworkFee = onchainMethod.NetworkFeeRate.GetFee(100); // assume price for 100 bytes + onchainMethod.NextNetworkFee = + onchainMethod.NetworkFeeRate.GetFee(100); // assume price for 100 bytes break; case NetworkFeeMode.Never: onchainMethod.NetworkFeeRate = FeeRate.Zero; @@ -140,17 +153,29 @@ namespace BTCPayServer.Payments.Bitcoin break; case NetworkFeeMode.MultiplePaymentsOnly: onchainMethod.NetworkFeeRate = (await prepare.GetNetworkFeeRate); - onchainMethod.NextNetworkFee = Money.Zero; + onchainMethod.NextNetworkFee = Money.Zero; break; } onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString(); - onchainMethod.PayJoin = new PayJoinPaymentState() + onchainMethod.PayjoinEnabled = blob.PayJoinEnabled && + supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() == + ScriptPubKeyType.Segwit && + network.SupportPayJoin; + if (onchainMethod.PayjoinEnabled) { - Enabled = blob.PayJoinEnabled && - supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() != - ScriptPubKeyType.Legacy - }; + var nodeSupport = _dashboard?.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities + ?.CanSupportTransactionCheck is true; + bool isHotwallet = supportedPaymentMethod.Source == "NBXplorer"; + onchainMethod.PayjoinEnabled &= isHotwallet && nodeSupport; + if (!isHotwallet) + logs.Write("Payjoin should have been enabled, but your store is not a hotwallet"); + if (!nodeSupport) + logs.Write("Payjoin should have been enabled, but your version of NBXplorer or full node does not support it."); + if (onchainMethod.PayjoinEnabled) + logs.Write("Payjoin is enabled for this invoice."); + } + return onchainMethod; } } diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index b4db9eb23..e6b1b8eff 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -22,6 +22,7 @@ using BTCPayServer.HostedServices; using BTCPayServer.Payments.PayJoin; using NBitcoin.Altcoins.Elements; using NBitcoin.RPC; +using BTCPayServer; namespace BTCPayServer.Payments.Bitcoin { @@ -31,19 +32,18 @@ namespace BTCPayServer.Payments.Bitcoin public class NBXplorerListener : IHostedService { EventAggregator _Aggregator; - private readonly PayJoinStateProvider _payJoinStateProvider; + private readonly PayJoinRepository _payJoinRepository; ExplorerClientProvider _ExplorerClients; IHostApplicationLifetime _Lifetime; InvoiceRepository _InvoiceRepository; private TaskCompletionSource _RunningTask; private CancellationTokenSource _Cts; BTCPayWalletProvider _Wallets; - public NBXplorerListener(ExplorerClientProvider explorerClients, BTCPayWalletProvider wallets, InvoiceRepository invoiceRepository, - EventAggregator aggregator, - PayJoinStateProvider payJoinStateProvider, + EventAggregator aggregator, + PayJoinRepository payjoinRepository, IHostApplicationLifetime lifetime) { PollInterval = TimeSpan.FromMinutes(1.0); @@ -51,7 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin _InvoiceRepository = invoiceRepository; _ExplorerClients = explorerClients; _Aggregator = aggregator; - _payJoinStateProvider = payJoinStateProvider; + _payJoinRepository = payjoinRepository; _Lifetime = lifetime; } @@ -160,16 +160,11 @@ namespace BTCPayServer.Payments.Bitcoin var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy, output.Item1.KeyPath, output.Item1.ScriptPubKey); - var payJoinSelfContributedAmount = GetPayJoinContributedAmount( - new WalletId(invoice.StoreId, network.CryptoCode), - output.matchedOutput.Value.GetValue(network), - evt.TransactionData.TransactionHash); - var paymentData = new BitcoinLikePaymentData(address, output.matchedOutput.Value, output.outPoint, - evt.TransactionData.Transaction.RBF, payJoinSelfContributedAmount); - - var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any(); + evt.TransactionData.Transaction.RBF); + + var alreadyExist = invoice.GetAllBitcoinPaymentData().Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any(); if (!alreadyExist) { var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network); @@ -216,23 +211,19 @@ namespace BTCPayServer.Payments.Bitcoin } } - IEnumerable GetAllBitcoinPaymentData(InvoiceEntity invoice) - { - return invoice.GetPayments() - .Where(p => p.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike) - .Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData()); - } - async Task UpdatePaymentStates(BTCPayWallet wallet, string invoiceId) { var invoice = await _InvoiceRepository.GetInvoice(invoiceId, false); if (invoice == null) return null; List updatedPaymentEntities = new List(); - var transactions = await wallet.GetTransactions(GetAllBitcoinPaymentData(invoice) + var transactions = await wallet.GetTransactions(invoice.GetAllBitcoinPaymentData() .Select(p => p.Outpoint.Hash) - .ToArray()); - var payJoinState = _payJoinStateProvider.Get(new WalletId(invoice.StoreId, wallet.Network.CryptoCode)); + .ToArray(), true); + bool? originalPJBroadcasted = null; + bool? originalPJBroadcastable = null; + bool? cjPJBroadcasted = null; + OutPoint[] ourPJOutpoints = null; foreach (var payment in invoice.GetPayments(wallet.Network)) { if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike) @@ -240,15 +231,16 @@ namespace BTCPayServer.Payments.Bitcoin var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData(); if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx)) continue; - var txId = tx.Transaction.GetHash(); + bool accounted = true; - if (tx.Confirmations == 0) + + if (tx.Confirmations == 0 || tx.Confirmations == -1) { // Let's check if it was orphaned by broadcasting it again var explorerClient = _ExplorerClients.GetExplorerClient(wallet.Network); try { - var result = await explorerClient.BroadcastAsync(tx.Transaction, _Cts.Token); + var result = await explorerClient.BroadcastAsync(tx.Transaction, testMempoolAccept: tx.Confirmations == -1, _Cts.Token); accounted = result.Success || result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN || !( @@ -257,10 +249,24 @@ namespace BTCPayServer.Payments.Bitcoin result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR || // Happen if RBF is on and fee insufficient result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED); - if (!accounted && payment.Accounted) + if (!accounted && payment.Accounted && tx.Confirmations != -1) { Logs.PayServer.LogInformation($"{wallet.Network.CryptoCode}: The transaction {tx.TransactionHash} has been replaced."); } + if (paymentData.PayjoinInformation is PayjoinInformation pj) + { + ourPJOutpoints = pj.ContributedOutPoints; + switch (pj.Type) + { + case PayjoinTransactionType.Original: + originalPJBroadcasted = accounted && tx.Confirmations >= 0; + originalPJBroadcastable = accounted; + break; + case PayjoinTransactionType.Coinjoin: + cjPJBroadcasted = accounted && tx.Confirmations >= 0; + break; + } + } } // RPC might be unavailable, we can't check double spend so let's assume there is none catch @@ -268,7 +274,7 @@ namespace BTCPayServer.Payments.Bitcoin } } - + bool updated = false; if (accounted != payment.Accounted) { @@ -285,12 +291,7 @@ namespace BTCPayServer.Payments.Bitcoin updated = true; } } - - // we keep the state of the payjoin tx until it is confirmed in case of rbf situations where the tx is cancelled - if (paymentData.PayJoinSelfContributedAmount> 0 && accounted && paymentData.PaymentConfirmed(payment, invoice.SpeedPolicy)) - { - payJoinState?.RemoveRecord(paymentData.Outpoint.Hash); - } + // if needed add invoice back to pending to track number of confirmations if (paymentData.ConfirmationCount < wallet.Network.MaxTrackedConfirmation) await _InvoiceRepository.AddPendingInvoiceIfNotPresent(invoice.Id); @@ -298,6 +299,17 @@ namespace BTCPayServer.Payments.Bitcoin if (updated) updatedPaymentEntities.Add(payment); } + + // If the origin tx of a payjoin has been broadcasted, then we know we can + // reuse our outpoint for another PJ + if (originalPJBroadcasted is true || + // If the original tx is not broadcastable anymore and nor does the coinjoin + // reuse our outpoint for another PJ + (originalPJBroadcastable is false && !(cjPJBroadcasted is true))) + { + await _payJoinRepository.TryUnlock(ourPJOutpoints); + } + await _InvoiceRepository.UpdatePayments(updatedPaymentEntities); if (updatedPaymentEntities.Count != 0) _Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id)); @@ -313,7 +325,7 @@ namespace BTCPayServer.Payments.Bitcoin var invoice = await _InvoiceRepository.GetInvoice(invoiceId, true); if (invoice == null) continue; - var alreadyAccounted = GetAllBitcoinPaymentData(invoice).Select(p => p.Outpoint).ToHashSet(); + var alreadyAccounted = invoice.GetAllBitcoinPaymentData().Select(p => p.Outpoint).ToHashSet(); var strategy = GetDerivationStrategy(invoice, network); if (strategy == null) continue; @@ -330,11 +342,9 @@ namespace BTCPayServer.Payments.Bitcoin var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash); var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey); - var payJoinSelfContributedAmount = - GetPayJoinContributedAmount(paymentMethod, coin.Value.GetValue(network), transaction.TransactionHash); var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint, - transaction.Transaction.RBF, payJoinSelfContributedAmount); + transaction.Transaction.RBF); var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false); alreadyAccounted.Add(coin.OutPoint); @@ -350,31 +360,6 @@ namespace BTCPayServer.Payments.Bitcoin return totalPayment; } - private decimal GetPayJoinContributedAmount(BitcoinLikeOnChainPaymentMethod paymentMethod, decimal amount, uint256 transactionHash) - { - if (paymentMethod.PayJoin?.Enabled is true && - paymentMethod.PayJoin.ProposedTransactionHash == transactionHash && - paymentMethod.PayJoin.TotalOutputAmount == amount) - { - //this is the payjoin output! - return paymentMethod.PayJoin.ContributedAmount; - } - - return 0; - } - private decimal GetPayJoinContributedAmount(WalletId walletId, decimal amount, uint256 transactionHash) - { - var payJoinState = - _payJoinStateProvider.Get(walletId); - - if (payJoinState == null || !payJoinState.TryGetWithProposedHash(transactionHash, out var record) || - record.TotalOutputAmount != amount) return 0; - record.TxSeen = true; - //this is the payjoin output! - return record.ContributedAmount; - - } - private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network) { return invoice.GetSupportedPaymentMethod(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike)) diff --git a/BTCPayServer/Payments/IPaymentMethodHandler.cs b/BTCPayServer/Payments/IPaymentMethodHandler.cs index dde02e8ba..e621b4729 100644 --- a/BTCPayServer/Payments/IPaymentMethodHandler.cs +++ b/BTCPayServer/Payments/IPaymentMethodHandler.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using BTCPayServer.Data; +using BTCPayServer.Logging; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Rating; using BTCPayServer.Services.Invoices; @@ -25,7 +26,7 @@ namespace BTCPayServer.Payments /// /// /// - Task CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod, + Task CreatePaymentMethodDetails(InvoiceLogs logs, ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, BTCPayNetworkBase network, object preparePaymentObject); /// @@ -53,7 +54,7 @@ namespace BTCPayServer.Payments where TSupportedPaymentMethod : ISupportedPaymentMethod where TBTCPayNetwork : BTCPayNetworkBase { - Task CreatePaymentMethodDetails(TSupportedPaymentMethod supportedPaymentMethod, + Task CreatePaymentMethodDetails(InvoiceLogs logs, TSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject); } @@ -65,6 +66,7 @@ namespace BTCPayServer.Payments public abstract PaymentType PaymentType { get; } public abstract Task CreatePaymentMethodDetails( + InvoiceLogs logs, TSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject); @@ -99,12 +101,12 @@ namespace BTCPayServer.Payments return null; } - public Task CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, + public Task CreatePaymentMethodDetails(InvoiceLogs logs, ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, BTCPayNetworkBase network, object preparePaymentObject) { if (supportedPaymentMethod is TSupportedPaymentMethod method && network is TBTCPayNetwork correctNetwork) { - return CreatePaymentMethodDetails(method, paymentMethod, store, correctNetwork, preparePaymentObject); + return CreatePaymentMethodDetails(logs, method, paymentMethod, store, correctNetwork, preparePaymentObject); } throw new NotSupportedException("Invalid supportedPaymentMethod"); diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs index 9b27f0562..f8e19cf08 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -14,6 +14,7 @@ using BTCPayServer.Services; using BTCPayServer.Services.Rates; using NBitcoin; using System.Globalization; +using BTCPayServer.Logging; namespace BTCPayServer.Payments.Lightning { @@ -40,6 +41,7 @@ namespace BTCPayServer.Payments.Lightning public override PaymentType PaymentType => PaymentTypes.LightningLike; public override async Task CreatePaymentMethodDetails( + InvoiceLogs logs, LightningSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, BTCPayNetwork network, object preparePaymentObject) { diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index 6a9fdf118..b14d5ee05 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -5,18 +5,26 @@ using System.Linq; using System.Text; using System.Threading.Tasks; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Filters; +using BTCPayServer.HostedServices; using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using NBitcoin; using NBitcoin.DataEncoders; +using NBitcoin.Logging; using NBXplorer; using NBXplorer.Models; +using Newtonsoft.Json.Linq; using NicolasDorier.RateLimits; +using Microsoft.Extensions.Logging; namespace BTCPayServer.Payments.PayJoin { @@ -28,374 +36,404 @@ namespace BTCPayServer.Payments.PayJoin private readonly ExplorerClientProvider _explorerClientProvider; private readonly StoreRepository _storeRepository; private readonly BTCPayWalletProvider _btcPayWalletProvider; - private readonly PayJoinStateProvider _payJoinStateProvider; + private readonly PayJoinRepository _payJoinRepository; + private readonly EventAggregator _eventAggregator; + private readonly NBXplorerDashboard _dashboard; + private readonly DelayedTransactionBroadcaster _broadcaster; public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider, InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider, StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider, - PayJoinStateProvider payJoinStateProvider) + PayJoinRepository payJoinRepository, + EventAggregator eventAggregator, + NBXplorerDashboard dashboard, + DelayedTransactionBroadcaster broadcaster) { _btcPayNetworkProvider = btcPayNetworkProvider; _invoiceRepository = invoiceRepository; _explorerClientProvider = explorerClientProvider; _storeRepository = storeRepository; _btcPayWalletProvider = btcPayWalletProvider; - _payJoinStateProvider = payJoinStateProvider; + _payJoinRepository = payJoinRepository; + _eventAggregator = eventAggregator; + _dashboard = dashboard; + _broadcaster = broadcaster; } - [HttpPost("{invoice}")] + [HttpPost("")] [IgnoreAntiforgeryToken] [EnableCors(CorsPolicies.All)] [MediaTypeConstraint("text/plain")] [RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)] - public async Task Submit(string cryptoCode, string invoice) + public async Task Submit(string cryptoCode) { var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); if (network == null) { - return UnprocessableEntity("Incorrect network"); + return BadRequest(CreatePayjoinError(400, "invalid-network", "Incorrect network")); + } + + var explorer = _explorerClientProvider.GetExplorerClient(network); + if (Request.ContentLength is long length) + { + if (length > 1_000_000) + return this.StatusCode(413, + CreatePayjoinError(413, "payload-too-large", "The transaction is too big to be processed")); + } + else + { + return StatusCode(411, + CreatePayjoinError(411, "missing-content-length", + "The http header Content-Length should be filled")); } string rawBody; using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8)) { - rawBody = await reader.ReadToEndAsync(); + rawBody = (await reader.ReadToEndAsync()) ?? string.Empty; } - if (string.IsNullOrEmpty(rawBody)) + Transaction originalTx = null; + FeeRate originalFeeRate = null; + bool psbtFormat = true; + if (!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out var psbt)) { - return UnprocessableEntity("raw tx not provided"); - } - - PSBT psbt = null; - if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var transaction) && - !PSBT.TryParse(rawBody, network.NBitcoinNetwork, out psbt)) - { - return UnprocessableEntity("invalid raw transaction or psbt"); - } - - if (psbt != null) - { - try + psbtFormat = false; + if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx)) + return BadRequest(CreatePayjoinError(400, "invalid-format", "invalid transaction or psbt")); + originalTx = tx; + psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork); + psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() {PSBT = psbt})).PSBT; + for (int i = 0; i < tx.Inputs.Count; i++) { - transaction = psbt.ExtractTransaction(); - } - catch (Exception e) - { - return UnprocessableEntity("invalid psbt"); + psbt.Inputs[i].FinalScriptSig = tx.Inputs[i].ScriptSig; + psbt.Inputs[i].FinalScriptWitness = tx.Inputs[i].WitScript; } } - - if (transaction.Check() != TransactionCheckResult.Success) + else { - return UnprocessableEntity($"invalid tx: {transaction.Check()}"); + if (!psbt.IsAllFinalized()) + return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT should be finalized")); + originalTx = psbt.ExtractTransaction(); + } + + if (originalTx.Inputs.Any(i => !(i.GetSigner() is WitKeyId))) + return BadRequest(CreatePayjoinError(400, "not-using-p2wpkh", "Payjoin only support P2WPKH inputs")); + if (psbt.CheckSanity() is var errors && errors.Count != 0) + { + return BadRequest(CreatePayjoinError(400, "insane-psbt", $"This PSBT is insane ({errors[0]})")); + } + if (!psbt.TryGetEstimatedFeeRate(out originalFeeRate)) + { + return BadRequest(CreatePayjoinError(400, "need-utxo-information", + "You need to provide Witness UTXO information to the PSBT.")); } - if (transaction.Inputs.Any(txin => txin.ScriptSig == null || txin.WitScript == null)) + // This is actually not a mandatory check, but we don't want implementers + // to leak global xpubs + if (psbt.GlobalXPubs.Any()) { - return UnprocessableEntity($"all inputs must be segwit and signed"); + return BadRequest(CreatePayjoinError(400, "leaking-data", + "GlobalXPubs should not be included in the PSBT")); } - var explorerClient = _explorerClientProvider.GetExplorerClient(network); - var mempool = await explorerClient.BroadcastAsync(transaction, true); + if (psbt.Outputs.Any(o => o.HDKeyPaths.Count != 0) || psbt.Inputs.Any(o => o.HDKeyPaths.Count != 0)) + { + return BadRequest(CreatePayjoinError(400, "leaking-data", + "Keypath information should not be included in the PSBT")); + } + + if (psbt.Inputs.Any(o => !o.IsFinalized())) + { + return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT Should be finalized")); + } + //////////// + + var mempool = await explorer.BroadcastAsync(originalTx, true); if (!mempool.Success) { - return UnprocessableEntity($"provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"); + return BadRequest(CreatePayjoinError(400, "invalid-transaction", + $"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}")); } var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); - - //multiple outs could mean a payment being done to multiple invoices to multiple stores in one payjoin tx which makes life unbearable - //UNLESS the request specified an invoice Id, which is mandatory :) - var matchingInvoice = await _invoiceRepository.GetInvoice(invoice); - if (matchingInvoice == null) + bool paidSomething = false; + Money due = null; + Dictionary selectedUTXOs = new Dictionary(); + PSBTOutput paymentOutput = null; + BitcoinAddress paymentAddress = null; + InvoiceEntity invoice = null; + int ourOutputIndex = -1; + DerivationSchemeSettings derivationSchemeSettings = null; + foreach (var output in psbt.Outputs) { - return UnprocessableEntity($"invalid invoice"); - } - - var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId); - //get outs to our current invoice address - var currentPaymentMethodDetails = - (BitcoinLikeOnChainPaymentMethod) invoicePaymentMethod.GetPaymentMethodDetails(); - - if (!currentPaymentMethodDetails.PayJoin?.Enabled is true) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - //the invoice must be active, and the status must be new OR paid if - if (matchingInvoice.IsExpired() || - ((matchingInvoice.GetInvoiceState().Status == InvoiceStatus.Paid && - currentPaymentMethodDetails.PayJoin.OriginalTransactionHash == null) || - matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New)) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - - if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null && - currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash() && - !transaction.RBF) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - var address = currentPaymentMethodDetails.GetDepositAddress(network.NBitcoinNetwork); - var matchingTXOuts = transaction.Outputs.Where(txout => txout.IsTo(address)); - var nonMatchingTXOuts = transaction.Outputs.Where(txout => !txout.IsTo(address)); - if (!matchingTXOuts.Any()) - { - return UnprocessableEntity($"tx does not pay invoice"); - } - - var store = await _storeRepository.FindStore(matchingInvoice.StoreId); - - //check if store is enabled - var derivationSchemeSettings = store.GetSupportedPaymentMethods(_btcPayNetworkProvider) - .OfType().SingleOrDefault(settings => - settings.PaymentId == paymentMethodId && store.GetEnabledPaymentIds(_btcPayNetworkProvider) - .Contains(settings.PaymentId)); - if (derivationSchemeSettings == null) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - var state = _payJoinStateProvider.GetOrAdd(new WalletId(matchingInvoice.StoreId, cryptoCode), - derivationSchemeSettings.AccountDerivation); - - //check if any of the inputs have been spotted in other txs sent our way..Reject anything but the original - //also reject if the invoice being payjoined to already has a record - var validity = state.CheckIfTransactionValid(transaction, invoice); - if (validity == PayJoinState.TransactionValidityResult.Invalid_Inputs_Seen || validity == PayJoinState.TransactionValidityResult.Invalid_PartialMatch) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - //check if wallet of store is configured to be hot wallet - var extKeyStr = await explorerClient.GetMetadataAsync( - derivationSchemeSettings.AccountDerivation, - WellknownMetadataKeys.MasterHDKey); - if (extKeyStr == null) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - var extKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork); - - var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); - if (signingKeySettings.RootFingerprint is null) - signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint(); - - RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath(); - if (rootedKeyPath == null) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - // The master fingerprint and/or account key path of your seed are not set in the wallet settings - } - - // The user gave the root key, let's try to rebase the PSBT, and derive the account private key - if (rootedKeyPath.MasterFingerprint == extKey.GetPublicKey().GetHDFingerPrint()) - { - extKey = extKey.Derive(rootedKeyPath.KeyPath); - } - - //check if the store uses segwit -- mixing inputs of different types is suspicious - if (derivationSchemeSettings.AccountDerivation.ScriptPubKeyType() == ScriptPubKeyType.Legacy) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - //get previous payments so that we can check if their address is also used in the txouts) - var previousPayments = matchingInvoice.GetPayments(network) - .Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData); - - if (transaction.Outputs.Any( - txout => previousPayments.Any(data => !txout.IsTo(address) && txout.IsTo(data.GetDestination())))) - { - //Meh, address reuse from the customer would be happening with this tx, skip - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - //get any utxos we exposed already that match any of the inputs sent to us. - var utxosToContributeToThisPayment = state.GetExposed(transaction); - - var invoicePaymentMethodAccounting = invoicePaymentMethod.Calculate(); - if (invoicePaymentMethodAccounting.Due != matchingTXOuts.Sum(txout => txout.Value) && - !utxosToContributeToThisPayment.Any()) - { - //the invoice would be under/overpaid with this tx and we have not exposed utxos so no worries - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - //if we have not exposed any utxos to any of the inputs - if (!utxosToContributeToThisPayment.Any()) - { - var wallet = _btcPayWalletProvider.GetWallet(network); - //get all utxos we have so far exposed - var coins = state.GetRecords().SelectMany(list => - list.CoinsExposed.Select(coin => coin.OutPoint.Hash)); - - //get all utxos we have NOT so far exposed - var availableUtxos = (await wallet.GetUnspentCoins(derivationSchemeSettings.AccountDerivation)).Where( - coin => - !coins.Contains(coin.OutPoint.Hash)); - if (availableUtxos.Any()) + ourOutputIndex++; + var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant(); + invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {key})).FirstOrDefault(); + if (invoice is null) + continue; + derivationSchemeSettings = invoice.GetSupportedPaymentMethod(paymentMethodId) + .SingleOrDefault(); + if (derivationSchemeSettings is null) + continue; + var paymentMethod = invoice.GetPaymentMethod(paymentMethodId); + var paymentDetails = + paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod; + if (paymentDetails is null || !paymentDetails.PayjoinEnabled) + continue; + if (invoice.GetAllBitcoinPaymentData().Any()) { - //clean up the state by removing utxos from the exposed list that we no longer have - state.PruneExposedButSpentCoins(availableUtxos); - //if we have coins that were exposed before but were not spent, prioritize them - var exposedAlready = state.GetExposedCoins(); - if (exposedAlready.Any()) - { - utxosToContributeToThisPayment = SelectCoins(network, exposedAlready, - invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC), - nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC))); - state.PruneExposedBySpentCoins(utxosToContributeToThisPayment.Select(coin => coin.OutPoint)); - } - else - { - utxosToContributeToThisPayment = SelectCoins(network, availableUtxos, - invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC), - nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC))); - } + return UnprocessableEntity(CreatePayjoinError(422, "already-paid", + $"The invoice this PSBT is paying has already been partially or completely paid")); } - } - //we don't have any utxos to provide to this tx - if (!utxosToContributeToThisPayment.Any()) - { - return UnprocessableEntity($"cannot handle payjoin tx"); - } - - //we rebuild the tx using 1 output to the invoice designed address - var cjOutputContributedAmount = utxosToContributeToThisPayment.Sum(coin => coin.Value.GetValue(network)); - var cjOutputSum = matchingTXOuts.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)) + - cjOutputContributedAmount; - - var newTx = transaction.Clone(); - - - if (matchingTXOuts.Count() > 1) - { - //if there are more than 1 outputs to our address, consolidate them to 1 + coinjoined amount to avoid unnecessary utxos - newTx.Outputs.Clear(); - newTx.Outputs.Add(new Money(cjOutputSum, MoneyUnit.BTC), address.ScriptPubKey); - foreach (var nonmatchingTxOut in nonMatchingTXOuts) + paidSomething = true; + due = paymentMethod.Calculate().TotalDue - output.Value; + if (due > Money.Zero) { - newTx.Outputs.Add(nonmatchingTxOut.Value, nonmatchingTxOut.ScriptPubKey); - } - } - else - { - //set the value of the out to our address to the sum of the coinjoined amount - foreach (var txOutput in newTx.Outputs.Where(txOutput => - txOutput.Value == matchingTXOuts.First().Value && - txOutput.ScriptPubKey == matchingTXOuts.First().ScriptPubKey)) - { - txOutput.Value = new Money(cjOutputSum, MoneyUnit.BTC); break; } + + if (!await _payJoinRepository.TryLockInputs(originalTx.Inputs.Select(i => i.PrevOut).ToArray())) + { + return BadRequest(CreatePayjoinError(400, "inputs-already-used", + "Some of those inputs have already been used to make payjoin transaction")); + } + + var utxos = (await explorer.GetUTXOsAsync(derivationSchemeSettings.AccountDerivation)) + .GetUnspentUTXOs(false); + // In case we are paying ourselves, be need to make sure + // we can't take spent outpoints. + var prevOuts = originalTx.Inputs.Select(o => o.PrevOut).ToHashSet(); + utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray(); + foreach (var utxo in await SelectUTXO(network, utxos, output.Value, + psbt.Outputs.Where(o => o.Index != output.Index).Select(o => o.Value).ToArray())) + { + selectedUTXOs.Add(utxo.Outpoint, utxo); + } + + paymentOutput = output; + paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork); + break; } - newTx.Inputs.AddRange(utxosToContributeToThisPayment.Select(coin => - new TxIn(coin.OutPoint) {Sequence = newTx.Inputs.First().Sequence})); - - if (psbt != null) + if (!paidSomething) { - psbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork); - - psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest() - { - DerivationScheme = derivationSchemeSettings.AccountDerivation, - PSBT = psbt, - RebaseKeyPaths = derivationSchemeSettings.GetPSBTRebaseKeyRules().ToList() - })).PSBT; - - - psbt = psbt.SignWithKeys(utxosToContributeToThisPayment - .Select(coin => extKey.Derive(coin.KeyPath).PrivateKey) - .ToArray()); - - if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs) - { - //if the invoice was rbfed, remove the current record and replace it with the new one - state.RemoveRecord(invoice); - } - if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch) - { - await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, - cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, - invoicePaymentMethod); - } - - return Ok(HexEncoder.IsWellFormed(rawBody) ? psbt.ToHex() : psbt.ToBase64()); + return BadRequest(CreatePayjoinError(400, "invoice-not-found", + "This transaction does not pay any invoice with payjoin")); } - else + + if (due is null || due > Money.Zero) { - // Since we're going to modify the transaction, we're going invalidate all signatures - foreach (TxIn newTxInput in newTx.Inputs) - { - newTxInput.WitScript = WitScript.Empty; - } - - newTx.Sign( - utxosToContributeToThisPayment.Select(coin => - extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)), - utxosToContributeToThisPayment.Select(coin => coin.Coin)); - - if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs) - { - //if the invoice was rbfed, remove the current record and replace it with the new one - state.RemoveRecord(invoice); - } - if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch) - { - await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, - cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, - invoicePaymentMethod); - } - - return Ok(newTx.ToHex()); + return BadRequest(CreatePayjoinError(400, "invoice-not-fully-paid", + "The transaction must pay the whole invoice")); } - } - private async Task AddRecord(string invoice, PayJoinState joinState, Transaction transaction, - List utxosToContributeToThisPayment, decimal cjOutputContributedAmount, decimal cjOutputSum, - Transaction newTx, - BitcoinLikeOnChainPaymentMethod currentPaymentMethodDetails, PaymentMethod invoicePaymentMethod) - { - //keep a record of the tx and check if we have seen the tx before or any of its inputs - //on a timer service: if x amount of times passes, broadcast this tx - joinState.AddRecord(new PayJoinStateRecordedItem() + if (selectedUTXOs.Count == 0) { - Timestamp = DateTimeOffset.Now, - Transaction = transaction, - OriginalTransactionHash = transaction.GetHash(), - CoinsExposed = utxosToContributeToThisPayment, - ContributedAmount = cjOutputContributedAmount, - TotalOutputAmount = cjOutputSum, - ProposedTransactionHash = newTx.GetHash(), - InvoiceId = invoice - }); - //we also store a record in the payment method details of the invoice, - //Tn case the server is shut down and a payjoin payment is made before it is turned back on. - //Otherwise we would end up marking the invoice as overPaid with our own inputs! - currentPaymentMethodDetails.PayJoin = new PayJoinPaymentState() + await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx); + return StatusCode(503, + CreatePayjoinError(503, "out-of-utxos", + "We do not have any UTXO available for making a payjoin for now")); + } + + var originalPaymentValue = paymentOutput.Value; + // Add the original transaction to the payment + var originalPaymentData = new BitcoinLikePaymentData(paymentAddress, + paymentOutput.Value, + new OutPoint(originalTx.GetHash(), paymentOutput.Index), + originalTx.RBF); + originalPaymentData.PayjoinInformation = new PayjoinInformation() { - Enabled = true, - CoinsExposed = utxosToContributeToThisPayment, - ContributedAmount = cjOutputContributedAmount, - TotalOutputAmount = cjOutputSum, - ProposedTransactionHash = newTx.GetHash(), - OriginalTransactionHash = transaction.GetHash(), + Type = PayjoinTransactionType.Original, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray() }; - invoicePaymentMethod.SetPaymentMethodDetails(currentPaymentMethodDetails); - await _invoiceRepository.UpdateInvoicePaymentMethod(invoice, invoicePaymentMethod); + originalPaymentData.ConfirmationCount = -1; + var now = DateTimeOffset.UtcNow; + var payment = await _invoiceRepository.AddPayment(invoice.Id, now, originalPaymentData, network, true); + if (payment is null) + { + return UnprocessableEntity(CreatePayjoinError(422, "already-paid", + $"The original transaction has already been accounted")); + } + + await _broadcaster.Schedule(now + TimeSpan.FromMinutes(1.0), originalTx, network); + await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx); + _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment}); + + //check if wallet of store is configured to be hot wallet + var extKeyStr = await explorer.GetMetadataAsync( + derivationSchemeSettings.AccountDerivation, + WellknownMetadataKeys.AccountHDKey); + if (extKeyStr == null) + { + // This should not happen, as we check the existance of private key before creating invoice with payjoin + return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now")); + } + + var newTx = originalTx.Clone(); + var ourOutput = newTx.Outputs[ourOutputIndex]; + foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value)) + { + ourOutput.Value += (Money)selectedUTXO.Value; + newTx.Inputs.Add(selectedUTXO.Outpoint); + } + + var rand = new Random(); + Utils.Shuffle(newTx.Inputs, rand); + Utils.Shuffle(newTx.Outputs, rand); + ourOutputIndex = newTx.Outputs.IndexOf(ourOutput); + + // Remove old signatures as they are not valid anymore + foreach (var input in newTx.Inputs) + { + input.WitScript = WitScript.Empty; + } + + Money ourFeeContribution = Money.Zero; + // We need to adjust the fee to keep a constant fee rate + var originalNewTx = newTx.Clone(); + bool isSecondPass = false; + recalculateFee: + ourOutput = newTx.Outputs[ourOutputIndex]; + var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder(); + txBuilder.AddCoins(psbt.Inputs.Select(i => i.GetCoin())); + txBuilder.AddCoins(selectedUTXOs.Select(o => o.Value.AsCoin())); + Money expectedFee = txBuilder.EstimateFees(newTx, originalFeeRate); + Money actualFee = newTx.GetFee(txBuilder.FindSpentCoins(newTx)); + Money additionalFee = expectedFee - actualFee; + if (additionalFee > Money.Zero) + { + var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ?? + new FeeRate(1.0m); + + // If the user overpaid, taking fee on our output (useful if they dump a full UTXO for privacy) + if (due < Money.Zero) + { + ourFeeContribution = Money.Min(additionalFee, -due); + ourFeeContribution = Money.Min(ourFeeContribution, + ourOutput.Value - ourOutput.GetDustThreshold(minRelayTxFee)); + ourOutput.Value -= ourFeeContribution; + additionalFee -= ourFeeContribution; + } + + // The rest, we take from user's change + if (additionalFee > Money.Zero) + { + for (int i = 0; i < newTx.Outputs.Count && additionalFee != Money.Zero; i++) + { + if (i != ourOutputIndex) + { + var outputContribution = Money.Min(additionalFee, newTx.Outputs[i].Value); + newTx.Outputs[i].Value -= outputContribution; + additionalFee -= outputContribution; + } + } + } + + List dustIndices = new List(); + for (int i = 0; i < newTx.Outputs.Count; i++) + { + if (newTx.Outputs[i].IsDust(minRelayTxFee)) + { + dustIndices.Insert(0, i); + } + } + + if (dustIndices.Count > 0) + { + if (isSecondPass) + { + // This should not happen + return StatusCode(500, + CreatePayjoinError(500, "unavailable", + $"This service is unavailable for now (isSecondPass)")); + } + + foreach (var dustIndex in dustIndices) + { + newTx.Outputs.RemoveAt(dustIndex); + } + + ourOutputIndex = newTx.Outputs.IndexOf(ourOutput); + newTx = originalNewTx.Clone(); + foreach (var dustIndex in dustIndices) + { + newTx.Outputs.RemoveAt(dustIndex); + } + ourFeeContribution = Money.Zero; + isSecondPass = true; + goto recalculateFee; + } + + if (additionalFee > Money.Zero) + { + // We could not pay fully the additional fee, however, as long as + // we are not under the relay fee, it should be OK. + var newVSize = txBuilder.EstimateSize(newTx, true); + var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx)); + if (new FeeRate(newFeePaid, newVSize) < minRelayTxFee) + { + await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray()); + return UnprocessableEntity(CreatePayjoinError(422, "not-enough-money", + "Not enough money is sent to pay for the additional payjoin inputs")); + } + } + } + + var accountKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork); + var newPsbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork); + foreach (var selectedUtxo in selectedUTXOs.Select(o => o.Value)) + { + var signedInput = newPsbt.Inputs.FindIndexedInput(selectedUtxo.Outpoint); + signedInput.UpdateFromCoin(selectedUtxo.AsCoin()); + var privateKey = accountKey.Derive(selectedUtxo.KeyPath).PrivateKey; + signedInput.Sign(privateKey); + signedInput.FinalizeInput(); + newTx.Inputs[signedInput.Index].WitScript = newPsbt.Inputs[(int)signedInput.Index].FinalScriptWitness; + } + + // Add the coinjoin transaction to the payments + var coinjoinPaymentData = new BitcoinLikePaymentData(paymentAddress, + originalPaymentValue - ourFeeContribution, + new OutPoint(newPsbt.GetGlobalTransaction().GetHash(), ourOutputIndex), + originalTx.RBF); + coinjoinPaymentData.PayjoinInformation = new PayjoinInformation() + { + Type = PayjoinTransactionType.Coinjoin, + ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray() + }; + coinjoinPaymentData.ConfirmationCount = -1; + payment = await _invoiceRepository.AddPayment(invoice.Id, now, coinjoinPaymentData, network, false, + payment.NetworkFee); + // We do not publish an event on purpose, this would be confusing for the merchant. + + if (psbtFormat) + return Ok(newPsbt.ToBase64()); + else + return Ok(newTx.ToHex()); } - private List SelectCoins(BTCPayNetwork network, IEnumerable availableUtxos, - decimal paymentAmount, IEnumerable otherOutputs) + private JObject CreatePayjoinError(int httpCode, string errorCode, string friendlyMessage) { + var o = new JObject(); + o.Add(new JProperty("httpCode", httpCode)); + o.Add(new JProperty("errorCode", errorCode)); + o.Add(new JProperty("message", friendlyMessage)); + return o; + } + + private async Task SelectUTXO(BTCPayNetwork network, UTXO[] availableUtxos, Money paymentAmount, + Money[] otherOutputs) + { + if (availableUtxos.Length == 0) + return Array.Empty(); + // Assume the merchant wants to get rid of the dust + Utils.Shuffle(availableUtxos); + HashSet locked = new HashSet(); + // We don't want to make too many db roundtrip which would be inconvenient for the sender + int maxTries = 30; + int currentTry = 0; + List utxosByPriority = new List(); // UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario". // // "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output. @@ -405,8 +443,10 @@ namespace BTCPayServer.Payments.PayJoin foreach (var availableUtxo in availableUtxos) { + if (currentTry >= maxTries) + break; //we can only check against our input as we dont know the value of the rest. - var input = availableUtxo.Value.GetValue(network); + var input = (Money)availableUtxo.Value; var paymentAmountSum = input + paymentAmount; if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output)) { @@ -414,12 +454,24 @@ namespace BTCPayServer.Payments.PayJoin continue; } - return new List {availableUtxo}; + if (await _payJoinRepository.TryLock(availableUtxo.Outpoint)) + { + return new UTXO[] { availableUtxo }; + } + locked.Add(availableUtxo.Outpoint); + currentTry++; } - - //For now we just grab a utxo "at random" - Random r = new Random(); - return new List() {availableUtxos.ElementAt(r.Next(0, availableUtxos.Count()))}; + foreach (var utxo in availableUtxos.Where(u => !locked.Contains(u.Outpoint))) + { + if (currentTry >= maxTries) + break; + if (await _payJoinRepository.TryLock(utxo.Outpoint)) + { + return new UTXO[] { utxo }; + } + currentTry++; + } + return Array.Empty(); } } } diff --git a/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs index 377fa2304..c55c1159e 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs @@ -1,13 +1,18 @@ +using BTCPayServer.HostedServices; +using BTCPayServer.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace BTCPayServer.Payments.PayJoin { public static class PayJoinExtensions { - public static void AddPayJoinServices(this IServiceCollection serviceCollection) + public static void AddPayJoinServices(this IServiceCollection services) { - serviceCollection.AddSingleton(); - serviceCollection.AddHostedService(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } } } diff --git a/BTCPayServer/Payments/PayJoin/PayJoinRepository.cs b/BTCPayServer/Payments/PayJoin/PayJoinRepository.cs new file mode 100644 index 000000000..f67e2a0fa --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinRepository.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NBitcoin; +using NBXplorer.Models; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinRepository + { + HashSet _Outpoints = new HashSet(); + HashSet _LockedInputs = new HashSet(); + public Task TryLock(OutPoint outpoint) + { + lock (_Outpoints) + { + return Task.FromResult(_Outpoints.Add(outpoint)); + } + } + + public Task TryUnlock(params OutPoint[] outPoints) + { + if (outPoints.Length == 0) + return Task.FromResult(true); + lock (_Outpoints) + { + bool r = true; + foreach (var outpoint in outPoints) + { + r &= _Outpoints.Remove(outpoint); + } + return Task.FromResult(r); + } + } + + public Task TryLockInputs(OutPoint[] outPoint) + { + lock (_LockedInputs) + { + foreach (var o in outPoint) + if (!_LockedInputs.Add(o)) + return Task.FromResult(false); + } + return Task.FromResult(true); + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinState.cs b/BTCPayServer/Payments/PayJoin/PayJoinState.cs deleted file mode 100644 index 7a564cce6..000000000 --- a/BTCPayServer/Payments/PayJoin/PayJoinState.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using BTCPayServer.Services.Wallets; -using NBitcoin; - -namespace BTCPayServer.Payments.PayJoin -{ - public class PayJoinState - { - //keep track of all transactions sent to us via this protocol - private readonly ConcurrentDictionary RecordedTransactions = - new ConcurrentDictionary(); - - //utxos that have been exposed but the original tx was broadcasted instead. - private readonly ConcurrentDictionary ExposedCoins; - - public PayJoinState(ConcurrentDictionary exposedCoins = null) - { - ExposedCoins = exposedCoins ?? new ConcurrentDictionary(); - } - - public IEnumerable GetRecords() - { - return RecordedTransactions.Values; - } - - public IEnumerable GetStaleRecords(TimeSpan cutoff) - { - return GetRecords().Where(pair => - DateTimeOffset.Now.Subtract(pair.Timestamp).TotalMilliseconds >= - cutoff.TotalMilliseconds); - } - - public enum TransactionValidityResult - { - Valid_ExactMatch, - Invalid_PartialMatch, - Valid_NoMatch, - Invalid_Inputs_Seen, - Valid_SameInputs - } - - public TransactionValidityResult CheckIfTransactionValid(Transaction transaction, string invoiceId) - { - if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}")) - { - return TransactionValidityResult.Valid_ExactMatch; - } - - var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString()); - - var matches = RecordedTransactions.Where(record => - record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) || - record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase)); - - if (matches.Any()) - { - if(matches.Any(record => - record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) && - record.Value.Transaction.RBF && - record.Value.Transaction.Inputs.All(recordTxIn => hashes.Contains(recordTxIn.PrevOut.ToString())))) - { - return TransactionValidityResult.Valid_SameInputs; - } - - return TransactionValidityResult.Invalid_PartialMatch; - } - - return RecordedTransactions.Any(record => - record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString()))) - ? TransactionValidityResult.Invalid_Inputs_Seen: TransactionValidityResult.Valid_NoMatch; - } - - public void AddRecord(PayJoinStateRecordedItem recordedItem) - { - RecordedTransactions.TryAdd(recordedItem.ToString(), recordedItem); - foreach (var receivedCoin in recordedItem.CoinsExposed) - { - ExposedCoins.TryRemove(receivedCoin.OutPoint.ToString(), out _); - } - } - - public void RemoveRecord(PayJoinStateRecordedItem item, bool keepExposed) - { - if (keepExposed) - { - foreach (var receivedCoin in item.CoinsExposed) - { - ExposedCoins.AddOrReplace(receivedCoin.OutPoint.ToString(), receivedCoin); - } - } - - RecordedTransactions.TryRemove(item.ToString(), out _); - } - - public void RemoveRecord(uint256 proposedTxHash) - { - var id = RecordedTransactions.SingleOrDefault(pair => - pair.Value.ProposedTransactionHash == proposedTxHash || - pair.Value.OriginalTransactionHash == proposedTxHash).Key; - if (id != null) - { - RecordedTransactions.TryRemove(id, out _); - } - } - - public void RemoveRecord(string invoiceId) - { - var id = RecordedTransactions.Single(pair => - pair.Value.InvoiceId == invoiceId).Key; - RecordedTransactions.TryRemove(id, out _); - } - - public List GetExposed(Transaction transaction) - { - return RecordedTransactions.Values - .Where(pair => - pair.Transaction.Inputs.Any(txIn => - transaction.Inputs.Any(txIn2 => txIn.PrevOut == txIn2.PrevOut))) - .SelectMany(pair => pair.CoinsExposed).ToList(); - } - - public bool TryGetWithProposedHash(uint256 hash, out PayJoinStateRecordedItem item) - { - item = - RecordedTransactions.Values.SingleOrDefault( - recordedItem => recordedItem.ProposedTransactionHash == hash); - return item != null; - } - - public IEnumerable GetExposedCoins(bool includeOnesInOngoingBPUs = false) - { - var result = ExposedCoins.Values; - return includeOnesInOngoingBPUs - ? result.Concat(RecordedTransactions.Values.SelectMany(item => item.CoinsExposed)) - : result; - } - - public void PruneExposedButSpentCoins(IEnumerable stillAvailable) - { - var keys = stillAvailable.Select(coin => coin.OutPoint.ToString()); - var keysToRemove = ExposedCoins.Keys.Where(s => !keys.Contains(s)); - foreach (var key in keysToRemove) - { - ExposedCoins.TryRemove(key, out _); - } - } - - - public void PruneExposedBySpentCoins(IEnumerable taken) - { - var keys = taken.Select(coin => coin.ToString()); - var keysToRemove = ExposedCoins.Keys.Where(s => keys.Contains(s)); - foreach (var key in keysToRemove) - { - ExposedCoins.TryRemove(key, out _); - } - } - - public void PruneRecordsOfUsedInputs(TxInList transactionInputs) - { - foreach (PayJoinStateRecordedItem payJoinStateRecordedItem in RecordedTransactions.Values) - { - if (payJoinStateRecordedItem.CoinsExposed.Any(coin => - transactionInputs.Any(txin => txin.PrevOut == coin.OutPoint))) - { - RemoveRecord(payJoinStateRecordedItem, true); - } - } - - PruneExposedBySpentCoins(transactionInputs.Select(coin => coin.PrevOut)); - } - } -} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs b/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs deleted file mode 100644 index fac5b4765..000000000 --- a/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using BTCPayServer.Data; -using BTCPayServer.Services; -using BTCPayServer.Services.Stores; -using BTCPayServer.Services.Wallets; -using NBitcoin; -using NBXplorer.DerivationStrategy; - -namespace BTCPayServer.Payments.PayJoin -{ - public class PayJoinStateProvider - { - private readonly SettingsRepository _settingsRepository; - private readonly StoreRepository _storeRepository; - private readonly BTCPayNetworkProvider _btcPayNetworkProvider; - private readonly BTCPayWalletProvider _btcPayWalletProvider; - - private MultiValueDictionary Lookup = - new MultiValueDictionary(); - - private ConcurrentDictionary States = - new ConcurrentDictionary(); - - public PayJoinStateProvider(SettingsRepository settingsRepository, StoreRepository storeRepository, - BTCPayNetworkProvider btcPayNetworkProvider, BTCPayWalletProvider btcPayWalletProvider) - { - _settingsRepository = settingsRepository; - _storeRepository = storeRepository; - _btcPayNetworkProvider = btcPayNetworkProvider; - _btcPayWalletProvider = btcPayWalletProvider; - } - - public IEnumerable Get(string cryptoCode, DerivationStrategyBase derivationStrategyBase) - { - if (Lookup.TryGetValue(derivationStrategyBase, out var walletIds)) - { - var matchedWalletKeys = walletIds.Where(id => - id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)); - - return matchedWalletKeys.Select(id => States.TryGet(id)); - } - - return Array.Empty(); - } - - public PayJoinState Get(WalletId walletId) - { - return States.TryGet(walletId); - } - - public ConcurrentDictionary GetAll() - { - return States; - } - - public PayJoinState GetOrAdd(WalletId key, DerivationStrategyBase derivationStrategyBase, - IEnumerable exposedCoins = null) - { - return States.GetOrAdd(key, id => - { - Lookup.Add(derivationStrategyBase, id); - return new PayJoinState(exposedCoins == null - ? null - : new ConcurrentDictionary(exposedCoins.Select(coin => - new KeyValuePair(coin.OutPoint.ToString(), coin)))); - }); - } - - public void RemoveState(WalletId walletId) - { - States.TryRemove(walletId, out _); - } - - public async Task SaveCoins() - { - Dictionary> saved = - new Dictionary>(); - foreach (var payState in GetAll()) - { - saved.Add(payState.Key.ToString(), - payState.Value.GetExposedCoins(true).Select(coin => coin.OutPoint)); - } - - await _settingsRepository.UpdateSetting(saved, "bpu-state"); - } - - public async Task LoadCoins() - { - Dictionary> saved = - await _settingsRepository.GetSettingAsync>>("bpu-state"); - if (saved == null) - { - return; - } - - foreach (KeyValuePair> keyValuePair in saved) - { - var walletId = WalletId.Parse(keyValuePair.Key); - var store = await _storeRepository.FindStore(walletId.StoreId); - var derivationSchemeSettings = store?.GetSupportedPaymentMethods(_btcPayNetworkProvider) - .OfType().SingleOrDefault(settings => - settings.PaymentId.CryptoCode.Equals(walletId.CryptoCode, - StringComparison.InvariantCultureIgnoreCase)); - if (derivationSchemeSettings == null) - { - continue; - } - - var utxos = await _btcPayWalletProvider.GetWallet(walletId.CryptoCode) - .GetUnspentCoins(derivationSchemeSettings.AccountDerivation); - - _ = GetOrAdd(walletId, derivationSchemeSettings.AccountDerivation, - utxos.Where(coin => keyValuePair.Value.Contains(coin.OutPoint))); - } - } - } -} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs b/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs deleted file mode 100644 index b155308b7..000000000 --- a/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using BTCPayServer.Services.Wallets; -using NBitcoin; - -namespace BTCPayServer.Payments.PayJoin -{ - public class PayJoinStateRecordedItem - { - public Transaction Transaction { get; set; } - public DateTimeOffset Timestamp { get; set; } - public uint256 ProposedTransactionHash { get; set; } - public List CoinsExposed { get; set; } - public decimal TotalOutputAmount { get; set; } - public decimal ContributedAmount { get; set ; } - public uint256 OriginalTransactionHash { get; set; } - - public string InvoiceId { get; set; } - public bool TxSeen { get; set; } - - public override string ToString() - { - return $"{InvoiceId}_{OriginalTransactionHash}"; - } - } -} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs b/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs deleted file mode 100644 index 50486cdf0..000000000 --- a/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using BTCPayServer.Data; -using BTCPayServer.Events; -using BTCPayServer.Payments.Bitcoin; -using BTCPayServer.Services.Invoices; -using BTCPayServer.Services.Stores; -using Microsoft.Extensions.Hosting; -using NBitcoin.RPC; -using NBXplorer; - -namespace BTCPayServer.Payments.PayJoin -{ - public class PayJoinTransactionBroadcaster : IHostedService - { - // The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok. - private static readonly TimeSpan BroadcastAfter = TimeSpan.FromMinutes(5); - - private readonly EventAggregator _eventAggregator; - private readonly ExplorerClientProvider _explorerClientProvider; - private readonly PayJoinStateProvider _payJoinStateProvider; - - private CompositeDisposable leases = new CompositeDisposable(); - - public PayJoinTransactionBroadcaster( - EventAggregator eventAggregator, - ExplorerClientProvider explorerClientProvider, - PayJoinStateProvider payJoinStateProvider) - { - _eventAggregator = eventAggregator; - _explorerClientProvider = explorerClientProvider; - _payJoinStateProvider = payJoinStateProvider; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - var loadCoins = _payJoinStateProvider.LoadCoins(); - //if the wallet was updated, we need to remove the state as the utxos no longer fit - leases.Add(_eventAggregator.Subscribe(evt => - _payJoinStateProvider.RemoveState(evt.WalletId))); - - leases.Add(_eventAggregator.Subscribe(txEvent => - { - if (!txEvent.NewTransactionEvent.Outputs.Any() || - (txEvent.NewTransactionEvent.TransactionData.Transaction.RBF && - txEvent.NewTransactionEvent.TransactionData.Confirmations == 0)) - { - return; - } - - var relevantStates = - _payJoinStateProvider.Get(txEvent.CryptoCode, txEvent.NewTransactionEvent.DerivationStrategy); - - foreach (var relevantState in relevantStates) - { - //if any of the exposed inputs were spent, remove them from our state - relevantState.PruneRecordsOfUsedInputs(txEvent.NewTransactionEvent.TransactionData.Transaction - .Inputs); - } - })); - _ = BroadcastTransactionsPeriodically(cancellationToken); - await loadCoins; - } - - private async Task BroadcastTransactionsPeriodically(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - await BroadcastStaleTransactions(BroadcastAfter, cancellationToken); - await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); - } - } - - public async Task BroadcastStaleTransactions(TimeSpan broadcastAfter, CancellationToken cancellationToken) - { - List tasks = new List(); - foreach (var state in _payJoinStateProvider.GetAll()) - { - var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode); - //broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time. - //This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible. - var staleTxs = state.Value.GetStaleRecords(broadcastAfter) - .Where(item => !item.TxSeen || item.Transaction.RBF); - - tasks.AddRange(staleTxs.Select(async staleTx => - { - //if the transaction signals RBF and was broadcasted, check if it was rbfed out - if (staleTx.TxSeen && staleTx.Transaction.RBF) - { - var proposedTransaction = await explorerClient.GetTransactionAsync(staleTx.ProposedTransactionHash, cancellationToken); - var result = await explorerClient.BroadcastAsync(proposedTransaction.Transaction, cancellationToken); - var accounted = result.Success || - result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN || - !( - // Happen if a blocks mined a replacement - // Or if the tx is a double spend of something already in the mempool without rbf - result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR || - // Happen if RBF is on and fee insufficient - result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED); - - if (accounted) - { - //if it wasn't replaced just yet, do not attempt to move the exposed coins to the priority list - return; - } - } - else - { - await explorerClient - .BroadcastAsync(staleTx.Transaction, cancellationToken); - } - - - state.Value.RemoveRecord(staleTx, true); - })); - } - - await Task.WhenAll(tasks); - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - await _payJoinStateProvider.SaveCoins(); - leases.Dispose(); - } - } -} diff --git a/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroLikePaymentMethodHandler.cs b/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroLikePaymentMethodHandler.cs index 1f5e9d1dc..c808efba7 100644 --- a/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroLikePaymentMethodHandler.cs +++ b/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroLikePaymentMethodHandler.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using BTCPayServer.Data; using BTCPayServer.Lightning; +using BTCPayServer.Logging; using BTCPayServer.Models; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Services.Altcoins.Monero.RPC.Models; @@ -30,7 +31,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments } public override PaymentType PaymentType => MoneroPaymentType.Instance; - public override async Task CreatePaymentMethodDetails(MoneroSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, + public override async Task CreatePaymentMethodDetails(InvoiceLogs logs, MoneroSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, MoneroLikeSpecificBtcPayNetwork network, object preparePaymentObject) { diff --git a/BTCPayServer/Services/DelayedTransactionBroadcaster.cs b/BTCPayServer/Services/DelayedTransactionBroadcaster.cs new file mode 100644 index 000000000..5e6e4a50d --- /dev/null +++ b/BTCPayServer/Services/DelayedTransactionBroadcaster.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.Extensions.Logging; +using NBitcoin; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NBXplorer; +using System.Threading.Channels; +using System.Threading; +using BTCPayServer.Logging; + +namespace BTCPayServer.Services +{ + public class DelayedTransactionBroadcaster + { + class Record + { + public DateTimeOffset Recorded; + public DateTimeOffset BroadcastTime; + public Transaction Transaction; + public BTCPayNetwork Network; + } + Channel _Records = Channel.CreateUnbounded(); + private readonly ExplorerClientProvider _explorerClientProvider; + + public DelayedTransactionBroadcaster(ExplorerClientProvider explorerClientProvider) + { + if (explorerClientProvider == null) + throw new ArgumentNullException(nameof(explorerClientProvider)); + _explorerClientProvider = explorerClientProvider; + } + + public Task Schedule(DateTimeOffset broadcastTime, Transaction transaction, BTCPayNetwork network) + { + if (transaction == null) + throw new ArgumentNullException(nameof(transaction)); + if (network == null) + throw new ArgumentNullException(nameof(network)); + var now = DateTimeOffset.UtcNow; + var record = new Record() + { + Recorded = now, + BroadcastTime = broadcastTime, + Transaction = transaction, + Network = network + }; + _Records.Writer.TryWrite(record); + // TODO: persist + return Task.CompletedTask; + } + + public async Task ProcessAll(CancellationToken cancellationToken = default) + { + if (disabled) + return; + var now = DateTimeOffset.UtcNow; + List rescheduled = new List(); + List scheduled = new List(); + List broadcasted = new List(); + while (_Records.Reader.TryRead(out var r)) + { + (r.BroadcastTime > now ? rescheduled : scheduled).Add(r); + } + + var broadcasts = scheduled.Select(async (record) => + { + var explorer = _explorerClientProvider.GetExplorerClient(record.Network); + if (explorer is null) + return false; + try + { + // We don't look the result, this is a best effort basis. + var result = await explorer.BroadcastAsync(record.Transaction, cancellationToken); + if (result.Success) + { + Logs.PayServer.LogInformation($"{record.Network.CryptoCode}: {record.Transaction.GetHash()} has been successfully broadcasted"); + } + return false; + } + catch + { + // If this goes here, maybe RPC is down or NBX is down, we should reschedule + return true; + } + }).ToArray(); + + for (int i = 0; i < scheduled.Count; i++) + { + var needReschedule = await broadcasts[i]; + (needReschedule ? rescheduled : broadcasted).Add(scheduled[i]); + } + foreach (var record in rescheduled) + { + _Records.Writer.TryWrite(record); + } + // TODO: Remove everything in broadcasted from DB + } + + private bool disabled = false; + public void Disable() + { + disabled = true; + } + } +} diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index d3728e38b..6cc2c1ee2 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -469,9 +469,9 @@ namespace BTCPayServer.Services.Invoices dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo); var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due); - if (((details as BitcoinLikeOnChainPaymentMethod)?.PayJoin?.Enabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase)) + if (((details as BitcoinLikeOnChainPaymentMethod)?.PayjoinEnabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase)) { - bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu/{Id}"; + bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu"; } cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() { diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 6f6f8dafe..a3190bd70 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -314,7 +314,11 @@ retry: if (!context.PendingInvoices.Any(a => a.Id == invoiceId)) { context.PendingInvoices.Add(new PendingInvoiceData() { Id = invoiceId }); - await context.SaveChangesAsync(); + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateException) { } // Already exists } } } @@ -683,7 +687,7 @@ retry: /// /// /// The PaymentEntity or null if already added - public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false) + public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false, decimal? networkFee = null) { using (var context = _ContextFactory.CreateContext()) { @@ -701,7 +705,7 @@ retry: #pragma warning restore CS0618 ReceivedTime = date.UtcDateTime, Accounted = accounted, - NetworkFee = paymentMethodDetails.GetNextNetworkFee(), + NetworkFee = networkFee ?? paymentMethodDetails.GetNextNetworkFee(), Network = network }; entity.SetCryptoPaymentData(paymentData); diff --git a/BTCPayServer/Services/PayjoinClient.cs b/BTCPayServer/Services/PayjoinClient.cs new file mode 100644 index 000000000..678b90dd1 --- /dev/null +++ b/BTCPayServer/Services/PayjoinClient.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Util; +using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Services +{ + public class PayjoinClient + { + private readonly ExplorerClientProvider _explorerClientProvider; + private HttpClient _httpClient; + + public PayjoinClient(ExplorerClientProvider explorerClientProvider, IHttpClientFactory httpClientFactory) + { + if (httpClientFactory == null) throw new ArgumentNullException(nameof(httpClientFactory)); + _explorerClientProvider = + explorerClientProvider ?? throw new ArgumentNullException(nameof(explorerClientProvider)); + _httpClient = httpClientFactory.CreateClient("payjoin"); + } + + public async Task RequestPayjoin(Uri endpoint, DerivationSchemeSettings derivationSchemeSettings, + PSBT originalTx, CancellationToken cancellationToken) + { + if (endpoint == null) throw new ArgumentNullException(nameof(endpoint)); + if (derivationSchemeSettings == null) throw new ArgumentNullException(nameof(derivationSchemeSettings)); + if (originalTx == null) throw new ArgumentNullException(nameof(originalTx)); + + var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings(); + var sentBefore = -originalTx.GetBalance(derivationSchemeSettings.AccountDerivation, + signingAccount.AccountKey, + signingAccount.GetRootedKeyPath()); + + if (!originalTx.TryGetEstimatedFeeRate(out var oldFeeRate)) + throw new ArgumentException("originalTx should have utxo information", nameof(originalTx)); + var cloned = originalTx.Clone(); + if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors)) + { + return null; + } + + // We make sure we don't send unnecessary information to the receiver + foreach (var finalized in cloned.Inputs.Where(i => i.IsFinalized())) + { + finalized.ClearForFinalize(); + } + + foreach (var output in cloned.Outputs) + { + output.HDKeyPaths.Clear(); + } + + cloned.GlobalXPubs.Clear(); + var bpuresponse = await _httpClient.PostAsync(endpoint, + new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain"), cancellationToken); + if (!bpuresponse.IsSuccessStatusCode) + { + var errorStr = await bpuresponse.Content.ReadAsStringAsync(); + try + { + var error = JObject.Parse(errorStr); + throw new PayjoinReceiverException((int)bpuresponse.StatusCode, error["errorCode"].Value(), + error["message"].Value()); + } + catch (JsonReaderException) + { + // will throw + bpuresponse.EnsureSuccessStatusCode(); + throw; + } + } + + var hex = await bpuresponse.Content.ReadAsStringAsync(); + var newPSBT = PSBT.Parse(hex, originalTx.Network); + + // Checking that the PSBT of the receiver is clean + if (newPSBT.GlobalXPubs.Any()) + { + throw new PayjoinSenderException("GlobalXPubs should not be included in the receiver's PSBT"); + } + + if (newPSBT.Outputs.Any(o => o.HDKeyPaths.Count != 0) || newPSBT.Inputs.Any(o => o.HDKeyPaths.Count != 0)) + { + throw new PayjoinSenderException("Keypath information should not be included in the receiver's PSBT"); + } + //////////// + + newPSBT = await _explorerClientProvider.UpdatePSBT(derivationSchemeSettings, newPSBT); + if (newPSBT.CheckSanity() is IList errors2 && errors2.Count != 0) + { + throw new PayjoinSenderException($"The PSBT of the receiver is insane ({errors2[0]})"); + } + // We make sure we don't sign things what should not be signed + foreach (var finalized in newPSBT.Inputs.Where(i => i.IsFinalized())) + { + finalized.ClearForFinalize(); + } + // Make sure only the only our output have any information + foreach (var output in newPSBT.Outputs) + { + output.HDKeyPaths.Clear(); + foreach (var originalOutput in originalTx.Outputs) + { + if (output.ScriptPubKey == originalOutput.ScriptPubKey) + output.UpdateFrom(originalOutput); + } + } + + // Making sure that our inputs are finalized, and that some of our inputs have not been added + int ourInputCount = 0; + foreach (var input in newPSBT.Inputs.CoinsFor(derivationSchemeSettings.AccountDerivation, + signingAccount.AccountKey, signingAccount.GetRootedKeyPath())) + { + if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is PSBTInput ourInput) + { + ourInputCount++; + if (input.IsFinalized()) + throw new PayjoinSenderException("A PSBT input from us should not be finalized"); + } + else + { + throw new PayjoinSenderException( + "The payjoin receiver added some of our own inputs in the proposal"); + } + } + + // Making sure that the receiver's inputs are finalized + foreach (var input in newPSBT.Inputs) + { + if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is null && !input.IsFinalized()) + throw new PayjoinSenderException("The payjoin receiver included a non finalized input"); + } + + if (ourInputCount < originalTx.Inputs.Count) + throw new PayjoinSenderException("The payjoin receiver removed some of our inputs"); + + // We limit the number of inputs the receiver can add + var addedInputs = newPSBT.Inputs.Count - originalTx.Inputs.Count; + if (originalTx.Inputs.Count < addedInputs) + throw new PayjoinSenderException("The payjoin receiver added too much inputs"); + + var sentAfter = -newPSBT.GetBalance(derivationSchemeSettings.AccountDerivation, + signingAccount.AccountKey, + signingAccount.GetRootedKeyPath()); + if (sentAfter > sentBefore) + { + if (!newPSBT.TryGetEstimatedFeeRate(out var newFeeRate) || !newPSBT.TryGetVirtualSize(out var newVirtualSize)) + throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly"); + // Let's check the difference is only for the fee and that feerate + // did not changed that much + var expectedFee = oldFeeRate.GetFee(newVirtualSize); + // Signing precisely is hard science, give some breathing room for error. + expectedFee += newPSBT.Inputs.Count * Money.Satoshis(2); + + // If the payjoin is removing some dust, we may pay a bit more as a whole output has been removed + var removedOutputs = Math.Max(0, originalTx.Outputs.Count - newPSBT.Outputs.Count); + expectedFee += removedOutputs * oldFeeRate.GetFee(294); + + var actualFee = newFeeRate.GetFee(newVirtualSize); + if (actualFee > expectedFee && actualFee - expectedFee > Money.Satoshis(546)) + throw new PayjoinSenderException("The payjoin receiver is paying too much fee"); + } + + return newPSBT; + } + } + + public class PayjoinException : Exception + { + public PayjoinException(string message) : base(message) + { + } + } + + public class PayjoinReceiverException : PayjoinException + { + public PayjoinReceiverException(int httpCode, string errorCode, string message) : base(FormatMessage(httpCode, + errorCode, message)) + { + HttpCode = httpCode; + ErrorCode = errorCode; + ErrorMessage = message; + } + + public int HttpCode { get; } + public string ErrorCode { get; } + public string ErrorMessage { get; } + + private static string FormatMessage(in int httpCode, string errorCode, string message) + { + return $"{errorCode}: {message} (HTTP: {httpCode})"; + } + } + + public class PayjoinSenderException : PayjoinException + { + public PayjoinSenderException(string message) : base(message) + { + } + } +} diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index 26604e53b..b6d4f15a9 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -96,14 +96,44 @@ namespace BTCPayServer.Services.Wallets await _Client.TrackAsync(derivationStrategy); } - public async Task GetTransactionAsync(uint256 txId, CancellationToken cancellation = default(CancellationToken)) + public async Task GetTransactionAsync(uint256 txId, bool includeOffchain = false, CancellationToken cancellation = default(CancellationToken)) { if (txId == null) throw new ArgumentNullException(nameof(txId)); var tx = await _Client.GetTransactionAsync(txId, cancellation); + if (tx is null && includeOffchain) + { + var offchainTx = await GetOffchainTransactionAsync(txId); + if (offchainTx != null) + tx = new TransactionResult() + { + Confirmations = -1, + TransactionHash = offchainTx.GetHash(), + Transaction = offchainTx + }; + } return tx; } + public Task GetOffchainTransactionAsync(uint256 txid) + { + lock (offchain) + { + return Task.FromResult(offchain.TryGet(txid)); + } + } + public Task SaveOffchainTransactionAsync(Transaction tx) + { + // TODO: Save in database + lock (offchain) + { + offchain.Add(tx.GetHash(), tx); + return Task.CompletedTask; + } + } + + private Dictionary offchain = new Dictionary(); + public void InvalidateCache(DerivationStrategyBase strategy) { _MemoryCache.Remove("CACHEDCOINS_" + strategy.ToString()); diff --git a/BTCPayServer/Views/Server/_Nav.cshtml b/BTCPayServer/Views/Server/_Nav.cshtml index 2de86dc91..c3f59e9c6 100644 --- a/BTCPayServer/Views/Server/_Nav.cshtml +++ b/BTCPayServer/Views/Server/_Nav.cshtml @@ -8,4 +8,3 @@ Logs Files
- diff --git a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml index b0f756cca..7cafcd0f8 100644 --- a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml +++ b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml @@ -52,7 +52,7 @@ @payment.Crypto @payment.DepositAddress - @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"
(Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount})") + @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin? string.Empty : $"
(Payjoin)")