diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs new file mode 100644 index 000000000..5b7d84bb9 --- /dev/null +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -0,0 +1,411 @@ +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Controllers; +using BTCPayServer.Data; +using BTCPayServer.Models; +using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Payments.PayJoin; +using BTCPayServer.Services.Wallets; +using BTCPayServer.Tests.Logging; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBitcoin.Payment; +using NBitpayClient; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests +{ + public class PayJoinTests + { + public const int TestTimeout = 60_000; + + public PayJoinTests(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.LogProvider = new XUnitLogProvider(helper); + } + + [Fact] + // [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanUseBIP79() + { + using (var tester = ServerTester.Create()) + { + await tester.StartAsync(); + var btcPayNetwork = tester.NetworkProvider.GetNetwork("BTC"); + var btcPayWallet = tester.PayTester.GetService().GetWallet(btcPayNetwork); + var cashCow = tester.ExplorerNode; + cashCow.Generate(2); // get some money in case + + var senderUser = tester.NewAccount(); + senderUser.GrantAccess(true); + senderUser.RegisterDerivationScheme("BTC", true, true); + + var invoice = senderUser.BitPay.CreateInvoice( + new Invoice() {Price = 100, Currency = "USD", FullNotifications = true}); + //payjoin is not enabled by default. + Assert.DoesNotContain("bpu", invoice.CryptoInfo.First().PaymentUrls.BIP21); + cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network), + Money.Coins(0.06m)); + + var receiverUser = tester.NewAccount(); + receiverUser.GrantAccess(true); + receiverUser.RegisterDerivationScheme("BTC", true, true); + + await receiverUser.EnablePayJoin(); + // payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer + invoice = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + + //check that the BIP21 has an endpoint + var bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21; + Assert.Contains("bpu", bip21); + var parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork); + var endpoint = parsedBip21.UnknowParameters["bpu"]; + + + //see if the btcpay send wallet supports BIP21 properly and also the payjoin endpoint + var receiverWalletId = new WalletId(receiverUser.StoreId, "BTC"); + var senderWalletId = new WalletId(senderUser.StoreId, "BTC"); + var senderWallerController = senderUser.GetController(); + var senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId) + .AssertViewModelAsync(); + senderWalletSendVM = await senderWallerController + .WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21) + .AssertViewModelAsync(); + + Assert.Single(senderWalletSendVM.Outputs); + Assert.Equal(endpoint, senderWalletSendVM.PayJoinEndpointUrl); + Assert.Equal(parsedBip21.Address.ToString(), senderWalletSendVM.Outputs.First().DestinationAddress); + Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC), senderWalletSendVM.Outputs.First().Amount); + + //the nbx wallet option should also be available + Assert.True(senderWalletSendVM.NBXSeedAvailable); + + //pay the invoice with the nbx seed wallet option + also the invoice + var postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId, + senderWalletSendVM, "nbx-seed", CancellationToken.None) + .AssertViewModelAsync(); + var redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value; + var psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork); + var senderWalletSendPSBTResult = new WalletPSBTReadyViewModel() + { + PSBT = redirectedPSBT, + SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value, + SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value + }; + //While the endpoint was set, the receiver had no utxos. The payment should fall back to original payment terms instead + Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC).ToString(), + psbt.Outputs.Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value); + + Assert.Equal("WalletTransactions", + Assert.IsType( + await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult, + "broadcast")) + .ActionName); + + //we used the bip21 link straight away to pay the invoice so it should be paid straight away. + TestUtils.Eventually(() => + { + invoice = receiverUser.BitPay.GetInvoice(invoice.Id); + Assert.Equal(Invoice.STATUS_PAID, invoice.Status); + }); + + //now that there is a utxo, let's do it again + + invoice = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21; + parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork); + senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId) + .AssertViewModelAsync(); + senderWalletSendVM = await senderWallerController + .WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21) + .AssertViewModelAsync(); + postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId, + senderWalletSendVM, "nbx-seed", CancellationToken.None) + .AssertViewModelAsync(); + redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value; + psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork); + senderWalletSendPSBTResult = new WalletPSBTReadyViewModel() + { + PSBT = redirectedPSBT, + SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value, + SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value + }; + //the payjoin should make the amount being paid to the address higher + Assert.True(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC) < psbt.Outputs + .Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value + .ToDecimal(MoneyUnit.BTC)); + + var payJoinStateProvider = tester.PayTester.GetService(); + //the state should now hold that there is an ongoing utxo + var state = payJoinStateProvider.Get(receiverWalletId); + Assert.NotNull(state); + Assert.Single(state.GetRecords()); + Assert.Equal(0.02m, state.GetRecords().First().ContributedAmount); + Assert.Single(state.GetRecords().First().CoinsExposed); + Assert.Equal(psbt.Finalize().ExtractTransaction().GetHash(), + state.GetRecords().First().ProposedTransactionHash); + + Assert.Equal("WalletTransactions", + Assert.IsType( + await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult, + "broadcast")) + .ActionName); + + TestUtils.Eventually(() => + { + invoice = receiverUser.BitPay.GetInvoice(invoice.Id); + + Assert.Equal(Invoice.STATUS_PAID, invoice.Status); + Assert.Equal(Invoice.EXSTATUS_FALSE, invoice.ExceptionStatus.ToString().ToLowerInvariant()); + }); + + //verify that we have a record that it was a payjoin + var receiverController = receiverUser.GetController(); + var invoiceVM = + await receiverController.Invoice(invoice.Id).AssertViewModelAsync(); + Assert.Single(invoiceVM.Payments); + Assert.True(Assert.IsType(invoiceVM.Payments.First().GetCryptoPaymentData()) + .PayJoinSelfContributedAmount > 0); + + //check that the state has cleared that ongoing tx + state = payJoinStateProvider.Get(receiverWalletId); + Assert.NotNull(state); + Assert.Empty(state.GetRecords()); + Assert.Empty(state.GetExposedCoins()); + + //Cool, so the payjoin works! + //The cool thing with payjoin is that your utxos don't grow + Assert.Single(await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme)); + + //Let's be as malicious as CSW + + //give the cow some cash + await cashCow.GenerateAsync(1); + //let's get some more utxos first + Assert.NotNull(await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.011m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.012m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, + new Money(0.013m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.021m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.022m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.023m, MoneyUnit.BTC))); + Assert.NotNull( await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.024m, MoneyUnit.BTC))); + + await cashCow.SendToAddressAsync( + (await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, + new Money(0.014m, MoneyUnit.BTC)); + var senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme); + + var senderChange = (await btcPayWallet.GetChangeAddressAsync(senderUser.DerivationScheme)).Item1; + + //Let's start the harassment + invoice = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + + parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + endpoint = parsedBip21.UnknowParameters["bpu"]; + + var invoice2 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); + var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var endpoint2 = secondInvoiceParsedBip21.UnknowParameters["bpu"]; + + var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId); + var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike); + var derivationSchemeSettings = senderStore.GetSupportedPaymentMethods(tester.NetworkProvider) + .OfType().SingleOrDefault(settings => + settings.PaymentId == paymentMethodId); + var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m); + var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m); + var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m); + var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m); + + var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); + signingKeySettings.RootFingerprint = + senderUser.GenerateWalletResponseV.MasterHDKey.GetPublicKey().GetHDFingerPrint(); + + var extKey = + senderUser.GenerateWalletResponseV.MasterHDKey.Derive(signingKeySettings.GetRootedKeyPath() + .KeyPath); + + + var n = tester.ExplorerClient.Network.NBitcoinNetwork; + var Invoice1Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(parsedBip21.Address, parsedBip21.Amount) + .AddCoins(coin.Coin) + .AddKeys(extKey.Derive(coin.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice1Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(parsedBip21.Address, parsedBip21.Amount) + .AddCoins(coin2.Coin) + .AddKeys(extKey.Derive(coin2.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice2Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount) + .AddCoins(coin.Coin) + .AddKeys(extKey.Derive(coin.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice2Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount) + .AddCoins(coin2.Coin) + .AddKeys(extKey.Derive(coin2.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + //Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all + //Result: reject + Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint, + new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); + + //Attempt 2: Create two transactions using different inputs and send them to the same invoice. + //Result: Second Tx should be rejected. + var Invoice1Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint, + new StringContent(Invoice1Coin1.ToHex(), Encoding.UTF8, "text/plain")); + + var Invoice1Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint, + new StringContent(Invoice1Coin2.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.True(Invoice1Coin1Response.IsSuccessStatusCode); + Assert.False(Invoice1Coin2Response.IsSuccessStatusCode); + var Invoice1Coin1ResponseTx = + Transaction.Parse(await Invoice1Coin1Response.Content.ReadAsStringAsync(), n); + var contributedInputsInvoice1Coin1ResponseTx = + Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut); + Assert.Single(contributedInputsInvoice1Coin1ResponseTx); + + //Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted + //Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1 + + var Invoice2Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint2, + new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain")); + + var Invoice2Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint2, + new StringContent(Invoice2Coin2.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.False(Invoice2Coin1Response.IsSuccessStatusCode); + Assert.True(Invoice2Coin2Response.IsSuccessStatusCode); + + var Invoice2Coin2ResponseTx = + Transaction.Parse(await Invoice2Coin2Response.Content.ReadAsStringAsync(), n); + var contributedInputsInvoice2Coin2ResponseTx = + Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut); + Assert.Single(contributedInputsInvoice2Coin2ResponseTx); + + //Attempt 4: Make tx that pays invoice 3 and 4 and submit to both + //Result: reject on 4: the protocol should not worry about this complexity + + var invoice3 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice3Endpoint = invoice3ParsedBip21.UnknowParameters["bpu"]; + + + var invoice4 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice4Endpoint = invoice4ParsedBip21.UnknowParameters["bpu"]; + + + var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice3ParsedBip21.Address, invoice3ParsedBip21.Amount) + .Send(invoice4ParsedBip21.Address, invoice4ParsedBip21.Amount) + .AddCoins(coin3.Coin) + .AddKeys(extKey.Derive(coin3.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice3Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice3Endpoint, + new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain")); + + var Invoice4Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice4Endpoint, + new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.True(Invoice3Coin3Response.IsSuccessStatusCode); + Assert.False(Invoice4Coin3Response.IsSuccessStatusCode); + + //Attempt 5: Make tx that pays invoice 5 with 2 outputs + //Result: proposed tx consolidates the outputs + + var invoice5 = receiverUser.BitPay.CreateInvoice( + new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); + var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21, + tester.ExplorerClient.Network.NBitcoinNetwork); + var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"]; + + var Invoice5Coin4 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() + .SetChange(senderChange) + .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) + .Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2) + .AddCoins(coin4.Coin) + .AddKeys(extKey.Derive(coin4.KeyPath)) + .SendEstimatedFees(new FeeRate(100m)) + .BuildTransaction(true); + + var Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); + + Assert.True(Invoice5Coin4Response.IsSuccessStatusCode); + var Invoice5Coin4ResponseTx = + Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n); + Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address)); + + //Attempt 6: submit the same tx over and over in the hopes of getting new utxos + //Result: same tx gets sent back + for (int i = 0; i < 5; i++) + { + var Invoice5Coin4Response2 = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint, + new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain")); + if (!Invoice5Coin4Response2.IsSuccessStatusCode) + { + Logs.Tester.LogInformation( + $"Failed on try {i + 1} with {await Invoice5Coin4Response2.Content.ReadAsStringAsync()}"); + } + + Assert.True(Invoice5Coin4Response2.IsSuccessStatusCode); + var Invoice5Coin4Response2Tx = + Transaction.Parse(await Invoice5Coin4Response2.Content.ReadAsStringAsync(), n); + Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), Invoice5Coin4Response2Tx.GetHash()); + } + } + } + } +} diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 78d0d7208..fddf8e1bb 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -33,9 +33,9 @@ namespace BTCPayServer.Tests BitPay = new Bitpay(new Key(), parent.PayTester.ServerUri); } - public void GrantAccess() + public void GrantAccess(bool isAdmin = false) { - GrantAccessAsync().GetAwaiter().GetResult(); + GrantAccessAsync(isAdmin).GetAwaiter().GetResult(); } public async Task MakeAdmin(bool isAdmin = true) @@ -74,13 +74,13 @@ namespace BTCPayServer.Tests return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey); } - public void Register() + public void Register(bool isAdmin = false) { - RegisterAsync().GetAwaiter().GetResult(); + RegisterAsync(isAdmin).GetAwaiter().GetResult(); } - public async Task GrantAccessAsync() + public async Task GrantAccessAsync(bool isAdmin = false) { - await RegisterAsync(); + await RegisterAsync(isAdmin); await CreateStoreAsync(); var store = this.GetController(); var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant); @@ -160,6 +160,20 @@ namespace BTCPayServer.Tests return new WalletId(StoreId, cryptoCode); } + public async Task EnablePayJoin() + { + var storeController = parent.PayTester.GetController(UserId, StoreId); + var checkoutExperienceVM = + Assert.IsType(Assert + .IsType(storeController.CheckoutExperience()).Model); + + checkoutExperienceVM.PayJoinEnabled = true; + + Assert.Equal(nameof(storeController.CheckoutExperience), + Assert.IsType( + await storeController.CheckoutExperience(checkoutExperienceVM)).ActionName); + } + public GenerateWalletResponse GenerateWalletResponseV { get; set; } public DerivationStrategyBase DerivationScheme @@ -170,7 +184,7 @@ namespace BTCPayServer.Tests } } - private async Task RegisterAsync() + private async Task RegisterAsync(bool isAdmin = false) { var account = parent.PayTester.GetController(); RegisterDetails = new RegisterViewModel() @@ -178,6 +192,7 @@ namespace BTCPayServer.Tests Email = Guid.NewGuid() + "@toto.com", ConfirmPassword = "Kitten0@", Password = "Kitten0@", + IsAdmin = isAdmin }; await account.Register(RegisterDetails); UserId = account.RegisteredUserId; diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 47a0c18c3..cd2f54f3a 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -51,6 +51,7 @@ + all diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 9a8a3c2c1..e29e84d21 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -381,6 +381,7 @@ namespace BTCPayServer.Controllers vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? ""; vm.LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi; vm.RedirectAutomatically = storeBlob.RedirectAutomatically; + vm.PayJoinEnabled = storeBlob.PayJoinEnabled; return View(vm); } void SetCryptoCurrencies(CheckoutExperienceViewModel vm, Data.StoreData storeData) @@ -441,6 +442,7 @@ namespace BTCPayServer.Controllers blob.LightningMaxValue = lightningMaxValue; blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi; blob.RedirectAutomatically = model.RedirectAutomatically; + blob.PayJoinEnabled = model.PayJoinEnabled; if (CurrentStore.SetStoreBlob(blob)) { needUpdate = true; diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index 781488129..85d9029d2 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.ModelBinders; +using BTCPayServer.Models; using BTCPayServer.Models.WalletViewModels; using Microsoft.AspNetCore.Mvc; using NBitcoin; @@ -121,7 +124,7 @@ namespace BTCPayServer.Controllers .GetMetadataAsync(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey); - return SignWithSeed(walletId, + return await SignWithSeed(walletId, new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64()}); } @@ -153,6 +156,80 @@ namespace BTCPayServer.Controllers return result.PSBT; } + private async Task TryGetBPProposedTX(PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork) + { + + if (TempData.TryGetValue( "bpu", out var bpu) && !string.IsNullOrEmpty(bpu?.ToString()) && Uri.TryCreate(bpu.ToString(), UriKind.Absolute, out var endpoint)) + { + TempData.Remove("bpu"); + HttpClient httpClient; + if (endpoint.IsOnion() && _socketFactory.SocksClient!= null) + { + if ( _socketFactory.SocksClient == null) + { + return null; + } + httpClient = _socketFactory.SocksClient; + } + else + { + httpClient = _httpClientFactory.CreateClient("bpu"); + } + + var cloned = psbt.Clone(); + + if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors)) + { + return null; + } + + var bpuresponse = await httpClient.PostAsync(bpu.ToString(), new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain")); + if (bpuresponse.IsSuccessStatusCode) + { + var hex = await bpuresponse.Content.ReadAsStringAsync(); + if (PSBT.TryParse(hex, btcPayNetwork.NBitcoinNetwork, out var newPSBT)) + { + //check that all the inputs we provided are still there and that there is at least one new(signed) input. + bool valid = false; + var existingInputs = psbt.Inputs.Select(input => input.PrevOut).ToList(); + foreach (var input in newPSBT.Inputs) + { + var existingInput = existingInputs.SingleOrDefault(point => point == input.PrevOut); + if (existingInput != null) + { + existingInputs.Remove(existingInput); + continue; + } + + if (!input.TryFinalizeInput(out _)) + { + valid = false; + break; + } + // a new signed input was provided + valid = true; + } + + if (!valid || existingInputs.Any()) + { + return null; + } + + newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Info, + AllowDismiss = false, + Message = "This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same" + }); + return newPSBT; + } + } + } + + return null; + } + [HttpGet] [Route("{walletId}/psbt/ready")] public async Task WalletPSBTReady( diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index f429bde91..78850b3a6 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net.Http; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -49,6 +50,8 @@ namespace BTCPayServer.Controllers private readonly WalletReceiveStateService _WalletReceiveStateService; private readonly EventAggregator _EventAggregator; private readonly SettingsRepository _settingsRepository; + private readonly SocketFactory _socketFactory; + private readonly IHttpClientFactory _httpClientFactory; public RateFetcher RateFetcher { get; } CurrencyNameTable _currencyTable; @@ -66,7 +69,9 @@ namespace BTCPayServer.Controllers BTCPayWalletProvider walletProvider, WalletReceiveStateService walletReceiveStateService, EventAggregator eventAggregator, - SettingsRepository settingsRepository) + SettingsRepository settingsRepository, + SocketFactory socketFactory, + IHttpClientFactory httpClientFactory) { _currencyTable = currencyTable; Repository = repo; @@ -83,6 +88,8 @@ namespace BTCPayServer.Controllers _WalletReceiveStateService = walletReceiveStateService; _EventAggregator = eventAggregator; _settingsRepository = settingsRepository; + _socketFactory = socketFactory; + _httpClientFactory = httpClientFactory; } // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md @@ -456,7 +463,9 @@ namespace BTCPayServer.Controllers if (network == null || network.ReadonlyWallet) return NotFound(); vm.SupportRBF = network.SupportRBF; - + vm.NBXSeedAvailable = await CanUseHotWallet() && !string.IsNullOrEmpty(await ExplorerClientProvider.GetExplorerClient(network) + .GetMetadataAsync(GetDerivationSchemeSettings(walletId).AccountDerivation, + WellknownMetadataKeys.MasterHDKey, cancellation)); if (!string.IsNullOrEmpty(bip21)) { LoadFromBIP21(vm, bip21, network); @@ -594,7 +603,7 @@ namespace BTCPayServer.Controllers return View(vm); } derivationScheme.RebaseKeyPaths(psbt.PSBT); - + TempData.AddOrReplace("bpu", vm.PayJoinEndpointUrl); switch (command) { case "vault": @@ -603,7 +612,7 @@ namespace BTCPayServer.Controllers var extKey = await ExplorerClientProvider.GetExplorerClient(network) .GetMetadataAsync(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey, cancellation); - return SignWithSeed(walletId, new SignWithSeedViewModel() + return await SignWithSeed(walletId, new SignWithSeedViewModel() { SeedOrKey = extKey, PSBT = psbt.PSBT.ToBase64() @@ -650,6 +659,8 @@ namespace BTCPayServer.Controllers $"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}" }); } + uriBuilder.UnknowParameters.TryGetValue("bpu", out var vmPayJoinEndpointUrl); + vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl; } catch (Exception) { @@ -675,9 +686,17 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/vault")] - public IActionResult SubmitVault([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SubmitVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendVaultModel model) { + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + var newPSBT = await TryGetBPProposedTX(PSBT.Parse(model.PSBT, network.NBitcoinNetwork), GetDerivationSchemeSettings(walletId), network); + if (newPSBT != null) + { + model.PSBT = newPSBT.ToBase64(); + return View("WalletSendVault", model); + } + return RedirectToWalletPSBTReady(model.PSBT); } private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null) @@ -747,9 +766,16 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/ledger")] - public IActionResult SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendLedgerModel model) { + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + var newPSBT = await TryGetBPProposedTX(PSBT.Parse(model.PSBT,network.NBitcoinNetwork ), GetDerivationSchemeSettings(walletId), network); + if (newPSBT != null) + { + model.PSBT = newPSBT.ToBase64(); + return View("WalletSendLedger", model); + } return RedirectToWalletPSBTReady(model.PSBT); } @@ -764,7 +790,7 @@ namespace BTCPayServer.Controllers } [HttpPost("{walletId}/psbt/seed")] - public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] + public async Task SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, SignWithSeedViewModel viewModel) { if (!ModelState.IsValid) @@ -826,6 +852,12 @@ namespace BTCPayServer.Controllers return View(viewModel); } ModelState.Remove(nameof(viewModel.PSBT)); + var newPSBT = await TryGetBPProposedTX(psbt, GetDerivationSchemeSettings(walletId), network); + if (newPSBT != null) + { + viewModel.PSBT = newPSBT.ToBase64(); + return await SignWithSeed(walletId, viewModel); + } return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString()); } diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index d7782d264..99ee6e4e7 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -173,6 +173,7 @@ namespace BTCPayServer.Data public EmailSettings EmailSettings { get; set; } public bool RedirectAutomatically { get; set; } + public bool PayJoinEnabled { get; set; } public IPaymentFilter GetExcludedPaymentMethods() { diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 74f967690..041496c63 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -31,6 +31,7 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Changelly; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Payments.PayJoin; using BTCPayServer.Security; using BTCPayServer.Services.PaymentRequests; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -62,6 +63,7 @@ namespace BTCPayServer.Hosting { httpClient.Timeout = Timeout.InfiniteTimeSpan; }); + services.AddPayJoinServices(); services.AddMoneroLike(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -257,11 +259,13 @@ namespace BTCPayServer.Hosting { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=1000r/min burst=100 nodelay"); } else { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst= nodelay"); } return rateLimits; }); diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs index f9763be47..88857000f 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs @@ -57,6 +57,9 @@ namespace BTCPayServer.Models.StoreViewModels [Display(Name = "Redirect invoice to redirect url automatically after paid")] public bool RedirectAutomatically { get; set; } + + [Display(Name = "Enable BIP79 Payjoin/P2EP")] + public bool PayJoinEnabled { get; set; } public void SetLanguages(LanguageService langService, string defaultLang) { diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs index 5cc12c361..f28c5a606 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs @@ -10,7 +10,6 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletSendModel { - public List Outputs { get; set; } = new List(); public class TransactionOutput @@ -49,6 +48,8 @@ namespace BTCPayServer.Models.WalletViewModels public bool DisableRBF { get; set; } public bool NBXSeedAvailable { get; set; } + [Display(Name = "PayJoin Endpoint Url (BIP79)")] + public string PayJoinEndpointUrl { get; set; } public bool InputSelection { get; set; } public InputSelectionOption[] InputsAvailable { get; set; } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs index baed24724..11dd8eae3 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Wallets; using NBitcoin; using NBXplorer.JsonConverters; using Newtonsoft.Json; @@ -56,10 +57,25 @@ namespace BTCPayServer.Payments.Bitcoin public Money NextNetworkFee { get; set; } [JsonIgnore] public String DepositAddress { get; set; } + + public PayJoinPaymentState PayJoin { get; set; } = new PayJoinPaymentState(); + + + public BitcoinAddress GetDepositAddress(Network network) { return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network); } /////////////////////////////////////////////////////////////////////////////////////// } + + public class PayJoinPaymentState + { + public bool Enabled { get; set; } = false; + public uint256 ProposedTransactionHash { get; set; } + public List CoinsExposed { get; set; } + public decimal TotalOutputAmount { get; set; } + public decimal ContributedAmount { get; set; } + public uint256 OriginalTransactionHash { get; set; } + } } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs index c57ce7729..c3d52fcd2 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs @@ -21,13 +21,14 @@ namespace BTCPayServer.Payments.Bitcoin } - public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf) + public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf, decimal payJoinSelfContributedAmount) { Address = address; Value = value; Outpoint = outpoint; ConfirmationCount = 0; RBF = rbf; + PayJoinSelfContributedAmount = payJoinSelfContributedAmount; } [JsonIgnore] public BTCPayNetworkBase Network { get; set; } @@ -40,7 +41,8 @@ namespace BTCPayServer.Payments.Bitcoin public decimal NetworkFee { get; set; } public BitcoinAddress Address { get; set; } public IMoney Value { get; set; } - + public decimal PayJoinSelfContributedAmount { get; set; } = 0; + [JsonIgnore] public Script ScriptPubKey { @@ -67,7 +69,8 @@ namespace BTCPayServer.Payments.Bitcoin public decimal GetValue() { - return Value?.GetValue(Network as BTCPayNetwork)??Output.Value.ToDecimal(MoneyUnit.BTC); + return (Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC)) - + PayJoinSelfContributedAmount; } public bool PaymentCompleted(PaymentEntity entity) diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs index 7aa3091d8..ba99a47a3 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs @@ -125,7 +125,8 @@ namespace BTCPayServer.Payments.Bitcoin var prepare = (Prepare)preparePaymentObject; Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod onchainMethod = new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod(); - onchainMethod.NetworkFeeMode = store.GetStoreBlob().NetworkFeeMode; + var blob = store.GetStoreBlob(); + onchainMethod.NetworkFeeMode = blob.NetworkFeeMode; onchainMethod.FeeRate = await prepare.GetFeeRate; switch (onchainMethod.NetworkFeeMode) { @@ -142,7 +143,14 @@ namespace BTCPayServer.Payments.Bitcoin onchainMethod.NextNetworkFee = Money.Zero; break; } + onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString(); + onchainMethod.PayJoin = new PayJoinPaymentState() + { + Enabled = blob.PayJoinEnabled && + supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() != + ScriptPubKeyType.Legacy + }; return onchainMethod; } } diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index 674f29810..f8d91460b 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using NBXplorer; using System.Collections.Concurrent; +using BTCPayServer.Controllers; using NBXplorer.DerivationStrategy; using BTCPayServer.Events; using BTCPayServer.Services; @@ -18,6 +19,7 @@ using NBitcoin; using NBXplorer.Models; using BTCPayServer.Payments; using BTCPayServer.HostedServices; +using BTCPayServer.Payments.PayJoin; using NBitcoin.Altcoins.Elements; using NBitcoin.RPC; @@ -29,6 +31,7 @@ namespace BTCPayServer.Payments.Bitcoin public class NBXplorerListener : IHostedService { EventAggregator _Aggregator; + private readonly PayJoinStateProvider _payJoinStateProvider; ExplorerClientProvider _ExplorerClients; IHostApplicationLifetime _Lifetime; InvoiceRepository _InvoiceRepository; @@ -39,7 +42,8 @@ namespace BTCPayServer.Payments.Bitcoin public NBXplorerListener(ExplorerClientProvider explorerClients, BTCPayWalletProvider wallets, InvoiceRepository invoiceRepository, - EventAggregator aggregator, + EventAggregator aggregator, + PayJoinStateProvider payJoinStateProvider, IHostApplicationLifetime lifetime) { PollInterval = TimeSpan.FromMinutes(1.0); @@ -47,6 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin _InvoiceRepository = invoiceRepository; _ExplorerClients = explorerClients; _Aggregator = aggregator; + _payJoinStateProvider = payJoinStateProvider; _Lifetime = lifetime; } @@ -138,7 +143,6 @@ namespace BTCPayServer.Payments.Bitcoin switch (newEvent) { case NBXplorer.Models.NewBlockEvent evt: - await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices()) .Select(invoiceId => UpdatePaymentStates(wallet, invoiceId)) .ToArray()); @@ -146,11 +150,7 @@ namespace BTCPayServer.Payments.Bitcoin break; case NBXplorer.Models.NewTransactionEvent evt: wallet.InvalidateCache(evt.DerivationStrategy); - _Aggregator.Publish(new NewOnChainTransactionEvent() - { - CryptoCode = wallet.Network.CryptoCode, - NewTransactionEvent = evt - }); + foreach (var output in network.GetValidOutputs(evt)) { var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant(); @@ -159,7 +159,16 @@ namespace BTCPayServer.Payments.Bitcoin { var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy, output.Item1.KeyPath, output.Item1.ScriptPubKey); - var paymentData = new BitcoinLikePaymentData(address, output.matchedOutput.Value, output.outPoint, evt.TransactionData.Transaction.RBF); + + var payJoinSelfContributedAmount = GetPayJoinContributedAmount( + new WalletId(invoice.StoreId, network.CryptoCode), + output.matchedOutput.Value.GetValue(network), + evt.TransactionData.TransactionHash); + + var paymentData = new BitcoinLikePaymentData(address, + output.matchedOutput.Value, output.outPoint, + evt.TransactionData.Transaction.RBF, payJoinSelfContributedAmount); + var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any(); if (!alreadyExist) { @@ -174,6 +183,12 @@ namespace BTCPayServer.Payments.Bitcoin } } + _Aggregator.Publish(new NewOnChainTransactionEvent() + { + CryptoCode = wallet.Network.CryptoCode, + NewTransactionEvent = evt + }); + break; default: Logs.PayServer.LogWarning("Received unknown message from NBXplorer"); @@ -297,6 +312,8 @@ namespace BTCPayServer.Payments.Bitcoin if (strategy == null) continue; var cryptoId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); + var paymentMethod = invoice.GetPaymentMethod(cryptoId).GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod; + if (!invoice.Support(cryptoId)) continue; var coins = (await wallet.GetUnspentCoins(strategy)) @@ -307,9 +324,12 @@ namespace BTCPayServer.Payments.Bitcoin var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash); var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey); - var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint, - transaction.Transaction.RBF); + var payJoinSelfContributedAmount = + GetPayJoinContributedAmount(paymentMethod, coin.Value.GetValue(network), transaction.TransactionHash); + var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint, + transaction.Transaction.RBF, payJoinSelfContributedAmount); + var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false); alreadyAccounted.Add(coin.OutPoint); if (payment != null) @@ -324,6 +344,32 @@ namespace BTCPayServer.Payments.Bitcoin return totalPayment; } + private decimal GetPayJoinContributedAmount(BitcoinLikeOnChainPaymentMethod paymentMethod, decimal amount, uint256 transactionHash) + { + if (paymentMethod.PayJoin.Enabled && + paymentMethod.PayJoin.ProposedTransactionHash == transactionHash && + paymentMethod.PayJoin.TotalOutputAmount == amount) + { + //this is the payjoin output! + return paymentMethod.PayJoin.ContributedAmount; + } + + return 0; + } + private decimal GetPayJoinContributedAmount(WalletId walletId, decimal amount, uint256 transactionHash) + { + var payJoinState = + _payJoinStateProvider.Get(walletId); + + if (payJoinState == null || !payJoinState.TryGetWithProposedHash(transactionHash, out var record) || + record.TotalOutputAmount != amount) return 0; + + //this is the payjoin output! + payJoinState.RemoveRecord(transactionHash); + return record.ContributedAmount; + + } + private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network) { return invoice.GetSupportedPaymentMethod(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike)) diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs new file mode 100644 index 000000000..c394cf93f --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Filters; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBitcoin.DataEncoders; +using NBXplorer; +using NBXplorer.Models; +using NicolasDorier.RateLimits; + +namespace BTCPayServer.Payments.PayJoin +{ + [Route("{cryptoCode}/bpu")] + public class PayJoinEndpointController : ControllerBase + { + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly InvoiceRepository _invoiceRepository; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly StoreRepository _storeRepository; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + private readonly PayJoinStateProvider _payJoinStateProvider; + + public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider, + InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider, + StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider, + PayJoinStateProvider payJoinStateProvider) + { + _btcPayNetworkProvider = btcPayNetworkProvider; + _invoiceRepository = invoiceRepository; + _explorerClientProvider = explorerClientProvider; + _storeRepository = storeRepository; + _btcPayWalletProvider = btcPayWalletProvider; + _payJoinStateProvider = payJoinStateProvider; + } + + [HttpPost("{invoice}")] + [IgnoreAntiforgeryToken] + [EnableCors(CorsPolicies.All)] + [MediaTypeConstraint("text/plain")] + [RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)] + public async Task Submit(string cryptoCode, string invoice) + { + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network == null) + { + return UnprocessableEntity("Incorrect network"); + } + + string rawBody; + using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8)) + { + rawBody = await reader.ReadToEndAsync(); + } + + if (string.IsNullOrEmpty(rawBody)) + { + return UnprocessableEntity("raw tx not provided"); + } + + PSBT psbt = null; + if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var transaction) && + !PSBT.TryParse(rawBody, network.NBitcoinNetwork, out psbt)) + { + return UnprocessableEntity("invalid raw transaction or psbt"); + } + + if (psbt != null) + { + transaction = psbt.ExtractTransaction(); + } + + if (transaction.Check() != TransactionCheckResult.Success) + { + return UnprocessableEntity($"invalid tx: {transaction.Check()}"); + } + + if (transaction.Inputs.Any(txin => txin.ScriptSig == null || txin.WitScript == null)) + { + return UnprocessableEntity($"all inputs must be segwit and signed"); + } + + var explorerClient = _explorerClientProvider.GetExplorerClient(network); + var mempool = await explorerClient.BroadcastAsync(transaction, true); + if (!mempool.Success) + { + return UnprocessableEntity($"provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"); + } + + var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); + + //multiple outs could mean a payment being done to multiple invoices to multiple stores in one payjoin tx which makes life unbearable + //UNLESS the request specified an invoice Id, which is mandatory :) + var matchingInvoice = await _invoiceRepository.GetInvoice(invoice); + if (matchingInvoice == null) + { + return UnprocessableEntity($"invalid invoice"); + } + + if (matchingInvoice.IsExpired() || matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId); + //get outs to our current invoice address + var currentPaymentMethodDetails = + invoicePaymentMethod.GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod; + + if (!currentPaymentMethodDetails.PayJoin.Enabled) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null && + currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash()) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var address = currentPaymentMethodDetails.GetDepositAddress(network.NBitcoinNetwork); + var matchingTXOuts = transaction.Outputs.Where(txout => txout.IsTo(address)); + var nonMatchingTXOuts = transaction.Outputs.Where(txout => !txout.IsTo(address)); + if (!matchingTXOuts.Any()) + { + return UnprocessableEntity($"tx does not pay invoice"); + } + + var store = await _storeRepository.FindStore(matchingInvoice.StoreId); + + //check if store is enabled + var derivationSchemeSettings = store.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType().SingleOrDefault(settings => + settings.PaymentId == paymentMethodId && store.GetEnabledPaymentIds(_btcPayNetworkProvider) + .Contains(settings.PaymentId)); + if (derivationSchemeSettings == null) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var state = _payJoinStateProvider.GetOrAdd(new WalletId(matchingInvoice.StoreId, cryptoCode), + derivationSchemeSettings.AccountDerivation); + + //check if any of the inputs have been spotted in other txs sent our way..Reject anything but the original + //also reject if the invoice being payjoined to already has a record + if (!state.CheckIfTransactionValid(transaction, invoice, out var alreadyExists)) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //check if wallet of store is configured to be hot wallet + var extKeyStr = await explorerClient.GetMetadataAsync( + derivationSchemeSettings.AccountDerivation, + WellknownMetadataKeys.MasterHDKey); + if (extKeyStr == null) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + var extKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork); + + var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); + if (signingKeySettings.RootFingerprint is null) + signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint(); + + RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath(); + if (rootedKeyPath == null) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + // The master fingerprint and/or account key path of your seed are not set in the wallet settings + } + + // The user gave the root key, let's try to rebase the PSBT, and derive the account private key + if (rootedKeyPath.MasterFingerprint == extKey.GetPublicKey().GetHDFingerPrint()) + { + extKey = extKey.Derive(rootedKeyPath.KeyPath); + } + + //check if the store uses segwit -- mixing inputs of different types is suspicious + if (derivationSchemeSettings.AccountDerivation.ScriptPubKeyType() == ScriptPubKeyType.Legacy) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //get previous payments so that we can check if their address is also used in the txouts) + var previousPayments = matchingInvoice.GetPayments(network) + .Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData); + + if (transaction.Outputs.Any( + txout => previousPayments.Any(data => txout.IsTo(data.GetDestination())))) + { + //Meh, address reuse from the customer would be happening with this tx, skip + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //get any utxos we exposed already that match any of the inputs sent to us. + var utxosToContributeToThisPayment = state.GetExposed(transaction); + + var invoicePaymentMethodAccounting = invoicePaymentMethod.Calculate(); + if (invoicePaymentMethodAccounting.Due != matchingTXOuts.Sum(txout => txout.Value) && + !utxosToContributeToThisPayment.Any()) + { + //the invoice would be under/overpaid with this tx and we have not exposed utxos so no worries + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //if we have not exposed any utxos to any of the inputs + if (!utxosToContributeToThisPayment.Any()) + { + var wallet = _btcPayWalletProvider.GetWallet(network); + //get all utxos we have so far exposed + var coins = state.GetRecords().SelectMany(list => + list.CoinsExposed.Select(coin => coin.OutPoint.Hash)); + + //get all utxos we have NOT so far exposed + var availableUtxos = (await wallet.GetUnspentCoins(derivationSchemeSettings.AccountDerivation)).Where( + coin => + !coins.Contains(coin.OutPoint.Hash)); + if (availableUtxos.Any()) + { + //clean up the state by removing utxos from the exposed list that we no longer have + state.PruneExposedButSpentCoins(availableUtxos); + //if we have coins that were exposed before but were not spent, prioritize them + var exposedAlready = state.GetExposedCoins(); + if (exposedAlready.Any()) + { + utxosToContributeToThisPayment = SelectCoins(network, exposedAlready, + invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC), + nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC))); + state.PruneExposedBySpentCoins(utxosToContributeToThisPayment.Select(coin => coin.OutPoint)); + } + else + { + utxosToContributeToThisPayment = SelectCoins(network, availableUtxos, + invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC), + nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC))); + } + } + } + + //we don't have any utxos to provide to this tx + if (!utxosToContributeToThisPayment.Any()) + { + return UnprocessableEntity($"cannot handle payjoin tx"); + } + + //we rebuild the tx using 1 output to the invoice designed address + var cjOutputContributedAmount = utxosToContributeToThisPayment.Sum(coin => coin.Value.GetValue(network)); + var cjOutputSum = matchingTXOuts.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)) + + cjOutputContributedAmount; + + var newTx = transaction.Clone(); + + + if (matchingTXOuts.Count() > 1) + { + //if there are more than 1 outputs to our address, consolidate them to 1 + coinjoined amount to avoid unnecessary utxos + newTx.Outputs.Clear(); + newTx.Outputs.Add(new Money(cjOutputSum, MoneyUnit.BTC), address.ScriptPubKey); + foreach (var nonmatchingTxOut in nonMatchingTXOuts) + { + newTx.Outputs.Add(nonmatchingTxOut.Value, nonmatchingTxOut.ScriptPubKey); + } + } + else + { + //set the value of the out to our address to the sum of the coinjoined amount + foreach (var txOutput in newTx.Outputs.Where(txOutput => + txOutput.Value == matchingTXOuts.First().Value && + txOutput.ScriptPubKey == matchingTXOuts.First().ScriptPubKey)) + { + txOutput.Value = new Money(cjOutputSum, MoneyUnit.BTC); + break; + } + } + + newTx.Inputs.AddRange(utxosToContributeToThisPayment.Select(coin => + new TxIn(coin.OutPoint) {Sequence = newTx.Inputs.First().Sequence})); + + if (psbt != null) + { + psbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork); + + psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest() + { + DerivationScheme = derivationSchemeSettings.AccountDerivation, + PSBT = psbt, + RebaseKeyPaths = derivationSchemeSettings.GetPSBTRebaseKeyRules().ToList() + })).PSBT; + + + psbt = psbt.SignWithKeys(utxosToContributeToThisPayment + .Select(coin => extKey.Derive(coin.KeyPath).PrivateKey) + .ToArray()); + + if (!alreadyExists) + { + await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, + cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, + invoicePaymentMethod); + } + + return Ok(HexEncoder.IsWellFormed(rawBody) ? psbt.ToHex() : psbt.ToBase64()); + } + else + { + // Since we're going to modify the transaction, we're going invalidate all signatures + foreach (TxIn newTxInput in newTx.Inputs) + { + newTxInput.WitScript = WitScript.Empty; + } + + newTx.Sign( + utxosToContributeToThisPayment.Select(coin => + extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)), + utxosToContributeToThisPayment.Select(coin => coin.Coin)); + + if (!alreadyExists) + { + await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment, + cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails, + invoicePaymentMethod); + } + + return Ok(newTx.ToHex()); + } + } + + private async Task AddRecord(string invoice, PayJoinState joinState, Transaction transaction, + List utxosToContributeToThisPayment, decimal cjOutputContributedAmount, decimal cjOutputSum, + Transaction newTx, + BitcoinLikeOnChainPaymentMethod currentPaymentMethodDetails, PaymentMethod invoicePaymentMethod) + { + //keep a record of the tx and check if we have seen the tx before or any of its inputs + //on a timer service: if x amount of times passes, broadcast this tx + joinState.AddRecord(new PayJoinStateRecordedItem() + { + Timestamp = DateTimeOffset.Now, + Transaction = transaction, + OriginalTransactionHash = transaction.GetHash(), + CoinsExposed = utxosToContributeToThisPayment, + ContributedAmount = cjOutputContributedAmount, + TotalOutputAmount = cjOutputSum, + ProposedTransactionHash = newTx.GetHash(), + InvoiceId = invoice + }); + //we also store a record in the payment method details of the invoice, + //Tn case the server is shut down and a payjoin payment is made before it is turned back on. + //Otherwise we would end up marking the invoice as overPaid with our own inputs! + currentPaymentMethodDetails.PayJoin = new PayJoinPaymentState() + { + Enabled = true, + CoinsExposed = utxosToContributeToThisPayment, + ContributedAmount = cjOutputContributedAmount, + TotalOutputAmount = cjOutputSum, + ProposedTransactionHash = newTx.GetHash(), + OriginalTransactionHash = transaction.GetHash(), + }; + invoicePaymentMethod.SetPaymentMethodDetails(currentPaymentMethodDetails); + await _invoiceRepository.UpdateInvoicePaymentMethod(invoice, invoicePaymentMethod); + } + + private List SelectCoins(BTCPayNetwork network, IEnumerable availableUtxos, + decimal paymentAmount, IEnumerable otherOutputs) + { + // UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario". + // + // "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output. + // + // "UIH2": one input is larger than any output. This heuristically implies that no output is a payment, or, to say it better, it implies that this is not a normal wallet-created payment, it's something strange/exotic. + //src: https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2796539 + + foreach (var availableUtxo in availableUtxos) + { + //we can only check against our input as we dont know the value of the rest. + var input = availableUtxo.Value.GetValue(network); + var paymentAmountSum = input + paymentAmount; + if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output)) + { + //UIH 1 & 2 + continue; + } + + return new List {availableUtxo}; + } + + //For now we just grab a utxo "at random" + Random r = new Random(); + return new List() {availableUtxos.ElementAt(r.Next(0, availableUtxos.Count()))}; + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs new file mode 100644 index 000000000..377fa2304 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace BTCPayServer.Payments.PayJoin +{ + public static class PayJoinExtensions + { + public static void AddPayJoinServices(this IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinState.cs b/BTCPayServer/Payments/PayJoin/PayJoinState.cs new file mode 100644 index 000000000..9f73c8807 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinState.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using BTCPayServer.Services.Wallets; +using NBitcoin; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinState + { + //keep track of all transactions sent to us via this protocol + private readonly ConcurrentDictionary RecordedTransactions = + new ConcurrentDictionary(); + + //utxos that have been exposed but the original tx was broadcasted instead. + private readonly ConcurrentDictionary ExposedCoins; + + public PayJoinState(ConcurrentDictionary exposedCoins = null) + { + ExposedCoins = exposedCoins ?? new ConcurrentDictionary(); + } + + public IEnumerable GetRecords() + { + return RecordedTransactions.Values; + } + + public IEnumerable GetStaleRecords(TimeSpan cutoff) + { + return GetRecords().Where(pair => + DateTimeOffset.Now.Subtract(pair.Timestamp).TotalMilliseconds >= + cutoff.TotalMilliseconds); + } + + public bool CheckIfTransactionValid(Transaction transaction, string invoiceId, out bool alreadyExists) + { + if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}")) + { + alreadyExists = true; + return true; + } + + alreadyExists = false; + var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString()); + return !RecordedTransactions.Any(record => + record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) || + record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase) || + record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString()))); + } + + public void AddRecord(PayJoinStateRecordedItem recordedItem) + { + RecordedTransactions.TryAdd(recordedItem.ToString(), recordedItem); + foreach (var receivedCoin in recordedItem.CoinsExposed) + { + ExposedCoins.TryRemove(receivedCoin.OutPoint.ToString(), out _); + } + } + + public void RemoveRecord(PayJoinStateRecordedItem item, bool keepExposed) + { + if (keepExposed) + { + foreach (var receivedCoin in item.CoinsExposed) + { + ExposedCoins.AddOrReplace(receivedCoin.OutPoint.ToString(), receivedCoin); + } + } + + RecordedTransactions.TryRemove(item.ToString(), out _); + } + + public void RemoveRecord(uint256 proposedTxHash) + { + var id = RecordedTransactions.Single(pair => + pair.Value.ProposedTransactionHash == proposedTxHash || + pair.Value.OriginalTransactionHash == proposedTxHash).Key; + RecordedTransactions.TryRemove(id, out _); + } + + public List GetExposed(Transaction transaction) + { + return RecordedTransactions.Values + .Where(pair => + pair.Transaction.Inputs.Any(txIn => + transaction.Inputs.Any(txIn2 => txIn.PrevOut == txIn2.PrevOut))) + .SelectMany(pair => pair.CoinsExposed).ToList(); + } + + public bool TryGetWithProposedHash(uint256 hash, out PayJoinStateRecordedItem item) + { + item = + RecordedTransactions.Values.SingleOrDefault( + recordedItem => recordedItem.ProposedTransactionHash == hash); + return item != null; + } + + public IEnumerable GetExposedCoins(bool includeOnesInOngoingBPUs = false) + { + var result = ExposedCoins.Values; + return includeOnesInOngoingBPUs + ? result.Concat(RecordedTransactions.Values.SelectMany(item => item.CoinsExposed)) + : result; + } + + public void PruneExposedButSpentCoins(IEnumerable stillAvailable) + { + var keys = stillAvailable.Select(coin => coin.OutPoint.ToString()); + var keysToRemove = ExposedCoins.Keys.Where(s => !keys.Contains(s)); + foreach (var key in keysToRemove) + { + ExposedCoins.TryRemove(key, out _); + } + } + + + public void PruneExposedBySpentCoins(IEnumerable taken) + { + var keys = taken.Select(coin => coin.ToString()); + var keysToRemove = ExposedCoins.Keys.Where(s => keys.Contains(s)); + foreach (var key in keysToRemove) + { + ExposedCoins.TryRemove(key, out _); + } + } + + public void PruneRecordsOfUsedInputs(TxInList transactionInputs) + { + foreach (PayJoinStateRecordedItem payJoinStateRecordedItem in RecordedTransactions.Values) + { + if (payJoinStateRecordedItem.CoinsExposed.Any(coin => + transactionInputs.Any(txin => txin.PrevOut == coin.OutPoint))) + { + RemoveRecord(payJoinStateRecordedItem, true); + } + } + + PruneExposedBySpentCoins(transactionInputs.Select(coin => coin.PrevOut)); + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs b/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs new file mode 100644 index 000000000..fac5b4765 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using NBitcoin; +using NBXplorer.DerivationStrategy; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinStateProvider + { + private readonly SettingsRepository _settingsRepository; + private readonly StoreRepository _storeRepository; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + + private MultiValueDictionary Lookup = + new MultiValueDictionary(); + + private ConcurrentDictionary States = + new ConcurrentDictionary(); + + public PayJoinStateProvider(SettingsRepository settingsRepository, StoreRepository storeRepository, + BTCPayNetworkProvider btcPayNetworkProvider, BTCPayWalletProvider btcPayWalletProvider) + { + _settingsRepository = settingsRepository; + _storeRepository = storeRepository; + _btcPayNetworkProvider = btcPayNetworkProvider; + _btcPayWalletProvider = btcPayWalletProvider; + } + + public IEnumerable Get(string cryptoCode, DerivationStrategyBase derivationStrategyBase) + { + if (Lookup.TryGetValue(derivationStrategyBase, out var walletIds)) + { + var matchedWalletKeys = walletIds.Where(id => + id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)); + + return matchedWalletKeys.Select(id => States.TryGet(id)); + } + + return Array.Empty(); + } + + public PayJoinState Get(WalletId walletId) + { + return States.TryGet(walletId); + } + + public ConcurrentDictionary GetAll() + { + return States; + } + + public PayJoinState GetOrAdd(WalletId key, DerivationStrategyBase derivationStrategyBase, + IEnumerable exposedCoins = null) + { + return States.GetOrAdd(key, id => + { + Lookup.Add(derivationStrategyBase, id); + return new PayJoinState(exposedCoins == null + ? null + : new ConcurrentDictionary(exposedCoins.Select(coin => + new KeyValuePair(coin.OutPoint.ToString(), coin)))); + }); + } + + public void RemoveState(WalletId walletId) + { + States.TryRemove(walletId, out _); + } + + public async Task SaveCoins() + { + Dictionary> saved = + new Dictionary>(); + foreach (var payState in GetAll()) + { + saved.Add(payState.Key.ToString(), + payState.Value.GetExposedCoins(true).Select(coin => coin.OutPoint)); + } + + await _settingsRepository.UpdateSetting(saved, "bpu-state"); + } + + public async Task LoadCoins() + { + Dictionary> saved = + await _settingsRepository.GetSettingAsync>>("bpu-state"); + if (saved == null) + { + return; + } + + foreach (KeyValuePair> keyValuePair in saved) + { + var walletId = WalletId.Parse(keyValuePair.Key); + var store = await _storeRepository.FindStore(walletId.StoreId); + var derivationSchemeSettings = store?.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType().SingleOrDefault(settings => + settings.PaymentId.CryptoCode.Equals(walletId.CryptoCode, + StringComparison.InvariantCultureIgnoreCase)); + if (derivationSchemeSettings == null) + { + continue; + } + + var utxos = await _btcPayWalletProvider.GetWallet(walletId.CryptoCode) + .GetUnspentCoins(derivationSchemeSettings.AccountDerivation); + + _ = GetOrAdd(walletId, derivationSchemeSettings.AccountDerivation, + utxos.Where(coin => keyValuePair.Value.Contains(coin.OutPoint))); + } + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs b/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs new file mode 100644 index 000000000..0e480fcb5 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using BTCPayServer.Services.Wallets; +using NBitcoin; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinStateRecordedItem + { + public Transaction Transaction { get; set; } + public DateTimeOffset Timestamp { get; set; } + public uint256 ProposedTransactionHash { get; set; } + public List CoinsExposed { get; set; } + public decimal TotalOutputAmount { get; set; } + public decimal ContributedAmount { get; set; } + public uint256 OriginalTransactionHash { get; set; } + + public string InvoiceId { get; set; } + + public override string ToString() + { + return $"{InvoiceId}_{OriginalTransactionHash}"; + } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs b/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs new file mode 100644 index 000000000..778e8bdb6 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayJoinTransactionBroadcaster.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Events; +using Microsoft.Extensions.Hosting; +using NBXplorer; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayJoinTransactionBroadcaster : IHostedService + { + private readonly TimeSpan + BroadcastAfter = + TimeSpan.FromMinutes( + 5); // The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok. + + private readonly EventAggregator _eventAggregator; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly PayJoinStateProvider _payJoinStateProvider; + + private CompositeDisposable leases = new CompositeDisposable(); + + public PayJoinTransactionBroadcaster( + EventAggregator eventAggregator, + ExplorerClientProvider explorerClientProvider, + PayJoinStateProvider payJoinStateProvider) + { + _eventAggregator = eventAggregator; + _explorerClientProvider = explorerClientProvider; + _payJoinStateProvider = payJoinStateProvider; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var loadCoins = _payJoinStateProvider.LoadCoins(); + //if the wallet was updated, we need to remove the state as the utxos no longer fit + leases.Add(_eventAggregator.Subscribe(evt => + _payJoinStateProvider.RemoveState(evt.WalletId))); + + leases.Add(_eventAggregator.Subscribe(txEvent => + { + if (!txEvent.NewTransactionEvent.Outputs.Any()) + { + return; + } + + var relevantStates = + _payJoinStateProvider.Get(txEvent.CryptoCode, txEvent.NewTransactionEvent.DerivationStrategy); + + foreach (var relevantState in relevantStates) + { + //if any of the exposed inputs where spent, remove them from our state + relevantState.PruneRecordsOfUsedInputs(txEvent.NewTransactionEvent.TransactionData.Transaction + .Inputs); + } + })); + _ = BroadcastTransactionsPeriodically(cancellationToken); + await loadCoins; + } + + private async Task BroadcastTransactionsPeriodically(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var tasks = new List(); + + foreach (var state in _payJoinStateProvider.GetAll()) + { + var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode); + //broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time. + //This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible. + var staleTxs = state.Value.GetStaleRecords(BroadcastAfter); + + tasks.AddRange(staleTxs.Select(staleTx => explorerClient + .BroadcastAsync(staleTx.Transaction, cancellationToken) + .ContinueWith(task => { state.Value.RemoveRecord(staleTx, true); }, TaskScheduler.Default))); + } + + await Task.WhenAll(tasks); + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _payJoinStateProvider.SaveCoins(); + leases.Dispose(); + } + } +} diff --git a/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs index 461d189e7..3e6f7b4b1 100644 --- a/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs +++ b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs @@ -33,12 +33,12 @@ namespace BTCPayServer.Payments public override IPaymentMethodDetails DeserializePaymentMethodDetails(BTCPayNetworkBase network, string str) { - return JsonConvert.DeserializeObject(str); + return ((BTCPayNetwork) network).ToObject(str); } public override string SerializePaymentMethodDetails(BTCPayNetworkBase network, IPaymentMethodDetails details) { - return JsonConvert.SerializeObject(details); + return ((BTCPayNetwork) network).ToString((BitcoinLikeOnChainPaymentMethod)details); } public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value) diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 1b219f2d1..d3728e38b 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -411,7 +411,8 @@ namespace BTCPayServer.Services.Invoices var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo(); var subtotalPrice = accounting.TotalDue - accounting.NetworkFee; var cryptoCode = info.GetId().CryptoCode; - var address = info.GetPaymentMethodDetails()?.GetPaymentDestination(); + var details = info.GetPaymentMethodDetails(); + var address = details?.GetPaymentDestination(); var exrates = new Dictionary { { ProductInformation.Currency, cryptoInfo.Rate } @@ -463,12 +464,18 @@ namespace BTCPayServer.Services.Invoices { var minerInfo = new MinerFeeInfo(); minerInfo.TotalFee = accounting.NetworkFee.Satoshi; - minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)info.GetPaymentMethodDetails()).FeeRate + minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)details).FeeRate .GetFee(1).Satoshi; dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo); + var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due); + + if (((details as BitcoinLikeOnChainPaymentMethod)?.PayJoin?.Enabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase)) + { + bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu/{Id}"; + } cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() { - BIP21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due), + BIP21 = bip21, }; #pragma warning disable 618 diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 59f5d52b5..6f6f8dafe 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -292,6 +292,21 @@ retry: } } + public async Task UpdateInvoicePaymentMethod(string invoiceId, PaymentMethod paymentMethod) + { + using (var context = _ContextFactory.CreateContext()) + { + var invoice = await context.Invoices.FindAsync(invoiceId); + if (invoice == null) + return; + var network = paymentMethod.Network; + var invoiceEntity = ToObject(invoice.Blob); + invoiceEntity.SetPaymentMethod(paymentMethod); + invoice.Blob = ToBytes(invoiceEntity, network); + await context.SaveChangesAsync(); + } + } + public async Task AddPendingInvoiceIfNotPresent(string invoiceId) { using (var context = _ContextFactory.CreateContext()) diff --git a/BTCPayServer/Services/SocketFactory.cs b/BTCPayServer/Services/SocketFactory.cs index e604c1b06..ed2da78b5 100644 --- a/BTCPayServer/Services/SocketFactory.cs +++ b/BTCPayServer/Services/SocketFactory.cs @@ -1,13 +1,12 @@ -using System; -using NBitcoin; -using System.Collections.Generic; -using System.Linq; +using NBitcoin; using System.Net; +using System.Net.Http; using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Configuration; +using com.LandonKey.SocksWebProxy; +using com.LandonKey.SocksWebProxy.Proxy; using NBitcoin.Protocol.Connectors; using NBitcoin.Protocol; @@ -16,9 +15,11 @@ namespace BTCPayServer.Services public class SocketFactory { private readonly BTCPayServerOptions _options; + public readonly HttpClient SocksClient; public SocketFactory(BTCPayServerOptions options) { _options = options; + SocksClient = CreateHttpClientUsingSocks(); } public async Task ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken) { @@ -60,5 +61,21 @@ namespace BTCPayServer.Services { } } + + private HttpClient CreateHttpClientUsingSocks() + { + if (_options.SocksEndpoint == null) + return null; + return new HttpClient(new HttpClientHandler + { + Proxy = new SocksWebProxy(new ProxyConfig() + { + Version = ProxyConfig.SocksVersion.Five, + SocksAddress = _options.SocksEndpoint.AsOnionCatIPEndpoint().Address, + SocksPort = _options.SocksEndpoint.AsOnionCatIPEndpoint().Port, + }), + UseProxy = true + }); + } } } diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index da13c7890..26604e53b 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -23,7 +23,7 @@ namespace BTCPayServer.Services.Wallets public DateTimeOffset Timestamp { get; set; } public KeyPath KeyPath { get; set; } public IMoney Value { get; set; } - + public Coin Coin { get; set; } } public class NetworkCoins { @@ -180,7 +180,8 @@ namespace BTCPayServer.Services.Wallets Value = c.Value, Timestamp = c.Timestamp, OutPoint = c.Outpoint, - ScriptPubKey = c.ScriptPubKey + ScriptPubKey = c.ScriptPubKey, + Coin = c.AsCoin(derivationStrategy) }).ToArray(); } diff --git a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml index d30391173..9efdba957 100644 --- a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml +++ b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml @@ -52,7 +52,7 @@ @payment.Crypto @payment.DepositAddress - @payment.CryptoPaymentData.GetValue() + @payment.CryptoPaymentData.GetValue() @(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"(+ Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount })") +
+ + + +
} + @if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl)) + { +
+ + + +
+ }
diff --git a/BTCPayServer/ZoneLimits.cs b/BTCPayServer/ZoneLimits.cs index 08e722cec..85a424967 100644 --- a/BTCPayServer/ZoneLimits.cs +++ b/BTCPayServer/ZoneLimits.cs @@ -9,5 +9,6 @@ namespace BTCPayServer { public const string Login = "btcpaylogin"; public const string Register = "btcpayregister"; + public const string PayJoin = "PayJoin"; } }