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 () =>
{
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);

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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:
payjoinInformation = pj;
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
@@ -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);

View File

@@ -117,6 +117,11 @@ namespace BTCPayServer.Payments.PayJoin
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"));
if (psbt.CheckSanity() is var errors && errors.Count != 0)
@@ -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());

View File

@@ -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);

View File

@@ -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">