Refactor the detection of the payjoin payment

This commit is contained in:
nicolas.dorier
2020-04-05 22:44:34 +09:00
parent 42aead3c89
commit 4c966e2a09
8 changed files with 98 additions and 77 deletions

View File

@@ -130,21 +130,22 @@ namespace BTCPayServer.Tests
await TestUtils.EventuallyAsync(async () => await TestUtils.EventuallyAsync(async () =>
{ {
var invoice = await invoiceRepository.GetInvoice(invoiceId); var invoice = await invoiceRepository.GetInvoice(invoiceId);
var payments = invoice.GetPayments().ToArray(); var payments = invoice.GetPayments();
var originalPayment = payments Assert.Equal(2, payments.Count);
.Single(p => var originalPayment = payments[0];
p.GetCryptoPaymentData() is BitcoinLikePaymentData pd && var coinjoinPayment = payments[1];
pd.PayjoinInformation?.Type is PayjoinTransactionType.Original);
var coinjoinPayment = payments
.Single(p =>
p.GetCryptoPaymentData() is BitcoinLikePaymentData pd &&
pd.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin);
Assert.Equal(-1, ((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).ConfirmationCount); Assert.Equal(-1, ((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).ConfirmationCount);
Assert.Equal(0, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).ConfirmationCount); Assert.Equal(0, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).ConfirmationCount);
Assert.False(originalPayment.Accounted); Assert.False(originalPayment.Accounted);
Assert.True(coinjoinPayment.Accounted); Assert.True(coinjoinPayment.Accounted);
Assert.Equal(((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).Value, Assert.Equal(((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).Value,
((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).Value); ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).Value);
Assert.Equal(originalPayment.GetCryptoPaymentData()
.AssertType<BitcoinLikePaymentData>()
.Value,
coinjoinPayment.GetCryptoPaymentData()
.AssertType<BitcoinLikePaymentData>()
.Value);
}); });
await TestUtils.EventuallyAsync(async () => await TestUtils.EventuallyAsync(async () =>
@@ -211,10 +212,21 @@ namespace BTCPayServer.Tests
return pj; 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" + 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"); 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 RunVector();
await LockNewReceiverCoin();
Logs.Tester.LogInformation("We don't pay enough"); 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"); 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); 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"); 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 RunVector();
await TestUtils.EventuallyAsync(async () => await LockNewReceiverCoin();
{
var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme);
Assert.Equal(2, coins.Length);
var newCoin = coins.First(c => (Money)c.Value == Money.Satoshis(500));
await payjoinRepository.TryLock(newCoin.OutPoint);
});
var originalSenderUser = senderUser; var originalSenderUser = senderUser;
retry: retry:
// Additional fee is 96 , minrelaytx is 294 // Additional fee is 96 , minrelaytx is 294
@@ -574,7 +581,8 @@ namespace BTCPayServer.Tests
{ {
var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id); var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
Assert.Equal(InvoiceStatus.Paid, invoiceEntity.Status); 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); ////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);

View File

@@ -301,9 +301,11 @@ namespace BTCPayServer.Tests
var store = await storeRepository.FindStore(StoreId); var store = await storeRepository.FindStore(StoreId);
var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType<DerivationSchemeSettings>() var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType<DerivationSchemeSettings>()
.First(); .First();
Logs.Tester.LogInformation($"Proposing {psbt.GetGlobalTransaction().GetHash()}");
if (expectedError is null) if (expectedError is null)
{ {
var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default); var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default);
Logs.Tester.LogInformation($"Proposed payjoin is {proposed.GetGlobalTransaction().GetHash()}");
Assert.NotNull(proposed); Assert.NotNull(proposed);
return proposed; return proposed;
} }

View File

@@ -23,6 +23,7 @@ namespace BTCPayServer.Models.InvoicingModels
public bool Replaced { get; set; } public bool Replaced { get; set; }
public BitcoinLikePaymentData CryptoPaymentData { get; set; } public BitcoinLikePaymentData CryptoPaymentData { get; set; }
public string AdditionalInformation { get; set; }
} }
public class OffChainPaymentViewModel public class OffChainPaymentViewModel

View File

@@ -111,13 +111,8 @@ namespace BTCPayServer.Payments.Bitcoin
public class PayjoinInformation public class PayjoinInformation
{ {
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] public uint256 CoinjoinTransactionHash { get; set; }
public PayjoinTransactionType Type { get; set; } public Money CoinjoinValue { get; set; }
public OutPoint[] ContributedOutPoints { get; set; } public OutPoint[] ContributedOutPoints { get; set; }
} }
public enum PayjoinTransactionType
{
Original,
Coinjoin
}
} }

View File

@@ -222,8 +222,8 @@ namespace BTCPayServer.Payments.Bitcoin
.ToArray(), true); .ToArray(), true);
bool? originalPJBroadcasted = null; bool? originalPJBroadcasted = null;
bool? originalPJBroadcastable = null; bool? originalPJBroadcastable = null;
bool? cjPJBroadcasted = null; bool cjPJBroadcasted = false;
OutPoint[] ourPJOutpoints = null; PayjoinInformation payjoinInformation = null;
var paymentEntitiesByPrevOut = new Dictionary<OutPoint, PaymentEntity>(); var paymentEntitiesByPrevOut = new Dictionary<OutPoint, PaymentEntity>();
foreach (var payment in invoice.GetPayments(wallet.Network)) foreach (var payment in invoice.GetPayments(wallet.Network))
{ {
@@ -256,17 +256,9 @@ namespace BTCPayServer.Payments.Bitcoin
} }
if (paymentData.PayjoinInformation is PayjoinInformation pj) if (paymentData.PayjoinInformation is PayjoinInformation pj)
{ {
ourPJOutpoints = pj.ContributedOutPoints; payjoinInformation = pj;
switch (pj.Type) originalPJBroadcasted = accounted && tx.Confirmations >= 0;
{ originalPJBroadcastable = accounted;
case PayjoinTransactionType.Original:
originalPJBroadcasted = accounted && tx.Confirmations >= 0;
originalPJBroadcastable = accounted;
break;
case PayjoinTransactionType.Coinjoin:
cjPJBroadcasted = accounted && tx.Confirmations >= 0;
break;
}
} }
} }
// RPC might be unavailable, we can't check double spend so let's assume there is none // 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) if (paymentEntitiesByPrevOut.TryGetValue(prevout, out var replaced) && !replaced.Accounted)
{ {
payment.NetworkFee = replaced.NetworkFee; 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 (originalPJBroadcasted is true ||
// If the original tx is not broadcastable anymore and nor does the coinjoin // If the original tx is not broadcastable anymore and nor does the coinjoin
// reuse our outpoint for another PJ // 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); await _InvoiceRepository.UpdatePayments(updatedPaymentEntities);

View File

@@ -117,6 +117,11 @@ namespace BTCPayServer.Payments.PayJoin
originalTx = psbt.ExtractTransaction(); originalTx = psbt.ExtractTransaction();
} }
async Task BroadcastNow()
{
await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx);
}
if (originalTx.Inputs.Any(i => !(i.GetSigner() is WitKeyId))) if (originalTx.Inputs.Any(i => !(i.GetSigner() is WitKeyId)))
return BadRequest(CreatePayjoinError(400, "not-using-p2wpkh", "Payjoin only support P2WPKH inputs")); return BadRequest(CreatePayjoinError(400, "not-using-p2wpkh", "Payjoin only support P2WPKH inputs"));
if (psbt.CheckSanity() is var errors && errors.Count != 0) if (psbt.CheckSanity() is var errors && errors.Count != 0)
@@ -160,6 +165,11 @@ namespace BTCPayServer.Payments.PayJoin
bool paidSomething = false; bool paidSomething = false;
Money due = null; Money due = null;
Dictionary<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>(); Dictionary<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>();
async Task UnlockUTXOs()
{
await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray());
}
PSBTOutput paymentOutput = null; PSBTOutput paymentOutput = null;
BitcoinAddress paymentAddress = null; BitcoinAddress paymentAddress = null;
InvoiceEntity invoice = null; InvoiceEntity invoice = null;
@@ -231,34 +241,14 @@ namespace BTCPayServer.Payments.PayJoin
if (selectedUTXOs.Count == 0) if (selectedUTXOs.Count == 0)
{ {
await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx); await BroadcastNow();
return StatusCode(503, return StatusCode(503,
CreatePayjoinError(503, "out-of-utxos", CreatePayjoinError(503, "out-of-utxos",
"We do not have any UTXO available for making a payjoin for now")); "We do not have any UTXO available for making a payjoin for now"));
} }
var originalPaymentValue = paymentOutput.Value; var originalPaymentValue = paymentOutput.Value;
// Add the original transaction to the payment await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1.0), originalTx, network);
var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
paymentOutput.Value,
new OutPoint(originalTx.GetHash(), paymentOutput.Index),
originalTx.RBF);
originalPaymentData.PayjoinInformation = new PayjoinInformation()
{
Type = PayjoinTransactionType.Original, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
};
originalPaymentData.ConfirmationCount = -1;
var now = DateTimeOffset.UtcNow;
var payment = await _invoiceRepository.AddPayment(invoice.Id, now, originalPaymentData, network, true);
if (payment is null)
{
return UnprocessableEntity(CreatePayjoinError(422, "already-paid",
$"The original transaction has already been accounted"));
}
await _broadcaster.Schedule(now + TimeSpan.FromMinutes(1.0), originalTx, network);
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx);
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment});
//check if wallet of store is configured to be hot wallet //check if wallet of store is configured to be hot wallet
var extKeyStr = await explorer.GetMetadataAsync<string>( var extKeyStr = await explorer.GetMetadataAsync<string>(
@@ -267,6 +257,8 @@ namespace BTCPayServer.Payments.PayJoin
if (extKeyStr == null) if (extKeyStr == null)
{ {
// This should not happen, as we check the existance of private key before creating invoice with payjoin // 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")); return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now"));
} }
@@ -344,6 +336,8 @@ namespace BTCPayServer.Payments.PayJoin
if (isSecondPass) if (isSecondPass)
{ {
// This should not happen // This should not happen
await UnlockUTXOs();
await BroadcastNow();
return StatusCode(500, return StatusCode(500,
CreatePayjoinError(500, "unavailable", CreatePayjoinError(500, "unavailable",
$"This service is unavailable for now (isSecondPass)")); $"This service is unavailable for now (isSecondPass)"));
@@ -373,7 +367,8 @@ namespace BTCPayServer.Payments.PayJoin
var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx)); var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
if (new FeeRate(newFeePaid, newVSize) < minRelayTxFee) 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", return UnprocessableEntity(CreatePayjoinError(422, "not-enough-money",
"Not enough money is sent to pay for the additional payjoin inputs")); "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; newTx.Inputs[signedInput.Index].WitScript = newPsbt.Inputs[(int)signedInput.Index].FinalScriptWitness;
} }
// Add the coinjoin transaction to the payments // Add the transaction to the payments with a confirmation of -1.
var coinjoinPaymentData = new BitcoinLikePaymentData(paymentAddress, // This will make the invoice paid even if the user do not
originalPaymentValue - ourFeeContribution, // broadcast the payjoin.
new OutPoint(newPsbt.GetGlobalTransaction().GetHash(), ourOutputIndex), var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
paymentOutput.Value,
new OutPoint(originalTx.GetHash(), paymentOutput.Index),
originalTx.RBF); 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() ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
}; };
coinjoinPaymentData.ConfirmationCount = -1; var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true);
payment = await _invoiceRepository.AddPayment(invoice.Id, now, coinjoinPaymentData, network, false, if (payment is null)
payment.NetworkFee); {
// We do not publish an event on purpose, this would be confusing for the merchant. 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) if (psbtFormat)
return Ok(newPsbt.ToBase64()); return Ok(newPsbt.ToBase64());

View File

@@ -687,7 +687,7 @@ retry:
/// <param name="cryptoCode"></param> /// <param name="cryptoCode"></param>
/// <param name="accounted"></param> /// <param name="accounted"></param>
/// <returns>The PaymentEntity or null if already added</returns> /// <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()) using (var context = _ContextFactory.CreateContext())
{ {
@@ -705,7 +705,7 @@ retry:
#pragma warning restore CS0618 #pragma warning restore CS0618
ReceivedTime = date.UtcDateTime, ReceivedTime = date.UtcDateTime,
Accounted = accounted, Accounted = accounted,
NetworkFee = networkFee ?? paymentMethodDetails.GetNextNetworkFee(), NetworkFee = paymentMethodDetails.GetNextNetworkFee(),
Network = network Network = network
}; };
entity.SetCryptoPaymentData(paymentData); entity.SetCryptoPaymentData(paymentData);

View File

@@ -4,6 +4,7 @@
@model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity> @model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity>
@{ @{
PayjoinInformation payjoinIformation = null;
var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == BitcoinPaymentType.Instance).Select(payment => var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == BitcoinPaymentType.Instance).Select(payment =>
{ {
var m = new OnchainPaymentViewModel(); var m = new OnchainPaymentViewModel();
@@ -21,7 +22,16 @@
{ {
m.Confirmations = confirmationCount.ToString(CultureInfo.InvariantCulture); 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.TransactionId = onChainPaymentData.Outpoint.Hash.ToString();
m.ReceivedTime = payment.ReceivedTime; m.ReceivedTime = payment.ReceivedTime;
m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId); m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId);
@@ -52,7 +62,7 @@
<tr class="@(payment.Replaced ? "linethrough" : "")" > <tr class="@(payment.Replaced ? "linethrough" : "")" >
<td>@payment.Crypto</td> <td>@payment.Crypto</td>
<td>@payment.DepositAddress</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> <td>
<div class="wraptextAuto"> <div class="wraptextAuto">
<a href="@payment.TransactionLink" target="_blank"> <a href="@payment.TransactionLink" target="_blank">