diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 83c4b536c..3a360d757 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -194,17 +194,13 @@ namespace BTCPayServer.Tests var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network); var clientShouldError = unsupportedFormats.Contains(senderAddressType); - string errorCode = null; + string errorCode = receiverAddressType == senderAddressType ? null : "unavailable|any UTXO available"; + var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true }); if (unsupportedFormats.Contains(receiverAddressType)) { - errorCode = "unsupported-inputs"; + Assert.Null(TestAccount.GetPayjoinEndpoint(invoice, cashCow.Network)); + continue; } - else if (receiverAddressType != senderAddressType) - { - errorCode = "out-of-utxos"; - } - var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true }); - var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network); var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder(); @@ -551,7 +547,7 @@ namespace BTCPayServer.Tests 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), InvoicePaid: true, ExpectedError: "not-enough-money"); + var vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money", OriginalTxBroadcasted: true); async Task RunVector(bool skipLockedCheck = false) { var coin = await senderUser.ReceiveUTXO(vector.SpentCoin, network); @@ -592,6 +588,19 @@ namespace BTCPayServer.Tests Assert.Equal("paid", invoice.Status); }); } + + psbt.Finalize(); + var broadcasted = await tester.PayTester.GetService().GetExplorerClient("BTC").BroadcastAsync(psbt.ExtractTransaction(), true); + if (vector.OriginalTxBroadcasted) + { + Assert.Equal("txn-already-in-mempool", broadcasted.RPCCodeMessage); + } + else + { + Assert.True(broadcasted.Success); + } + receiverCoin = await receiverUser.ReceiveUTXO(receiverCoin.Amount, network); + await LockAllButReceiverCoin(); return pj; } @@ -610,32 +619,35 @@ namespace BTCPayServer.Tests 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" + "However, the original tx has been broadcasted!"); - vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money"); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money", OriginalTxBroadcasted: true); await RunVector(); - await LockAllButReceiverCoin(); 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), InvoicePaid: false, ExpectedError: "invoice-not-fully-paid"); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), InvoicePaid: false, ExpectedError: "invoice-not-fully-paid", OriginalTxBroadcasted: true); 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), InvoicePaid: true, ExpectedError: null as string); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false); await RunVector(); - await LockAllButReceiverCoin(); - Logs.Tester.LogInformation("We pay a little bit more the invoice with enough fees to support additional input\n" + - "The receiver should have added a fake output"); - vector = (SpentCoin: Money.Satoshis(910), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string); - var proposedPSBT = await RunVector(); - Assert.Equal(2, proposedPSBT.Outputs.Count); - await LockAllButReceiverCoin(); + PSBT proposedPSBT = null; + var outputCountReceived = new bool[2]; + do + { + Logs.Tester.LogInformation("We pay a little bit more the invoice with enough fees to support additional input\n" + + "The receiver should have added a fake output"); + vector = (SpentCoin: Money.Satoshis(910), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false); + proposedPSBT = await RunVector(); + Assert.True(proposedPSBT.Outputs.Count == 1 || proposedPSBT.Outputs.Count == 2); + outputCountReceived[proposedPSBT.Outputs.Count - 1] = true; + cashCow.Generate(1); + } while (outputCountReceived.All(o => o)); 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), InvoicePaid: true, ExpectedError: "out-of-utxos"); + vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "unavailable|any UTXO available", OriginalTxBroadcasted: true); await RunVector(true); - await LockAllButReceiverCoin(); var originalSenderUser = senderUser; retry: @@ -645,7 +657,7 @@ namespace BTCPayServer.Tests // 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), InvoicePaid: true, ExpectedError: null as string); + vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false); proposedPSBT = await RunVector(); Assert.Equal(2, proposedPSBT.Outputs.Count); Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(500) + receiverCoin.Amount); @@ -684,7 +696,7 @@ namespace BTCPayServer.Tests // Same as above. Except the sender send one satoshi less, so the change // output would get below dust and would be removed completely. // So we remove as much fee as we can, and still accept the transaction because it is above minrelay fee - vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string); + vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false); proposedPSBT = await RunVector(); Assert.Equal(2, proposedPSBT.Outputs.Count); // We should have our payment @@ -848,7 +860,7 @@ namespace BTCPayServer.Tests //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"); + await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, expectedError: "unavailable|Some of those inputs have already been used"); var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork); var contributedInputsInvoice2Coin2ResponseTx = diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 5911d0a59..01302d6a4 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -136,6 +136,13 @@ namespace BTCPayServer.Tests modify(store); storeController.UpdateStore(store).GetAwaiter().GetResult(); } + public Task ModifyStoreAsync(Action modify) + { + var storeController = GetController(); + StoreViewModel store = (StoreViewModel)((ViewResult)storeController.UpdateStore()).Model; + modify(store); + return storeController.UpdateStore(store); + } public T GetController(bool setImplicitStore = true) where T : Controller { @@ -192,18 +199,9 @@ namespace BTCPayServer.Tests return new WalletId(StoreId, cryptoCode); } - public async Task EnablePayJoin() + public 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); + return ModifyStoreAsync(s => s.PayJoinEnabled = true); } public GenerateWalletResponse GenerateWalletResponseV { get; set; } @@ -332,7 +330,7 @@ namespace BTCPayServer.Tests var endpoint = GetPayjoinEndpoint(invoice, psbt.Network); if (endpoint == null) { - return null; + throw new InvalidOperationException("No payjoin endpoint for the invoice"); } var pjClient = parent.PayTester.GetService(); var storeRepository = parent.PayTester.GetService(); @@ -356,7 +354,10 @@ namespace BTCPayServer.Tests else { var ex = await Assert.ThrowsAsync(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default)); - Assert.Equal(expectedError, ex.ErrorCode); + var split = expectedError.Split('|'); + Assert.Equal(split[0], ex.ErrorCode); + if (split.Length > 1) + Assert.Contains(split[1], ex.ReceiverMessage); } return null; } @@ -381,9 +382,13 @@ namespace BTCPayServer.Tests new StringContent(content, Encoding.UTF8, "text/plain")); if (expectedError != null) { + var split = expectedError.Split('|'); Assert.False(response.IsSuccessStatusCode); var error = JObject.Parse(await response.Content.ReadAsStringAsync()); - Assert.Equal(expectedError, error["errorCode"].Value()); + if (split.Length > 0) + Assert.Equal(split[0], error["errorCode"].Value()); + if (split.Length > 1) + Assert.Contains(split[1], error["message"].Value()); return null; } else diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index 2eb5b5841..e8c10766b 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -24,6 +24,7 @@ using System.Diagnostics.CodeAnalysis; using BTCPayServer.Data; using NBitcoin.DataEncoders; using Amazon.S3.Model; +using BTCPayServer.Logging; namespace BTCPayServer.Payments.PayJoin { @@ -89,6 +90,7 @@ namespace BTCPayServer.Payments.PayJoin private readonly NBXplorerDashboard _dashboard; private readonly DelayedTransactionBroadcaster _broadcaster; private readonly WalletRepository _walletRepository; + private readonly BTCPayServerEnvironment _env; public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider, InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider, @@ -97,7 +99,8 @@ namespace BTCPayServer.Payments.PayJoin EventAggregator eventAggregator, NBXplorerDashboard dashboard, DelayedTransactionBroadcaster broadcaster, - WalletRepository walletRepository) + WalletRepository walletRepository, + BTCPayServerEnvironment env) { _btcPayNetworkProvider = btcPayNetworkProvider; _invoiceRepository = invoiceRepository; @@ -109,6 +112,7 @@ namespace BTCPayServer.Payments.PayJoin _dashboard = dashboard; _broadcaster = broadcaster; _walletRepository = walletRepository; + _env = env; } [HttpPost("")] @@ -131,14 +135,18 @@ namespace BTCPayServer.Payments.PayJoin new JProperty("message", "This version of payjoin is not supported.") }); } - FeeRate senderMinFeeRate = minfeerate < 0.0m ? null : new FeeRate(minfeerate); - Money allowedSenderFeeContribution = Money.Satoshis(maxadditionalfeecontribution >= 0 ? maxadditionalfeecontribution : long.MaxValue); + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); if (network == null) { return BadRequest(CreatePayjoinError("invalid-network", "Incorrect network")); } - + await using var ctx = new PayjoinReceiverContext(_invoiceRepository, _explorerClientProvider.GetExplorerClient(network), _payJoinRepository); + ObjectResult CreatePayjoinErrorAndLog(int httpCode, PayjoinReceiverWellknownErrors err, string debug) + { + ctx.Logs.Write($"Payjoin error: {debug}"); + return StatusCode(httpCode, CreatePayjoinError(err, debug)); + } var explorer = _explorerClientProvider.GetExplorerClient(network); if (Request.ContentLength is long length) { @@ -159,7 +167,6 @@ namespace BTCPayServer.Payments.PayJoin rawBody = (await reader.ReadToEndAsync()) ?? string.Empty; } - Transaction originalTx = null; FeeRate originalFeeRate = null; bool psbtFormat = true; @@ -167,7 +174,7 @@ namespace BTCPayServer.Payments.PayJoin { if (!psbt.IsAllFinalized()) return BadRequest(CreatePayjoinError("psbt-not-finalized", "The PSBT should be finalized")); - originalTx = psbt.ExtractTransaction(); + ctx.OriginalTransaction = psbt.ExtractTransaction(); } // BTCPay Server implementation support a transaction instead of PSBT else @@ -175,7 +182,7 @@ namespace BTCPayServer.Payments.PayJoin psbtFormat = false; if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx)) return BadRequest(CreatePayjoinError("invalid-format", "invalid transaction or psbt")); - originalTx = tx; + ctx.OriginalTransaction = 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++) @@ -185,10 +192,9 @@ namespace BTCPayServer.Payments.PayJoin } } - async Task BroadcastNow() - { - await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx); - } + bool spareChangeCase = psbt.Outputs.Count == 1; + FeeRate senderMinFeeRate = !spareChangeCase && minfeerate >= 0.0m ? new FeeRate(minfeerate) : null; + Money allowedSenderFeeContribution = Money.Satoshis(!spareChangeCase && maxadditionalfeecontribution >= 0 ? maxadditionalfeecontribution : long.MaxValue); var sendersInputType = psbt.GetInputsScriptPubKeyType(); if (psbt.CheckSanity() is var errors && errors.Count != 0) @@ -221,9 +227,10 @@ namespace BTCPayServer.Payments.PayJoin } //////////// - var mempool = await explorer.BroadcastAsync(originalTx, true); + var mempool = await explorer.BroadcastAsync(ctx.OriginalTransaction, true); if (!mempool.Success) { + ctx.DoNotBroadcast(); return BadRequest(CreatePayjoinError("invalid-transaction", $"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}")); } @@ -232,11 +239,6 @@ namespace BTCPayServer.Payments.PayJoin bool paidSomething = false; Money due = null; Dictionary selectedUTXOs = new Dictionary(); - - async Task UnlockUTXOs() - { - await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray()); - } PSBTOutput originalPaymentOutput = null; BitcoinAddress paymentAddress = null; InvoiceEntity invoice = null; @@ -244,7 +246,7 @@ namespace BTCPayServer.Payments.PayJoin foreach (var output in psbt.Outputs) { var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant(); - invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {key})).FirstOrDefault(); + invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] { key })).FirstOrDefault(); if (invoice is null) continue; derivationSchemeSettings = invoice.GetSupportedPaymentMethod(paymentMethodId) @@ -256,13 +258,11 @@ namespace BTCPayServer.Payments.PayJoin if (!PayjoinClient.SupportedFormats.Contains(receiverInputsType)) { //this should never happen, unless the store owner changed the wallet mid way through an invoice - return StatusCode(500, CreatePayjoinError("unavailable", $"This service is unavailable for now")); + return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Our wallet does not support payjoin"); } if (sendersInputType is ScriptPubKeyType t && t != receiverInputsType) { - return StatusCode(503, - CreatePayjoinError("out-of-utxos", - "We do not have any UTXO available for making a payjoin with the sender's inputs type")); + return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for making a payjoin with the sender's inputs type"); } var paymentMethod = invoice.GetPaymentMethod(paymentMethodId); var paymentDetails = @@ -271,6 +271,7 @@ namespace BTCPayServer.Payments.PayJoin continue; if (invoice.GetAllBitcoinPaymentData().Any()) { + ctx.DoNotBroadcast(); return UnprocessableEntity(CreatePayjoinError("already-paid", $"The invoice this PSBT is paying has already been partially or completely paid")); } @@ -282,26 +283,24 @@ namespace BTCPayServer.Payments.PayJoin break; } - if (!await _payJoinRepository.TryLockInputs(originalTx.Inputs.Select(i => i.PrevOut).ToArray())) + if (!await _payJoinRepository.TryLockInputs(ctx.OriginalTransaction.Inputs.Select(i => i.PrevOut).ToArray())) { - return BadRequest(CreatePayjoinError("inputs-already-used", - "Some of those inputs have already been used to make payjoin transaction")); + return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Some of those inputs have already been used to make another 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(); + var prevOuts = ctx.OriginalTransaction.Inputs.Select(o => o.PrevOut).ToHashSet(); utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray(); Array.Sort(utxos, UTXODeterministicComparer.Instance); - - foreach (var utxo in (await SelectUTXO(network, utxos, psbt.Inputs.Select(input => input.WitnessUtxo.Value.ToDecimal(MoneyUnit.BTC)), output.Value.ToDecimal(MoneyUnit.BTC), + foreach (var utxo in (await SelectUTXO(network, utxos, psbt.Inputs.Select(input => input.WitnessUtxo.Value.ToDecimal(MoneyUnit.BTC)), output.Value.ToDecimal(MoneyUnit.BTC), psbt.Outputs.Where(psbtOutput => psbtOutput.Index != output.Index).Select(psbtOutput => psbtOutput.Value.ToDecimal(MoneyUnit.BTC)))).selectedUTXO) { selectedUTXOs.Add(utxo.Outpoint, utxo); } - + ctx.LockedUTXOs = selectedUTXOs.Select(u => u.Key).ToArray(); originalPaymentOutput = output; paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork); break; @@ -321,14 +320,11 @@ namespace BTCPayServer.Payments.PayJoin if (selectedUTXOs.Count == 0) { - await BroadcastNow(); - return StatusCode(503, - CreatePayjoinError("out-of-utxos", - "We do not have any UTXO available for making a payjoin for now")); + return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for contributing to a payjoin"); } var originalPaymentValue = originalPaymentOutput.Value; - await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), originalTx, network); + await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), ctx.OriginalTransaction, network); //check if wallet of store is configured to be hot wallet var extKeyStr = await explorer.GetMetadataAsync( @@ -337,13 +333,11 @@ namespace BTCPayServer.Payments.PayJoin if (extKeyStr == null) { // This should not happen, as we check the existance of private key before creating invoice with payjoin - await UnlockUTXOs(); - await BroadcastNow(); - return StatusCode(500, CreatePayjoinError("unavailable", $"This service is unavailable for now")); + return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "The HD Key of the store changed"); } - + Money contributedAmount = Money.Zero; - var newTx = originalTx.Clone(); + var newTx = ctx.OriginalTransaction.Clone(); var ourNewOutput = newTx.Outputs[originalPaymentOutput.Index]; HashSet isOurOutput = new HashSet(); isOurOutput.Add(ourNewOutput); @@ -362,40 +356,44 @@ namespace BTCPayServer.Payments.PayJoin ourNewOutput.Value += contributedAmount; var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ?? new FeeRate(1.0m); - // Probably receiving some spare change, let's add an output to make // it looks more like a normal transaction - if (newTx.Outputs.Count == 1) + if (spareChangeCase) { - var change = await explorer.GetUnusedAsync(derivationSchemeSettings.AccountDerivation, DerivationFeature.Change); - var randomChangeAmount = RandomUtils.GetUInt64() % (ulong)contributedAmount.Satoshi; + ctx.Logs.Write($"The payjoin receiver sent only a single output"); + if (RandomUtils.GetUInt64() % 2 == 0) + { + var change = await explorer.GetUnusedAsync(derivationSchemeSettings.AccountDerivation, DerivationFeature.Change); + var randomChangeAmount = RandomUtils.GetUInt64() % (ulong)contributedAmount.Satoshi; - // Randomly round the amount to make the payment output look like a change output - var roundMultiple = (ulong)Math.Pow(10, (ulong)Math.Log10(randomChangeAmount)); - while (roundMultiple > 1_000UL) - { - if (RandomUtils.GetUInt32() % 2 == 0) + // Randomly round the amount to make the payment output look like a change output + var roundMultiple = (ulong)Math.Pow(10, (ulong)Math.Log10(randomChangeAmount)); + while (roundMultiple > 1_000UL) { - roundMultiple = roundMultiple / 10; + if (RandomUtils.GetUInt32() % 2 == 0) + { + roundMultiple = roundMultiple / 10; + } + else + { + randomChangeAmount = (randomChangeAmount / roundMultiple) * roundMultiple; + break; + } } - else - { - randomChangeAmount = (randomChangeAmount / roundMultiple) * roundMultiple; - break; - } - } - var fakeChange = newTx.Outputs.CreateNewTxOut(randomChangeAmount, change.ScriptPubKey); - if (fakeChange.IsDust(minRelayTxFee)) - { - randomChangeAmount = fakeChange.GetDustThreshold(minRelayTxFee); - fakeChange.Value = randomChangeAmount; - } - if (randomChangeAmount < contributedAmount) - { - ourNewOutput.Value -= fakeChange.Value; - newTx.Outputs.Add(fakeChange); - isOurOutput.Add(fakeChange); + var fakeChange = newTx.Outputs.CreateNewTxOut(randomChangeAmount, change.ScriptPubKey); + if (fakeChange.IsDust(minRelayTxFee)) + { + randomChangeAmount = fakeChange.GetDustThreshold(minRelayTxFee); + fakeChange.Value = randomChangeAmount; + } + if (randomChangeAmount < contributedAmount) + { + ourNewOutput.Value -= fakeChange.Value; + newTx.Outputs.Add(fakeChange); + isOurOutput.Add(fakeChange); + ctx.Logs.Write($"Added a fake change output of {fakeChange.Value} {network.CryptoCode} in the payjoin proposal"); + } } } @@ -461,10 +459,7 @@ namespace BTCPayServer.Payments.PayJoin var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx)); if (new FeeRate(newFeePaid, newVSize) < (senderMinFeeRate ?? minRelayTxFee)) { - await UnlockUTXOs(); - await BroadcastNow(); - return UnprocessableEntity(CreatePayjoinError("not-enough-money", - "Not enough money is sent to pay for the additional payjoin inputs")); + return CreatePayjoinErrorAndLog(422, PayjoinReceiverWellknownErrors.NotEnoughMoney, "Not enough money is sent to pay for the additional payjoin inputs"); } } } @@ -487,8 +482,8 @@ namespace BTCPayServer.Payments.PayJoin // broadcast the payjoin. var originalPaymentData = new BitcoinLikePaymentData(paymentAddress, originalPaymentOutput.Value, - new OutPoint(originalTx.GetHash(), originalPaymentOutput.Index), - originalTx.RBF); + new OutPoint(ctx.OriginalTransaction.GetHash(), originalPaymentOutput.Index), + ctx.OriginalTransaction.RBF); originalPaymentData.ConfirmationCount = -1; originalPaymentData.PayjoinInformation = new PayjoinInformation() { @@ -499,25 +494,23 @@ namespace BTCPayServer.Payments.PayJoin var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true); if (payment is null) { - await UnlockUTXOs(); - await BroadcastNow(); return UnprocessableEntity(CreatePayjoinError("already-paid", $"The original transaction has already been accounted")); } - await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx); - _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment}); + await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(ctx.OriginalTransaction); + _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment }); _eventAggregator.Publish(new UpdateTransactionLabel() { WalletId = new WalletId(invoice.StoreId, network.CryptoCode), - TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash ).Select(utxo => - new KeyValuePair>(utxo.Key, - new List<(string color, string label)>() - { + TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash).Select(utxo => + new KeyValuePair>(utxo.Key, + new List<(string color, string label)>() + { UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice.Id) - })) + })) .ToDictionary(pair => pair.Key, pair => pair.Value) }); - + ctx.Success(); // BTCPay Server support PSBT set as hex if (psbtFormat && HexEncoder.IsWellFormed(rawBody)) { @@ -549,6 +542,21 @@ namespace BTCPayServer.Payments.PayJoin return o; } + private JObject CreatePayjoinError(PayjoinReceiverWellknownErrors error, string debug) + { + var o = new JObject(); + o.Add(new JProperty("errorCode", PayjoinReceiverHelper.GetErrorCode(error))); + if (string.IsNullOrEmpty(debug) || !_env.IsDevelopping) + { + o.Add(new JProperty("message", PayjoinReceiverHelper.GetMessage(error))); + } + else + { + o.Add(new JProperty("message", debug)); + } + return o; + } + public enum PayjoinUtxoSelectionType { Unavailable, @@ -562,7 +570,7 @@ namespace BTCPayServer.Payments.PayJoin if (availableUtxos.Length == 0) return (Array.Empty(), PayjoinUtxoSelectionType.Unavailable); // Assume the merchant wants to get rid of the dust - HashSet locked = new HashSet(); + 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; @@ -572,17 +580,17 @@ namespace BTCPayServer.Payments.PayJoin // // "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; var invalid = false; - foreach (var input in otherInputs.Concat(new[] {availableUtxo.Value.GetValue(network)})) + foreach (var input in otherInputs.Concat(new[] { availableUtxo.Value.GetValue(network) })) { var computedOutputs = - otherOutputs.Concat(new[] {mainPaymentOutput + availableUtxo.Value.GetValue(network)}); + otherOutputs.Concat(new[] { mainPaymentOutput + availableUtxo.Value.GetValue(network) }); if (computedOutputs.Any(output => input > output)) { //UIH 1 & 2 @@ -597,7 +605,7 @@ namespace BTCPayServer.Payments.PayJoin } if (await _payJoinRepository.TryLock(availableUtxo.Outpoint)) { - return (new[] {availableUtxo}, PayjoinUtxoSelectionType.HeuristicBased); + return (new[] { availableUtxo }, PayjoinUtxoSelectionType.HeuristicBased); } locked.Add(availableUtxo.Outpoint); @@ -609,7 +617,7 @@ namespace BTCPayServer.Payments.PayJoin break; if (await _payJoinRepository.TryLock(utxo.Outpoint)) { - return (new[] {utxo}, PayjoinUtxoSelectionType.Ordered); + return (new[] { utxo }, PayjoinUtxoSelectionType.Ordered); } currentTry++; } diff --git a/BTCPayServer/Payments/PayJoin/PayjoinReceiverContext.cs b/BTCPayServer/Payments/PayJoin/PayjoinReceiverContext.cs new file mode 100644 index 000000000..631ee6350 --- /dev/null +++ b/BTCPayServer/Payments/PayJoin/PayjoinReceiverContext.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Logging; +using BTCPayServer.Services.Invoices; +using Microsoft.Extensions.Logging; +using NBitcoin; +using NBitcoin.JsonConverters; +using NBitpayClient; +using NBXplorer; + +namespace BTCPayServer.Payments.PayJoin +{ + public class PayjoinReceiverContext + { + private readonly InvoiceRepository _invoiceRepository; + private readonly ExplorerClient _explorerClient; + private readonly PayJoinRepository _payJoinRepository; + + public PayjoinReceiverContext(InvoiceRepository invoiceRepository, ExplorerClient explorerClient, PayJoinRepository payJoinRepository) + { + _invoiceRepository = invoiceRepository; + _explorerClient = explorerClient; + _payJoinRepository = payJoinRepository; + } + public Invoice Invoice { get; set; } + public NBitcoin.Transaction OriginalTransaction { get; set; } + public InvoiceLogs Logs { get; } = new InvoiceLogs(); + public OutPoint[] LockedUTXOs { get; set; } + public async Task DisposeAsync() + { + List disposing = new List(); + if (Invoice != null) + { + disposing.Add(_invoiceRepository.AddInvoiceLogs(Invoice.Id, Logs)); + } + if (!doNotBroadcast && OriginalTransaction != null) + { + disposing.Add(_explorerClient.BroadcastAsync(OriginalTransaction)); + } + if (!success && LockedUTXOs != null) + { + disposing.Add(_payJoinRepository.TryUnlock(LockedUTXOs)); + } + try + { + await Task.WhenAll(disposing); + } + catch (Exception ex) + { + BTCPayServer.Logging.Logs.PayServer.LogWarning(ex, "Error while disposing the PayjoinReceiverContext"); + } + } + + bool doNotBroadcast = false; + public void DoNotBroadcast() + { + doNotBroadcast = true; + } + + bool success = false; + public void Success() + { + doNotBroadcast = true; + success = true; + } + } +} diff --git a/BTCPayServer/Services/PayjoinClient.cs b/BTCPayServer/Services/PayjoinClient.cs index b8b936041..7f065c831 100644 --- a/BTCPayServer/Services/PayjoinClient.cs +++ b/BTCPayServer/Services/PayjoinClient.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Payments.Changelly.Models; using Google.Apis.Http; using NBitcoin; using Newtonsoft.Json; @@ -302,55 +303,61 @@ namespace BTCPayServer.Services NeedUTXOInformation, InvalidTransaction } + public class PayjoinReceiverHelper + { + static IEnumerable<(PayjoinReceiverWellknownErrors EnumValue, string ErrorCode, string Message)> Get() + { + yield return (PayjoinReceiverWellknownErrors.LeakingData, "leaking-data", "Key path information or GlobalXPubs should not be included in the original PSBT."); + yield return (PayjoinReceiverWellknownErrors.PSBTNotFinalized, "psbt-not-finalized", "The original PSBT must be finalized."); + yield return (PayjoinReceiverWellknownErrors.Unavailable, "unavailable", "The payjoin endpoint is not available for now."); + yield return (PayjoinReceiverWellknownErrors.NotEnoughMoney, "not-enough-money", "The receiver added some inputs but could not bump the fee of the payjoin proposal."); + yield return (PayjoinReceiverWellknownErrors.InsanePSBT, "insane-psbt", "Some consistency check on the PSBT failed."); + yield return (PayjoinReceiverWellknownErrors.VersionUnsupported, "version-unsupported", "This version of payjoin is not supported."); + yield return (PayjoinReceiverWellknownErrors.NeedUTXOInformation, "need-utxo-information", "The witness UTXO or non witness UTXO is missing."); + yield return (PayjoinReceiverWellknownErrors.InvalidTransaction, "invalid-transaction", "The original transaction is invalid for payjoin"); + } + public static string GetErrorCode(PayjoinReceiverWellknownErrors err) + { + return Get().Single(o => o.EnumValue == err).ErrorCode; + } + public static PayjoinReceiverWellknownErrors? GetWellknownError(string errorCode) + { + var t = Get().FirstOrDefault(o => o.ErrorCode == errorCode); + if (t == default) + return null; + return t.EnumValue; + } + static string UnknownError = "Unknown error from the receiver"; + public static string GetMessage(string errorCode) + { + return Get().FirstOrDefault(o => o.ErrorCode == errorCode).Message ?? UnknownError; + } + public static string GetMessage(PayjoinReceiverWellknownErrors err) + { + return Get().Single(o => o.EnumValue == err).Message; + } + } public class PayjoinReceiverException : PayjoinException { - public PayjoinReceiverException(string errorCode, string receiverDebugMessage) : base(FormatMessage(errorCode, receiverDebugMessage)) + public PayjoinReceiverException(string errorCode, string receiverMessage) : base(FormatMessage(errorCode)) { ErrorCode = errorCode; - ReceiverDebugMessage = receiverDebugMessage; - WellknownError = errorCode switch - { - "leaking-data" => PayjoinReceiverWellknownErrors.LeakingData, - "psbt-not-finalized" => PayjoinReceiverWellknownErrors.PSBTNotFinalized, - "unavailable" => PayjoinReceiverWellknownErrors.Unavailable, - "out-of-utxos" => PayjoinReceiverWellknownErrors.OutOfUTXOS, - "not-enough-money" => PayjoinReceiverWellknownErrors.NotEnoughMoney, - "insane-psbt" => PayjoinReceiverWellknownErrors.InsanePSBT, - "version-unsupported" => PayjoinReceiverWellknownErrors.VersionUnsupported, - "need-utxo-information" => PayjoinReceiverWellknownErrors.NeedUTXOInformation, - "invalid-transaction" => PayjoinReceiverWellknownErrors.InvalidTransaction, - _ => null - }; + ReceiverMessage = receiverMessage; + WellknownError = PayjoinReceiverHelper.GetWellknownError(errorCode); + ErrorMessage = PayjoinReceiverHelper.GetMessage(errorCode); } public string ErrorCode { get; } public string ErrorMessage { get; } - public string ReceiverDebugMessage { get; } + public string ReceiverMessage { get; } public PayjoinReceiverWellknownErrors? WellknownError { get; } - private static string FormatMessage(string errorCode, string receiverDebugMessage) + private static string FormatMessage(string errorCode) { - return $"{errorCode}: {GetMessage(errorCode)}"; - } - - private static string GetMessage(string errorCode) - { - return errorCode switch - { - "leaking-data" => "Key path information or GlobalXPubs should not be included in the original PSBT.", - "psbt-not-finalized" => "The original PSBT must be finalized.", - "unavailable" => "The payjoin endpoint is not available for now.", - "out-of-utxos" => "The receiver does not have any UTXO to contribute in a payjoin proposal.", - "not-enough-money" => "The receiver added some inputs but could not bump the fee of the payjoin proposal.", - "insane-psbt" => "Some consistency check on the PSBT failed.", - "version-unsupported" => "This version of payjoin is not supported.", - "need-utxo-information" => "The witness UTXO or non witness UTXO is missing", - "invalid-transaction" => "The original transaction is invalid for payjoin", - _ => "Unknown error" - }; + return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}"; } }