From fd026a97332a7797f4b1ca43350316cd1659bfa6 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Mar 2020 00:28:22 +0900 Subject: [PATCH] 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)")