mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 14:22:40 +01:00
RBF Protection & Handling
This commit is contained in:
parent
89da4184ff
commit
2e3a0706ee
8 changed files with 357 additions and 66 deletions
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
@ -5,6 +6,7 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
|
@ -14,6 +16,8 @@ using BTCPayServer.Payments.PayJoin;
|
|||
using BTCPayServer.Services.Wallets;
|
||||
using BTCPayServer.Tests.Logging;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Payment;
|
||||
using NBitpayClient;
|
||||
|
@ -118,6 +122,12 @@ namespace BTCPayServer.Tests
|
|||
invoice = receiverUser.BitPay.GetInvoice(invoice.Id);
|
||||
Assert.Equal(Invoice.STATUS_PAID, invoice.Status);
|
||||
});
|
||||
//verify that there is nothing in the payment state
|
||||
|
||||
var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>();
|
||||
var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
|
||||
Assert.NotNull(receiverWalletPayJoinState);
|
||||
Assert.Empty(receiverWalletPayJoinState.GetRecords());
|
||||
|
||||
//now that there is a utxo, let's do it again
|
||||
|
||||
|
@ -146,15 +156,13 @@ namespace BTCPayServer.Tests
|
|||
.Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value
|
||||
.ToDecimal(MoneyUnit.BTC));
|
||||
|
||||
var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>();
|
||||
//the state should now hold that there is an ongoing utxo
|
||||
var state = payJoinStateProvider.Get(receiverWalletId);
|
||||
Assert.NotNull(state);
|
||||
Assert.Single(state.GetRecords());
|
||||
Assert.Equal(0.02m, state.GetRecords().First().ContributedAmount);
|
||||
Assert.Single(state.GetRecords().First().CoinsExposed);
|
||||
Assert.Single(receiverWalletPayJoinState.GetRecords());
|
||||
Assert.Equal(0.02m, receiverWalletPayJoinState.GetRecords().First().ContributedAmount);
|
||||
Assert.Single(receiverWalletPayJoinState.GetRecords().First().CoinsExposed);
|
||||
Assert.False(receiverWalletPayJoinState.GetRecords().First().TxSeen);
|
||||
Assert.Equal(psbt.Finalize().ExtractTransaction().GetHash(),
|
||||
state.GetRecords().First().ProposedTransactionHash);
|
||||
receiverWalletPayJoinState.GetRecords().First().ProposedTransactionHash);
|
||||
|
||||
Assert.Equal("WalletTransactions",
|
||||
Assert.IsType<RedirectToActionResult>(
|
||||
|
@ -178,11 +186,31 @@ namespace BTCPayServer.Tests
|
|||
Assert.True(Assert.IsType<BitcoinLikePaymentData>(invoiceVM.Payments.First().GetCryptoPaymentData())
|
||||
.PayJoinSelfContributedAmount > 0);
|
||||
|
||||
|
||||
//we dont remove the payjoin tx state even if we detect it, for cases of RBF
|
||||
Assert.NotEmpty(receiverWalletPayJoinState.GetRecords());
|
||||
Assert.Single(receiverWalletPayJoinState.GetRecords());
|
||||
Assert.True(receiverWalletPayJoinState.GetRecords().First().TxSeen);
|
||||
|
||||
var debugData = new
|
||||
{
|
||||
StoreId = receiverWalletId.StoreId,
|
||||
InvoiceId = receiverWalletPayJoinState.GetRecords().First().InvoiceId,
|
||||
PayJoinTx = receiverWalletPayJoinState.GetRecords().First().ProposedTransactionHash
|
||||
};
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
await tester.WaitForEvent<NewBlockEvent>(async () =>
|
||||
{
|
||||
await cashCow.GenerateAsync(1);
|
||||
});
|
||||
}
|
||||
|
||||
//check that the state has cleared that ongoing tx
|
||||
state = payJoinStateProvider.Get(receiverWalletId);
|
||||
Assert.NotNull(state);
|
||||
Assert.Empty(state.GetRecords());
|
||||
Assert.Empty(state.GetExposedCoins());
|
||||
receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
|
||||
Assert.NotNull(receiverWalletPayJoinState);
|
||||
Assert.Empty(receiverWalletPayJoinState.GetRecords());
|
||||
Assert.Empty(receiverWalletPayJoinState.GetExposedCoins());
|
||||
|
||||
//Cool, so the payjoin works!
|
||||
//The cool thing with payjoin is that your utxos don't grow
|
||||
|
@ -196,24 +224,30 @@ namespace BTCPayServer.Tests
|
|||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.011m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.012m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.013m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.021m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.022m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.023m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.024m, MoneyUnit.BTC)));
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.025m, MoneyUnit.BTC)));
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.026m, MoneyUnit.BTC)));
|
||||
|
||||
await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
|
@ -245,6 +279,8 @@ namespace BTCPayServer.Tests
|
|||
var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m);
|
||||
var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m);
|
||||
var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m);
|
||||
var coin5 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.025m);
|
||||
var coin6 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.026m);
|
||||
|
||||
var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
|
||||
signingKeySettings.RootFingerprint =
|
||||
|
@ -371,14 +407,15 @@ namespace BTCPayServer.Tests
|
|||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
var Invoice5Coin4 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2)
|
||||
.Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2)
|
||||
.AddCoins(coin4.Coin)
|
||||
.AddKeys(extKey.Derive(coin4.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
.SendEstimatedFees(new FeeRate(100m));
|
||||
|
||||
var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true);
|
||||
|
||||
var Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
@ -405,6 +442,153 @@ namespace BTCPayServer.Tests
|
|||
Transaction.Parse(await Invoice5Coin4Response2.Content.ReadAsStringAsync(), n);
|
||||
Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), Invoice5Coin4Response2Tx.GetHash());
|
||||
}
|
||||
|
||||
//Attempt 7: send the payjoin porposed tx to the endpoint
|
||||
//Result: get same tx sent back as is
|
||||
Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
|
||||
Assert.Equal(Invoice5Coin4ResponseTx.GetHash(),
|
||||
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash());
|
||||
|
||||
//Attempt 8: sign the payjoin and send it back to the endpoint
|
||||
//Result: get same tx sent back as is
|
||||
var Invoice5Coin4ResponseTxSigned = Invoice5Coin4TxBuilder.SignTransaction(Invoice5Coin4ResponseTx);
|
||||
Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
|
||||
Assert.Equal(Invoice5Coin4ResponseTxSigned.GetHash(),
|
||||
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash());
|
||||
|
||||
//Attempt 9: broadcast a payjoin tx, then try to submit both original tx and the payjoin itself again
|
||||
//Result: fails
|
||||
await tester.ExplorerClient.BroadcastAsync(Invoice5Coin4ResponseTxSigned);
|
||||
|
||||
Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
||||
|
||||
Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
||||
|
||||
//Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again
|
||||
//Result: same tx gets sent back
|
||||
|
||||
//give the receiver some more utxos
|
||||
Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.1m, MoneyUnit.BTC)));
|
||||
|
||||
var invoice6 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var invoice6Endpoint = invoice6ParsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice6ParsedBip21.Address, invoice6ParsedBip21.Amount)
|
||||
.AddCoins(coin5.Coin)
|
||||
.AddKeys(extKey.Derive(coin5.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.SetLockTime(0);
|
||||
|
||||
var invoice6Coin5 = invoice6Coin5TxBuilder
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice6Coin5Response1 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
|
||||
new StringContent(invoice6Coin5.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
Assert.True(Invoice6Coin5Response1.IsSuccessStatusCode);
|
||||
var Invoice6Coin5Response1Tx =
|
||||
Transaction.Parse(await Invoice6Coin5Response1.Content.ReadAsStringAsync(), n);
|
||||
var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx);
|
||||
//broadcast the first payjoin
|
||||
await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned);
|
||||
|
||||
invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m));
|
||||
var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
|
||||
new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode);
|
||||
var Invoice6Coin5Response3Tx =
|
||||
Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n);
|
||||
Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin =>
|
||||
Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut)));
|
||||
|
||||
//Attempt 11:
|
||||
//send tx with rbt, broadcast payjoin,
|
||||
//create tx spending the original tx inputs with rbf to self,
|
||||
//Result: the exposed utxos are priorized in the next p2ep
|
||||
|
||||
//give the receiver some more utxos
|
||||
Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.1m, MoneyUnit.BTC)));
|
||||
|
||||
var invoice7 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var invoice7Endpoint = invoice7ParsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice7ParsedBip21.Address, invoice7ParsedBip21.Amount)
|
||||
.AddCoins(coin6.Coin)
|
||||
.AddKeys(extKey.Derive(coin6.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.SetLockTime(0);
|
||||
|
||||
var invoice7Coin6Tx = invoice7Coin6TxBuilder
|
||||
.BuildTransaction(true);
|
||||
|
||||
var invoice7Coin6Response1 = await tester.PayTester.HttpClient.PostAsync(invoice7Endpoint,
|
||||
new StringContent(invoice7Coin6Tx.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
Assert.True(invoice7Coin6Response1.IsSuccessStatusCode);
|
||||
var invoice7Coin6Response1Tx =
|
||||
Transaction.Parse(await invoice7Coin6Response1.Content.ReadAsStringAsync(), n);
|
||||
var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx);
|
||||
var contributedInputsInvoice7Coin6Response1TxSigned =
|
||||
Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut);
|
||||
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
|
||||
//broadcast the payjoin
|
||||
await tester.WaitForEvent<InvoiceEvent>(async () =>
|
||||
{
|
||||
var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned));
|
||||
Assert.True(res.Success);
|
||||
});
|
||||
|
||||
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);
|
||||
|
||||
var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.AddCoins(coin6.Coin)
|
||||
.SendAll(senderChange)
|
||||
.SubtractFees()
|
||||
.AddKeys(extKey.Derive(coin6.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(200m))
|
||||
.SetLockTime(0)
|
||||
.BuildTransaction(true);
|
||||
|
||||
//broadcast the "rbf cancel" tx
|
||||
await tester.WaitForEvent<InvoiceEvent>(async () =>
|
||||
{
|
||||
var res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2));
|
||||
Assert.True(res.Success);
|
||||
});
|
||||
//btcpay does not know of replaced txs where the outputs do not pay it(double spends using RBF to "cancel" a payment)
|
||||
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);
|
||||
|
||||
//hijack our automated payjoin original broadcaster and force it to broadcast all, now
|
||||
var payJoinTransactionBroadcaster = tester.PayTester.ServiceProvider.GetServices<IHostedService>()
|
||||
.OfType<PayJoinTransactionBroadcaster>().First();
|
||||
await payJoinTransactionBroadcaster.BroadcastStaleTransactions(TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
|
||||
//all our failed payjoins are clear and any exposed utxo has been moved to the prioritized list
|
||||
Assert.Contains(receiverWalletPayJoinState.GetExposedCoins(), receivedCoin =>
|
||||
receivedCoin.OutPoint == contributedInputsInvoice7Coin6Response1TxSigned.PrevOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -144,6 +144,18 @@ namespace BTCPayServer.Tests
|
|||
await CustomerLightningD.Pay(bolt11);
|
||||
}
|
||||
|
||||
public async Task WaitForEvent<T>(Func<Task> action)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt =>
|
||||
{
|
||||
tcs.SetResult(true);
|
||||
});
|
||||
await action.Invoke();
|
||||
await tcs.Task;
|
||||
sub.Dispose();
|
||||
}
|
||||
|
||||
public ILightningClient CustomerLightningD { get; set; }
|
||||
|
||||
public ILightningClient MerchantLightningD { get; private set; }
|
||||
|
|
|
@ -232,6 +232,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
|||
var transactions = await wallet.GetTransactions(GetAllBitcoinPaymentData(invoice)
|
||||
.Select(p => p.Outpoint.Hash)
|
||||
.ToArray());
|
||||
var payJoinState = _payJoinStateProvider.Get(new WalletId(invoice.StoreId, wallet.Network.CryptoCode));
|
||||
foreach (var payment in invoice.GetPayments(wallet.Network))
|
||||
{
|
||||
if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike)
|
||||
|
@ -267,7 +268,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool updated = false;
|
||||
if (accounted != payment.Accounted)
|
||||
{
|
||||
|
@ -284,7 +285,12 @@ namespace BTCPayServer.Payments.Bitcoin
|
|||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// we keep the state of the payjoin tx until it is confirmed in case of rbf situations where the tx is cancelled
|
||||
if (paymentData.PayJoinSelfContributedAmount> 0 && accounted && paymentData.PaymentConfirmed(payment, invoice.SpeedPolicy))
|
||||
{
|
||||
payJoinState?.RemoveRecord(paymentData.Outpoint.Hash);
|
||||
}
|
||||
// if needed add invoice back to pending to track number of confirmations
|
||||
if (paymentData.ConfirmationCount < wallet.Network.MaxTrackedConfirmation)
|
||||
await _InvoiceRepository.AddPendingInvoiceIfNotPresent(invoice.Id);
|
||||
|
@ -363,9 +369,8 @@ namespace BTCPayServer.Payments.Bitcoin
|
|||
|
||||
if (payJoinState == null || !payJoinState.TryGetWithProposedHash(transactionHash, out var record) ||
|
||||
record.TotalOutputAmount != amount) return 0;
|
||||
|
||||
record.TxSeen = true;
|
||||
//this is the payjoin output!
|
||||
payJoinState.RemoveRecord(transactionHash);
|
||||
return record.ContributedAmount;
|
||||
|
||||
}
|
||||
|
|
|
@ -105,12 +105,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
{
|
||||
return UnprocessableEntity($"invalid invoice");
|
||||
}
|
||||
|
||||
if (matchingInvoice.IsExpired() || matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
|
||||
var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId);
|
||||
//get outs to our current invoice address
|
||||
var currentPaymentMethodDetails =
|
||||
|
@ -121,8 +116,19 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
//the invoice must be active, and the status must be new OR paid if
|
||||
if (matchingInvoice.IsExpired() ||
|
||||
((matchingInvoice.GetInvoiceState().Status == InvoiceStatus.Paid &&
|
||||
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash == null) ||
|
||||
matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New))
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
|
||||
if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null &&
|
||||
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash())
|
||||
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash() &&
|
||||
!transaction.RBF)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
@ -152,7 +158,8 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
|
||||
//check if any of the inputs have been spotted in other txs sent our way..Reject anything but the original
|
||||
//also reject if the invoice being payjoined to already has a record
|
||||
if (!state.CheckIfTransactionValid(transaction, invoice, out var alreadyExists))
|
||||
var validity = state.CheckIfTransactionValid(transaction, invoice);
|
||||
if (validity == PayJoinState.TransactionValidityResult.Invalid_Inputs_Seen || validity == PayJoinState.TransactionValidityResult.Invalid_PartialMatch)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
@ -196,7 +203,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
.Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData);
|
||||
|
||||
if (transaction.Outputs.Any(
|
||||
txout => previousPayments.Any(data => txout.IsTo(data.GetDestination()))))
|
||||
txout => previousPayments.Any(data => !txout.IsTo(address) && txout.IsTo(data.GetDestination()))))
|
||||
{
|
||||
//Meh, address reuse from the customer would be happening with this tx, skip
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
|
@ -302,7 +309,12 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
.Select(coin => extKey.Derive(coin.KeyPath).PrivateKey)
|
||||
.ToArray());
|
||||
|
||||
if (!alreadyExists)
|
||||
if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
|
||||
{
|
||||
//if the invoice was rbfed, remove the current record and replace it with the new one
|
||||
state.RemoveRecord(invoice);
|
||||
}
|
||||
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
|
||||
{
|
||||
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
|
||||
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
|
||||
|
@ -324,7 +336,12 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)),
|
||||
utxosToContributeToThisPayment.Select(coin => coin.Coin));
|
||||
|
||||
if (!alreadyExists)
|
||||
if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
|
||||
{
|
||||
//if the invoice was rbfed, remove the current record and replace it with the new one
|
||||
state.RemoveRecord(invoice);
|
||||
}
|
||||
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
|
||||
{
|
||||
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
|
||||
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
|
||||
|
|
|
@ -33,20 +33,44 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
cutoff.TotalMilliseconds);
|
||||
}
|
||||
|
||||
public bool CheckIfTransactionValid(Transaction transaction, string invoiceId, out bool alreadyExists)
|
||||
public enum TransactionValidityResult
|
||||
{
|
||||
Valid_ExactMatch,
|
||||
Invalid_PartialMatch,
|
||||
Valid_NoMatch,
|
||||
Invalid_Inputs_Seen,
|
||||
Valid_SameInputs
|
||||
}
|
||||
|
||||
public TransactionValidityResult CheckIfTransactionValid(Transaction transaction, string invoiceId)
|
||||
{
|
||||
if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}"))
|
||||
{
|
||||
alreadyExists = true;
|
||||
return true;
|
||||
return TransactionValidityResult.Valid_ExactMatch;
|
||||
}
|
||||
|
||||
alreadyExists = false;
|
||||
var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString());
|
||||
return !RecordedTransactions.Any(record =>
|
||||
|
||||
var matches = RecordedTransactions.Where(record =>
|
||||
record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase) ||
|
||||
record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString())));
|
||||
record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (matches.Any())
|
||||
{
|
||||
if(matches.Any(record =>
|
||||
record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) &&
|
||||
record.Value.Transaction.RBF &&
|
||||
record.Value.Transaction.Inputs.All(recordTxIn => hashes.Contains(recordTxIn.PrevOut.ToString()))))
|
||||
{
|
||||
return TransactionValidityResult.Valid_SameInputs;
|
||||
}
|
||||
|
||||
return TransactionValidityResult.Invalid_PartialMatch;
|
||||
}
|
||||
|
||||
return RecordedTransactions.Any(record =>
|
||||
record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString())))
|
||||
? TransactionValidityResult.Invalid_Inputs_Seen: TransactionValidityResult.Valid_NoMatch;
|
||||
}
|
||||
|
||||
public void AddRecord(PayJoinStateRecordedItem recordedItem)
|
||||
|
@ -73,9 +97,19 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
|
||||
public void RemoveRecord(uint256 proposedTxHash)
|
||||
{
|
||||
var id = RecordedTransactions.Single(pair =>
|
||||
var id = RecordedTransactions.SingleOrDefault(pair =>
|
||||
pair.Value.ProposedTransactionHash == proposedTxHash ||
|
||||
pair.Value.OriginalTransactionHash == proposedTxHash).Key;
|
||||
if (id != null)
|
||||
{
|
||||
RecordedTransactions.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveRecord(string invoiceId)
|
||||
{
|
||||
var id = RecordedTransactions.Single(pair =>
|
||||
pair.Value.InvoiceId == invoiceId).Key;
|
||||
RecordedTransactions.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
|
|
|
@ -12,10 +12,11 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
public uint256 ProposedTransactionHash { get; set; }
|
||||
public List<ReceivedCoin> CoinsExposed { get; set; }
|
||||
public decimal TotalOutputAmount { get; set; }
|
||||
public decimal ContributedAmount { get; set; }
|
||||
public decimal ContributedAmount { get; set ; }
|
||||
public uint256 OriginalTransactionHash { get; set; }
|
||||
|
||||
public string InvoiceId { get; set; }
|
||||
public bool TxSeen { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
|
|
|
@ -3,18 +3,21 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NBitcoin.RPC;
|
||||
using NBXplorer;
|
||||
|
||||
namespace BTCPayServer.Payments.PayJoin
|
||||
{
|
||||
public class PayJoinTransactionBroadcaster : IHostedService
|
||||
{
|
||||
private readonly TimeSpan
|
||||
BroadcastAfter =
|
||||
TimeSpan.FromMinutes(
|
||||
5); // The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok.
|
||||
// The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok.
|
||||
private static readonly TimeSpan BroadcastAfter = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly ExplorerClientProvider _explorerClientProvider;
|
||||
|
@ -41,7 +44,9 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
|
||||
leases.Add(_eventAggregator.Subscribe<NewOnChainTransactionEvent>(txEvent =>
|
||||
{
|
||||
if (!txEvent.NewTransactionEvent.Outputs.Any())
|
||||
if (!txEvent.NewTransactionEvent.Outputs.Any() ||
|
||||
(txEvent.NewTransactionEvent.TransactionData.Transaction.RBF &&
|
||||
txEvent.NewTransactionEvent.TransactionData.Confirmations == 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
@ -51,7 +56,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
|
||||
foreach (var relevantState in relevantStates)
|
||||
{
|
||||
//if any of the exposed inputs where spent, remove them from our state
|
||||
//if any of the exposed inputs were spent, remove them from our state
|
||||
relevantState.PruneRecordsOfUsedInputs(txEvent.NewTransactionEvent.TransactionData.Transaction
|
||||
.Inputs);
|
||||
}
|
||||
|
@ -64,25 +69,58 @@ namespace BTCPayServer.Payments.PayJoin
|
|||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var state in _payJoinStateProvider.GetAll())
|
||||
{
|
||||
var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode);
|
||||
//broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time.
|
||||
//This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible.
|
||||
var staleTxs = state.Value.GetStaleRecords(BroadcastAfter);
|
||||
|
||||
tasks.AddRange(staleTxs.Select(staleTx => explorerClient
|
||||
.BroadcastAsync(staleTx.Transaction, cancellationToken)
|
||||
.ContinueWith(task => { state.Value.RemoveRecord(staleTx, true); }, TaskScheduler.Default)));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
await BroadcastStaleTransactions(BroadcastAfter, cancellationToken);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task BroadcastStaleTransactions(TimeSpan broadcastAfter, CancellationToken cancellationToken)
|
||||
{
|
||||
List<Task> tasks = new List<Task>();
|
||||
foreach (var state in _payJoinStateProvider.GetAll())
|
||||
{
|
||||
var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode);
|
||||
//broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time.
|
||||
//This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible.
|
||||
var staleTxs = state.Value.GetStaleRecords(broadcastAfter)
|
||||
.Where(item => !item.TxSeen || item.Transaction.RBF);
|
||||
|
||||
tasks.AddRange(staleTxs.Select(async staleTx =>
|
||||
{
|
||||
//if the transaction signals RBF and was broadcasted, check if it was rbfed out
|
||||
if (staleTx.TxSeen && staleTx.Transaction.RBF)
|
||||
{
|
||||
var proposedTransaction = await explorerClient.GetTransactionAsync(staleTx.ProposedTransactionHash, cancellationToken);
|
||||
var result = await explorerClient.BroadcastAsync(proposedTransaction.Transaction, cancellationToken);
|
||||
var accounted = result.Success ||
|
||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN ||
|
||||
!(
|
||||
// Happen if a blocks mined a replacement
|
||||
// Or if the tx is a double spend of something already in the mempool without rbf
|
||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
|
||||
// Happen if RBF is on and fee insufficient
|
||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED);
|
||||
|
||||
if (accounted)
|
||||
{
|
||||
//if it wasn't replaced just yet, do not attempt to move the exposed coins to the priority list
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await explorerClient
|
||||
.BroadcastAsync(staleTx.Transaction, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
state.Value.RemoveRecord(staleTx, true);
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _payJoinStateProvider.SaveCoins();
|
||||
|
|
Loading…
Add table
Reference in a new issue