RBF Protection & Handling

This commit is contained in:
Kukks 2020-03-05 19:04:08 +01:00
parent 89da4184ff
commit 2e3a0706ee
8 changed files with 357 additions and 66 deletions

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
{

View file

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