diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index e027166e3..bd183fe93 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -130,21 +130,22 @@ namespace BTCPayServer.Tests await TestUtils.EventuallyAsync(async () => { var invoice = await invoiceRepository.GetInvoice(invoiceId); - var payments = invoice.GetPayments().ToArray(); - var originalPayment = payments - .Single(p => - p.GetCryptoPaymentData() is BitcoinLikePaymentData pd && - pd.PayjoinInformation?.Type is PayjoinTransactionType.Original); - var coinjoinPayment = payments - .Single(p => - p.GetCryptoPaymentData() is BitcoinLikePaymentData pd && - pd.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin); + var payments = invoice.GetPayments(); + Assert.Equal(2, payments.Count); + var originalPayment = payments[0]; + var coinjoinPayment = payments[1]; Assert.Equal(-1, ((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).ConfirmationCount); Assert.Equal(0, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).ConfirmationCount); Assert.False(originalPayment.Accounted); Assert.True(coinjoinPayment.Accounted); Assert.Equal(((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).Value, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).Value); + Assert.Equal(originalPayment.GetCryptoPaymentData() + .AssertType() + .Value, + coinjoinPayment.GetCryptoPaymentData() + .AssertType() + .Value); }); await TestUtils.EventuallyAsync(async () => @@ -211,10 +212,21 @@ namespace BTCPayServer.Tests return pj; } + async Task LockNewReceiverCoin() + { + var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme); + foreach (var coin in coins.Where(c => c.OutPoint != receiverCoin.Outpoint)) + { + await payjoinRepository.TryLock(coin.OutPoint); + } + } + 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"); + "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), ExpectLocked: false, ExpectedError: "not-enough-money"); await RunVector(); + await LockNewReceiverCoin(); Logs.Tester.LogInformation("We don't pay enough"); vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "invoice-not-fully-paid"); @@ -229,13 +241,8 @@ namespace BTCPayServer.Tests await payjoinRepository.TryLock(receiverCoin.Outpoint); vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), ExpectLocked: true, ExpectedError: "out-of-utxos"); await RunVector(); - await TestUtils.EventuallyAsync(async () => - { - var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme); - Assert.Equal(2, coins.Length); - var newCoin = coins.First(c => (Money)c.Value == Money.Satoshis(500)); - await payjoinRepository.TryLock(newCoin.OutPoint); - }); + await LockNewReceiverCoin(); + var originalSenderUser = senderUser; retry: // Additional fee is 96 , minrelaytx is 294 @@ -574,7 +581,8 @@ namespace BTCPayServer.Tests { var invoiceEntity = await tester.PayTester.GetService().GetInvoice(invoice7.Id); Assert.Equal(InvoiceStatus.Paid, invoiceEntity.Status); - Assert.Contains(invoiceEntity.GetPayments(), p => p.Accounted && ((BitcoinLikePaymentData)p.GetCryptoPaymentData()).PayjoinInformation.Type is PayjoinTransactionType.Coinjoin); + Assert.Contains(invoiceEntity.GetPayments(), p => p.Accounted && + ((BitcoinLikePaymentData)p.GetCryptoPaymentData()).PayjoinInformation is null); }); ////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index e5f220c26..afca1c3d1 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -301,9 +301,11 @@ namespace BTCPayServer.Tests var store = await storeRepository.FindStore(StoreId); var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType() .First(); + Logs.Tester.LogInformation($"Proposing {psbt.GetGlobalTransaction().GetHash()}"); if (expectedError is null) { var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default); + Logs.Tester.LogInformation($"Proposed payjoin is {proposed.GetGlobalTransaction().GetHash()}"); Assert.NotNull(proposed); return proposed; } diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs index 10fef1bf7..0ed6402f4 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs @@ -23,6 +23,7 @@ namespace BTCPayServer.Models.InvoicingModels public bool Replaced { get; set; } public BitcoinLikePaymentData CryptoPaymentData { get; set; } + public string AdditionalInformation { get; set; } } public class OffChainPaymentViewModel diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs index bbbd7799c..43316cbee 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs @@ -111,13 +111,8 @@ namespace BTCPayServer.Payments.Bitcoin public class PayjoinInformation { - [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] - public PayjoinTransactionType Type { get; set; } + public uint256 CoinjoinTransactionHash { get; set; } + public Money CoinjoinValue { get; set; } public OutPoint[] ContributedOutPoints { get; set; } } - public enum PayjoinTransactionType - { - Original, - Coinjoin - } } diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index 6471cce60..96124d9a3 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -222,8 +222,8 @@ namespace BTCPayServer.Payments.Bitcoin .ToArray(), true); bool? originalPJBroadcasted = null; bool? originalPJBroadcastable = null; - bool? cjPJBroadcasted = null; - OutPoint[] ourPJOutpoints = null; + bool cjPJBroadcasted = false; + PayjoinInformation payjoinInformation = null; var paymentEntitiesByPrevOut = new Dictionary(); foreach (var payment in invoice.GetPayments(wallet.Network)) { @@ -256,17 +256,9 @@ namespace BTCPayServer.Payments.Bitcoin } if (paymentData.PayjoinInformation is PayjoinInformation pj) { - ourPJOutpoints = pj.ContributedOutPoints; - switch (pj.Type) - { - case PayjoinTransactionType.Original: - originalPJBroadcasted = accounted && tx.Confirmations >= 0; - originalPJBroadcastable = accounted; - break; - case PayjoinTransactionType.Coinjoin: - cjPJBroadcasted = accounted && tx.Confirmations >= 0; - break; - } + payjoinInformation = pj; + originalPJBroadcasted = accounted && tx.Confirmations >= 0; + originalPJBroadcastable = accounted; } } // RPC might be unavailable, we can't check double spend so let's assume there is none @@ -287,6 +279,14 @@ namespace BTCPayServer.Payments.Bitcoin if (paymentEntitiesByPrevOut.TryGetValue(prevout, out var replaced) && !replaced.Accounted) { payment.NetworkFee = replaced.NetworkFee; + if (payjoinInformation is PayjoinInformation pj && + pj.CoinjoinTransactionHash == tx.TransactionHash) + { + // This payment is a coinjoin, so the value of + // the payment output is different from the real value of the payment + paymentData.Value = pj.CoinjoinValue; + payment.SetCryptoPaymentData(paymentData); + } } } } @@ -322,9 +322,9 @@ namespace BTCPayServer.Payments.Bitcoin if (originalPJBroadcasted is true || // If the original tx is not broadcastable anymore and nor does the coinjoin // reuse our outpoint for another PJ - (originalPJBroadcastable is false && !(cjPJBroadcasted is true))) + (originalPJBroadcastable is false && !cjPJBroadcasted)) { - await _payJoinRepository.TryUnlock(ourPJOutpoints); + await _payJoinRepository.TryUnlock(payjoinInformation.ContributedOutPoints); } await _InvoiceRepository.UpdatePayments(updatedPaymentEntities); diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index b14d5ee05..11e45139d 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -116,6 +116,11 @@ namespace BTCPayServer.Payments.PayJoin return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT should be finalized")); originalTx = psbt.ExtractTransaction(); } + + async Task BroadcastNow() + { + await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx); + } if (originalTx.Inputs.Any(i => !(i.GetSigner() is WitKeyId))) return BadRequest(CreatePayjoinError(400, "not-using-p2wpkh", "Payjoin only support P2WPKH inputs")); @@ -160,6 +165,11 @@ 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 paymentOutput = null; BitcoinAddress paymentAddress = null; InvoiceEntity invoice = null; @@ -231,34 +241,14 @@ namespace BTCPayServer.Payments.PayJoin if (selectedUTXOs.Count == 0) { - await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx); + await BroadcastNow(); return StatusCode(503, CreatePayjoinError(503, "out-of-utxos", "We do not have any UTXO available for making a payjoin for now")); } var originalPaymentValue = paymentOutput.Value; - // Add the original transaction to the payment - var originalPaymentData = new BitcoinLikePaymentData(paymentAddress, - paymentOutput.Value, - new OutPoint(originalTx.GetHash(), paymentOutput.Index), - originalTx.RBF); - originalPaymentData.PayjoinInformation = new PayjoinInformation() - { - Type = PayjoinTransactionType.Original, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray() - }; - originalPaymentData.ConfirmationCount = -1; - var now = DateTimeOffset.UtcNow; - var payment = await _invoiceRepository.AddPayment(invoice.Id, now, originalPaymentData, network, true); - if (payment is null) - { - return UnprocessableEntity(CreatePayjoinError(422, "already-paid", - $"The original transaction has already been accounted")); - } - - await _broadcaster.Schedule(now + TimeSpan.FromMinutes(1.0), originalTx, network); - await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx); - _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment}); + await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1.0), originalTx, network); //check if wallet of store is configured to be hot wallet var extKeyStr = await explorer.GetMetadataAsync( @@ -267,6 +257,8 @@ 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(500, "unavailable", $"This service is unavailable for now")); } @@ -344,6 +336,8 @@ namespace BTCPayServer.Payments.PayJoin if (isSecondPass) { // This should not happen + await UnlockUTXOs(); + await BroadcastNow(); return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now (isSecondPass)")); @@ -373,7 +367,8 @@ namespace BTCPayServer.Payments.PayJoin var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx)); if (new FeeRate(newFeePaid, newVSize) < minRelayTxFee) { - await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray()); + await UnlockUTXOs(); + await BroadcastNow(); return UnprocessableEntity(CreatePayjoinError(422, "not-enough-money", "Not enough money is sent to pay for the additional payjoin inputs")); } @@ -392,20 +387,30 @@ namespace BTCPayServer.Payments.PayJoin newTx.Inputs[signedInput.Index].WitScript = newPsbt.Inputs[(int)signedInput.Index].FinalScriptWitness; } - // Add the coinjoin transaction to the payments - var coinjoinPaymentData = new BitcoinLikePaymentData(paymentAddress, - originalPaymentValue - ourFeeContribution, - new OutPoint(newPsbt.GetGlobalTransaction().GetHash(), ourOutputIndex), + // Add the transaction to the payments with a confirmation of -1. + // This will make the invoice paid even if the user do not + // broadcast the payjoin. + var originalPaymentData = new BitcoinLikePaymentData(paymentAddress, + paymentOutput.Value, + new OutPoint(originalTx.GetHash(), paymentOutput.Index), originalTx.RBF); - coinjoinPaymentData.PayjoinInformation = new PayjoinInformation() + originalPaymentData.ConfirmationCount = -1; + originalPaymentData.PayjoinInformation = new PayjoinInformation() { - Type = PayjoinTransactionType.Coinjoin, + CoinjoinTransactionHash = newPsbt.GetGlobalTransaction().GetHash(), + CoinjoinValue = originalPaymentValue - ourFeeContribution, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray() }; - coinjoinPaymentData.ConfirmationCount = -1; - payment = await _invoiceRepository.AddPayment(invoice.Id, now, coinjoinPaymentData, network, false, - payment.NetworkFee); - // We do not publish an event on purpose, this would be confusing for the merchant. + var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true); + if (payment is null) + { + await UnlockUTXOs(); + await BroadcastNow(); + return UnprocessableEntity(CreatePayjoinError(422, "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}); if (psbtFormat) return Ok(newPsbt.ToBase64()); diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index a3190bd70..abf0f9cac 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -687,7 +687,7 @@ retry: /// /// /// The PaymentEntity or null if already added - public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false, decimal? networkFee = null) + public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false) { using (var context = _ContextFactory.CreateContext()) { @@ -705,7 +705,7 @@ retry: #pragma warning restore CS0618 ReceivedTime = date.UtcDateTime, Accounted = accounted, - NetworkFee = networkFee ?? paymentMethodDetails.GetNextNetworkFee(), + NetworkFee = paymentMethodDetails.GetNextNetworkFee(), Network = network }; entity.SetCryptoPaymentData(paymentData); diff --git a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml index 7cafcd0f8..f5305647e 100644 --- a/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml +++ b/BTCPayServer/Views/Shared/ViewBitcoinLikePaymentData.cshtml @@ -4,6 +4,7 @@ @model IEnumerable @{ + PayjoinInformation payjoinIformation = null; var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == BitcoinPaymentType.Instance).Select(payment => { var m = new OnchainPaymentViewModel(); @@ -21,7 +22,16 @@ { m.Confirmations = confirmationCount.ToString(CultureInfo.InvariantCulture); } - + if (onChainPaymentData?.PayjoinInformation is PayjoinInformation pj) + { + payjoinIformation = pj; + m.AdditionalInformation = "Original tranasaction"; + } + if (payjoinIformation is PayjoinInformation && + payjoinIformation.CoinjoinTransactionHash == onChainPaymentData?.Outpoint.Hash) + { + m.AdditionalInformation = "Payjoin transaction"; + } m.TransactionId = onChainPaymentData.Outpoint.Hash.ToString(); m.ReceivedTime = payment.ReceivedTime; m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId); @@ -52,7 +62,7 @@ @payment.Crypto @payment.DepositAddress - @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin? string.Empty : $"
(Payjoin)") + @payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.AdditionalInformation is string i ? $"
({i})" : string.Empty)