mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 14:34:23 +01:00
Refactor the detection of the payjoin payment
This commit is contained in:
@@ -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<BitcoinLikePaymentData>()
|
||||
.Value,
|
||||
coinjoinPayment.GetCryptoPaymentData()
|
||||
.AssertType<BitcoinLikePaymentData>()
|
||||
.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<InvoiceRepository>().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);
|
||||
|
||||
|
||||
@@ -301,9 +301,11 @@ namespace BTCPayServer.Tests
|
||||
var store = await storeRepository.FindStore(StoreId);
|
||||
var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType<DerivationSchemeSettings>()
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OutPoint, PaymentEntity>();
|
||||
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);
|
||||
|
||||
@@ -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<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>();
|
||||
|
||||
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<string>(
|
||||
@@ -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());
|
||||
|
||||
@@ -687,7 +687,7 @@ retry:
|
||||
/// <param name="cryptoCode"></param>
|
||||
/// <param name="accounted"></param>
|
||||
/// <returns>The PaymentEntity or null if already added</returns>
|
||||
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false, decimal? networkFee = null)
|
||||
public async Task<PaymentEntity> 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);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity>
|
||||
|
||||
@{
|
||||
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 @@
|
||||
<tr class="@(payment.Replaced ? "linethrough" : "")" >
|
||||
<td>@payment.Crypto</td>
|
||||
<td>@payment.DepositAddress</td>
|
||||
<td class="payment-value">@payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin? string.Empty : $"<br/>(Payjoin)")</td>
|
||||
<td class="payment-value">@payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.AdditionalInformation is string i ? $"<br/>({i})" : string.Empty)</td>
|
||||
<td>
|
||||
<div class="wraptextAuto">
|
||||
<a href="@payment.TransactionLink" target="_blank">
|
||||
|
||||
Reference in New Issue
Block a user