mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
BIP79 Support
This commit is contained in:
411
BTCPayServer.Tests/PayJoinTests.cs
Normal file
411
BTCPayServer.Tests/PayJoinTests.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.PayJoin;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using BTCPayServer.Tests.Logging;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Payment;
|
||||
using NBitpayClient;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
public class PayJoinTests
|
||||
{
|
||||
public const int TestTimeout = 60_000;
|
||||
|
||||
public PayJoinTests(ITestOutputHelper helper)
|
||||
{
|
||||
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
|
||||
Logs.LogProvider = new XUnitLogProvider(helper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// [Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUseBIP79()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var btcPayNetwork = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(btcPayNetwork);
|
||||
var cashCow = tester.ExplorerNode;
|
||||
cashCow.Generate(2); // get some money in case
|
||||
|
||||
var senderUser = tester.NewAccount();
|
||||
senderUser.GrantAccess(true);
|
||||
senderUser.RegisterDerivationScheme("BTC", true, true);
|
||||
|
||||
var invoice = senderUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 100, Currency = "USD", FullNotifications = true});
|
||||
//payjoin is not enabled by default.
|
||||
Assert.DoesNotContain("bpu", invoice.CryptoInfo.First().PaymentUrls.BIP21);
|
||||
cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network),
|
||||
Money.Coins(0.06m));
|
||||
|
||||
var receiverUser = tester.NewAccount();
|
||||
receiverUser.GrantAccess(true);
|
||||
receiverUser.RegisterDerivationScheme("BTC", true, true);
|
||||
|
||||
await receiverUser.EnablePayJoin();
|
||||
// payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer
|
||||
invoice = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||
|
||||
//check that the BIP21 has an endpoint
|
||||
var bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21;
|
||||
Assert.Contains("bpu", bip21);
|
||||
var parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var endpoint = parsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
|
||||
//see if the btcpay send wallet supports BIP21 properly and also the payjoin endpoint
|
||||
var receiverWalletId = new WalletId(receiverUser.StoreId, "BTC");
|
||||
var senderWalletId = new WalletId(senderUser.StoreId, "BTC");
|
||||
var senderWallerController = senderUser.GetController<WalletsController>();
|
||||
var senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId)
|
||||
.AssertViewModelAsync<WalletSendModel>();
|
||||
senderWalletSendVM = await senderWallerController
|
||||
.WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21)
|
||||
.AssertViewModelAsync<WalletSendModel>();
|
||||
|
||||
Assert.Single(senderWalletSendVM.Outputs);
|
||||
Assert.Equal(endpoint, senderWalletSendVM.PayJoinEndpointUrl);
|
||||
Assert.Equal(parsedBip21.Address.ToString(), senderWalletSendVM.Outputs.First().DestinationAddress);
|
||||
Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC), senderWalletSendVM.Outputs.First().Amount);
|
||||
|
||||
//the nbx wallet option should also be available
|
||||
Assert.True(senderWalletSendVM.NBXSeedAvailable);
|
||||
|
||||
//pay the invoice with the nbx seed wallet option + also the invoice
|
||||
var postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId,
|
||||
senderWalletSendVM, "nbx-seed", CancellationToken.None)
|
||||
.AssertViewModelAsync<PostRedirectViewModel>();
|
||||
var redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value;
|
||||
var psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var senderWalletSendPSBTResult = new WalletPSBTReadyViewModel()
|
||||
{
|
||||
PSBT = redirectedPSBT,
|
||||
SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value,
|
||||
SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value
|
||||
};
|
||||
//While the endpoint was set, the receiver had no utxos. The payment should fall back to original payment terms instead
|
||||
Assert.Equal(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC).ToString(),
|
||||
psbt.Outputs.Single(model => model.ScriptPubKey == parsedBip21.Address.ScriptPubKey).Value);
|
||||
|
||||
Assert.Equal("WalletTransactions",
|
||||
Assert.IsType<RedirectToActionResult>(
|
||||
await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult,
|
||||
"broadcast"))
|
||||
.ActionName);
|
||||
|
||||
//we used the bip21 link straight away to pay the invoice so it should be paid straight away.
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
invoice = receiverUser.BitPay.GetInvoice(invoice.Id);
|
||||
Assert.Equal(Invoice.STATUS_PAID, invoice.Status);
|
||||
});
|
||||
|
||||
//now that there is a utxo, let's do it again
|
||||
|
||||
invoice = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||
bip21 = invoice.CryptoInfo.First().PaymentUrls.BIP21;
|
||||
parsedBip21 = new BitcoinUrlBuilder(bip21, tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
senderWalletSendVM = await senderWallerController.WalletSend(senderWalletId)
|
||||
.AssertViewModelAsync<WalletSendModel>();
|
||||
senderWalletSendVM = await senderWallerController
|
||||
.WalletSend(senderWalletId, senderWalletSendVM, "", CancellationToken.None, bip21)
|
||||
.AssertViewModelAsync<WalletSendModel>();
|
||||
postRedirectViewModel = await senderWallerController.WalletSend(senderWalletId,
|
||||
senderWalletSendVM, "nbx-seed", CancellationToken.None)
|
||||
.AssertViewModelAsync<PostRedirectViewModel>();
|
||||
redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt").Value;
|
||||
psbt = PSBT.Parse(redirectedPSBT, tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
senderWalletSendPSBTResult = new WalletPSBTReadyViewModel()
|
||||
{
|
||||
PSBT = redirectedPSBT,
|
||||
SigningKeyPath = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKeyPath").Value,
|
||||
SigningKey = postRedirectViewModel.Parameters.Single(p => p.Key == "SigningKey").Value
|
||||
};
|
||||
//the payjoin should make the amount being paid to the address higher
|
||||
Assert.True(parsedBip21.Amount.ToDecimal(MoneyUnit.BTC) < psbt.Outputs
|
||||
.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.Equal(psbt.Finalize().ExtractTransaction().GetHash(),
|
||||
state.GetRecords().First().ProposedTransactionHash);
|
||||
|
||||
Assert.Equal("WalletTransactions",
|
||||
Assert.IsType<RedirectToActionResult>(
|
||||
await senderWallerController.WalletPSBTReady(senderWalletId, senderWalletSendPSBTResult,
|
||||
"broadcast"))
|
||||
.ActionName);
|
||||
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
invoice = receiverUser.BitPay.GetInvoice(invoice.Id);
|
||||
|
||||
Assert.Equal(Invoice.STATUS_PAID, invoice.Status);
|
||||
Assert.Equal(Invoice.EXSTATUS_FALSE, invoice.ExceptionStatus.ToString().ToLowerInvariant());
|
||||
});
|
||||
|
||||
//verify that we have a record that it was a payjoin
|
||||
var receiverController = receiverUser.GetController<InvoiceController>();
|
||||
var invoiceVM =
|
||||
await receiverController.Invoice(invoice.Id).AssertViewModelAsync<InvoiceDetailsModel>();
|
||||
Assert.Single(invoiceVM.Payments);
|
||||
Assert.True(Assert.IsType<BitcoinLikePaymentData>(invoiceVM.Payments.First().GetCryptoPaymentData())
|
||||
.PayJoinSelfContributedAmount > 0);
|
||||
|
||||
//check that the state has cleared that ongoing tx
|
||||
state = payJoinStateProvider.Get(receiverWalletId);
|
||||
Assert.NotNull(state);
|
||||
Assert.Empty(state.GetRecords());
|
||||
Assert.Empty(state.GetExposedCoins());
|
||||
|
||||
//Cool, so the payjoin works!
|
||||
//The cool thing with payjoin is that your utxos don't grow
|
||||
Assert.Single(await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme));
|
||||
|
||||
//Let's be as malicious as CSW
|
||||
|
||||
//give the cow some cash
|
||||
await cashCow.GenerateAsync(1);
|
||||
//let's get some more utxos first
|
||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.011m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.012m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.013m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.021m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.022m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.023m, MoneyUnit.BTC)));
|
||||
Assert.NotNull( await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.024m, MoneyUnit.BTC)));
|
||||
|
||||
await cashCow.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
||||
new Money(0.014m, MoneyUnit.BTC));
|
||||
var senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme);
|
||||
|
||||
var senderChange = (await btcPayWallet.GetChangeAddressAsync(senderUser.DerivationScheme)).Item1;
|
||||
|
||||
//Let's start the harassment
|
||||
invoice = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||
|
||||
parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
endpoint = parsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
var invoice2 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||
var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var endpoint2 = secondInvoiceParsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId);
|
||||
var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
|
||||
var derivationSchemeSettings = senderStore.GetSupportedPaymentMethods(tester.NetworkProvider)
|
||||
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
|
||||
settings.PaymentId == paymentMethodId);
|
||||
var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m);
|
||||
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 signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
|
||||
signingKeySettings.RootFingerprint =
|
||||
senderUser.GenerateWalletResponseV.MasterHDKey.GetPublicKey().GetHDFingerPrint();
|
||||
|
||||
var extKey =
|
||||
senderUser.GenerateWalletResponseV.MasterHDKey.Derive(signingKeySettings.GetRootedKeyPath()
|
||||
.KeyPath);
|
||||
|
||||
|
||||
var n = tester.ExplorerClient.Network.NBitcoinNetwork;
|
||||
var Invoice1Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(parsedBip21.Address, parsedBip21.Amount)
|
||||
.AddCoins(coin.Coin)
|
||||
.AddKeys(extKey.Derive(coin.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice1Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(parsedBip21.Address, parsedBip21.Amount)
|
||||
.AddCoins(coin2.Coin)
|
||||
.AddKeys(extKey.Derive(coin2.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice2Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount)
|
||||
.AddCoins(coin.Coin)
|
||||
.AddKeys(extKey.Derive(coin.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice2Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount)
|
||||
.AddCoins(coin2.Coin)
|
||||
.AddKeys(extKey.Derive(coin2.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
//Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all
|
||||
//Result: reject
|
||||
Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint,
|
||||
new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
||||
|
||||
//Attempt 2: Create two transactions using different inputs and send them to the same invoice.
|
||||
//Result: Second Tx should be rejected.
|
||||
var Invoice1Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint,
|
||||
new StringContent(Invoice1Coin1.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
||||
var Invoice1Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint,
|
||||
new StringContent(Invoice1Coin2.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
||||
Assert.True(Invoice1Coin1Response.IsSuccessStatusCode);
|
||||
Assert.False(Invoice1Coin2Response.IsSuccessStatusCode);
|
||||
var Invoice1Coin1ResponseTx =
|
||||
Transaction.Parse(await Invoice1Coin1Response.Content.ReadAsStringAsync(), n);
|
||||
var contributedInputsInvoice1Coin1ResponseTx =
|
||||
Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut);
|
||||
Assert.Single(contributedInputsInvoice1Coin1ResponseTx);
|
||||
|
||||
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted
|
||||
//Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
|
||||
|
||||
var Invoice2Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint2,
|
||||
new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
||||
var Invoice2Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint2,
|
||||
new StringContent(Invoice2Coin2.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
||||
Assert.False(Invoice2Coin1Response.IsSuccessStatusCode);
|
||||
Assert.True(Invoice2Coin2Response.IsSuccessStatusCode);
|
||||
|
||||
var Invoice2Coin2ResponseTx =
|
||||
Transaction.Parse(await Invoice2Coin2Response.Content.ReadAsStringAsync(), n);
|
||||
var contributedInputsInvoice2Coin2ResponseTx =
|
||||
Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut);
|
||||
Assert.Single(contributedInputsInvoice2Coin2ResponseTx);
|
||||
|
||||
//Attempt 4: Make tx that pays invoice 3 and 4 and submit to both
|
||||
//Result: reject on 4: the protocol should not worry about this complexity
|
||||
|
||||
var invoice3 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var invoice3Endpoint = invoice3ParsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
|
||||
var invoice4 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var invoice4Endpoint = invoice4ParsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
|
||||
var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice3ParsedBip21.Address, invoice3ParsedBip21.Amount)
|
||||
.Send(invoice4ParsedBip21.Address, invoice4ParsedBip21.Amount)
|
||||
.AddCoins(coin3.Coin)
|
||||
.AddKeys(extKey.Derive(coin3.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice3Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice3Endpoint,
|
||||
new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
||||
var Invoice4Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice4Endpoint,
|
||||
new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
||||
Assert.True(Invoice3Coin3Response.IsSuccessStatusCode);
|
||||
Assert.False(Invoice4Coin3Response.IsSuccessStatusCode);
|
||||
|
||||
//Attempt 5: Make tx that pays invoice 5 with 2 outputs
|
||||
//Result: proposed tx consolidates the outputs
|
||||
|
||||
var invoice5 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"];
|
||||
|
||||
var Invoice5Coin4 = 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);
|
||||
|
||||
var Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
|
||||
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
|
||||
var Invoice5Coin4ResponseTx =
|
||||
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n);
|
||||
Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address));
|
||||
|
||||
//Attempt 6: submit the same tx over and over in the hopes of getting new utxos
|
||||
//Result: same tx gets sent back
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var Invoice5Coin4Response2 = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
if (!Invoice5Coin4Response2.IsSuccessStatusCode)
|
||||
{
|
||||
Logs.Tester.LogInformation(
|
||||
$"Failed on try {i + 1} with {await Invoice5Coin4Response2.Content.ReadAsStringAsync()}");
|
||||
}
|
||||
|
||||
Assert.True(Invoice5Coin4Response2.IsSuccessStatusCode);
|
||||
var Invoice5Coin4Response2Tx =
|
||||
Transaction.Parse(await Invoice5Coin4Response2.Content.ReadAsStringAsync(), n);
|
||||
Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), Invoice5Coin4Response2Tx.GetHash());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,9 @@ namespace BTCPayServer.Tests
|
||||
BitPay = new Bitpay(new Key(), parent.PayTester.ServerUri);
|
||||
}
|
||||
|
||||
public void GrantAccess()
|
||||
public void GrantAccess(bool isAdmin = false)
|
||||
{
|
||||
GrantAccessAsync().GetAwaiter().GetResult();
|
||||
GrantAccessAsync(isAdmin).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task MakeAdmin(bool isAdmin = true)
|
||||
@@ -74,13 +74,13 @@ namespace BTCPayServer.Tests
|
||||
return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey);
|
||||
}
|
||||
|
||||
public void Register()
|
||||
public void Register(bool isAdmin = false)
|
||||
{
|
||||
RegisterAsync().GetAwaiter().GetResult();
|
||||
RegisterAsync(isAdmin).GetAwaiter().GetResult();
|
||||
}
|
||||
public async Task GrantAccessAsync()
|
||||
public async Task GrantAccessAsync(bool isAdmin = false)
|
||||
{
|
||||
await RegisterAsync();
|
||||
await RegisterAsync(isAdmin);
|
||||
await CreateStoreAsync();
|
||||
var store = this.GetController<StoresController>();
|
||||
var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant);
|
||||
@@ -160,6 +160,20 @@ namespace BTCPayServer.Tests
|
||||
return new WalletId(StoreId, cryptoCode);
|
||||
}
|
||||
|
||||
public async Task EnablePayJoin()
|
||||
{
|
||||
var storeController = parent.PayTester.GetController<StoresController>(UserId, StoreId);
|
||||
var checkoutExperienceVM =
|
||||
Assert.IsType<CheckoutExperienceViewModel>(Assert
|
||||
.IsType<ViewResult>(storeController.CheckoutExperience()).Model);
|
||||
|
||||
checkoutExperienceVM.PayJoinEnabled = true;
|
||||
|
||||
Assert.Equal(nameof(storeController.CheckoutExperience),
|
||||
Assert.IsType<RedirectToActionResult>(
|
||||
await storeController.CheckoutExperience(checkoutExperienceVM)).ActionName);
|
||||
}
|
||||
|
||||
public GenerateWalletResponse GenerateWalletResponseV { get; set; }
|
||||
|
||||
public DerivationStrategyBase DerivationScheme
|
||||
@@ -170,7 +184,7 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RegisterAsync()
|
||||
private async Task RegisterAsync(bool isAdmin = false)
|
||||
{
|
||||
var account = parent.PayTester.GetController<AccountController>();
|
||||
RegisterDetails = new RegisterViewModel()
|
||||
@@ -178,6 +192,7 @@ namespace BTCPayServer.Tests
|
||||
Email = Guid.NewGuid() + "@toto.com",
|
||||
ConfirmPassword = "Kitten0@",
|
||||
Password = "Kitten0@",
|
||||
IsAdmin = isAdmin
|
||||
};
|
||||
await account.Register(RegisterDetails);
|
||||
UserId = account.RegisteredUserId;
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<PackageReference Include="Serilog" Version="2.9.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
|
||||
<PackageReference Include="SocksWebProxy" Version="1.0.5" />
|
||||
<PackageReference Include="SSH.NET" Version="2016.1.0" />
|
||||
<PackageReference Include="Text.Analyzers" Version="2.6.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -381,6 +381,7 @@ namespace BTCPayServer.Controllers
|
||||
vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? "";
|
||||
vm.LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi;
|
||||
vm.RedirectAutomatically = storeBlob.RedirectAutomatically;
|
||||
vm.PayJoinEnabled = storeBlob.PayJoinEnabled;
|
||||
return View(vm);
|
||||
}
|
||||
void SetCryptoCurrencies(CheckoutExperienceViewModel vm, Data.StoreData storeData)
|
||||
@@ -441,6 +442,7 @@ namespace BTCPayServer.Controllers
|
||||
blob.LightningMaxValue = lightningMaxValue;
|
||||
blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi;
|
||||
blob.RedirectAutomatically = model.RedirectAutomatically;
|
||||
blob.PayJoinEnabled = model.PayJoinEnabled;
|
||||
if (CurrentStore.SetStoreBlob(blob))
|
||||
{
|
||||
needUpdate = true;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.ModelBinders;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitcoin;
|
||||
@@ -121,7 +124,7 @@ namespace BTCPayServer.Controllers
|
||||
.GetMetadataAsync<string>(derivationScheme.AccountDerivation,
|
||||
WellknownMetadataKeys.MasterHDKey);
|
||||
|
||||
return SignWithSeed(walletId,
|
||||
return await SignWithSeed(walletId,
|
||||
new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64()});
|
||||
}
|
||||
|
||||
@@ -153,6 +156,80 @@ namespace BTCPayServer.Controllers
|
||||
return result.PSBT;
|
||||
}
|
||||
|
||||
private async Task<PSBT> TryGetBPProposedTX(PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork)
|
||||
{
|
||||
|
||||
if (TempData.TryGetValue( "bpu", out var bpu) && !string.IsNullOrEmpty(bpu?.ToString()) && Uri.TryCreate(bpu.ToString(), UriKind.Absolute, out var endpoint))
|
||||
{
|
||||
TempData.Remove("bpu");
|
||||
HttpClient httpClient;
|
||||
if (endpoint.IsOnion() && _socketFactory.SocksClient!= null)
|
||||
{
|
||||
if ( _socketFactory.SocksClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
httpClient = _socketFactory.SocksClient;
|
||||
}
|
||||
else
|
||||
{
|
||||
httpClient = _httpClientFactory.CreateClient("bpu");
|
||||
}
|
||||
|
||||
var cloned = psbt.Clone();
|
||||
|
||||
if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bpuresponse = await httpClient.PostAsync(bpu.ToString(), new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
if (bpuresponse.IsSuccessStatusCode)
|
||||
{
|
||||
var hex = await bpuresponse.Content.ReadAsStringAsync();
|
||||
if (PSBT.TryParse(hex, btcPayNetwork.NBitcoinNetwork, out var newPSBT))
|
||||
{
|
||||
//check that all the inputs we provided are still there and that there is at least one new(signed) input.
|
||||
bool valid = false;
|
||||
var existingInputs = psbt.Inputs.Select(input => input.PrevOut).ToList();
|
||||
foreach (var input in newPSBT.Inputs)
|
||||
{
|
||||
var existingInput = existingInputs.SingleOrDefault(point => point == input.PrevOut);
|
||||
if (existingInput != null)
|
||||
{
|
||||
existingInputs.Remove(existingInput);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!input.TryFinalizeInput(out _))
|
||||
{
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
// a new signed input was provided
|
||||
valid = true;
|
||||
}
|
||||
|
||||
if (!valid || existingInputs.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork);
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
AllowDismiss = false,
|
||||
Message = "This transaction has been coordinated between the receiver and you to create a <a href='https://en.bitcoin.it/wiki/PayJoin' target='_blank'>payjoin transaction</a> by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same"
|
||||
});
|
||||
return newPSBT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("{walletId}/psbt/ready")]
|
||||
public async Task<IActionResult> WalletPSBTReady(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
@@ -49,6 +50,8 @@ namespace BTCPayServer.Controllers
|
||||
private readonly WalletReceiveStateService _WalletReceiveStateService;
|
||||
private readonly EventAggregator _EventAggregator;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly SocketFactory _socketFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
public RateFetcher RateFetcher { get; }
|
||||
|
||||
CurrencyNameTable _currencyTable;
|
||||
@@ -66,7 +69,9 @@ namespace BTCPayServer.Controllers
|
||||
BTCPayWalletProvider walletProvider,
|
||||
WalletReceiveStateService walletReceiveStateService,
|
||||
EventAggregator eventAggregator,
|
||||
SettingsRepository settingsRepository)
|
||||
SettingsRepository settingsRepository,
|
||||
SocketFactory socketFactory,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_currencyTable = currencyTable;
|
||||
Repository = repo;
|
||||
@@ -83,6 +88,8 @@ namespace BTCPayServer.Controllers
|
||||
_WalletReceiveStateService = walletReceiveStateService;
|
||||
_EventAggregator = eventAggregator;
|
||||
_settingsRepository = settingsRepository;
|
||||
_socketFactory = socketFactory;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
|
||||
@@ -456,7 +463,9 @@ namespace BTCPayServer.Controllers
|
||||
if (network == null || network.ReadonlyWallet)
|
||||
return NotFound();
|
||||
vm.SupportRBF = network.SupportRBF;
|
||||
|
||||
vm.NBXSeedAvailable = await CanUseHotWallet() && !string.IsNullOrEmpty(await ExplorerClientProvider.GetExplorerClient(network)
|
||||
.GetMetadataAsync<string>(GetDerivationSchemeSettings(walletId).AccountDerivation,
|
||||
WellknownMetadataKeys.MasterHDKey, cancellation));
|
||||
if (!string.IsNullOrEmpty(bip21))
|
||||
{
|
||||
LoadFromBIP21(vm, bip21, network);
|
||||
@@ -594,7 +603,7 @@ namespace BTCPayServer.Controllers
|
||||
return View(vm);
|
||||
}
|
||||
derivationScheme.RebaseKeyPaths(psbt.PSBT);
|
||||
|
||||
TempData.AddOrReplace("bpu", vm.PayJoinEndpointUrl);
|
||||
switch (command)
|
||||
{
|
||||
case "vault":
|
||||
@@ -603,7 +612,7 @@ namespace BTCPayServer.Controllers
|
||||
var extKey = await ExplorerClientProvider.GetExplorerClient(network)
|
||||
.GetMetadataAsync<string>(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey, cancellation);
|
||||
|
||||
return SignWithSeed(walletId, new SignWithSeedViewModel()
|
||||
return await SignWithSeed(walletId, new SignWithSeedViewModel()
|
||||
{
|
||||
SeedOrKey = extKey,
|
||||
PSBT = psbt.PSBT.ToBase64()
|
||||
@@ -650,6 +659,8 @@ namespace BTCPayServer.Controllers
|
||||
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}"
|
||||
});
|
||||
}
|
||||
uriBuilder.UnknowParameters.TryGetValue("bpu", out var vmPayJoinEndpointUrl);
|
||||
vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -675,9 +686,17 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpPost]
|
||||
[Route("{walletId}/vault")]
|
||||
public IActionResult SubmitVault([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
public async Task<IActionResult> SubmitVault([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, WalletSendVaultModel model)
|
||||
{
|
||||
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
||||
var newPSBT = await TryGetBPProposedTX(PSBT.Parse(model.PSBT, network.NBitcoinNetwork), GetDerivationSchemeSettings(walletId), network);
|
||||
if (newPSBT != null)
|
||||
{
|
||||
model.PSBT = newPSBT.ToBase64();
|
||||
return View("WalletSendVault", model);
|
||||
}
|
||||
|
||||
return RedirectToWalletPSBTReady(model.PSBT);
|
||||
}
|
||||
private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null)
|
||||
@@ -747,9 +766,16 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpPost]
|
||||
[Route("{walletId}/ledger")]
|
||||
public IActionResult SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
public async Task<IActionResult> SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, WalletSendLedgerModel model)
|
||||
{
|
||||
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
||||
var newPSBT = await TryGetBPProposedTX(PSBT.Parse(model.PSBT,network.NBitcoinNetwork ), GetDerivationSchemeSettings(walletId), network);
|
||||
if (newPSBT != null)
|
||||
{
|
||||
model.PSBT = newPSBT.ToBase64();
|
||||
return View("WalletSendLedger", model);
|
||||
}
|
||||
return RedirectToWalletPSBTReady(model.PSBT);
|
||||
}
|
||||
|
||||
@@ -764,7 +790,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
[HttpPost("{walletId}/psbt/seed")]
|
||||
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, SignWithSeedViewModel viewModel)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
@@ -826,6 +852,12 @@ namespace BTCPayServer.Controllers
|
||||
return View(viewModel);
|
||||
}
|
||||
ModelState.Remove(nameof(viewModel.PSBT));
|
||||
var newPSBT = await TryGetBPProposedTX(psbt, GetDerivationSchemeSettings(walletId), network);
|
||||
if (newPSBT != null)
|
||||
{
|
||||
viewModel.PSBT = newPSBT.ToBase64();
|
||||
return await SignWithSeed(walletId, viewModel);
|
||||
}
|
||||
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString());
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ namespace BTCPayServer.Data
|
||||
|
||||
public EmailSettings EmailSettings { get; set; }
|
||||
public bool RedirectAutomatically { get; set; }
|
||||
public bool PayJoinEnabled { get; set; }
|
||||
|
||||
public IPaymentFilter GetExcludedPaymentMethods()
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.Changelly;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Payments.PayJoin;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services.PaymentRequests;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
@@ -62,6 +63,7 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
services.AddPayJoinServices();
|
||||
services.AddMoneroLike();
|
||||
services.TryAddSingleton<SettingsRepository>();
|
||||
services.TryAddSingleton<TorServices>();
|
||||
@@ -257,11 +259,13 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=1000r/min burst=100 nodelay");
|
||||
}
|
||||
else
|
||||
{
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst= nodelay");
|
||||
}
|
||||
return rateLimits;
|
||||
});
|
||||
|
||||
@@ -58,6 +58,9 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[Display(Name = "Redirect invoice to redirect url automatically after paid")]
|
||||
public bool RedirectAutomatically { get; set; }
|
||||
|
||||
[Display(Name = "Enable BIP79 Payjoin/P2EP")]
|
||||
public bool PayJoinEnabled { get; set; }
|
||||
|
||||
public void SetLanguages(LanguageService langService, string defaultLang)
|
||||
{
|
||||
defaultLang = langService.GetLanguages().Any(language => language.Code == defaultLang) ? defaultLang : "en";
|
||||
|
||||
@@ -10,7 +10,6 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
{
|
||||
public class WalletSendModel
|
||||
{
|
||||
|
||||
public List<TransactionOutput> Outputs { get; set; } = new List<TransactionOutput>();
|
||||
|
||||
public class TransactionOutput
|
||||
@@ -49,6 +48,8 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public bool DisableRBF { get; set; }
|
||||
|
||||
public bool NBXSeedAvailable { get; set; }
|
||||
[Display(Name = "PayJoin Endpoint Url (BIP79)")]
|
||||
public string PayJoinEndpointUrl { get; set; }
|
||||
public bool InputSelection { get; set; }
|
||||
public InputSelectionOption[] InputsAvailable { get; set; }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using NBitcoin;
|
||||
using NBXplorer.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
@@ -56,10 +57,25 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
public Money NextNetworkFee { get; set; }
|
||||
[JsonIgnore]
|
||||
public String DepositAddress { get; set; }
|
||||
|
||||
public PayJoinPaymentState PayJoin { get; set; } = new PayJoinPaymentState();
|
||||
|
||||
|
||||
|
||||
public BitcoinAddress GetDepositAddress(Network network)
|
||||
{
|
||||
return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network);
|
||||
}
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
}
|
||||
|
||||
public class PayJoinPaymentState
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
public uint256 ProposedTransactionHash { get; set; }
|
||||
public List<ReceivedCoin> CoinsExposed { get; set; }
|
||||
public decimal TotalOutputAmount { get; set; }
|
||||
public decimal ContributedAmount { get; set; }
|
||||
public uint256 OriginalTransactionHash { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,14 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
|
||||
}
|
||||
|
||||
public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf)
|
||||
public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf, decimal payJoinSelfContributedAmount)
|
||||
{
|
||||
Address = address;
|
||||
Value = value;
|
||||
Outpoint = outpoint;
|
||||
ConfirmationCount = 0;
|
||||
RBF = rbf;
|
||||
PayJoinSelfContributedAmount = payJoinSelfContributedAmount;
|
||||
}
|
||||
[JsonIgnore]
|
||||
public BTCPayNetworkBase Network { get; set; }
|
||||
@@ -40,6 +41,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
public decimal NetworkFee { get; set; }
|
||||
public BitcoinAddress Address { get; set; }
|
||||
public IMoney Value { get; set; }
|
||||
public decimal PayJoinSelfContributedAmount { get; set; } = 0;
|
||||
|
||||
[JsonIgnore]
|
||||
public Script ScriptPubKey
|
||||
@@ -67,7 +69,8 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
|
||||
public decimal GetValue()
|
||||
{
|
||||
return Value?.GetValue(Network as BTCPayNetwork)??Output.Value.ToDecimal(MoneyUnit.BTC);
|
||||
return (Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC)) -
|
||||
PayJoinSelfContributedAmount;
|
||||
}
|
||||
|
||||
public bool PaymentCompleted(PaymentEntity entity)
|
||||
|
||||
@@ -125,7 +125,8 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
var prepare = (Prepare)preparePaymentObject;
|
||||
Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod onchainMethod =
|
||||
new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod();
|
||||
onchainMethod.NetworkFeeMode = store.GetStoreBlob().NetworkFeeMode;
|
||||
var blob = store.GetStoreBlob();
|
||||
onchainMethod.NetworkFeeMode = blob.NetworkFeeMode;
|
||||
onchainMethod.FeeRate = await prepare.GetFeeRate;
|
||||
switch (onchainMethod.NetworkFeeMode)
|
||||
{
|
||||
@@ -142,7 +143,14 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
onchainMethod.NextNetworkFee = Money.Zero;
|
||||
break;
|
||||
}
|
||||
|
||||
onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString();
|
||||
onchainMethod.PayJoin = new PayJoinPaymentState()
|
||||
{
|
||||
Enabled = blob.PayJoinEnabled &&
|
||||
supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() !=
|
||||
ScriptPubKeyType.Legacy
|
||||
};
|
||||
return onchainMethod;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NBXplorer;
|
||||
using System.Collections.Concurrent;
|
||||
using BTCPayServer.Controllers;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Services;
|
||||
@@ -18,6 +19,7 @@ using NBitcoin;
|
||||
using NBXplorer.Models;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Payments.PayJoin;
|
||||
using NBitcoin.Altcoins.Elements;
|
||||
using NBitcoin.RPC;
|
||||
|
||||
@@ -29,6 +31,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
public class NBXplorerListener : IHostedService
|
||||
{
|
||||
EventAggregator _Aggregator;
|
||||
private readonly PayJoinStateProvider _payJoinStateProvider;
|
||||
ExplorerClientProvider _ExplorerClients;
|
||||
IHostApplicationLifetime _Lifetime;
|
||||
InvoiceRepository _InvoiceRepository;
|
||||
@@ -40,6 +43,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
BTCPayWalletProvider wallets,
|
||||
InvoiceRepository invoiceRepository,
|
||||
EventAggregator aggregator,
|
||||
PayJoinStateProvider payJoinStateProvider,
|
||||
IHostApplicationLifetime lifetime)
|
||||
{
|
||||
PollInterval = TimeSpan.FromMinutes(1.0);
|
||||
@@ -47,6 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
_InvoiceRepository = invoiceRepository;
|
||||
_ExplorerClients = explorerClients;
|
||||
_Aggregator = aggregator;
|
||||
_payJoinStateProvider = payJoinStateProvider;
|
||||
_Lifetime = lifetime;
|
||||
}
|
||||
|
||||
@@ -138,7 +143,6 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
switch (newEvent)
|
||||
{
|
||||
case NBXplorer.Models.NewBlockEvent evt:
|
||||
|
||||
await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices())
|
||||
.Select(invoiceId => UpdatePaymentStates(wallet, invoiceId))
|
||||
.ToArray());
|
||||
@@ -146,11 +150,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
break;
|
||||
case NBXplorer.Models.NewTransactionEvent evt:
|
||||
wallet.InvalidateCache(evt.DerivationStrategy);
|
||||
_Aggregator.Publish(new NewOnChainTransactionEvent()
|
||||
{
|
||||
CryptoCode = wallet.Network.CryptoCode,
|
||||
NewTransactionEvent = evt
|
||||
});
|
||||
|
||||
foreach (var output in network.GetValidOutputs(evt))
|
||||
{
|
||||
var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
|
||||
@@ -159,7 +159,16 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
{
|
||||
var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy,
|
||||
output.Item1.KeyPath, output.Item1.ScriptPubKey);
|
||||
var paymentData = new BitcoinLikePaymentData(address, output.matchedOutput.Value, output.outPoint, evt.TransactionData.Transaction.RBF);
|
||||
|
||||
var payJoinSelfContributedAmount = GetPayJoinContributedAmount(
|
||||
new WalletId(invoice.StoreId, network.CryptoCode),
|
||||
output.matchedOutput.Value.GetValue(network),
|
||||
evt.TransactionData.TransactionHash);
|
||||
|
||||
var paymentData = new BitcoinLikePaymentData(address,
|
||||
output.matchedOutput.Value, output.outPoint,
|
||||
evt.TransactionData.Transaction.RBF, payJoinSelfContributedAmount);
|
||||
|
||||
var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
|
||||
if (!alreadyExist)
|
||||
{
|
||||
@@ -174,6 +183,12 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
}
|
||||
|
||||
}
|
||||
_Aggregator.Publish(new NewOnChainTransactionEvent()
|
||||
{
|
||||
CryptoCode = wallet.Network.CryptoCode,
|
||||
NewTransactionEvent = evt
|
||||
});
|
||||
|
||||
break;
|
||||
default:
|
||||
Logs.PayServer.LogWarning("Received unknown message from NBXplorer");
|
||||
@@ -297,6 +312,8 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
if (strategy == null)
|
||||
continue;
|
||||
var cryptoId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
|
||||
var paymentMethod = invoice.GetPaymentMethod(cryptoId).GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod;
|
||||
|
||||
if (!invoice.Support(cryptoId))
|
||||
continue;
|
||||
var coins = (await wallet.GetUnspentCoins(strategy))
|
||||
@@ -307,8 +324,11 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash);
|
||||
|
||||
var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey);
|
||||
var payJoinSelfContributedAmount =
|
||||
GetPayJoinContributedAmount(paymentMethod, coin.Value.GetValue(network), transaction.TransactionHash);
|
||||
|
||||
var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint,
|
||||
transaction.Transaction.RBF);
|
||||
transaction.Transaction.RBF, payJoinSelfContributedAmount);
|
||||
|
||||
var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false);
|
||||
alreadyAccounted.Add(coin.OutPoint);
|
||||
@@ -324,6 +344,32 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
return totalPayment;
|
||||
}
|
||||
|
||||
private decimal GetPayJoinContributedAmount(BitcoinLikeOnChainPaymentMethod paymentMethod, decimal amount, uint256 transactionHash)
|
||||
{
|
||||
if (paymentMethod.PayJoin.Enabled &&
|
||||
paymentMethod.PayJoin.ProposedTransactionHash == transactionHash &&
|
||||
paymentMethod.PayJoin.TotalOutputAmount == amount)
|
||||
{
|
||||
//this is the payjoin output!
|
||||
return paymentMethod.PayJoin.ContributedAmount;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
private decimal GetPayJoinContributedAmount(WalletId walletId, decimal amount, uint256 transactionHash)
|
||||
{
|
||||
var payJoinState =
|
||||
_payJoinStateProvider.Get(walletId);
|
||||
|
||||
if (payJoinState == null || !payJoinState.TryGetWithProposedHash(transactionHash, out var record) ||
|
||||
record.TotalOutputAmount != amount) return 0;
|
||||
|
||||
//this is the payjoin output!
|
||||
payJoinState.RemoveRecord(transactionHash);
|
||||
return record.ContributedAmount;
|
||||
|
||||
}
|
||||
|
||||
private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network)
|
||||
{
|
||||
return invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike))
|
||||
|
||||
401
BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs
Normal file
401
BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using NBXplorer;
|
||||
using NBXplorer.Models;
|
||||
using NicolasDorier.RateLimits;
|
||||
|
||||
namespace BTCPayServer.Payments.PayJoin
|
||||
{
|
||||
[Route("{cryptoCode}/bpu")]
|
||||
public class PayJoinEndpointController : ControllerBase
|
||||
{
|
||||
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly ExplorerClientProvider _explorerClientProvider;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly BTCPayWalletProvider _btcPayWalletProvider;
|
||||
private readonly PayJoinStateProvider _payJoinStateProvider;
|
||||
|
||||
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
|
||||
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
|
||||
StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider,
|
||||
PayJoinStateProvider payJoinStateProvider)
|
||||
{
|
||||
_btcPayNetworkProvider = btcPayNetworkProvider;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_explorerClientProvider = explorerClientProvider;
|
||||
_storeRepository = storeRepository;
|
||||
_btcPayWalletProvider = btcPayWalletProvider;
|
||||
_payJoinStateProvider = payJoinStateProvider;
|
||||
}
|
||||
|
||||
[HttpPost("{invoice}")]
|
||||
[IgnoreAntiforgeryToken]
|
||||
[EnableCors(CorsPolicies.All)]
|
||||
[MediaTypeConstraint("text/plain")]
|
||||
[RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)]
|
||||
public async Task<IActionResult> Submit(string cryptoCode, string invoice)
|
||||
{
|
||||
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||
if (network == null)
|
||||
{
|
||||
return UnprocessableEntity("Incorrect network");
|
||||
}
|
||||
|
||||
string rawBody;
|
||||
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
|
||||
{
|
||||
rawBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(rawBody))
|
||||
{
|
||||
return UnprocessableEntity("raw tx not provided");
|
||||
}
|
||||
|
||||
PSBT psbt = null;
|
||||
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var transaction) &&
|
||||
!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out psbt))
|
||||
{
|
||||
return UnprocessableEntity("invalid raw transaction or psbt");
|
||||
}
|
||||
|
||||
if (psbt != null)
|
||||
{
|
||||
transaction = psbt.ExtractTransaction();
|
||||
}
|
||||
|
||||
if (transaction.Check() != TransactionCheckResult.Success)
|
||||
{
|
||||
return UnprocessableEntity($"invalid tx: {transaction.Check()}");
|
||||
}
|
||||
|
||||
if (transaction.Inputs.Any(txin => txin.ScriptSig == null || txin.WitScript == null))
|
||||
{
|
||||
return UnprocessableEntity($"all inputs must be segwit and signed");
|
||||
}
|
||||
|
||||
var explorerClient = _explorerClientProvider.GetExplorerClient(network);
|
||||
var mempool = await explorerClient.BroadcastAsync(transaction, true);
|
||||
if (!mempool.Success)
|
||||
{
|
||||
return UnprocessableEntity($"provided transaction isn't mempool eligible {mempool.RPCCodeMessage}");
|
||||
}
|
||||
|
||||
var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
|
||||
|
||||
//multiple outs could mean a payment being done to multiple invoices to multiple stores in one payjoin tx which makes life unbearable
|
||||
//UNLESS the request specified an invoice Id, which is mandatory :)
|
||||
var matchingInvoice = await _invoiceRepository.GetInvoice(invoice);
|
||||
if (matchingInvoice == null)
|
||||
{
|
||||
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 =
|
||||
invoicePaymentMethod.GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod;
|
||||
|
||||
if (!currentPaymentMethodDetails.PayJoin.Enabled)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null &&
|
||||
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash())
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
var address = currentPaymentMethodDetails.GetDepositAddress(network.NBitcoinNetwork);
|
||||
var matchingTXOuts = transaction.Outputs.Where(txout => txout.IsTo(address));
|
||||
var nonMatchingTXOuts = transaction.Outputs.Where(txout => !txout.IsTo(address));
|
||||
if (!matchingTXOuts.Any())
|
||||
{
|
||||
return UnprocessableEntity($"tx does not pay invoice");
|
||||
}
|
||||
|
||||
var store = await _storeRepository.FindStore(matchingInvoice.StoreId);
|
||||
|
||||
//check if store is enabled
|
||||
var derivationSchemeSettings = store.GetSupportedPaymentMethods(_btcPayNetworkProvider)
|
||||
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
|
||||
settings.PaymentId == paymentMethodId && store.GetEnabledPaymentIds(_btcPayNetworkProvider)
|
||||
.Contains(settings.PaymentId));
|
||||
if (derivationSchemeSettings == null)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
var state = _payJoinStateProvider.GetOrAdd(new WalletId(matchingInvoice.StoreId, cryptoCode),
|
||||
derivationSchemeSettings.AccountDerivation);
|
||||
|
||||
//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))
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
//check if wallet of store is configured to be hot wallet
|
||||
var extKeyStr = await explorerClient.GetMetadataAsync<string>(
|
||||
derivationSchemeSettings.AccountDerivation,
|
||||
WellknownMetadataKeys.MasterHDKey);
|
||||
if (extKeyStr == null)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
var extKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
|
||||
|
||||
var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
|
||||
if (signingKeySettings.RootFingerprint is null)
|
||||
signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint();
|
||||
|
||||
RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath();
|
||||
if (rootedKeyPath == null)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
// The master fingerprint and/or account key path of your seed are not set in the wallet settings
|
||||
}
|
||||
|
||||
// The user gave the root key, let's try to rebase the PSBT, and derive the account private key
|
||||
if (rootedKeyPath.MasterFingerprint == extKey.GetPublicKey().GetHDFingerPrint())
|
||||
{
|
||||
extKey = extKey.Derive(rootedKeyPath.KeyPath);
|
||||
}
|
||||
|
||||
//check if the store uses segwit -- mixing inputs of different types is suspicious
|
||||
if (derivationSchemeSettings.AccountDerivation.ScriptPubKeyType() == ScriptPubKeyType.Legacy)
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
//get previous payments so that we can check if their address is also used in the txouts)
|
||||
var previousPayments = matchingInvoice.GetPayments(network)
|
||||
.Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData);
|
||||
|
||||
if (transaction.Outputs.Any(
|
||||
txout => previousPayments.Any(data => txout.IsTo(data.GetDestination()))))
|
||||
{
|
||||
//Meh, address reuse from the customer would be happening with this tx, skip
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
//get any utxos we exposed already that match any of the inputs sent to us.
|
||||
var utxosToContributeToThisPayment = state.GetExposed(transaction);
|
||||
|
||||
var invoicePaymentMethodAccounting = invoicePaymentMethod.Calculate();
|
||||
if (invoicePaymentMethodAccounting.Due != matchingTXOuts.Sum(txout => txout.Value) &&
|
||||
!utxosToContributeToThisPayment.Any())
|
||||
{
|
||||
//the invoice would be under/overpaid with this tx and we have not exposed utxos so no worries
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
//if we have not exposed any utxos to any of the inputs
|
||||
if (!utxosToContributeToThisPayment.Any())
|
||||
{
|
||||
var wallet = _btcPayWalletProvider.GetWallet(network);
|
||||
//get all utxos we have so far exposed
|
||||
var coins = state.GetRecords().SelectMany(list =>
|
||||
list.CoinsExposed.Select(coin => coin.OutPoint.Hash));
|
||||
|
||||
//get all utxos we have NOT so far exposed
|
||||
var availableUtxos = (await wallet.GetUnspentCoins(derivationSchemeSettings.AccountDerivation)).Where(
|
||||
coin =>
|
||||
!coins.Contains(coin.OutPoint.Hash));
|
||||
if (availableUtxos.Any())
|
||||
{
|
||||
//clean up the state by removing utxos from the exposed list that we no longer have
|
||||
state.PruneExposedButSpentCoins(availableUtxos);
|
||||
//if we have coins that were exposed before but were not spent, prioritize them
|
||||
var exposedAlready = state.GetExposedCoins();
|
||||
if (exposedAlready.Any())
|
||||
{
|
||||
utxosToContributeToThisPayment = SelectCoins(network, exposedAlready,
|
||||
invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC),
|
||||
nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
|
||||
state.PruneExposedBySpentCoins(utxosToContributeToThisPayment.Select(coin => coin.OutPoint));
|
||||
}
|
||||
else
|
||||
{
|
||||
utxosToContributeToThisPayment = SelectCoins(network, availableUtxos,
|
||||
invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC),
|
||||
nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//we don't have any utxos to provide to this tx
|
||||
if (!utxosToContributeToThisPayment.Any())
|
||||
{
|
||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
||||
}
|
||||
|
||||
//we rebuild the tx using 1 output to the invoice designed address
|
||||
var cjOutputContributedAmount = utxosToContributeToThisPayment.Sum(coin => coin.Value.GetValue(network));
|
||||
var cjOutputSum = matchingTXOuts.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)) +
|
||||
cjOutputContributedAmount;
|
||||
|
||||
var newTx = transaction.Clone();
|
||||
|
||||
|
||||
if (matchingTXOuts.Count() > 1)
|
||||
{
|
||||
//if there are more than 1 outputs to our address, consolidate them to 1 + coinjoined amount to avoid unnecessary utxos
|
||||
newTx.Outputs.Clear();
|
||||
newTx.Outputs.Add(new Money(cjOutputSum, MoneyUnit.BTC), address.ScriptPubKey);
|
||||
foreach (var nonmatchingTxOut in nonMatchingTXOuts)
|
||||
{
|
||||
newTx.Outputs.Add(nonmatchingTxOut.Value, nonmatchingTxOut.ScriptPubKey);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//set the value of the out to our address to the sum of the coinjoined amount
|
||||
foreach (var txOutput in newTx.Outputs.Where(txOutput =>
|
||||
txOutput.Value == matchingTXOuts.First().Value &&
|
||||
txOutput.ScriptPubKey == matchingTXOuts.First().ScriptPubKey))
|
||||
{
|
||||
txOutput.Value = new Money(cjOutputSum, MoneyUnit.BTC);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
newTx.Inputs.AddRange(utxosToContributeToThisPayment.Select(coin =>
|
||||
new TxIn(coin.OutPoint) {Sequence = newTx.Inputs.First().Sequence}));
|
||||
|
||||
if (psbt != null)
|
||||
{
|
||||
psbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork);
|
||||
|
||||
psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest()
|
||||
{
|
||||
DerivationScheme = derivationSchemeSettings.AccountDerivation,
|
||||
PSBT = psbt,
|
||||
RebaseKeyPaths = derivationSchemeSettings.GetPSBTRebaseKeyRules().ToList()
|
||||
})).PSBT;
|
||||
|
||||
|
||||
psbt = psbt.SignWithKeys(utxosToContributeToThisPayment
|
||||
.Select(coin => extKey.Derive(coin.KeyPath).PrivateKey)
|
||||
.ToArray());
|
||||
|
||||
if (!alreadyExists)
|
||||
{
|
||||
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
|
||||
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
|
||||
invoicePaymentMethod);
|
||||
}
|
||||
|
||||
return Ok(HexEncoder.IsWellFormed(rawBody) ? psbt.ToHex() : psbt.ToBase64());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Since we're going to modify the transaction, we're going invalidate all signatures
|
||||
foreach (TxIn newTxInput in newTx.Inputs)
|
||||
{
|
||||
newTxInput.WitScript = WitScript.Empty;
|
||||
}
|
||||
|
||||
newTx.Sign(
|
||||
utxosToContributeToThisPayment.Select(coin =>
|
||||
extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)),
|
||||
utxosToContributeToThisPayment.Select(coin => coin.Coin));
|
||||
|
||||
if (!alreadyExists)
|
||||
{
|
||||
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
|
||||
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
|
||||
invoicePaymentMethod);
|
||||
}
|
||||
|
||||
return Ok(newTx.ToHex());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddRecord(string invoice, PayJoinState joinState, Transaction transaction,
|
||||
List<ReceivedCoin> utxosToContributeToThisPayment, decimal cjOutputContributedAmount, decimal cjOutputSum,
|
||||
Transaction newTx,
|
||||
BitcoinLikeOnChainPaymentMethod currentPaymentMethodDetails, PaymentMethod invoicePaymentMethod)
|
||||
{
|
||||
//keep a record of the tx and check if we have seen the tx before or any of its inputs
|
||||
//on a timer service: if x amount of times passes, broadcast this tx
|
||||
joinState.AddRecord(new PayJoinStateRecordedItem()
|
||||
{
|
||||
Timestamp = DateTimeOffset.Now,
|
||||
Transaction = transaction,
|
||||
OriginalTransactionHash = transaction.GetHash(),
|
||||
CoinsExposed = utxosToContributeToThisPayment,
|
||||
ContributedAmount = cjOutputContributedAmount,
|
||||
TotalOutputAmount = cjOutputSum,
|
||||
ProposedTransactionHash = newTx.GetHash(),
|
||||
InvoiceId = invoice
|
||||
});
|
||||
//we also store a record in the payment method details of the invoice,
|
||||
//Tn case the server is shut down and a payjoin payment is made before it is turned back on.
|
||||
//Otherwise we would end up marking the invoice as overPaid with our own inputs!
|
||||
currentPaymentMethodDetails.PayJoin = new PayJoinPaymentState()
|
||||
{
|
||||
Enabled = true,
|
||||
CoinsExposed = utxosToContributeToThisPayment,
|
||||
ContributedAmount = cjOutputContributedAmount,
|
||||
TotalOutputAmount = cjOutputSum,
|
||||
ProposedTransactionHash = newTx.GetHash(),
|
||||
OriginalTransactionHash = transaction.GetHash(),
|
||||
};
|
||||
invoicePaymentMethod.SetPaymentMethodDetails(currentPaymentMethodDetails);
|
||||
await _invoiceRepository.UpdateInvoicePaymentMethod(invoice, invoicePaymentMethod);
|
||||
}
|
||||
|
||||
private List<ReceivedCoin> SelectCoins(BTCPayNetwork network, IEnumerable<ReceivedCoin> availableUtxos,
|
||||
decimal paymentAmount, IEnumerable<decimal> otherOutputs)
|
||||
{
|
||||
// UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario".
|
||||
//
|
||||
// "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output.
|
||||
//
|
||||
// "UIH2": one input is larger than any output. This heuristically implies that no output is a payment, or, to say it better, it implies that this is not a normal wallet-created payment, it's something strange/exotic.
|
||||
//src: https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2796539
|
||||
|
||||
foreach (var availableUtxo in availableUtxos)
|
||||
{
|
||||
//we can only check against our input as we dont know the value of the rest.
|
||||
var input = availableUtxo.Value.GetValue(network);
|
||||
var paymentAmountSum = input + paymentAmount;
|
||||
if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output))
|
||||
{
|
||||
//UIH 1 & 2
|
||||
continue;
|
||||
}
|
||||
|
||||
return new List<ReceivedCoin> {availableUtxo};
|
||||
}
|
||||
|
||||
//For now we just grab a utxo "at random"
|
||||
Random r = new Random();
|
||||
return new List<ReceivedCoin>() {availableUtxos.ElementAt(r.Next(0, availableUtxos.Count()))};
|
||||
}
|
||||
}
|
||||
}
|
||||
13
BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs
Normal file
13
BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BTCPayServer.Payments.PayJoin
|
||||
{
|
||||
public static class PayJoinExtensions
|
||||
{
|
||||
public static void AddPayJoinServices(this IServiceCollection serviceCollection)
|
||||
{
|
||||
serviceCollection.AddSingleton<PayJoinStateProvider>();
|
||||
serviceCollection.AddHostedService<PayJoinTransactionBroadcaster>();
|
||||
}
|
||||
}
|
||||
}
|
||||
142
BTCPayServer/Payments/PayJoin/PayJoinState.cs
Normal file
142
BTCPayServer/Payments/PayJoin/PayJoinState.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Payments.PayJoin
|
||||
{
|
||||
public class PayJoinState
|
||||
{
|
||||
//keep track of all transactions sent to us via this protocol
|
||||
private readonly ConcurrentDictionary<string, PayJoinStateRecordedItem> RecordedTransactions =
|
||||
new ConcurrentDictionary<string, PayJoinStateRecordedItem>();
|
||||
|
||||
//utxos that have been exposed but the original tx was broadcasted instead.
|
||||
private readonly ConcurrentDictionary<string, ReceivedCoin> ExposedCoins;
|
||||
|
||||
public PayJoinState(ConcurrentDictionary<string, ReceivedCoin> exposedCoins = null)
|
||||
{
|
||||
ExposedCoins = exposedCoins ?? new ConcurrentDictionary<string, ReceivedCoin>();
|
||||
}
|
||||
|
||||
public IEnumerable<PayJoinStateRecordedItem> GetRecords()
|
||||
{
|
||||
return RecordedTransactions.Values;
|
||||
}
|
||||
|
||||
public IEnumerable<PayJoinStateRecordedItem> GetStaleRecords(TimeSpan cutoff)
|
||||
{
|
||||
return GetRecords().Where(pair =>
|
||||
DateTimeOffset.Now.Subtract(pair.Timestamp).TotalMilliseconds >=
|
||||
cutoff.TotalMilliseconds);
|
||||
}
|
||||
|
||||
public bool CheckIfTransactionValid(Transaction transaction, string invoiceId, out bool alreadyExists)
|
||||
{
|
||||
if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}"))
|
||||
{
|
||||
alreadyExists = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
alreadyExists = false;
|
||||
var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString());
|
||||
return !RecordedTransactions.Any(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())));
|
||||
}
|
||||
|
||||
public void AddRecord(PayJoinStateRecordedItem recordedItem)
|
||||
{
|
||||
RecordedTransactions.TryAdd(recordedItem.ToString(), recordedItem);
|
||||
foreach (var receivedCoin in recordedItem.CoinsExposed)
|
||||
{
|
||||
ExposedCoins.TryRemove(receivedCoin.OutPoint.ToString(), out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveRecord(PayJoinStateRecordedItem item, bool keepExposed)
|
||||
{
|
||||
if (keepExposed)
|
||||
{
|
||||
foreach (var receivedCoin in item.CoinsExposed)
|
||||
{
|
||||
ExposedCoins.AddOrReplace(receivedCoin.OutPoint.ToString(), receivedCoin);
|
||||
}
|
||||
}
|
||||
|
||||
RecordedTransactions.TryRemove(item.ToString(), out _);
|
||||
}
|
||||
|
||||
public void RemoveRecord(uint256 proposedTxHash)
|
||||
{
|
||||
var id = RecordedTransactions.Single(pair =>
|
||||
pair.Value.ProposedTransactionHash == proposedTxHash ||
|
||||
pair.Value.OriginalTransactionHash == proposedTxHash).Key;
|
||||
RecordedTransactions.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
public List<ReceivedCoin> GetExposed(Transaction transaction)
|
||||
{
|
||||
return RecordedTransactions.Values
|
||||
.Where(pair =>
|
||||
pair.Transaction.Inputs.Any(txIn =>
|
||||
transaction.Inputs.Any(txIn2 => txIn.PrevOut == txIn2.PrevOut)))
|
||||
.SelectMany(pair => pair.CoinsExposed).ToList();
|
||||
}
|
||||
|
||||
public bool TryGetWithProposedHash(uint256 hash, out PayJoinStateRecordedItem item)
|
||||
{
|
||||
item =
|
||||
RecordedTransactions.Values.SingleOrDefault(
|
||||
recordedItem => recordedItem.ProposedTransactionHash == hash);
|
||||
return item != null;
|
||||
}
|
||||
|
||||
public IEnumerable<ReceivedCoin> GetExposedCoins(bool includeOnesInOngoingBPUs = false)
|
||||
{
|
||||
var result = ExposedCoins.Values;
|
||||
return includeOnesInOngoingBPUs
|
||||
? result.Concat(RecordedTransactions.Values.SelectMany(item => item.CoinsExposed))
|
||||
: result;
|
||||
}
|
||||
|
||||
public void PruneExposedButSpentCoins(IEnumerable<ReceivedCoin> stillAvailable)
|
||||
{
|
||||
var keys = stillAvailable.Select(coin => coin.OutPoint.ToString());
|
||||
var keysToRemove = ExposedCoins.Keys.Where(s => !keys.Contains(s));
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
ExposedCoins.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void PruneExposedBySpentCoins(IEnumerable<OutPoint> taken)
|
||||
{
|
||||
var keys = taken.Select(coin => coin.ToString());
|
||||
var keysToRemove = ExposedCoins.Keys.Where(s => keys.Contains(s));
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
ExposedCoins.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void PruneRecordsOfUsedInputs(TxInList transactionInputs)
|
||||
{
|
||||
foreach (PayJoinStateRecordedItem payJoinStateRecordedItem in RecordedTransactions.Values)
|
||||
{
|
||||
if (payJoinStateRecordedItem.CoinsExposed.Any(coin =>
|
||||
transactionInputs.Any(txin => txin.PrevOut == coin.OutPoint)))
|
||||
{
|
||||
RemoveRecord(payJoinStateRecordedItem, true);
|
||||
}
|
||||
}
|
||||
|
||||
PruneExposedBySpentCoins(transactionInputs.Select(coin => coin.PrevOut));
|
||||
}
|
||||
}
|
||||
}
|
||||
121
BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs
Normal file
121
BTCPayServer/Payments/PayJoin/PayJoinStateProvider.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using NBitcoin;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
|
||||
namespace BTCPayServer.Payments.PayJoin
|
||||
{
|
||||
public class PayJoinStateProvider
|
||||
{
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
|
||||
private readonly BTCPayWalletProvider _btcPayWalletProvider;
|
||||
|
||||
private MultiValueDictionary<DerivationStrategyBase, WalletId> Lookup =
|
||||
new MultiValueDictionary<DerivationStrategyBase, WalletId>();
|
||||
|
||||
private ConcurrentDictionary<WalletId, PayJoinState> States =
|
||||
new ConcurrentDictionary<WalletId, PayJoinState>();
|
||||
|
||||
public PayJoinStateProvider(SettingsRepository settingsRepository, StoreRepository storeRepository,
|
||||
BTCPayNetworkProvider btcPayNetworkProvider, BTCPayWalletProvider btcPayWalletProvider)
|
||||
{
|
||||
_settingsRepository = settingsRepository;
|
||||
_storeRepository = storeRepository;
|
||||
_btcPayNetworkProvider = btcPayNetworkProvider;
|
||||
_btcPayWalletProvider = btcPayWalletProvider;
|
||||
}
|
||||
|
||||
public IEnumerable<PayJoinState> Get(string cryptoCode, DerivationStrategyBase derivationStrategyBase)
|
||||
{
|
||||
if (Lookup.TryGetValue(derivationStrategyBase, out var walletIds))
|
||||
{
|
||||
var matchedWalletKeys = walletIds.Where(id =>
|
||||
id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
return matchedWalletKeys.Select(id => States.TryGet(id));
|
||||
}
|
||||
|
||||
return Array.Empty<PayJoinState>();
|
||||
}
|
||||
|
||||
public PayJoinState Get(WalletId walletId)
|
||||
{
|
||||
return States.TryGet(walletId);
|
||||
}
|
||||
|
||||
public ConcurrentDictionary<WalletId, PayJoinState> GetAll()
|
||||
{
|
||||
return States;
|
||||
}
|
||||
|
||||
public PayJoinState GetOrAdd(WalletId key, DerivationStrategyBase derivationStrategyBase,
|
||||
IEnumerable<ReceivedCoin> exposedCoins = null)
|
||||
{
|
||||
return States.GetOrAdd(key, id =>
|
||||
{
|
||||
Lookup.Add(derivationStrategyBase, id);
|
||||
return new PayJoinState(exposedCoins == null
|
||||
? null
|
||||
: new ConcurrentDictionary<string, ReceivedCoin>(exposedCoins.Select(coin =>
|
||||
new KeyValuePair<string, ReceivedCoin>(coin.OutPoint.ToString(), coin))));
|
||||
});
|
||||
}
|
||||
|
||||
public void RemoveState(WalletId walletId)
|
||||
{
|
||||
States.TryRemove(walletId, out _);
|
||||
}
|
||||
|
||||
public async Task SaveCoins()
|
||||
{
|
||||
Dictionary<string, IEnumerable<OutPoint>> saved =
|
||||
new Dictionary<string, IEnumerable<OutPoint>>();
|
||||
foreach (var payState in GetAll())
|
||||
{
|
||||
saved.Add(payState.Key.ToString(),
|
||||
payState.Value.GetExposedCoins(true).Select(coin => coin.OutPoint));
|
||||
}
|
||||
|
||||
await _settingsRepository.UpdateSetting(saved, "bpu-state");
|
||||
}
|
||||
|
||||
public async Task LoadCoins()
|
||||
{
|
||||
Dictionary<string, IEnumerable<OutPoint>> saved =
|
||||
await _settingsRepository.GetSettingAsync<Dictionary<string, IEnumerable<OutPoint>>>("bpu-state");
|
||||
if (saved == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, IEnumerable<OutPoint>> keyValuePair in saved)
|
||||
{
|
||||
var walletId = WalletId.Parse(keyValuePair.Key);
|
||||
var store = await _storeRepository.FindStore(walletId.StoreId);
|
||||
var derivationSchemeSettings = store?.GetSupportedPaymentMethods(_btcPayNetworkProvider)
|
||||
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
|
||||
settings.PaymentId.CryptoCode.Equals(walletId.CryptoCode,
|
||||
StringComparison.InvariantCultureIgnoreCase));
|
||||
if (derivationSchemeSettings == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var utxos = await _btcPayWalletProvider.GetWallet(walletId.CryptoCode)
|
||||
.GetUnspentCoins(derivationSchemeSettings.AccountDerivation);
|
||||
|
||||
_ = GetOrAdd(walletId, derivationSchemeSettings.AccountDerivation,
|
||||
utxos.Where(coin => keyValuePair.Value.Contains(coin.OutPoint)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs
Normal file
25
BTCPayServer/Payments/PayJoin/PayJoinStateRecordedItem.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Payments.PayJoin
|
||||
{
|
||||
public class PayJoinStateRecordedItem
|
||||
{
|
||||
public Transaction Transaction { get; set; }
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public uint256 ProposedTransactionHash { get; set; }
|
||||
public List<ReceivedCoin> CoinsExposed { get; set; }
|
||||
public decimal TotalOutputAmount { get; set; }
|
||||
public decimal ContributedAmount { get; set; }
|
||||
public uint256 OriginalTransactionHash { get; set; }
|
||||
|
||||
public string InvoiceId { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{InvoiceId}_{OriginalTransactionHash}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Events;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
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.
|
||||
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly ExplorerClientProvider _explorerClientProvider;
|
||||
private readonly PayJoinStateProvider _payJoinStateProvider;
|
||||
|
||||
private CompositeDisposable leases = new CompositeDisposable();
|
||||
|
||||
public PayJoinTransactionBroadcaster(
|
||||
EventAggregator eventAggregator,
|
||||
ExplorerClientProvider explorerClientProvider,
|
||||
PayJoinStateProvider payJoinStateProvider)
|
||||
{
|
||||
_eventAggregator = eventAggregator;
|
||||
_explorerClientProvider = explorerClientProvider;
|
||||
_payJoinStateProvider = payJoinStateProvider;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var loadCoins = _payJoinStateProvider.LoadCoins();
|
||||
//if the wallet was updated, we need to remove the state as the utxos no longer fit
|
||||
leases.Add(_eventAggregator.Subscribe<WalletChangedEvent>(evt =>
|
||||
_payJoinStateProvider.RemoveState(evt.WalletId)));
|
||||
|
||||
leases.Add(_eventAggregator.Subscribe<NewOnChainTransactionEvent>(txEvent =>
|
||||
{
|
||||
if (!txEvent.NewTransactionEvent.Outputs.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relevantStates =
|
||||
_payJoinStateProvider.Get(txEvent.CryptoCode, txEvent.NewTransactionEvent.DerivationStrategy);
|
||||
|
||||
foreach (var relevantState in relevantStates)
|
||||
{
|
||||
//if any of the exposed inputs where spent, remove them from our state
|
||||
relevantState.PruneRecordsOfUsedInputs(txEvent.NewTransactionEvent.TransactionData.Transaction
|
||||
.Inputs);
|
||||
}
|
||||
}));
|
||||
_ = BroadcastTransactionsPeriodically(cancellationToken);
|
||||
await loadCoins;
|
||||
}
|
||||
|
||||
private async Task BroadcastTransactionsPeriodically(CancellationToken cancellationToken)
|
||||
{
|
||||
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 Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _payJoinStateProvider.SaveCoins();
|
||||
leases.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,12 @@ namespace BTCPayServer.Payments
|
||||
|
||||
public override IPaymentMethodDetails DeserializePaymentMethodDetails(BTCPayNetworkBase network, string str)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<BitcoinLikeOnChainPaymentMethod>(str);
|
||||
return ((BTCPayNetwork) network).ToObject<BitcoinLikeOnChainPaymentMethod>(str);
|
||||
}
|
||||
|
||||
public override string SerializePaymentMethodDetails(BTCPayNetworkBase network, IPaymentMethodDetails details)
|
||||
{
|
||||
return JsonConvert.SerializeObject(details);
|
||||
return ((BTCPayNetwork) network).ToString((BitcoinLikeOnChainPaymentMethod)details);
|
||||
}
|
||||
|
||||
public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value)
|
||||
|
||||
@@ -411,7 +411,8 @@ namespace BTCPayServer.Services.Invoices
|
||||
var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo();
|
||||
var subtotalPrice = accounting.TotalDue - accounting.NetworkFee;
|
||||
var cryptoCode = info.GetId().CryptoCode;
|
||||
var address = info.GetPaymentMethodDetails()?.GetPaymentDestination();
|
||||
var details = info.GetPaymentMethodDetails();
|
||||
var address = details?.GetPaymentDestination();
|
||||
var exrates = new Dictionary<string, decimal>
|
||||
{
|
||||
{ ProductInformation.Currency, cryptoInfo.Rate }
|
||||
@@ -463,12 +464,18 @@ namespace BTCPayServer.Services.Invoices
|
||||
{
|
||||
var minerInfo = new MinerFeeInfo();
|
||||
minerInfo.TotalFee = accounting.NetworkFee.Satoshi;
|
||||
minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)info.GetPaymentMethodDetails()).FeeRate
|
||||
minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)details).FeeRate
|
||||
.GetFee(1).Satoshi;
|
||||
dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo);
|
||||
var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due);
|
||||
|
||||
if (((details as BitcoinLikeOnChainPaymentMethod)?.PayJoin?.Enabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu/{Id}";
|
||||
}
|
||||
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
|
||||
{
|
||||
BIP21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due),
|
||||
BIP21 = bip21,
|
||||
};
|
||||
|
||||
#pragma warning disable 618
|
||||
|
||||
@@ -292,6 +292,21 @@ retry:
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateInvoicePaymentMethod(string invoiceId, PaymentMethod paymentMethod)
|
||||
{
|
||||
using (var context = _ContextFactory.CreateContext())
|
||||
{
|
||||
var invoice = await context.Invoices.FindAsync(invoiceId);
|
||||
if (invoice == null)
|
||||
return;
|
||||
var network = paymentMethod.Network;
|
||||
var invoiceEntity = ToObject(invoice.Blob);
|
||||
invoiceEntity.SetPaymentMethod(paymentMethod);
|
||||
invoice.Blob = ToBytes(invoiceEntity, network);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddPendingInvoiceIfNotPresent(string invoiceId)
|
||||
{
|
||||
using (var context = _ContextFactory.CreateContext())
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using System;
|
||||
using NBitcoin;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NBitcoin;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Configuration;
|
||||
using com.LandonKey.SocksWebProxy;
|
||||
using com.LandonKey.SocksWebProxy.Proxy;
|
||||
using NBitcoin.Protocol.Connectors;
|
||||
using NBitcoin.Protocol;
|
||||
|
||||
@@ -16,9 +15,11 @@ namespace BTCPayServer.Services
|
||||
public class SocketFactory
|
||||
{
|
||||
private readonly BTCPayServerOptions _options;
|
||||
public readonly HttpClient SocksClient;
|
||||
public SocketFactory(BTCPayServerOptions options)
|
||||
{
|
||||
_options = options;
|
||||
SocksClient = CreateHttpClientUsingSocks();
|
||||
}
|
||||
public async Task<Socket> ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -60,5 +61,21 @@ namespace BTCPayServer.Services
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClientUsingSocks()
|
||||
{
|
||||
if (_options.SocksEndpoint == null)
|
||||
return null;
|
||||
return new HttpClient(new HttpClientHandler
|
||||
{
|
||||
Proxy = new SocksWebProxy(new ProxyConfig()
|
||||
{
|
||||
Version = ProxyConfig.SocksVersion.Five,
|
||||
SocksAddress = _options.SocksEndpoint.AsOnionCatIPEndpoint().Address,
|
||||
SocksPort = _options.SocksEndpoint.AsOnionCatIPEndpoint().Port,
|
||||
}),
|
||||
UseProxy = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace BTCPayServer.Services.Wallets
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public KeyPath KeyPath { get; set; }
|
||||
public IMoney Value { get; set; }
|
||||
|
||||
public Coin Coin { get; set; }
|
||||
}
|
||||
public class NetworkCoins
|
||||
{
|
||||
@@ -180,7 +180,8 @@ namespace BTCPayServer.Services.Wallets
|
||||
Value = c.Value,
|
||||
Timestamp = c.Timestamp,
|
||||
OutPoint = c.Outpoint,
|
||||
ScriptPubKey = c.ScriptPubKey
|
||||
ScriptPubKey = c.ScriptPubKey,
|
||||
Coin = c.AsCoin(derivationStrategy)
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<tr class="@(payment.Replaced ? "linethrough" : "")" >
|
||||
<td>@payment.Crypto</td>
|
||||
<td>@payment.DepositAddress</td>
|
||||
<td>@payment.CryptoPaymentData.GetValue()</td>
|
||||
<td>@payment.CryptoPaymentData.GetValue() @(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"(+ Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount })")</td>
|
||||
<td>
|
||||
<div class="wraptextAuto">
|
||||
<a href="@payment.TransactionLink" target="_blank">
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
<label asp-for="RedirectAutomatically" class="form-check-label"></label>
|
||||
<span asp-validation-for="RedirectAutomatically" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input asp-for="PayJoinEnabled" type="checkbox" class="form-check-input" />
|
||||
<label asp-for="PayJoinEnabled" class="form-check-label"></label>
|
||||
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input asp-for="ShowRecommendedFee" type="checkbox" class="form-check-input" />
|
||||
<label asp-for="ShowRecommendedFee" class="form-check-label"></label>
|
||||
|
||||
@@ -166,6 +166,14 @@
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl))
|
||||
{
|
||||
<div class="form-group">
|
||||
<label asp-for="PayJoinEndpointUrl" class="control-label"></label>
|
||||
<input asp-for="PayJoinEndpointUrl" class="form-control"/>
|
||||
<span asp-validation-for="PayJoinEndpointUrl" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
<div class="form-group">
|
||||
<button id="toggleInputSelection" type="submit" name="command" value="toggle-input-selection" class="btn btn-sm btn-secondary">Toggle coin selection</button>
|
||||
</div>
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace BTCPayServer
|
||||
{
|
||||
public const string Login = "btcpaylogin";
|
||||
public const string Register = "btcpayregister";
|
||||
public const string PayJoin = "PayJoin";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user