BIP79 Support

This commit is contained in:
Kukks
2020-01-06 13:57:32 +01:00
parent 1895e154d9
commit 89da4184ff
29 changed files with 1511 additions and 43 deletions

View 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());
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()))};
}
}
}

View 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>();
}
}
}

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

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

View 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}";
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,5 +9,6 @@ namespace BTCPayServer
{
public const string Login = "btcpaylogin";
public const string Register = "btcpayregister";
public const string PayJoin = "PayJoin";
}
}