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.Data/Data/PaymentData.cs b/BTCPayServer.Data/Data/PaymentData.cs index fb7c98784..2361ba097 100644 --- a/BTCPayServer.Data/Data/PaymentData.cs +++ b/BTCPayServer.Data/Data/PaymentData.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs new file mode 100644 index 000000000..e027166e3 --- /dev/null +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -0,0 +1,614 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Controllers; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.Models; +using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Payments.PayJoin; +using BTCPayServer.Services; +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; +using OpenQA.Selenium; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests +{ + public class PayJoinTests + { + public const int TestTimeout = 60_000; + + public PayJoinTests(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.LogProvider = new XUnitLogProvider(helper); + } + + [Fact] + [Trait("Selenium", "Selenium")] + public async Task CanUseBIP79Client() + { + 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"); + + //payjoin is not enabled by default. + var invoiceId = s.CreateInvoice(receiver.storeId); + s.GoToInvoiceCheckout(invoiceId); + var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.DoesNotContain("bpu=", bip21); + + s.GoToHome(); + s.GoToStore(receiver.storeId); + //payjoin is not enabled by default. + Assert.False(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected); + s.SetCheckbox(s,"PayJoinEnabled", true); + s.Driver.FindElement(By.Id("Save")).Click(); + Assert.True(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected); + var sender = s.CreateNewStore(); + var senderSeed = s.GenerateWallet("BTC", "", true, true); + var senderWalletId = new WalletId(sender.storeId, "BTC"); + await s.Server.ExplorerNode.GenerateAsync(1); + await s.FundStoreWallet(senderWalletId); + + invoiceId = s.CreateInvoice(receiver.storeId); + s.GoToInvoiceCheckout(invoiceId); + bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.Contains("bpu=", bip21); + + s.GoToWalletSend(senderWalletId); + s.Driver.FindElement(By.Id("bip21parse")).Click(); + s.Driver.SwitchTo().Alert().SendKeys(bip21); + s.Driver.SwitchTo().Alert().Accept(); + Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); + s.Driver.ScrollTo(By.Id("SendMenu")); + s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); + s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); + await s.Server.WaitForEvent(() => + { + 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); + await TestUtils.EventuallyAsync(async () => + { + var invoice = await s.Server.PayTester.GetService().GetInvoice(invoiceId); + Assert.Equal(InvoiceStatus.Paid, invoice.Status); + }); + + s.GoToInvoices(); + var paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value")); + Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase)); + + //let's do it all again, except now the receiver has funds and is able to payjoin + invoiceId = s.CreateInvoice(receiver.storeId); + s.GoToInvoiceCheckout(invoiceId); + bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.Contains("bpu", bip21); + + s.GoToWalletSend(senderWalletId); + s.Driver.FindElement(By.Id("bip21parse")).Click(); + s.Driver.SwitchTo().Alert().SendKeys(bip21); + s.Driver.SwitchTo().Alert().Accept(); + Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); + s.Driver.ScrollTo(By.Id("SendMenu")); + s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); + s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); + await s.Server.WaitForEvent(() => + { + 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(); + + 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); + } + } + + [Fact] + // [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanUseBIP79() + { + using (var tester = ServerTester.Create()) + { + await tester.StartAsync(); + + ////var payJoinStateProvider = tester.PayTester.GetService(); + var btcPayNetwork = tester.NetworkProvider.GetNetwork("BTC"); + var btcPayWallet = tester.PayTester.GetService().GetWallet(btcPayNetwork); + var cashCow = tester.ExplorerNode; + cashCow.Generate(2); // get some money in case + + var senderUser = tester.NewAccount(); + senderUser.GrantAccess(true); + senderUser.RegisterDerivationScheme("BTC", true, true); + + var invoice = senderUser.BitPay.CreateInvoice( + new Invoice() {Price = 100, Currency = "USD", FullNotifications = true}); + //payjoin is not enabled by default. + Assert.DoesNotContain("bpu", invoice.CryptoInfo.First().PaymentUrls.BIP21); + cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network), + Money.Coins(0.06m)); + + var receiverUser = tester.NewAccount(); + receiverUser.GrantAccess(true); + receiverUser.RegisterDerivationScheme("BTC", true, true); + + await receiverUser.EnablePayJoin(); + // payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer + invoice = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network), + Money.Coins(0.06m)); + var receiverWalletId = new WalletId(receiverUser.StoreId, "BTC"); + + //give the cow some cash + await cashCow.GenerateAsync(1); + //let's get some more utxos first + 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( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + + var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + + 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 senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId); + var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike); + var derivationSchemeSettings = senderStore.GetSupportedPaymentMethods(tester.NetworkProvider) + .OfType().SingleOrDefault(settings => + settings.PaymentId == paymentMethodId); + + ReceivedCoin[] senderCoins = null; + await TestUtils.EventuallyAsync(async () => + { + senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme); + Assert.Contains(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.026m); + }); + var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m); + var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m); + var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m); + var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m); + var coin5 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.025m); + var coin6 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.026m); + + var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); + signingKeySettings.RootFingerprint = + senderUser.GenerateWalletResponseV.MasterHDKey.GetPublicKey().GetHDFingerPrint(); + + var extKey = + senderUser.GenerateWalletResponseV.MasterHDKey.Derive(signingKeySettings.GetRootedKeyPath() + .KeyPath); + + + var n = tester.ExplorerClient.Network.NBitcoinNetwork; + var Invoice1Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(parsedBip21.Address, parsedBip21.Amount) + .AddCoins(coin.Coin) + .AddKeys(extKey.Derive(coin.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice1Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(parsedBip21.Address, parsedBip21.Amount) + .AddCoins(coin2.Coin) + .AddKeys(extKey.Derive(coin2.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice2Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount) + .AddCoins(coin.Coin) + .AddKeys(extKey.Derive(coin.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice2Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount) + .AddCoins(coin2.Coin) + .AddKeys(extKey.Derive(coin2.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + //Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all + //Result: reject + // Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint, + // new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); + + //Attempt 2: Create two transactions using different inputs and send them to the same invoice. + //Result: Second Tx should be rejected. + var 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 + 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); + + //Attempt 4: Make tx that pays invoice 3 and 4 and submit to both + //Result: reject on 4: the protocol should not worry about this complexity + + var invoice3 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + + + var 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 Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice3ParsedBip21.Address, invoice3ParsedBip21.Amount) + .Send(invoice4ParsedBip21.Address, invoice4ParsedBip21.Amount) + .AddCoins(coin3.Coin) + .AddKeys(extKey.Derive(coin3.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + 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 + + var invoice5 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + + var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) + .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) + .AddCoins(coin4.Coin) + .AddKeys(extKey.Derive(coin4.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)); + + var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true); + var Invoice5Coin4ResponseTx = await senderUser.SubmitPayjoin(invoice5, Invoice5Coin4, btcPayNetwork); + Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address)); + + //Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again + //Result: same tx gets sent back + + //give the receiver some more utxos + Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.1m, MoneyUnit.BTC))); + + var invoice6 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + + var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice6ParsedBip21.Address, invoice6ParsedBip21.Amount) + .AddCoins(coin5.Coin) + .AddKeys(extKey.Derive(coin5.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .SetLockTime(0); + + var invoice6Coin5 = invoice6Coin5TxBuilder + .BuildTransaction(true); + + var 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))); + + //Attempt 11: + //send tx with rbt, broadcast payjoin, + //create tx spending the original tx inputs with rbf to self, + //Result: the exposed utxos are priorized in the next p2ep + + //give the receiver some more utxos + Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.1m, MoneyUnit.BTC))); + + var invoice7 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + + var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice7ParsedBip21.Address, invoice7ParsedBip21.Amount) + .AddCoins(coin6.Coin) + .AddKeys(extKey.Derive(coin6.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .SetLockTime(0); + + var invoice7Coin6Tx = invoice7Coin6TxBuilder + .BuildTransaction(true); + + var 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); + //broadcast the payjoin + var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned)); + Assert.True(res.Success); + + // Paid with coinjoin + await TestUtils.EventuallyAsync(async () => + { + 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); + + var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .AddCoins(coin6.Coin) + .SendAll(senderChange) + .SubtractFees() + .AddKeys(extKey.Derive(coin6.KeyPath)) + .SendEstimatedFees(new FeeRate(200m)) + .SetLockTime(0) + .BuildTransaction(true); + + //broadcast the "rbf cancel" tx + 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 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]; + }); + 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/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 259e8a417..9a070f83f 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -120,6 +120,7 @@ namespace BTCPayServer.Tests return (usr, Driver.FindElement(By.Id("Id")).GetAttribute("value")); } + public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false) { @@ -314,8 +315,38 @@ namespace BTCPayServer.Tests return id; } + public async Task FundStoreWallet(WalletId walletId, int coins = 1, decimal denomination = 1m) + { + GoToWalletReceive(walletId); + Driver.FindElement(By.Id("generateButton")).Click(); + var addressStr = Driver.FindElement(By.Id("vue-address")).GetProperty("value"); + var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork); + for (int i = 0; i < coins; i++) + { + await Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(denomination)); + } + } + + public void PayInvoice(WalletId walletId, string invoiceId) + { + GoToInvoiceCheckout(invoiceId); + var bip21 = Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) + .GetAttribute("href"); + Assert.Contains("bpu", bip21); + + GoToWalletSend(walletId); + Driver.FindElement(By.Id("bip21parse")).Click(); + Driver.SwitchTo().Alert().SendKeys(bip21); + Driver.SwitchTo().Alert().Accept(); + Driver.ScrollTo(By.Id("SendMenu")); + Driver.FindElement(By.Id("SendMenu")).ForceClick(); + Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); + Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick(); + } + + private void CheckForJSErrors() { //wait for seleniun update: https://stackoverflow.com/questions/57520296/selenium-webdriver-3-141-0-driver-manage-logs-availablelogtypes-throwing-syste diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 714ff14d5..a01680499 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -144,6 +144,19 @@ namespace BTCPayServer.Tests await CustomerLightningD.Pay(bolt11); } + public async Task WaitForEvent(Func action) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sub = PayTester.GetService().Subscribe(evt => + { + tcs.TrySetResult(evt); + }); + await action.Invoke(); + var result = await tcs.Task; + sub.Dispose(); + return result; + } + public ILightningClient CustomerLightningD { get; set; } public ILightningClient MerchantLightningD { get; private set; } diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 78d0d7208..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,21 +23,27 @@ 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; BitPay = new Bitpay(new Key(), parent.PayTester.ServerUri); } - public void GrantAccess() + public void GrantAccess(bool isAdmin = false) { - GrantAccessAsync().GetAwaiter().GetResult(); + GrantAccessAsync(isAdmin).GetAwaiter().GetResult(); } public async Task MakeAdmin(bool isAdmin = true) @@ -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(); @@ -74,13 +84,14 @@ namespace BTCPayServer.Tests return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey); } - public void Register() + public void Register(bool isAdmin = false) { - RegisterAsync().GetAwaiter().GetResult(); + RegisterAsync(isAdmin).GetAwaiter().GetResult(); } - public async Task GrantAccessAsync() + + public async Task GrantAccessAsync(bool isAdmin = false) { - await RegisterAsync(); + await RegisterAsync(isAdmin); await CreateStoreAsync(); var store = this.GetController(); var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant); @@ -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,23 +157,38 @@ 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); } + public async Task EnablePayJoin() + { + var storeController = parent.PayTester.GetController(UserId, StoreId); + var storeVM = + Assert.IsType(Assert + .IsType(storeController.UpdateStore()).Model); + + storeVM.PayJoinEnabled = true; + + Assert.Equal(nameof(storeController.UpdateStore), + Assert.IsType( + await storeController.UpdateStore(storeVM)).ActionName); + } + public GenerateWalletResponse GenerateWalletResponseV { get; set; } public DerivationStrategyBase DerivationScheme @@ -170,7 +199,7 @@ namespace BTCPayServer.Tests } } - private async Task RegisterAsync() + private async Task RegisterAsync(bool isAdmin = false) { var account = parent.PayTester.GetController(); RegisterDetails = new RegisterViewModel() @@ -178,27 +207,33 @@ namespace BTCPayServer.Tests Email = Guid.NewGuid() + "@toto.com", ConfirmPassword = "Kitten0@", Password = "Kitten0@", + IsAdmin = isAdmin }; await account.Register(RegisterDetails); UserId = account.RegisteredUserId; 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) @@ -214,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 0b8031fd2..02c9e168d 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -183,7 +183,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"); @@ -298,7 +298,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(); @@ -487,7 +487,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 1018b60fe..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" @@ -318,7 +318,6 @@ services: - "bitcoin_datadir:/deps/.bitcoin" links: - bitcoind - volumes: sshd_datadir: bitcoin_datadir: 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/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 9a8a3c2c1..e157d2352 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -479,6 +479,7 @@ namespace BTCPayServer.Controllers vm.InvoiceExpiration = storeBlob.InvoiceExpiration; vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; vm.PaymentTolerance = storeBlob.PaymentTolerance; + vm.PayJoinEnabled = storeBlob.PayJoinEnabled; return View(vm); } @@ -573,7 +574,7 @@ namespace BTCPayServer.Controllers blob.InvoiceExpiration = model.InvoiceExpiration; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; blob.PaymentTolerance = model.PaymentTolerance; - + blob.PayJoinEnabled = model.PayJoinEnabled; if (CurrentStore.SetStoreBlob(blob)) { needUpdate = true; diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 781488129..449d8eeb2 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.ModelBinders; +using BTCPayServer.Models; using BTCPayServer.Models.WalletViewModels; using Microsoft.AspNetCore.Mvc; using NBitcoin; @@ -68,6 +71,7 @@ namespace BTCPayServer.Controllers vm.Decoded = psbt.ToString(); vm.PSBT = psbt.ToBase64(); } + return View(nameof(WalletPSBT), vm ?? new WalletPSBTViewModel() { CryptoCode = walletId.CryptoCode }); } [HttpPost] @@ -98,12 +102,12 @@ namespace BTCPayServer.Controllers vm.FileName = vm.UploadedPSBTFile?.FileName; return View(vm); case "vault": - return ViewVault(walletId, psbt); + return ViewVault(walletId, psbt, vm.PayJoinEndpointUrl); case "ledger": 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"); @@ -112,7 +116,7 @@ namespace BTCPayServer.Controllers TempData[WellKnownTempData.SuccessMessage] = "PSBT updated!"; return RedirectToWalletPSBT(psbt, vm.FileName); case "seed": - return SignWithSeed(walletId, psbt.ToBase64()); + return SignWithSeed(walletId, psbt.ToBase64(), vm.PayJoinEndpointUrl); case "nbx-seed": if (await CanUseHotWallet()) { @@ -121,8 +125,8 @@ namespace BTCPayServer.Controllers .GetMetadataAsync(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey); - return SignWithSeed(walletId, - new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64()}); + return await SignWithSeed(walletId, + new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64(), PayJoinEndpointUrl = vm.PayJoinEndpointUrl}); } return View(vm); @@ -140,31 +144,42 @@ namespace BTCPayServer.Controllers } } - private async Task UpdatePSBT(DerivationSchemeSettings derivationSchemeSettings, PSBT psbt, BTCPayNetwork network) + private async Task TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken) { - var result = await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(new UpdatePSBTRequest() + if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint)) { - PSBT = psbt, - DerivationScheme = derivationSchemeSettings.AccountDerivation, - }); - if (result == null) - return null; - derivationSchemeSettings.RebaseKeyPaths(result.PSBT); - return result.PSBT; + var cloned = psbt.Clone(); + 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; + } + + } + return null; } - + [HttpGet] [Route("{walletId}/psbt/ready")] public async Task WalletPSBTReady( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string psbt = null, string signingKey = null, - string signingKeyPath = null) + string signingKeyPath = null, + string originalPsbt = null, + string payJoinEndpointUrl = null) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var vm = new WalletPSBTReadyViewModel() { PSBT = psbt }; vm.SigningKey = signingKey; vm.SigningKeyPath = signingKeyPath; + vm.OriginalPSBT = originalPsbt; + vm.PayJoinEndpointUrl = payJoinEndpointUrl; var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); @@ -180,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 @@ -282,16 +297,17 @@ 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); + return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl); PSBT psbt = null; var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + DerivationSchemeSettings derivationSchemeSettings = null; try { psbt = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork); - var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); + derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); await FetchTransactionDetails(derivationSchemeSettings, vm, network); @@ -301,38 +317,88 @@ namespace BTCPayServer.Controllers vm.GlobalError = "Invalid PSBT"; return View(nameof(WalletPSBTReady),vm); } - if (command == "broadcast") + + switch (command) { - if (!psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors)) - { + case "payjoin": + var proposedPayjoin =await + 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 + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Warning, + AllowDismiss = false, + Html = $"The payjoin transaction could not be created. The original transaction was broadcast instead." + }); + return await WalletPSBTReady(walletId, vm, "broadcast"); + } + else + { + + try + { + var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork); + 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) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Info, + AllowDismiss = false, + Html = + $"This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" + }); + return ViewVault(walletId, proposedPayjoin, vm.PayJoinEndpointUrl, psbt); + } + } + case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors): vm.SetErrors(errors); return View(nameof(WalletPSBTReady),vm); - } - var transaction = psbt.ExtractTransaction(); - try + case "broadcast": { - var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction); - if (!broadcastResult.Success) + var transaction = psbt.ExtractTransaction(); + try { - vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"; + var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction); + if (!broadcastResult.Success) + { + if (!string.IsNullOrEmpty(vm.OriginalPSBT)) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Warning, + AllowDismiss = false, + Html = $"The payjoin transaction could not be broadcast.
({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).
The transaction has been reverted back to its original format and has been broadcast." + }); + vm.PSBT = vm.OriginalPSBT; + vm.OriginalPSBT = null; + return await WalletPSBTReady(walletId, vm, "broadcast"); + } + + vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"; + return View(nameof(WalletPSBTReady),vm); + } + } + catch (Exception ex) + { + vm.GlobalError = "Error while broadcasting: " + ex.Message; return View(nameof(WalletPSBTReady),vm); } + return RedirectToWalletTransaction(walletId, transaction); } - catch (Exception ex) - { - vm.GlobalError = "Error while broadcasting: " + ex.Message; + case "analyze-psbt": + return RedirectToWalletPSBT(psbt); + default: + vm.GlobalError = "Unknown command"; return View(nameof(WalletPSBTReady),vm); - } - return RedirectToWalletTransaction(walletId, transaction); - } - else if (command == "analyze-psbt") - { - return RedirectToWalletPSBT(psbt); - } - else - { - vm.GlobalError = "Unknown command"; - return View(nameof(WalletPSBTReady),vm); } } diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index f429bde91..016ba7992 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net.Http; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -49,6 +50,8 @@ namespace BTCPayServer.Controllers private readonly WalletReceiveStateService _WalletReceiveStateService; private readonly EventAggregator _EventAggregator; private readonly SettingsRepository _settingsRepository; + private readonly DelayedTransactionBroadcaster _broadcaster; + private readonly PayjoinClient _payjoinClient; public RateFetcher RateFetcher { get; } CurrencyNameTable _currencyTable; @@ -66,7 +69,9 @@ namespace BTCPayServer.Controllers BTCPayWalletProvider walletProvider, WalletReceiveStateService walletReceiveStateService, EventAggregator eventAggregator, - SettingsRepository settingsRepository) + SettingsRepository settingsRepository, + DelayedTransactionBroadcaster broadcaster, + PayjoinClient payjoinClient) { _currencyTable = currencyTable; Repository = repo; @@ -83,6 +88,8 @@ namespace BTCPayServer.Controllers _WalletReceiveStateService = walletReceiveStateService; _EventAggregator = eventAggregator; _settingsRepository = settingsRepository; + _broadcaster = broadcaster; + _payjoinClient = payjoinClient; } // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md @@ -456,7 +463,9 @@ namespace BTCPayServer.Controllers if (network == null || network.ReadonlyWallet) return NotFound(); vm.SupportRBF = network.SupportRBF; - + vm.NBXSeedAvailable = await CanUseHotWallet() && !string.IsNullOrEmpty(await ExplorerClientProvider.GetExplorerClient(network) + .GetMetadataAsync(GetDerivationSchemeSettings(walletId).AccountDerivation, + WellknownMetadataKeys.MasterHDKey, cancellation)); if (!string.IsNullOrEmpty(bip21)) { LoadFromBIP21(vm, bip21, network); @@ -594,28 +603,28 @@ namespace BTCPayServer.Controllers return View(vm); } derivationScheme.RebaseKeyPaths(psbt.PSBT); - switch (command) { case "vault": - return ViewVault(walletId, psbt.PSBT); + return ViewVault(walletId, psbt.PSBT, vm.PayJoinEndpointUrl); case "nbx-seed": var extKey = await ExplorerClientProvider.GetExplorerClient(network) .GetMetadataAsync(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey, cancellation); - return SignWithSeed(walletId, new SignWithSeedViewModel() + return await SignWithSeed(walletId, new SignWithSeedViewModel() { + PayJoinEndpointUrl = vm.PayJoinEndpointUrl, SeedOrKey = extKey, PSBT = psbt.PSBT.ToBase64() }); case "ledger": return ViewWalletSendLedger(walletId, psbt.PSBT, psbt.ChangeAddress); case "seed": - return SignWithSeed(walletId, psbt.PSBT.ToBase64()); + return SignWithSeed(walletId, psbt.PSBT.ToBase64(), vm.PayJoinEndpointUrl); case "analyze-psbt": var name = $"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt"; - return RedirectToWalletPSBT(psbt.PSBT, name); + return RedirectToWalletPSBT(psbt.PSBT, name, vm.PayJoinEndpointUrl); default: return View(vm); } @@ -650,6 +659,8 @@ namespace BTCPayServer.Controllers $"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}" }); } + uriBuilder.UnknowParameters.TryGetValue("bpu", out var vmPayJoinEndpointUrl); + vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl; } catch (Exception) { @@ -663,11 +674,13 @@ namespace BTCPayServer.Controllers ModelState.Clear(); } - private IActionResult ViewVault(WalletId walletId, PSBT psbt) + private IActionResult ViewVault(WalletId walletId, PSBT psbt, string payJoinEndpointUrl, PSBT originalPSBT = null) { - return View("WalletSendVault", new WalletSendVaultModel() + return View(nameof(WalletSendVault), new WalletSendVaultModel() { + PayJoinEndpointUrl = payJoinEndpointUrl, WalletId = walletId.ToString(), + OriginalPSBT = originalPSBT?.ToBase64(), PSBT = psbt.ToBase64(), WebsocketPath = this.Url.Action(nameof(VaultController.VaultBridgeConnection), "Vault", new { walletId = walletId.ToString() }) }); @@ -675,12 +688,12 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/vault")] - public IActionResult SubmitVault([ModelBinder(typeof(WalletIdModelBinder))] + public async Task WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendVaultModel model) { - return RedirectToWalletPSBTReady(model.PSBT); + return RedirectToWalletPSBTReady(model.PSBT, originalPsbt: model.OriginalPSBT, payJoinEndpointUrl: model.PayJoinEndpointUrl); } - private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null) + private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null, string originalPsbt = null, string payJoinEndpointUrl = null) { var vm = new PostRedirectViewModel() { @@ -689,6 +702,8 @@ namespace BTCPayServer.Controllers Parameters = { new KeyValuePair("psbt", psbt), + new KeyValuePair("originalPsbt", originalPsbt), + new KeyValuePair("payJoinEndpointUrl", payJoinEndpointUrl), new KeyValuePair("SigningKey", signingKey), new KeyValuePair("SigningKeyPath", signingKeyPath) } @@ -696,7 +711,7 @@ namespace BTCPayServer.Controllers return View("PostRedirect", vm); } - private IActionResult RedirectToWalletPSBT(PSBT psbt, string fileName = null) + private IActionResult RedirectToWalletPSBT(PSBT psbt, string fileName = null, string payJoinEndpointUrl = null) { var vm = new PostRedirectViewModel() { @@ -709,6 +724,8 @@ namespace BTCPayServer.Controllers }; if (!string.IsNullOrEmpty(fileName)) vm.Parameters.Add(new KeyValuePair("fileName", fileName)); + if (!string.IsNullOrEmpty(payJoinEndpointUrl)) + vm.Parameters.Add(new KeyValuePair("payJoinEndpointUrl", payJoinEndpointUrl)); return View("PostRedirect", vm); } @@ -747,7 +764,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/ledger")] - public IActionResult SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendLedgerModel model) { return RedirectToWalletPSBTReady(model.PSBT); @@ -755,16 +772,17 @@ namespace BTCPayServer.Controllers [HttpGet("{walletId}/psbt/seed")] public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId,string psbt) + WalletId walletId,string psbt, string payJoinEndpointUrl) { return View(nameof(SignWithSeed), new SignWithSeedViewModel() { + PayJoinEndpointUrl = payJoinEndpointUrl, PSBT = psbt }); } [HttpPost("{walletId}/psbt/seed")] - public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, SignWithSeedViewModel viewModel) { if (!ModelState.IsValid) @@ -826,9 +844,10 @@ namespace BTCPayServer.Controllers return View(viewModel); } ModelState.Remove(nameof(viewModel.PSBT)); - return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString()); + 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(); @@ -850,7 +869,17 @@ namespace BTCPayServer.Controllers var wallet = _walletProvider.GetWallet(network); var derivationSettings = GetDerivationSchemeSettings(walletId); wallet.InvalidateCache(derivationSettings.AccountDerivation); - TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash().ToString()})"; + if (TempData.GetStatusMessageModel() == null) + { + TempData[WellKnownTempData.SuccessMessage] = + $"Transaction broadcasted successfully ({transaction.GetHash()})"; + } + else + { + var statusMessageModel = TempData.GetStatusMessageModel(); + statusMessageModel.Message += $" ({transaction.GetHash()})"; + TempData.SetStatusMessageModel(statusMessageModel); + } } return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 3147663f3..143805d57 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -172,6 +172,7 @@ namespace BTCPayServer.Data public EmailSettings EmailSettings { get; set; } public bool RedirectAutomatically { get; set; } + public bool PayJoinEnabled { get; set; } public IPaymentFilter GetExcludedPaymentMethods() { diff --git a/BTCPayServer/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/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 74f967690..8c5875980 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -31,6 +31,7 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Changelly; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Payments.PayJoin; using BTCPayServer.Security; using BTCPayServer.Services.PaymentRequests; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -62,6 +63,7 @@ namespace BTCPayServer.Hosting { httpClient.Timeout = Timeout.InfiniteTimeSpan; }); + services.AddPayJoinServices(); services.AddMoneroLike(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -257,11 +259,13 @@ namespace BTCPayServer.Hosting { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=1000r/min burst=100 nodelay"); } else { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst=3 nodelay"); } return rateLimits; }); diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index 6475b4d15..b300405ea 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -86,6 +86,9 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Description template of the lightning invoice")] public string LightningDescriptionTemplate { get; set; } + + [Display(Name = "Enable BIP79 Payjoin/P2EP")] + public bool PayJoinEnabled { get; set; } public class LightningNode { diff --git a/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs b/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs index 8b753cf6c..d0e5074b3 100644 --- a/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs @@ -7,6 +7,8 @@ namespace BTCPayServer.Models.WalletViewModels { public class SignWithSeedViewModel { + public string OriginalPSBT { get; set; } + public string PayJoinEndpointUrl { get; set; } [Required] public string PSBT { get; set; } [Required][Display(Name = "BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)")] diff --git a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs index 985ee8ba8..daed6978e 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs @@ -8,6 +8,8 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletPSBTReadyViewModel { + public string PayJoinEndpointUrl { get; set; } + public string OriginalPSBT { get; set; } public string PSBT { get; set; } public string SigningKey { get; set; } public string SigningKeyPath { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs index fc627d624..ccb244ecc 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs @@ -10,6 +10,7 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletPSBTViewModel { + public string PayJoinEndpointUrl { get; set; } public string CryptoCode { get; set; } public string Decoded { get; set; } string _FileName; diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs index 5cc12c361..f28c5a606 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs @@ -10,7 +10,6 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletSendModel { - public List Outputs { get; set; } = new List(); public class TransactionOutput @@ -49,6 +48,8 @@ namespace BTCPayServer.Models.WalletViewModels public bool DisableRBF { get; set; } public bool NBXSeedAvailable { get; set; } + [Display(Name = "PayJoin Endpoint Url (BIP79)")] + public string PayJoinEndpointUrl { get; set; } public bool InputSelection { get; set; } public InputSelectionOption[] InputsAvailable { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs index 1c8698ca7..bf60de537 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs @@ -7,8 +7,10 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletSendVaultModel { + public string OriginalPSBT { get; set; } public string WalletId { get; set; } public string PSBT { get; set; } public string WebsocketPath { get; set; } + public string PayJoinEndpointUrl { get; set; } } } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs index baed24724..53506071c 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Wallets; using NBitcoin; using NBXplorer.JsonConverters; using Newtonsoft.Json; @@ -48,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; } @@ -56,6 +57,7 @@ namespace BTCPayServer.Payments.Bitcoin public Money NextNetworkFee { get; set; } [JsonIgnore] public String DepositAddress { get; set; } + public BitcoinAddress GetDepositAddress(Network network) { return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network); diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs index c57ce7729..bbbd7799c 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs @@ -37,10 +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 PayjoinInformation PayjoinInformation { get; set; } + [JsonIgnore] public Script ScriptPubKey { @@ -67,7 +68,7 @@ namespace BTCPayServer.Payments.Bitcoin public decimal GetValue() { - return Value?.GetValue(Network as BTCPayNetwork)??Output.Value.ToDecimal(MoneyUnit.BTC); + return Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC); } public bool PaymentCompleted(PaymentEntity entity) @@ -106,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 7aa3091d8..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) { @@ -125,13 +137,15 @@ namespace BTCPayServer.Payments.Bitcoin var prepare = (Prepare)preparePaymentObject; Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod onchainMethod = new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod(); - onchainMethod.NetworkFeeMode = store.GetStoreBlob().NetworkFeeMode; + var blob = store.GetStoreBlob(); + onchainMethod.NetworkFeeMode = blob.NetworkFeeMode; onchainMethod.FeeRate = await prepare.GetFeeRate; switch (onchainMethod.NetworkFeeMode) { 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; @@ -139,10 +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.PayjoinEnabled = blob.PayJoinEnabled && + supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() == + ScriptPubKeyType.Segwit && + network.SupportPayJoin; + if (onchainMethod.PayjoinEnabled) + { + 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 674f29810..e6b1b8eff 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using NBXplorer; using System.Collections.Concurrent; +using BTCPayServer.Controllers; using NBXplorer.DerivationStrategy; using BTCPayServer.Events; using BTCPayServer.Services; @@ -18,8 +19,10 @@ using NBitcoin; using NBXplorer.Models; using BTCPayServer.Payments; using BTCPayServer.HostedServices; +using BTCPayServer.Payments.PayJoin; using NBitcoin.Altcoins.Elements; using NBitcoin.RPC; +using BTCPayServer; namespace BTCPayServer.Payments.Bitcoin { @@ -29,17 +32,18 @@ namespace BTCPayServer.Payments.Bitcoin public class NBXplorerListener : IHostedService { EventAggregator _Aggregator; + 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, + PayJoinRepository payjoinRepository, IHostApplicationLifetime lifetime) { PollInterval = TimeSpan.FromMinutes(1.0); @@ -47,6 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin _InvoiceRepository = invoiceRepository; _ExplorerClients = explorerClients; _Aggregator = aggregator; + _payJoinRepository = payjoinRepository; _Lifetime = lifetime; } @@ -138,7 +143,6 @@ namespace BTCPayServer.Payments.Bitcoin switch (newEvent) { case NBXplorer.Models.NewBlockEvent evt: - await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices()) .Select(invoiceId => UpdatePaymentStates(wallet, invoiceId)) .ToArray()); @@ -146,11 +150,7 @@ namespace BTCPayServer.Payments.Bitcoin break; case NBXplorer.Models.NewTransactionEvent evt: wallet.InvalidateCache(evt.DerivationStrategy); - _Aggregator.Publish(new NewOnChainTransactionEvent() - { - CryptoCode = wallet.Network.CryptoCode, - NewTransactionEvent = evt - }); + foreach (var output in network.GetValidOutputs(evt)) { var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant(); @@ -159,8 +159,12 @@ namespace BTCPayServer.Payments.Bitcoin { var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy, output.Item1.KeyPath, output.Item1.ScriptPubKey); - var paymentData = new BitcoinLikePaymentData(address, output.matchedOutput.Value, output.outPoint, evt.TransactionData.Transaction.RBF); - var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any(); + + var paymentData = new BitcoinLikePaymentData(address, + output.matchedOutput.Value, output.outPoint, + 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); @@ -174,6 +178,12 @@ namespace BTCPayServer.Payments.Bitcoin } } + _Aggregator.Publish(new NewOnChainTransactionEvent() + { + CryptoCode = wallet.Network.CryptoCode, + NewTransactionEvent = evt + }); + break; default: Logs.PayServer.LogWarning("Received unknown message from NBXplorer"); @@ -201,22 +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()); + .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) @@ -224,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 || !( @@ -241,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 @@ -277,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)); @@ -292,11 +325,13 @@ 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; var cryptoId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); + var paymentMethod = invoice.GetPaymentMethod(cryptoId).GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod; + if (!invoice.Support(cryptoId)) continue; var coins = (await wallet.GetUnspentCoins(strategy)) @@ -307,9 +342,10 @@ namespace BTCPayServer.Payments.Bitcoin var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash); var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey); + var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint, transaction.Transaction.RBF); - + var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false); alreadyAccounted.Add(coin.OutPoint); if (payment != null) 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 new file mode 100644 index 000000000..b14d5ee05 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.IO; +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 +{ + [Route("{cryptoCode}/bpu")] + public class PayJoinEndpointController : ControllerBase + { + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly InvoiceRepository _invoiceRepository; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly StoreRepository _storeRepository; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + private readonly 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, + PayJoinRepository payJoinRepository, + EventAggregator eventAggregator, + NBXplorerDashboard dashboard, + DelayedTransactionBroadcaster broadcaster) + { + _btcPayNetworkProvider = btcPayNetworkProvider; + _invoiceRepository = invoiceRepository; + _explorerClientProvider = explorerClientProvider; + _storeRepository = storeRepository; + _btcPayWalletProvider = btcPayWalletProvider; + _payJoinRepository = payJoinRepository; + _eventAggregator = eventAggregator; + _dashboard = dashboard; + _broadcaster = broadcaster; + } + + [HttpPost("")] + [IgnoreAntiforgeryToken] + [EnableCors(CorsPolicies.All)] + [MediaTypeConstraint("text/plain")] + [RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)] + public async Task Submit(string cryptoCode) + { + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network == null) + { + 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()) ?? string.Empty; + } + + Transaction originalTx = null; + FeeRate originalFeeRate = null; + bool psbtFormat = true; + if (!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out var psbt)) + { + 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++) + { + psbt.Inputs[i].FinalScriptSig = tx.Inputs[i].ScriptSig; + psbt.Inputs[i].FinalScriptWitness = tx.Inputs[i].WitScript; + } + } + else + { + 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.")); + } + + // This is actually not a mandatory check, but we don't want implementers + // to leak global xpubs + if (psbt.GlobalXPubs.Any()) + { + return BadRequest(CreatePayjoinError(400, "leaking-data", + "GlobalXPubs should not be included in the PSBT")); + } + + 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 BadRequest(CreatePayjoinError(400, "invalid-transaction", + $"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}")); + } + + var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); + 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) + { + 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()) + { + return UnprocessableEntity(CreatePayjoinError(422, "already-paid", + $"The invoice this PSBT is paying has already been partially or completely paid")); + } + + paidSomething = true; + due = paymentMethod.Calculate().TotalDue - output.Value; + if (due > Money.Zero) + { + 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; + } + + if (!paidSomething) + { + return BadRequest(CreatePayjoinError(400, "invoice-not-found", + "This transaction does not pay any invoice with payjoin")); + } + + if (due is null || due > Money.Zero) + { + return BadRequest(CreatePayjoinError(400, "invoice-not-fully-paid", + "The transaction must pay the whole invoice")); + } + + if (selectedUTXOs.Count == 0) + { + 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() + { + Type = PayjoinTransactionType.Original, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray() + }; + 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 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. + // + // "UIH2": one input is larger than any output. This heuristically implies that no output is a payment, or, to say it better, it implies that this is not a normal wallet-created payment, it's something strange/exotic. + //src: https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2796539 + + foreach (var availableUtxo in availableUtxos) + { + if (currentTry >= maxTries) + break; + //we can only check against our input as we dont know the value of the rest. + var input = (Money)availableUtxo.Value; + var paymentAmountSum = input + paymentAmount; + if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output)) + { + //UIH 1 & 2 + continue; + } + + if (await _payJoinRepository.TryLock(availableUtxo.Outpoint)) + { + return new UTXO[] { availableUtxo }; + } + locked.Add(availableUtxo.Outpoint); + currentTry++; + } + 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 new file mode 100644 index 000000000..c55c1159e --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs @@ -0,0 +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 services) + { + 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/PaymentTypes.Bitcoin.cs b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs index 461d189e7..3e6f7b4b1 100644 --- a/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs +++ b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs @@ -33,12 +33,12 @@ namespace BTCPayServer.Payments public override IPaymentMethodDetails DeserializePaymentMethodDetails(BTCPayNetworkBase network, string str) { - return JsonConvert.DeserializeObject(str); + return ((BTCPayNetwork) network).ToObject(str); } public override string SerializePaymentMethodDetails(BTCPayNetworkBase network, IPaymentMethodDetails details) { - return JsonConvert.SerializeObject(details); + return ((BTCPayNetwork) network).ToString((BitcoinLikeOnChainPaymentMethod)details); } public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value) diff --git a/BTCPayServer/Properties/launchSettings.json b/BTCPayServer/Properties/launchSettings.json index 2f3db0eaa..c78e85659 100644 --- a/BTCPayServer/Properties/launchSettings.json +++ b/BTCPayServer/Properties/launchSettings.json @@ -51,7 +51,8 @@ "BTCPAY_SSHCONNECTION": "root@127.0.0.1:21622", "BTCPAY_SSHPASSWORD": "opD3i2282D", "BTCPAY_DEBUGLOG": "debug.log", - "BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc" + "BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc", + "BTCPAY_SOCKSENDPOINT": "localhost:9050" }, "applicationUrl": "https://localhost:14142/" } 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 1b219f2d1..6cc2c1ee2 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -411,7 +411,8 @@ namespace BTCPayServer.Services.Invoices var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo(); var subtotalPrice = accounting.TotalDue - accounting.NetworkFee; var cryptoCode = info.GetId().CryptoCode; - var address = info.GetPaymentMethodDetails()?.GetPaymentDestination(); + var details = info.GetPaymentMethodDetails(); + var address = details?.GetPaymentDestination(); var exrates = new Dictionary { { ProductInformation.Currency, cryptoInfo.Rate } @@ -463,12 +464,18 @@ namespace BTCPayServer.Services.Invoices { var minerInfo = new MinerFeeInfo(); minerInfo.TotalFee = accounting.NetworkFee.Satoshi; - minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)info.GetPaymentMethodDetails()).FeeRate + minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)details).FeeRate .GetFee(1).Satoshi; dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo); + var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due); + + if (((details as BitcoinLikeOnChainPaymentMethod)?.PayjoinEnabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase)) + { + bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu"; + } cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() { - BIP21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due), + BIP21 = bip21, }; #pragma warning disable 618 diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 59f5d52b5..a3190bd70 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -292,6 +292,21 @@ retry: } } + public async Task UpdateInvoicePaymentMethod(string invoiceId, PaymentMethod paymentMethod) + { + using (var context = _ContextFactory.CreateContext()) + { + var invoice = await context.Invoices.FindAsync(invoiceId); + if (invoice == null) + return; + var network = paymentMethod.Network; + var invoiceEntity = ToObject(invoice.Blob); + invoiceEntity.SetPaymentMethod(paymentMethod); + invoice.Blob = ToBytes(invoiceEntity, network); + await context.SaveChangesAsync(); + } + } + public async Task AddPendingInvoiceIfNotPresent(string invoiceId) { using (var context = _ContextFactory.CreateContext()) @@ -299,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 } } } @@ -668,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()) { @@ -686,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/SocketFactory.cs b/BTCPayServer/Services/SocketFactory.cs index e604c1b06..e508b452c 100644 --- a/BTCPayServer/Services/SocketFactory.cs +++ b/BTCPayServer/Services/SocketFactory.cs @@ -1,10 +1,5 @@ -using System; -using NBitcoin; -using System.Collections.Generic; -using System.Linq; -using System.Net; +using System.Net; using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Configuration; @@ -16,10 +11,12 @@ namespace BTCPayServer.Services public class SocketFactory { private readonly BTCPayServerOptions _options; + public SocketFactory(BTCPayServerOptions options) { _options = options; } + public async Task ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken) { DefaultEndpointConnector connector = new DefaultEndpointConnector(); @@ -31,6 +28,7 @@ namespace BTCPayServer.Services SocksEndpoint = _options.SocksEndpoint }); } + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); try { @@ -40,6 +38,7 @@ namespace BTCPayServer.Services { SafeCloseSocket(socket); } + return socket; } @@ -52,6 +51,7 @@ namespace BTCPayServer.Services catch { } + try { socket.Dispose(); diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index da13c7890..b6d4f15a9 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -23,7 +23,7 @@ namespace BTCPayServer.Services.Wallets public DateTimeOffset Timestamp { get; set; } public KeyPath KeyPath { get; set; } public IMoney Value { get; set; } - + public Coin Coin { get; set; } } public class NetworkCoins { @@ -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()); @@ -180,7 +210,8 @@ namespace BTCPayServer.Services.Wallets Value = c.Value, Timestamp = c.Timestamp, OutPoint = c.Outpoint, - ScriptPubKey = c.ScriptPubKey + ScriptPubKey = c.ScriptPubKey, + Coin = c.AsCoin(derivationStrategy) }).ToArray(); } diff --git a/BTCPayServer/Views/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 d30391173..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() + @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin? string.Empty : $"
(Payjoin)") +
+ + + +
Derivation Scheme
The DerivationScheme represents the destination of the funds received by your invoice on chain. diff --git a/BTCPayServer/Views/Wallets/SignWithSeed.cshtml b/BTCPayServer/Views/Wallets/SignWithSeed.cshtml index 6d9724154..0abad6fb2 100644 --- a/BTCPayServer/Views/Wallets/SignWithSeed.cshtml +++ b/BTCPayServer/Views/Wallets/SignWithSeed.cshtml @@ -27,7 +27,9 @@
+ +
-
diff --git a/BTCPayServer/ZoneLimits.cs b/BTCPayServer/ZoneLimits.cs index 08e722cec..85a424967 100644 --- a/BTCPayServer/ZoneLimits.cs +++ b/BTCPayServer/ZoneLimits.cs @@ -9,5 +9,6 @@ namespace BTCPayServer { public const string Login = "btcpaylogin"; public const string Register = "btcpayregister"; + public const string PayJoin = "PayJoin"; } }