Refactor server-side

This commit is contained in:
nicolas.dorier 2020-03-30 00:28:22 +09:00
parent 9e1ae29600
commit fd026a9733
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
36 changed files with 1381 additions and 1204 deletions

View file

@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework> <TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NBitcoin" Version="5.0.20" /> <PackageReference Include="NBitcoin" Version="5.0.27" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
</ItemGroup> </ItemGroup>

View file

@ -24,6 +24,7 @@ namespace BTCPayServer
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType), DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("0'") : new KeyPath("1'"), CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("0'") : new KeyPath("1'"),
SupportRBF = true, SupportRBF = true,
SupportPayJoin = true,
//https://github.com/spesmilo/electrum/blob/11733d6bc271646a00b69ff07657119598874da4/electrum/constants.py //https://github.com/spesmilo/electrum/blob/11733d6bc271646a00b69ff07657119598874da4/electrum/constants.py
ElectrumMapping = NetworkType == NetworkType.Mainnet ElectrumMapping = NetworkType == NetworkType.Mainnet
? new Dictionary<uint, DerivationType>() ? new Dictionary<uint, DerivationType>()

View file

@ -61,6 +61,8 @@ namespace BTCPayServer
public int MaxTrackedConfirmation { get; internal set; } = 6; public int MaxTrackedConfirmation { get; internal set; } = 6;
public string UriScheme { get; internal set; } public string UriScheme { get; internal set; }
public bool SupportPayJoin { get; set; } = false;
public KeyPath GetRootKeyPath(DerivationType type) public KeyPath GetRootKeyPath(DerivationType type)
{ {
KeyPath baseKey; KeyPath baseKey;

View file

@ -4,6 +4,6 @@
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NBXplorer.Client" Version="3.0.5" /> <PackageReference Include="NBXplorer.Client" Version="3.0.7" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -13,12 +13,14 @@ using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.PayJoin; using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
using BTCPayServer.Tests.Logging; using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel;
using NBitcoin; using NBitcoin;
using NBitcoin.Payment; using NBitcoin.Payment;
using NBitpayClient; using NBitpayClient;
@ -45,17 +47,20 @@ namespace BTCPayServer.Tests
using (var s = SeleniumTester.Create()) using (var s = SeleniumTester.Create())
{ {
await s.StartAsync(); await s.StartAsync();
var invoiceRepository = s.Server.PayTester.GetService<InvoiceRepository>();
// var payjoinRepository = s.Server.PayTester.GetService<PayJoinRepository>();
// var broadcaster = s.Server.PayTester.GetService<DelayedTransactionBroadcaster>();
s.RegisterNewUser(true); s.RegisterNewUser(true);
var receiver = s.CreateNewStore(); var receiver = s.CreateNewStore();
var receiverSeed = s.GenerateWallet("BTC", "", true, true); var receiverSeed = s.GenerateWallet("BTC", "", true, true);
var receiverWalletId = new WalletId(receiver.storeId, "BTC"); var receiverWalletId = new WalletId(receiver.storeId, "BTC");
var payJoinStateProvider = s.Server.PayTester.GetService<PayJoinStateProvider>();
//payjoin is not enabled by default. //payjoin is not enabled by default.
var invoiceId = s.CreateInvoice(receiver.storeId); var invoiceId = s.CreateInvoice(receiver.storeId);
s.GoToInvoiceCheckout(invoiceId); s.GoToInvoiceCheckout(invoiceId);
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href"); .GetAttribute("href");
Assert.DoesNotContain("bpu", bip21); Assert.DoesNotContain("bpu=", bip21);
s.GoToHome(); s.GoToHome();
s.GoToStore(receiver.storeId); s.GoToStore(receiver.storeId);
@ -74,19 +79,20 @@ namespace BTCPayServer.Tests
s.GoToInvoiceCheckout(invoiceId); s.GoToInvoiceCheckout(invoiceId);
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn")) bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href"); .GetAttribute("href");
Assert.Contains("bpu", bip21); Assert.Contains("bpu=", bip21);
s.GoToWalletSend(senderWalletId); s.GoToWalletSend(senderWalletId);
s.Driver.FindElement(By.Id("bip21parse")).Click(); s.Driver.FindElement(By.Id("bip21parse")).Click();
s.Driver.SwitchTo().Alert().SendKeys(bip21); s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept(); s.Driver.SwitchTo().Alert().Accept();
Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.ScrollTo(By.Id("SendMenu"));
s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(async () => await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
{ {
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick(); s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
return Task.CompletedTask;
}); });
//no funds in receiver wallet to do payjoin //no funds in receiver wallet to do payjoin
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning); s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning);
@ -111,32 +117,181 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("bip21parse")).Click(); s.Driver.FindElement(By.Id("bip21parse")).Click();
s.Driver.SwitchTo().Alert().SendKeys(bip21); s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept(); s.Driver.SwitchTo().Alert().Accept();
Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value"))); Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.ScrollTo(By.Id("SendMenu"));
s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(async () => await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
{ {
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick(); s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
return Task.CompletedTask;
}); });
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Success); s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Success);
await TestUtils.EventuallyAsync(async () =>
{
var invoice = await invoiceRepository.GetInvoice(invoiceId);
var payments = invoice.GetPayments().ToArray();
var originalPayment = payments
.Single(p =>
p.GetCryptoPaymentData() is BitcoinLikePaymentData pd &&
pd.PayjoinInformation?.Type is PayjoinTransactionType.Original);
var coinjoinPayment = payments
.Single(p =>
p.GetCryptoPaymentData() is BitcoinLikePaymentData pd &&
pd.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin);
Assert.Equal(-1, ((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).ConfirmationCount);
Assert.Equal(0, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).ConfirmationCount);
Assert.False(originalPayment.Accounted);
Assert.True(coinjoinPayment.Accounted);
Assert.Equal(((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).Value,
((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).Value);
});
await TestUtils.EventuallyAsync(async () => await TestUtils.EventuallyAsync(async () =>
{ {
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId); var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
var dto = invoice.EntityToDTO();
Assert.Equal(InvoiceStatus.Paid, invoice.Status); Assert.Equal(InvoiceStatus.Paid, invoice.Status);
}); });
s.GoToInvoices(); s.GoToInvoices();
paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value")); paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value"));
Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase)); Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase));
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseBIP79FeeCornerCase()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var broadcaster = tester.PayTester.GetService<DelayedTransactionBroadcaster>();
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
broadcaster.Disable();
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var cashCow = tester.ExplorerNode;
cashCow.Generate(2); // get some money in case
var senderUser = tester.NewAccount();
senderUser.GrantAccess(true);
senderUser.RegisterDerivationScheme("BTC", true);
var receiverUser = tester.NewAccount();
receiverUser.GrantAccess(true);
receiverUser.RegisterDerivationScheme("BTC", true, true);
await receiverUser.EnablePayJoin();
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
string lastInvoiceId = null;
var vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "not-enough-money");
async Task<PSBT> RunVector()
{
var coin = await senderUser.ReceiveUTXO(vector.SpentCoin, network);
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() {Price = vector.InvoiceAmount.ToDecimal(MoneyUnit.BTC), Currency = "BTC", FullNotifications = true});
lastInvoiceId = invoice.Id;
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
txBuilder.AddCoins(coin);
txBuilder.Send(invoiceAddress, vector.Paid);
txBuilder.SendFees(vector.Fee);
txBuilder.SetChange(await senderUser.GetNewAddress(network));
var psbt = txBuilder.BuildPSBT(false);
psbt = await senderUser.Sign(psbt);
var pj = await senderUser.SubmitPayjoin(invoice, psbt, vector.ExpectedError);
if (vector.ExpectLocked)
{
Assert.True(await payjoinRepository.TryUnlock(receiverCoin.Outpoint));
}
else
{
Assert.False(await payjoinRepository.TryUnlock(receiverCoin.Outpoint));
}
return pj;
}
Logs.Tester.LogInformation("Here we send exactly the right amount. This should fails as\n" +
"there is not enough to pay the additional payjoin input. (going below the min relay fee");
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "not-enough-money");
await RunVector();
Logs.Tester.LogInformation("We don't pay enough");
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "invoice-not-fully-paid");
await RunVector();
Logs.Tester.LogInformation("We pay correctly");
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), ExpectLocked: true, ExpectedError: null as string);
await RunVector();
Logs.Tester.LogInformation("We pay correctly, but no utxo\n" +
"However, this has the side effect of having the receiver broadcasting the original tx");
await payjoinRepository.TryLock(receiverCoin.Outpoint);
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), ExpectLocked: true, ExpectedError: "out-of-utxos");
await RunVector();
await TestUtils.EventuallyAsync(async () =>
{
var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme);
Assert.Equal(2, coins.Length);
var newCoin = coins.First(c => (Money)c.Value == Money.Satoshis(500));
await payjoinRepository.TryLock(newCoin.OutPoint);
});
var originalSenderUser = senderUser;
retry:
// Additional fee is 96 , minrelaytx is 294
// We pay correctly, fees partially taken from what is overpaid
// We paid 510, the receiver pay 10 sat
// The send pay remaining 86 sat from his pocket
// So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back)
Logs.Tester.LogInformation($"Check if we can take fee on overpaid utxo{(senderUser == receiverUser ? " (to self)" : "")}");
vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), ExpectLocked: true, ExpectedError: null as string);
var proposedPSBT = await RunVector();
Assert.Equal(2, proposedPSBT.Outputs.Count);
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(500) + receiverCoin.Amount);
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(294));
proposedPSBT = await senderUser.Sign(proposedPSBT);
proposedPSBT = proposedPSBT.Finalize();
var explorerClient = tester.PayTester.GetService<ExplorerClientProvider>().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode);
var result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction());
Assert.True(result.Success);
Logs.Tester.LogInformation($"We broadcasted the payjoin {proposedPSBT.ExtractTransaction().GetHash()}");
Logs.Tester.LogInformation($"Let's make sure that the coinjoin is not over paying, since the 10 overpaid sats have gone to fee");
await TestUtils.EventuallyAsync(async () =>
{
var invoice = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(lastInvoiceId);
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
Assert.Equal(InvoiceExceptionStatus.None, invoice.ExceptionStatus);
var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme);
foreach (var coin in coins)
await payjoinRepository.TryLock(coin.OutPoint);
});
tester.ExplorerNode.Generate(1);
receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
if (senderUser != receiverUser)
{
Logs.Tester.LogInformation("Let's do the same, this time paying to ourselves");
senderUser = receiverUser;
goto retry;
}
else
{
senderUser = originalSenderUser;
}
//the state should now hold that there is an ongoing utxo // Same as above. Except the sender send one satoshi less, so the change
var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); // output get below dust and should be removed completely.
Assert.NotNull(receiverWalletPayJoinState); vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), ExpectLocked: true, ExpectedError: null as string);
Assert.Single(receiverWalletPayJoinState.GetRecords()); proposedPSBT = await RunVector();
Assert.Equal(0.02m, receiverWalletPayJoinState.GetRecords().First().ContributedAmount); var output = Assert.Single(proposedPSBT.Outputs);
Assert.Single(receiverWalletPayJoinState.GetRecords().First().CoinsExposed); // With the output removed, the user should have largely pay all the needed fee
Assert.Equal(Money.Satoshis(510) + receiverCoin.Amount, output.Value);
proposedPSBT = await senderUser.Sign(proposedPSBT);
proposedPSBT = proposedPSBT.Finalize();
explorerClient = tester.PayTester.GetService<ExplorerClientProvider>().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode);
result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction(), true);
Assert.True(result.Success);
} }
} }
@ -149,7 +304,7 @@ namespace BTCPayServer.Tests
{ {
await tester.StartAsync(); await tester.StartAsync();
var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>(); ////var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>();
var btcPayNetwork = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC"); var btcPayNetwork = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(btcPayNetwork); var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(btcPayNetwork);
var cashCow = tester.ExplorerNode; var cashCow = tester.ExplorerNode;
@ -181,40 +336,17 @@ namespace BTCPayServer.Tests
//give the cow some cash //give the cow some cash
await cashCow.GenerateAsync(1); await cashCow.GenerateAsync(1);
//let's get some more utxos first //let's get some more utxos first
Assert.NotNull(await cashCow.SendToAddressAsync( await receiverUser.ReceiveUTXO(Money.Coins(0.011m), btcPayNetwork);
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, await receiverUser.ReceiveUTXO(Money.Coins(0.012m), btcPayNetwork);
new Money(0.011m, MoneyUnit.BTC))); await receiverUser.ReceiveUTXO(Money.Coins(0.013m), btcPayNetwork);
Assert.NotNull(await cashCow.SendToAddressAsync( await receiverUser.ReceiveUTXO(Money.Coins(0.014m), btcPayNetwork);
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, await senderUser.ReceiveUTXO(Money.Coins(0.021m), btcPayNetwork);
new Money(0.012m, MoneyUnit.BTC))); await senderUser.ReceiveUTXO(Money.Coins(0.022m), btcPayNetwork);
Assert.NotNull(await cashCow.SendToAddressAsync( await senderUser.ReceiveUTXO(Money.Coins(0.023m), btcPayNetwork);
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address, await senderUser.ReceiveUTXO(Money.Coins(0.024m), btcPayNetwork);
new Money(0.013m, MoneyUnit.BTC))); await senderUser.ReceiveUTXO(Money.Coins(0.025m), btcPayNetwork);
Assert.NotNull(await cashCow.SendToAddressAsync( await senderUser.ReceiveUTXO(Money.Coins(0.026m), btcPayNetwork);
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address, var senderChange = await senderUser.GetNewAddress(btcPayNetwork);
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)));
Assert.NotNull(await cashCow.SendToAddressAsync(
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
new Money(0.025m, MoneyUnit.BTC)));
Assert.NotNull(await cashCow.SendToAddressAsync(
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
new Money(0.026m, MoneyUnit.BTC)));
await cashCow.SendToAddressAsync(
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
new Money(0.014m, MoneyUnit.BTC));
var senderChange = (await btcPayWallet.GetChangeAddressAsync(senderUser.DerivationScheme)).Item1;
//Let's start the harassment //Let's start the harassment
invoice = receiverUser.BitPay.CreateInvoice( invoice = receiverUser.BitPay.CreateInvoice(
@ -222,13 +354,11 @@ namespace BTCPayServer.Tests
var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21, var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork); tester.ExplorerClient.Network.NBitcoinNetwork);
var endpoint = parsedBip21.UnknowParameters["bpu"];
var invoice2 = receiverUser.BitPay.CreateInvoice( var invoice2 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true}); new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21, var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork); tester.ExplorerClient.Network.NBitcoinNetwork);
var endpoint2 = secondInvoiceParsedBip21.UnknowParameters["bpu"];
var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId); var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId);
var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike); var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
@ -293,39 +423,22 @@ namespace BTCPayServer.Tests
//Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all //Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all
//Result: reject //Result: reject
Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint, // Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint,
new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode); // new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
//Attempt 2: Create two transactions using different inputs and send them to the same invoice. //Attempt 2: Create two transactions using different inputs and send them to the same invoice.
//Result: Second Tx should be rejected. //Result: Second Tx should be rejected.
var Invoice1Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint, var Invoice1Coin1ResponseTx = await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork);
new StringContent(Invoice1Coin1.ToHex(), Encoding.UTF8, "text/plain")); await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork, "already-paid");
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 = var contributedInputsInvoice1Coin1ResponseTx =
Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut); Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut);
Assert.Single(contributedInputsInvoice1Coin1ResponseTx); Assert.Single(contributedInputsInvoice1Coin1ResponseTx);
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted //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 //Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, "inputs-already-used");
var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork);
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 = var contributedInputsInvoice2Coin2ResponseTx =
Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut); Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut);
Assert.Single(contributedInputsInvoice2Coin2ResponseTx); Assert.Single(contributedInputsInvoice2Coin2ResponseTx);
@ -337,14 +450,12 @@ namespace BTCPayServer.Tests
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21, var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork); tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice3Endpoint = invoice3ParsedBip21.UnknowParameters["bpu"];
var invoice4 = receiverUser.BitPay.CreateInvoice( var invoice4 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21, var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork); tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice4Endpoint = invoice4ParsedBip21.UnknowParameters["bpu"];
var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
@ -356,14 +467,8 @@ namespace BTCPayServer.Tests
.SendEstimatedFees(new FeeRate(100m)) .SendEstimatedFees(new FeeRate(100m))
.BuildTransaction(true); .BuildTransaction(true);
var Invoice3Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice3Endpoint, await senderUser.SubmitPayjoin(invoice3, Invoice3AndInvoice4Coin3, btcPayNetwork);
new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain")); await senderUser.SubmitPayjoin(invoice4, Invoice3AndInvoice4Coin3, btcPayNetwork, "already-paid");
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 //Attempt 5: Make tx that pays invoice 5 with 2 outputs
//Result: proposed tx consolidates the outputs //Result: proposed tx consolidates the outputs
@ -372,7 +477,6 @@ namespace BTCPayServer.Tests
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21, var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork); tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"];
var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange) .SetChange(senderChange)
@ -383,60 +487,9 @@ namespace BTCPayServer.Tests
.SendEstimatedFees(new FeeRate(100m)); .SendEstimatedFees(new FeeRate(100m));
var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true); var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true);
var Invoice5Coin4ResponseTx = await senderUser.SubmitPayjoin(invoice5, Invoice5Coin4, btcPayNetwork);
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)); 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());
}
//Attempt 7: send the payjoin porposed tx to the endpoint
//Result: get same tx sent back as is
Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
Assert.Equal(Invoice5Coin4ResponseTx.GetHash(),
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash());
//Attempt 8: sign the payjoin and send it back to the endpoint
//Result: get same tx sent back as is
var Invoice5Coin4ResponseTxSigned = Invoice5Coin4TxBuilder.SignTransaction(Invoice5Coin4ResponseTx);
Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
Assert.Equal(Invoice5Coin4ResponseTxSigned.GetHash(),
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash());
//Attempt 9: broadcast a payjoin tx, then try to submit both original tx and the payjoin itself again
//Result: fails
await tester.ExplorerClient.BroadcastAsync(Invoice5Coin4ResponseTxSigned);
Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
//Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again //Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again
//Result: same tx gets sent back //Result: same tx gets sent back
@ -449,7 +502,6 @@ namespace BTCPayServer.Tests
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21, var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork); tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice6Endpoint = invoice6ParsedBip21.UnknowParameters["bpu"];
var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange) .SetChange(senderChange)
@ -462,26 +514,22 @@ namespace BTCPayServer.Tests
var invoice6Coin5 = invoice6Coin5TxBuilder var invoice6Coin5 = invoice6Coin5TxBuilder
.BuildTransaction(true); .BuildTransaction(true);
var Invoice6Coin5Response1 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint, var Invoice6Coin5Response1Tx =await senderUser.SubmitPayjoin(invoice6, invoice6Coin5, btcPayNetwork);
new StringContent(invoice6Coin5.ToHex(), Encoding.UTF8, "text/plain"));
Assert.True(Invoice6Coin5Response1.IsSuccessStatusCode);
var Invoice6Coin5Response1Tx =
Transaction.Parse(await Invoice6Coin5Response1.Content.ReadAsStringAsync(), n);
var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx); var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx);
//broadcast the first payjoin //broadcast the first payjoin
await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned); await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned);
invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m)); // invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m));
var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder // var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder
.BuildTransaction(true); // .BuildTransaction(true);
//
var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint, // var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain")); // new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain"));
Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode); // Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode);
var Invoice6Coin5Response3Tx = // var Invoice6Coin5Response3Tx =
Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n); // Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n);
Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin => // Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin =>
Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut))); // Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut)));
//Attempt 11: //Attempt 11:
//send tx with rbt, broadcast payjoin, //send tx with rbt, broadcast payjoin,
@ -497,7 +545,6 @@ namespace BTCPayServer.Tests
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true}); new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21, var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork); tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice7Endpoint = invoice7ParsedBip21.UnknowParameters["bpu"];
var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange) .SetChange(senderChange)
@ -510,26 +557,26 @@ namespace BTCPayServer.Tests
var invoice7Coin6Tx = invoice7Coin6TxBuilder var invoice7Coin6Tx = invoice7Coin6TxBuilder
.BuildTransaction(true); .BuildTransaction(true);
var invoice7Coin6Response1 = await tester.PayTester.HttpClient.PostAsync(invoice7Endpoint, var invoice7Coin6Response1Tx = await senderUser.SubmitPayjoin(invoice7, invoice7Coin6Tx, btcPayNetwork);
new StringContent(invoice7Coin6Tx.ToHex(), Encoding.UTF8, "text/plain"));
Assert.True(invoice7Coin6Response1.IsSuccessStatusCode);
var invoice7Coin6Response1Tx =
Transaction.Parse(await invoice7Coin6Response1.Content.ReadAsStringAsync(), n);
var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx); var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx);
var contributedInputsInvoice7Coin6Response1TxSigned = var contributedInputsInvoice7Coin6Response1TxSigned =
Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut); Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut);
var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId); ////var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id); ////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
//broadcast the payjoin //broadcast the payjoin
await tester.WaitForEvent<InvoiceEvent>(async () => var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned));
{ Assert.True(res.Success);
var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned));
Assert.True(res.Success);
});
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); // Paid with coinjoin
await TestUtils.EventuallyAsync(async () =>
{
var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
Assert.Equal(InvoiceStatus.Paid, invoiceEntity.Status);
Assert.Contains(invoiceEntity.GetPayments(), p => p.Accounted && ((BitcoinLikePaymentData)p.GetCryptoPaymentData()).PayjoinInformation.Type is PayjoinTransactionType.Coinjoin);
});
////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);
var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder() var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange) .SetChange(senderChange)
@ -542,23 +589,25 @@ namespace BTCPayServer.Tests
.BuildTransaction(true); .BuildTransaction(true);
//broadcast the "rbf cancel" tx //broadcast the "rbf cancel" tx
await tester.WaitForEvent<InvoiceEvent>(async () => res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2));
Assert.True(res.Success);
// Make a block, this should put back the invoice to new
var blockhash = tester.ExplorerNode.Generate(1)[0];
Assert.NotNull(await tester.ExplorerNode.GetRawTransactionAsync(invoice7Coin6Tx2.GetHash(), blockhash));
Assert.Null(await tester.ExplorerNode.GetRawTransactionAsync(Invoice7Coin6Response1TxSigned.GetHash(), blockhash, false));
// Now we should return to New
OutPoint ourOutpoint = null;
await TestUtils.EventuallyAsync(async () =>
{ {
var res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2)); var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
Assert.True(res.Success); Assert.Equal(InvoiceStatus.New, invoiceEntity.Status);
Assert.True(invoiceEntity.GetPayments().All(p => !p.Accounted));
ourOutpoint = invoiceEntity.GetAllBitcoinPaymentData().First().PayjoinInformation.ContributedOutPoints[0];
}); });
//btcpay does not know of replaced txs where the outputs do not pay it(double spends using RBF to "cancel" a payment) var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen); // The outpoint should now be available for next pj selection
Assert.False(await payjoinRepository.TryUnlock(ourOutpoint));
//hijack our automated payjoin original broadcaster and force it to broadcast all, now
var payJoinTransactionBroadcaster = tester.PayTester.ServiceProvider.GetServices<IHostedService>()
.OfType<PayJoinTransactionBroadcaster>().First();
await payJoinTransactionBroadcaster.BroadcastStaleTransactions(TimeSpan.Zero, CancellationToken.None);
Assert.DoesNotContain(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
//all our failed payjoins are clear and any exposed utxo has been moved to the prioritized list
Assert.Contains(receiverWalletPayJoinState.GetExposedCoins(), receivedCoin =>
receivedCoin.OutPoint == contributedInputsInvoice7Coin6Response1TxSigned.PrevOut);
} }
} }
} }

View file

@ -6,10 +6,12 @@ using BTCPayServer.Data;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Logging;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating; using BTCPayServer.Rating;
using Logs = BTCPayServer.Tests.Logging.Logs;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
{ {
@ -41,8 +43,8 @@ namespace BTCPayServer.Tests
currencyPairRateResult.Add(new CurrencyPair("USD", "BTC"), Task.FromResult(rateResultUSDBTC)); currencyPairRateResult.Add(new CurrencyPair("USD", "BTC"), Task.FromResult(rateResultUSDBTC));
currencyPairRateResult.Add(new CurrencyPair("BTC", "USD"), Task.FromResult(rateResultBTCUSD)); currencyPairRateResult.Add(new CurrencyPair("BTC", "USD"), Task.FromResult(rateResultBTCUSD));
InvoiceLogs logs = new InvoiceLogs();
handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null); handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null, null);
handlerLN = new LightningLikePaymentHandler(null, null, networkProvider, null); handlerLN = new LightningLikePaymentHandler(null, null, networkProvider, null);
#pragma warning restore CS0618 #pragma warning restore CS0618

View file

@ -144,16 +144,17 @@ namespace BTCPayServer.Tests
await CustomerLightningD.Pay(bolt11); await CustomerLightningD.Pay(bolt11);
} }
public async Task WaitForEvent<T>(Func<Task> action) public async Task<T> WaitForEvent<T>(Func<Task> action)
{ {
var tcs = new TaskCompletionSource<bool>(); var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt => var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt =>
{ {
tcs.SetResult(true); tcs.TrySetResult(evt);
}); });
await action.Invoke(); await action.Invoke();
await tcs.Task; var result = await tcs.Task;
sub.Dispose(); sub.Dispose();
return result;
} }
public ILightningClient CustomerLightningD { get; set; } public ILightningClient CustomerLightningD { get; set; }

View file

@ -8,8 +8,10 @@ using NBitcoin;
using NBitpayClient; using NBitpayClient;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.Http;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3.Model;
using Xunit; using Xunit;
using NBXplorer.DerivationStrategy; using NBXplorer.DerivationStrategy;
using BTCPayServer.Payments; using BTCPayServer.Payments;
@ -21,12 +23,18 @@ using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using NBXplorer.Models; using NBXplorer.Models;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using NBitcoin.Payment;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
{ {
public class TestAccount public class TestAccount
{ {
ServerTester parent; ServerTester parent;
public TestAccount(ServerTester parent) public TestAccount(ServerTester parent)
{ {
this.parent = parent; this.parent = parent;
@ -51,7 +59,8 @@ namespace BTCPayServer.Tests
public Task<BTCPayServerClient> CreateClient() public Task<BTCPayServerClient> CreateClient()
{ {
return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email, RegisterDetails.Password)); return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email,
RegisterDetails.Password));
} }
public async Task<BTCPayServerClient> CreateClient(params string[] permissions) public async Task<BTCPayServerClient> CreateClient(params string[] permissions)
@ -60,11 +69,12 @@ namespace BTCPayServer.Tests
var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey( var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey(
new ManageController.AddApiKeyViewModel() new ManageController.AddApiKeyViewModel()
{ {
PermissionValues = permissions.Select(s => new ManageController.AddApiKeyViewModel.PermissionValueItem() PermissionValues =
{ permissions.Select(s =>
Permission = s, new ManageController.AddApiKeyViewModel.PermissionValueItem()
Value = true {
}).ToList(), Permission = s, Value = true
}).ToList(),
StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores
})); }));
var statusMessage = manageController.TempData.GetStatusMessageModel(); var statusMessage = manageController.TempData.GetStatusMessageModel();
@ -78,6 +88,7 @@ namespace BTCPayServer.Tests
{ {
RegisterAsync(isAdmin).GetAwaiter().GetResult(); RegisterAsync(isAdmin).GetAwaiter().GetResult();
} }
public async Task GrantAccessAsync(bool isAdmin = false) public async Task GrantAccessAsync(bool isAdmin = false)
{ {
await RegisterAsync(isAdmin); await RegisterAsync(isAdmin);
@ -105,6 +116,7 @@ namespace BTCPayServer.Tests
store.NetworkFeeMode = mode; store.NetworkFeeMode = mode;
}); });
} }
public void ModifyStore(Action<StoreViewModel> modify) public void ModifyStore(Action<StoreViewModel> modify)
{ {
var storeController = GetController<StoresController>(); var storeController = GetController<StoresController>();
@ -122,7 +134,7 @@ namespace BTCPayServer.Tests
public async Task CreateStoreAsync() public async Task CreateStoreAsync()
{ {
var store = this.GetController<UserStoresController>(); var store = this.GetController<UserStoresController>();
await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" }); await store.CreateStore(new CreateStoreViewModel() {Name = "Test Store"});
StoreId = store.CreatedStoreId; StoreId = store.CreatedStoreId;
parent.Stores.Add(StoreId); parent.Stores.Add(StoreId);
} }
@ -133,7 +145,9 @@ namespace BTCPayServer.Tests
{ {
return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult(); return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult();
} }
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false, bool importKeysToNBX = false)
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false,
bool importKeysToNBX = false)
{ {
SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode); SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId); var store = parent.PayTester.GetController<StoresController>(UserId, StoreId);
@ -143,20 +157,21 @@ namespace BTCPayServer.Tests
SavePrivateKeys = importKeysToNBX SavePrivateKeys = importKeysToNBX
}); });
await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel() await store.AddDerivationScheme(StoreId,
{ new DerivationSchemeViewModel()
Enabled = true, {
CryptoCode = cryptoCode, Enabled = true,
Network = SupportedNetwork, CryptoCode = cryptoCode,
RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(), Network = SupportedNetwork,
RootKeyPath = SupportedNetwork.GetRootKeyPath(), RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(),
Source = "NBXplorer", RootKeyPath = SupportedNetwork.GetRootKeyPath(),
AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(), Source = "NBXplorer",
DerivationSchemeFormat = "BTCPay", AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(),
KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(), DerivationSchemeFormat = "BTCPay",
DerivationScheme = DerivationScheme.ToString(), KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(),
Confirmation = true DerivationScheme = DerivationScheme.ToString(),
}, cryptoCode); Confirmation = true
}, cryptoCode);
return new WalletId(StoreId, cryptoCode); return new WalletId(StoreId, cryptoCode);
} }
@ -199,21 +214,26 @@ namespace BTCPayServer.Tests
IsAdmin = account.RegisteredAdmin; IsAdmin = account.RegisteredAdmin;
} }
public RegisterViewModel RegisterDetails{ get; set; } public RegisterViewModel RegisterDetails { get; set; }
public Bitpay BitPay public Bitpay BitPay
{ {
get; set; get;
set;
} }
public string UserId public string UserId
{ {
get; set; get;
set;
} }
public string StoreId public string StoreId
{ {
get; set; get;
set;
} }
public bool IsAdmin { get; internal set; } public bool IsAdmin { get; internal set; }
public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType) public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType)
@ -229,19 +249,116 @@ namespace BTCPayServer.Tests
if (connectionType == LightningConnectionType.Charge) if (connectionType == LightningConnectionType.Charge)
connectionString = "type=charge;server=" + parent.MerchantCharge.Client.Uri.AbsoluteUri; connectionString = "type=charge;server=" + parent.MerchantCharge.Client.Uri.AbsoluteUri;
else if (connectionType == LightningConnectionType.CLightning) else if (connectionType == LightningConnectionType.CLightning)
connectionString = "type=clightning;server=" + ((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri; connectionString = "type=clightning;server=" +
((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri;
else if (connectionType == LightningConnectionType.LndREST) else if (connectionType == LightningConnectionType.LndREST)
connectionString = $"type=lnd-rest;server={parent.MerchantLnd.Swagger.BaseUrl};allowinsecure=true"; connectionString = $"type=lnd-rest;server={parent.MerchantLnd.Swagger.BaseUrl};allowinsecure=true";
else else
throw new NotSupportedException(connectionType.ToString()); throw new NotSupportedException(connectionType.ToString());
await storeController.AddLightningNode(StoreId, new LightningNodeViewModel() await storeController.AddLightningNode(StoreId,
{ new LightningNodeViewModel() {ConnectionString = connectionString, SkipPortTest = true}, "save", "BTC");
ConnectionString = connectionString,
SkipPortTest = true
}, "save", "BTC");
if (storeController.ModelState.ErrorCount != 0) if (storeController.ModelState.ErrorCount != 0)
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage); Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
} }
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network)
{
var cashCow = parent.ExplorerNode;
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
var txid = await cashCow.SendToAddressAsync(address, value);
var tx = await cashCow.GetRawTransactionAsync(txid);
return tx.Outputs.AsCoins().First(c => c.ScriptPubKey == address.ScriptPubKey);
}
public async Task<BitcoinAddress> GetNewAddress(BTCPayNetwork network)
{
var cashCow = parent.ExplorerNode;
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
return address;
}
public async Task<PSBT> Sign(PSBT psbt)
{
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>()
.GetWallet(psbt.Network.NetworkSet.CryptoCode);
var explorerClient = parent.PayTester.GetService<ExplorerClientProvider>()
.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode);
psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest()
{
DerivationScheme = DerivationScheme, PSBT = psbt
})).PSBT;
return psbt.SignAll(this.DerivationScheme, GenerateWalletResponseV.AccountHDKey,
GenerateWalletResponseV.AccountKeyPath);
}
public async Task<PSBT> SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null)
{
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
var pjClient = parent.PayTester.GetService<PayjoinClient>();
var storeRepository = parent.PayTester.GetService<StoreRepository>();
var store = await storeRepository.FindStore(StoreId);
var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType<DerivationSchemeSettings>()
.First();
if (expectedError is null)
{
var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default);
Assert.NotNull(proposed);
return proposed;
}
else
{
var ex = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
Assert.Equal(expectedError, ex.ErrorCode);
return null;
}
}
public async Task<Transaction> SubmitPayjoin(Invoice invoice, Transaction transaction, BTCPayNetwork network,
string expectedError = null)
{
var response =
await SubmitPayjoinCore(transaction.ToHex(), invoice, network.NBitcoinNetwork, expectedError);
if (response == null)
return null;
var signed = Transaction.Parse(await response.Content.ReadAsStringAsync(), network.NBitcoinNetwork);
return signed;
}
async Task<HttpResponseMessage> SubmitPayjoinCore(string content, Invoice invoice, Network network,
string expectedError)
{
var endpoint = GetPayjoinEndpoint(invoice, network);
var response = await parent.PayTester.HttpClient.PostAsync(endpoint,
new StringContent(content, Encoding.UTF8, "text/plain"));
if (expectedError != null)
{
Assert.False(response.IsSuccessStatusCode);
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(expectedError, error["errorCode"].Value<string>());
return null;
}
else
{
if (!response.IsSuccessStatusCode)
{
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
Assert.True(false,
$"Error: {error["errorCode"].Value<string>()}: {error["message"].Value<string>()}");
}
}
return response;
}
private static Uri GetPayjoinEndpoint(Invoice invoice, Network network)
{
var parsedBip21 = new BitcoinUrlBuilder(
invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21,
network);
return new Uri(parsedBip21.UnknowParameters["bpu"], UriKind.Absolute);
}
} }
} }

View file

@ -173,7 +173,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest); var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[] var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{ {
new BitcoinLikePaymentHandler(null, networkProvider, null, null), new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null), new LightningLikePaymentHandler(null, null, networkProvider, null),
}); });
var networkBTC = networkProvider.GetNetwork("BTC"); var networkBTC = networkProvider.GetNetwork("BTC");
@ -288,7 +288,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest); var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[] var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{ {
new BitcoinLikePaymentHandler(null, networkProvider, null, null), new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null), new LightningLikePaymentHandler(null, null, networkProvider, null),
}); });
var entity = new InvoiceEntity(); var entity = new InvoiceEntity();
@ -477,7 +477,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest); var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[] var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{ {
new BitcoinLikePaymentHandler(null, networkProvider, null, null), new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null), new LightningLikePaymentHandler(null, null, networkProvider, null),
}); });
var entity = new InvoiceEntity(); var entity = new InvoiceEntity();

View file

@ -76,7 +76,7 @@ services:
- customer_lnd - customer_lnd
- merchant_lnd - merchant_lnd
nbxplorer: nbxplorer:
image: nicolasdorier/nbxplorer:2.1.14 image: nicolasdorier/nbxplorer:2.1.21
restart: unless-stopped restart: unless-stopped
ports: ports:
- "32838:32838" - "32838:32838"

View file

@ -259,7 +259,7 @@ namespace BTCPayServer.Controllers
using (logs.Measure($"{logPrefix} Payment method details creation")) using (logs.Measure($"{logPrefix} Payment method details creation"))
{ {
var paymentDetails = await handler.CreatePaymentMethodDetails(supportedPaymentMethod, paymentMethod, store, network, preparePayment); var paymentDetails = await handler.CreatePaymentMethodDetails(logs, supportedPaymentMethod, paymentMethod, store, network, preparePayment);
paymentMethod.SetPaymentMethodDetails(paymentDetails); paymentMethod.SetPaymentMethodDetails(paymentDetails);
} }

View file

@ -107,7 +107,7 @@ namespace BTCPayServer.Controllers
return ViewWalletSendLedger(walletId, psbt); return ViewWalletSendLedger(walletId, psbt);
case "update": case "update":
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
psbt = await UpdatePSBT(derivationSchemeSettings, psbt, network); psbt = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbt);
if (psbt == null) if (psbt == null)
{ {
ModelState.AddModelError(nameof(vm.PSBT), "You need to update your version of NBXplorer"); ModelState.AddModelError(nameof(vm.PSBT), "You need to update your version of NBXplorer");
@ -144,84 +144,23 @@ namespace BTCPayServer.Controllers
} }
} }
private async Task<PSBT> UpdatePSBT(DerivationSchemeSettings derivationSchemeSettings, PSBT psbt, BTCPayNetwork network) private async Task<PSBT> TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken)
{
var result = await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(new UpdatePSBTRequest()
{
PSBT = psbt,
DerivationScheme = derivationSchemeSettings.AccountDerivation,
});
if (result == null)
return null;
derivationSchemeSettings.RebaseKeyPaths(result.PSBT);
return result.PSBT;
}
private async Task<PSBT> TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork)
{ {
if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint)) if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint))
{ {
var httpClient = _httpClientFactory.CreateClient("payjoin");
var cloned = psbt.Clone(); var cloned = psbt.Clone();
cloned = cloned.Finalize();
if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors)) await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1.0), cloned.ExtractTransaction(), btcPayNetwork);
try
{
return await _payjoinClient.RequestPayjoin(endpoint, derivationSchemeSettings, cloned, cancellationToken);
}
catch (Exception)
{ {
return null; return null;
} }
var bpuresponse = await httpClient.PostAsync(endpoint, 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).ToHashSet();
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 _))
{
//a new input was provided but was invalid.
valid = false;
break;
}
// a new signed input was provided
valid = true;
}
if (!valid || existingInputs.Any())
{
return null;
}
//check if output sum to self is the same.
var signingAccountKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
var newOutputSumToSelfSum = newPSBT.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccountKeySettings.AccountKey,
signingAccountKeySettings.GetRootedKeyPath()).Sum(output => output.Value);
var originalOutputSumToSelf = psbt.Outputs.Sum(output => output.Value);
if (originalOutputSumToSelf < newOutputSumToSelfSum)
{
return null;
}
newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork);
return newPSBT;
}
}
} }
return null; return null;
} }
@ -256,7 +195,7 @@ namespace BTCPayServer.Controllers
{ {
var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork); var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
if (!psbtObject.IsAllFinalized()) if (!psbtObject.IsAllFinalized())
psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject; psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject;
IHDKey signingKey = null; IHDKey signingKey = null;
RootedKeyPath signingKeyPath = null; RootedKeyPath signingKeyPath = null;
try try
@ -358,7 +297,7 @@ namespace BTCPayServer.Controllers
[Route("{walletId}/psbt/ready")] [Route("{walletId}/psbt/ready")]
public async Task<IActionResult> WalletPSBTReady( public async Task<IActionResult> WalletPSBTReady(
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null) WalletId walletId, WalletPSBTReadyViewModel vm, string command = null, CancellationToken cancellationToken = default)
{ {
if (command == null) if (command == null)
return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl); return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl);
@ -383,7 +322,7 @@ namespace BTCPayServer.Controllers
{ {
case "payjoin": case "payjoin":
var proposedPayjoin =await var proposedPayjoin =await
TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network); TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network, cancellationToken);
if (proposedPayjoin == null) if (proposedPayjoin == null)
{ {
//we possibly exposed the tx to the receiver, so we need to broadcast straight away //we possibly exposed the tx to the receiver, so we need to broadcast straight away
@ -401,25 +340,14 @@ namespace BTCPayServer.Controllers
try try
{ {
var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork); var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
var payjoinSigned = PSBTChanged(proposedPayjoin, proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
() => proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation, extKey,
extKey, RootedKeyPath.Parse(vm.SigningKeyPath));
RootedKeyPath.Parse(vm.SigningKeyPath)));
if (!payjoinSigned)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Warning,
AllowDismiss = false,
Html = $"The payjoin transaction could not be signed. The original transaction was broadcast instead."
});
return await WalletPSBTReady(walletId, vm, "broadcast");
}
vm.PSBT = proposedPayjoin.ToBase64(); vm.PSBT = proposedPayjoin.ToBase64();
vm.OriginalPSBT = psbt.ToBase64(); vm.OriginalPSBT = psbt.ToBase64();
return await WalletPSBTReady(walletId, vm, "broadcast"); return await WalletPSBTReady(walletId, vm, "broadcast");
} }
catch (Exception e) catch (Exception)
{ {
TempData.SetStatusMessageModel(new StatusMessageModel() TempData.SetStatusMessageModel(new StatusMessageModel()
{ {

View file

@ -50,7 +50,8 @@ namespace BTCPayServer.Controllers
private readonly WalletReceiveStateService _WalletReceiveStateService; private readonly WalletReceiveStateService _WalletReceiveStateService;
private readonly EventAggregator _EventAggregator; private readonly EventAggregator _EventAggregator;
private readonly SettingsRepository _settingsRepository; private readonly SettingsRepository _settingsRepository;
private readonly IHttpClientFactory _httpClientFactory; private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly PayjoinClient _payjoinClient;
public RateFetcher RateFetcher { get; } public RateFetcher RateFetcher { get; }
CurrencyNameTable _currencyTable; CurrencyNameTable _currencyTable;
@ -69,7 +70,8 @@ namespace BTCPayServer.Controllers
WalletReceiveStateService walletReceiveStateService, WalletReceiveStateService walletReceiveStateService,
EventAggregator eventAggregator, EventAggregator eventAggregator,
SettingsRepository settingsRepository, SettingsRepository settingsRepository,
IHttpClientFactory httpClientFactory) DelayedTransactionBroadcaster broadcaster,
PayjoinClient payjoinClient)
{ {
_currencyTable = currencyTable; _currencyTable = currencyTable;
Repository = repo; Repository = repo;
@ -86,7 +88,8 @@ namespace BTCPayServer.Controllers
_WalletReceiveStateService = walletReceiveStateService; _WalletReceiveStateService = walletReceiveStateService;
_EventAggregator = eventAggregator; _EventAggregator = eventAggregator;
_settingsRepository = settingsRepository; _settingsRepository = settingsRepository;
_httpClientFactory = httpClientFactory; _broadcaster = broadcaster;
_payjoinClient = payjoinClient;
} }
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
@ -844,6 +847,7 @@ namespace BTCPayServer.Controllers
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl); return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl);
} }
private bool PSBTChanged(PSBT psbt, Action act) private bool PSBTChanged(PSBT psbt, Action act)
{ {
var before = psbt.ToBase64(); var before = psbt.ToBase64();

View file

@ -36,6 +36,7 @@ using System.Net;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using BTCPayServer.Payments.Bitcoin;
namespace BTCPayServer namespace BTCPayServer
{ {
@ -136,15 +137,37 @@ namespace BTCPayServer
catch { } catch { }
finally { try { webSocket.Dispose(); } catch { } } finally { try { webSocket.Dispose(); } catch { } }
} }
public static async Task<Dictionary<uint256, TransactionResult>> GetTransactions(this BTCPayWallet client, uint256[] hashes, CancellationToken cts = default(CancellationToken))
public static IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(this InvoiceEntity invoice)
{
return invoice.GetPayments()
.Where(p => p.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike)
.Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData());
}
public static async Task<Dictionary<uint256, TransactionResult>> GetTransactions(this BTCPayWallet client, uint256[] hashes, bool includeOffchain = false, CancellationToken cts = default(CancellationToken))
{ {
hashes = hashes.Distinct().ToArray(); hashes = hashes.Distinct().ToArray();
var transactions = hashes var transactions = hashes
.Select(async o => await client.GetTransactionAsync(o, cts)) .Select(async o => await client.GetTransactionAsync(o, includeOffchain, cts))
.ToArray(); .ToArray();
await Task.WhenAll(transactions).ConfigureAwait(false); await Task.WhenAll(transactions).ConfigureAwait(false);
return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash()); return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash());
} }
public static async Task<PSBT> UpdatePSBT(this ExplorerClientProvider explorerClientProvider, DerivationSchemeSettings derivationSchemeSettings, PSBT psbt)
{
var result = await explorerClientProvider.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode).UpdatePSBTAsync(new UpdatePSBTRequest()
{
PSBT = psbt,
DerivationScheme = derivationSchemeSettings.AccountDerivation
});
if (result == null)
return null;
derivationSchemeSettings.RebaseKeyPaths(result.PSBT);
return result.PSBT;
}
public static string WithTrailingSlash(this string str) public static string WithTrailingSlash(this string str)
{ {
if (str.EndsWith("/", StringComparison.InvariantCulture)) if (str.EndsWith("/", StringComparison.InvariantCulture))

View file

@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Logging;
using BTCPayServer.Services;
namespace BTCPayServer.HostedServices
{
public class DelayedTransactionBroadcasterHostedService : BaseAsyncService
{
private readonly DelayedTransactionBroadcaster _transactionBroadcaster;
public DelayedTransactionBroadcasterHostedService(DelayedTransactionBroadcaster transactionBroadcaster)
{
_transactionBroadcaster = transactionBroadcaster;
}
internal override Task[] InitializeTasks()
{
return new Task[]
{
CreateLoopTask(Rebroadcast)
};
}
public TimeSpan PollInternal { get; set; } = TimeSpan.FromMinutes(1.0);
async Task Rebroadcast()
{
while (true)
{
await _transactionBroadcaster.ProcessAll(Cancellation);
await Task.Delay(PollInternal, Cancellation);
}
}
}
}

View file

@ -49,7 +49,7 @@ namespace BTCPayServer.Payments.Bitcoin
_NetworkFeeRate = value; _NetworkFeeRate = value;
} }
} }
public bool PayjoinEnabled { get; set; }
// Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason // Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason
[JsonIgnore] [JsonIgnore]
public FeeRate FeeRate { get; set; } public FeeRate FeeRate { get; set; }
@ -58,24 +58,10 @@ namespace BTCPayServer.Payments.Bitcoin
[JsonIgnore] [JsonIgnore]
public String DepositAddress { get; set; } public String DepositAddress { get; set; }
public PayJoinPaymentState PayJoin { get; set; } = new PayJoinPaymentState();
public BitcoinAddress GetDepositAddress(Network network) public BitcoinAddress GetDepositAddress(Network network)
{ {
return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, 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,14 +21,13 @@ namespace BTCPayServer.Payments.Bitcoin
} }
public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf, decimal payJoinSelfContributedAmount) public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf)
{ {
Address = address; Address = address;
Value = value; Value = value;
Outpoint = outpoint; Outpoint = outpoint;
ConfirmationCount = 0; ConfirmationCount = 0;
RBF = rbf; RBF = rbf;
PayJoinSelfContributedAmount = payJoinSelfContributedAmount;
} }
[JsonIgnore] [JsonIgnore]
public BTCPayNetworkBase Network { get; set; } public BTCPayNetworkBase Network { get; set; }
@ -38,10 +37,10 @@ namespace BTCPayServer.Payments.Bitcoin
public TxOut Output { get; set; } public TxOut Output { get; set; }
public int ConfirmationCount { get; set; } public int ConfirmationCount { get; set; }
public bool RBF { get; set; } public bool RBF { get; set; }
public decimal NetworkFee { get; set; }
public BitcoinAddress Address { get; set; } public BitcoinAddress Address { get; set; }
public IMoney Value { get; set; } public IMoney Value { get; set; }
public decimal PayJoinSelfContributedAmount { get; set; } = 0;
public PayjoinInformation PayjoinInformation { get; set; }
[JsonIgnore] [JsonIgnore]
public Script ScriptPubKey public Script ScriptPubKey
@ -69,8 +68,7 @@ namespace BTCPayServer.Payments.Bitcoin
public decimal GetValue() 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) public bool PaymentCompleted(PaymentEntity entity)
@ -109,4 +107,17 @@ namespace BTCPayServer.Payments.Bitcoin
return GetDestination().ToString(); return GetDestination().ToString();
} }
} }
public class PayjoinInformation
{
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public PayjoinTransactionType Type { get; set; }
public OutPoint[] ContributedOutPoints { get; set; }
}
public enum PayjoinTransactionType
{
Original,
Coinjoin
}
} }

View file

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Rating; using BTCPayServer.Rating;
@ -19,16 +21,19 @@ namespace BTCPayServer.Payments.Bitcoin
ExplorerClientProvider _ExplorerProvider; ExplorerClientProvider _ExplorerProvider;
private readonly BTCPayNetworkProvider _networkProvider; private readonly BTCPayNetworkProvider _networkProvider;
private IFeeProviderFactory _FeeRateProviderFactory; private IFeeProviderFactory _FeeRateProviderFactory;
private readonly NBXplorerDashboard _dashboard;
private Services.Wallets.BTCPayWalletProvider _WalletProvider; private Services.Wallets.BTCPayWalletProvider _WalletProvider;
public BitcoinLikePaymentHandler(ExplorerClientProvider provider, public BitcoinLikePaymentHandler(ExplorerClientProvider provider,
BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider networkProvider,
IFeeProviderFactory feeRateProviderFactory, IFeeProviderFactory feeRateProviderFactory,
NBXplorerDashboard dashboard,
Services.Wallets.BTCPayWalletProvider walletProvider) Services.Wallets.BTCPayWalletProvider walletProvider)
{ {
_ExplorerProvider = provider; _ExplorerProvider = provider;
_networkProvider = networkProvider; _networkProvider = networkProvider;
_FeeRateProviderFactory = feeRateProviderFactory; _FeeRateProviderFactory = feeRateProviderFactory;
_dashboard = dashboard;
_WalletProvider = walletProvider; _WalletProvider = walletProvider;
} }
@ -74,16 +79,19 @@ namespace BTCPayServer.Payments.Bitcoin
{ {
if (storeBlob.OnChainMinValue != null) if (storeBlob.OnChainMinValue != null)
{ {
var currentRateToCrypto = await rate[new CurrencyPair(paymentMethodId.CryptoCode, storeBlob.OnChainMinValue.Currency)]; var currentRateToCrypto =
await rate[new CurrencyPair(paymentMethodId.CryptoCode, storeBlob.OnChainMinValue.Currency)];
if (currentRateToCrypto?.BidAsk != null) if (currentRateToCrypto?.BidAsk != null)
{ {
var limitValueCrypto = Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid); var limitValueCrypto =
Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid);
if (amount < limitValueCrypto) if (amount < limitValueCrypto)
{ {
return "The amount of the invoice is too low to be paid on chain"; return "The amount of the invoice is too low to be paid on chain";
} }
} }
} }
return string.Empty; return string.Empty;
} }
@ -106,9 +114,12 @@ namespace BTCPayServer.Payments.Bitcoin
var storeBlob = store.GetStoreBlob(); var storeBlob = store.GetStoreBlob();
return new Prepare() return new Prepare()
{ {
GetFeeRate = _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget), GetFeeRate =
GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never ? null _FeeRateProviderFactory.CreateFeeProvider(network)
: _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(), .GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget),
GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never
? null
: _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(),
ReserveAddress = _WalletProvider.GetWallet(network) ReserveAddress = _WalletProvider.GetWallet(network)
.ReserveAddressAsync(supportedPaymentMethod.AccountDerivation) .ReserveAddressAsync(supportedPaymentMethod.AccountDerivation)
}; };
@ -117,6 +128,7 @@ namespace BTCPayServer.Payments.Bitcoin
public override PaymentType PaymentType => PaymentTypes.BTCLike; public override PaymentType PaymentType => PaymentTypes.BTCLike;
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails( public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
InvoiceLogs logs,
DerivationSchemeSettings supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, DerivationSchemeSettings supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store,
BTCPayNetwork network, object preparePaymentObject) BTCPayNetwork network, object preparePaymentObject)
{ {
@ -132,7 +144,8 @@ namespace BTCPayServer.Payments.Bitcoin
{ {
case NetworkFeeMode.Always: case NetworkFeeMode.Always:
onchainMethod.NetworkFeeRate = (await prepare.GetNetworkFeeRate); onchainMethod.NetworkFeeRate = (await prepare.GetNetworkFeeRate);
onchainMethod.NextNetworkFee = onchainMethod.NetworkFeeRate.GetFee(100); // assume price for 100 bytes onchainMethod.NextNetworkFee =
onchainMethod.NetworkFeeRate.GetFee(100); // assume price for 100 bytes
break; break;
case NetworkFeeMode.Never: case NetworkFeeMode.Never:
onchainMethod.NetworkFeeRate = FeeRate.Zero; onchainMethod.NetworkFeeRate = FeeRate.Zero;
@ -145,12 +158,24 @@ namespace BTCPayServer.Payments.Bitcoin
} }
onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString(); onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString();
onchainMethod.PayJoin = new PayJoinPaymentState() onchainMethod.PayjoinEnabled = blob.PayJoinEnabled &&
supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() ==
ScriptPubKeyType.Segwit &&
network.SupportPayJoin;
if (onchainMethod.PayjoinEnabled)
{ {
Enabled = blob.PayJoinEnabled && var nodeSupport = _dashboard?.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities
supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() != ?.CanSupportTransactionCheck is true;
ScriptPubKeyType.Legacy bool isHotwallet = supportedPaymentMethod.Source == "NBXplorer";
}; onchainMethod.PayjoinEnabled &= isHotwallet && nodeSupport;
if (!isHotwallet)
logs.Write("Payjoin should have been enabled, but your store is not a hotwallet");
if (!nodeSupport)
logs.Write("Payjoin should have been enabled, but your version of NBXplorer or full node does not support it.");
if (onchainMethod.PayjoinEnabled)
logs.Write("Payjoin is enabled for this invoice.");
}
return onchainMethod; return onchainMethod;
} }
} }

View file

@ -22,6 +22,7 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Payments.PayJoin; using BTCPayServer.Payments.PayJoin;
using NBitcoin.Altcoins.Elements; using NBitcoin.Altcoins.Elements;
using NBitcoin.RPC; using NBitcoin.RPC;
using BTCPayServer;
namespace BTCPayServer.Payments.Bitcoin namespace BTCPayServer.Payments.Bitcoin
{ {
@ -31,19 +32,18 @@ namespace BTCPayServer.Payments.Bitcoin
public class NBXplorerListener : IHostedService public class NBXplorerListener : IHostedService
{ {
EventAggregator _Aggregator; EventAggregator _Aggregator;
private readonly PayJoinStateProvider _payJoinStateProvider; private readonly PayJoinRepository _payJoinRepository;
ExplorerClientProvider _ExplorerClients; ExplorerClientProvider _ExplorerClients;
IHostApplicationLifetime _Lifetime; IHostApplicationLifetime _Lifetime;
InvoiceRepository _InvoiceRepository; InvoiceRepository _InvoiceRepository;
private TaskCompletionSource<bool> _RunningTask; private TaskCompletionSource<bool> _RunningTask;
private CancellationTokenSource _Cts; private CancellationTokenSource _Cts;
BTCPayWalletProvider _Wallets; BTCPayWalletProvider _Wallets;
public NBXplorerListener(ExplorerClientProvider explorerClients, public NBXplorerListener(ExplorerClientProvider explorerClients,
BTCPayWalletProvider wallets, BTCPayWalletProvider wallets,
InvoiceRepository invoiceRepository, InvoiceRepository invoiceRepository,
EventAggregator aggregator, EventAggregator aggregator,
PayJoinStateProvider payJoinStateProvider, PayJoinRepository payjoinRepository,
IHostApplicationLifetime lifetime) IHostApplicationLifetime lifetime)
{ {
PollInterval = TimeSpan.FromMinutes(1.0); PollInterval = TimeSpan.FromMinutes(1.0);
@ -51,7 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin
_InvoiceRepository = invoiceRepository; _InvoiceRepository = invoiceRepository;
_ExplorerClients = explorerClients; _ExplorerClients = explorerClients;
_Aggregator = aggregator; _Aggregator = aggregator;
_payJoinStateProvider = payJoinStateProvider; _payJoinRepository = payjoinRepository;
_Lifetime = lifetime; _Lifetime = lifetime;
} }
@ -160,16 +160,11 @@ namespace BTCPayServer.Payments.Bitcoin
var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy, var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy,
output.Item1.KeyPath, output.Item1.ScriptPubKey); output.Item1.KeyPath, output.Item1.ScriptPubKey);
var payJoinSelfContributedAmount = GetPayJoinContributedAmount(
new WalletId(invoice.StoreId, network.CryptoCode),
output.matchedOutput.Value.GetValue(network),
evt.TransactionData.TransactionHash);
var paymentData = new BitcoinLikePaymentData(address, var paymentData = new BitcoinLikePaymentData(address,
output.matchedOutput.Value, output.outPoint, output.matchedOutput.Value, output.outPoint,
evt.TransactionData.Transaction.RBF, payJoinSelfContributedAmount); evt.TransactionData.Transaction.RBF);
var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any(); var alreadyExist = invoice.GetAllBitcoinPaymentData().Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
if (!alreadyExist) if (!alreadyExist)
{ {
var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network); var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network);
@ -216,23 +211,19 @@ namespace BTCPayServer.Payments.Bitcoin
} }
} }
IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(InvoiceEntity invoice)
{
return invoice.GetPayments()
.Where(p => p.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike)
.Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData());
}
async Task<InvoiceEntity> UpdatePaymentStates(BTCPayWallet wallet, string invoiceId) async Task<InvoiceEntity> UpdatePaymentStates(BTCPayWallet wallet, string invoiceId)
{ {
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, false); var invoice = await _InvoiceRepository.GetInvoice(invoiceId, false);
if (invoice == null) if (invoice == null)
return null; return null;
List<PaymentEntity> updatedPaymentEntities = new List<PaymentEntity>(); List<PaymentEntity> updatedPaymentEntities = new List<PaymentEntity>();
var transactions = await wallet.GetTransactions(GetAllBitcoinPaymentData(invoice) var transactions = await wallet.GetTransactions(invoice.GetAllBitcoinPaymentData()
.Select(p => p.Outpoint.Hash) .Select(p => p.Outpoint.Hash)
.ToArray()); .ToArray(), true);
var payJoinState = _payJoinStateProvider.Get(new WalletId(invoice.StoreId, wallet.Network.CryptoCode)); bool? originalPJBroadcasted = null;
bool? originalPJBroadcastable = null;
bool? cjPJBroadcasted = null;
OutPoint[] ourPJOutpoints = null;
foreach (var payment in invoice.GetPayments(wallet.Network)) foreach (var payment in invoice.GetPayments(wallet.Network))
{ {
if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike) if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike)
@ -240,15 +231,16 @@ namespace BTCPayServer.Payments.Bitcoin
var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData(); var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData();
if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx)) if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx))
continue; continue;
var txId = tx.Transaction.GetHash();
bool accounted = true; bool accounted = true;
if (tx.Confirmations == 0)
if (tx.Confirmations == 0 || tx.Confirmations == -1)
{ {
// Let's check if it was orphaned by broadcasting it again // Let's check if it was orphaned by broadcasting it again
var explorerClient = _ExplorerClients.GetExplorerClient(wallet.Network); var explorerClient = _ExplorerClients.GetExplorerClient(wallet.Network);
try try
{ {
var result = await explorerClient.BroadcastAsync(tx.Transaction, _Cts.Token); var result = await explorerClient.BroadcastAsync(tx.Transaction, testMempoolAccept: tx.Confirmations == -1, _Cts.Token);
accounted = result.Success || accounted = result.Success ||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN || result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN ||
!( !(
@ -257,10 +249,24 @@ namespace BTCPayServer.Payments.Bitcoin
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR || result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
// Happen if RBF is on and fee insufficient // Happen if RBF is on and fee insufficient
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED); result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED);
if (!accounted && payment.Accounted) if (!accounted && payment.Accounted && tx.Confirmations != -1)
{ {
Logs.PayServer.LogInformation($"{wallet.Network.CryptoCode}: The transaction {tx.TransactionHash} has been replaced."); Logs.PayServer.LogInformation($"{wallet.Network.CryptoCode}: The transaction {tx.TransactionHash} has been replaced.");
} }
if (paymentData.PayjoinInformation is PayjoinInformation pj)
{
ourPJOutpoints = pj.ContributedOutPoints;
switch (pj.Type)
{
case PayjoinTransactionType.Original:
originalPJBroadcasted = accounted && tx.Confirmations >= 0;
originalPJBroadcastable = accounted;
break;
case PayjoinTransactionType.Coinjoin:
cjPJBroadcasted = accounted && tx.Confirmations >= 0;
break;
}
}
} }
// RPC might be unavailable, we can't check double spend so let's assume there is none // RPC might be unavailable, we can't check double spend so let's assume there is none
catch catch
@ -286,11 +292,6 @@ namespace BTCPayServer.Payments.Bitcoin
} }
} }
// we keep the state of the payjoin tx until it is confirmed in case of rbf situations where the tx is cancelled
if (paymentData.PayJoinSelfContributedAmount> 0 && accounted && paymentData.PaymentConfirmed(payment, invoice.SpeedPolicy))
{
payJoinState?.RemoveRecord(paymentData.Outpoint.Hash);
}
// if needed add invoice back to pending to track number of confirmations // if needed add invoice back to pending to track number of confirmations
if (paymentData.ConfirmationCount < wallet.Network.MaxTrackedConfirmation) if (paymentData.ConfirmationCount < wallet.Network.MaxTrackedConfirmation)
await _InvoiceRepository.AddPendingInvoiceIfNotPresent(invoice.Id); await _InvoiceRepository.AddPendingInvoiceIfNotPresent(invoice.Id);
@ -298,6 +299,17 @@ namespace BTCPayServer.Payments.Bitcoin
if (updated) if (updated)
updatedPaymentEntities.Add(payment); updatedPaymentEntities.Add(payment);
} }
// If the origin tx of a payjoin has been broadcasted, then we know we can
// reuse our outpoint for another PJ
if (originalPJBroadcasted is true ||
// If the original tx is not broadcastable anymore and nor does the coinjoin
// reuse our outpoint for another PJ
(originalPJBroadcastable is false && !(cjPJBroadcasted is true)))
{
await _payJoinRepository.TryUnlock(ourPJOutpoints);
}
await _InvoiceRepository.UpdatePayments(updatedPaymentEntities); await _InvoiceRepository.UpdatePayments(updatedPaymentEntities);
if (updatedPaymentEntities.Count != 0) if (updatedPaymentEntities.Count != 0)
_Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id)); _Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id));
@ -313,7 +325,7 @@ namespace BTCPayServer.Payments.Bitcoin
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, true); var invoice = await _InvoiceRepository.GetInvoice(invoiceId, true);
if (invoice == null) if (invoice == null)
continue; continue;
var alreadyAccounted = GetAllBitcoinPaymentData(invoice).Select(p => p.Outpoint).ToHashSet(); var alreadyAccounted = invoice.GetAllBitcoinPaymentData().Select(p => p.Outpoint).ToHashSet();
var strategy = GetDerivationStrategy(invoice, network); var strategy = GetDerivationStrategy(invoice, network);
if (strategy == null) if (strategy == null)
continue; continue;
@ -330,11 +342,9 @@ namespace BTCPayServer.Payments.Bitcoin
var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash); var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash);
var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey); 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, var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint,
transaction.Transaction.RBF, payJoinSelfContributedAmount); transaction.Transaction.RBF);
var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false); var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false);
alreadyAccounted.Add(coin.OutPoint); alreadyAccounted.Add(coin.OutPoint);
@ -350,31 +360,6 @@ namespace BTCPayServer.Payments.Bitcoin
return totalPayment; return totalPayment;
} }
private decimal GetPayJoinContributedAmount(BitcoinLikeOnChainPaymentMethod paymentMethod, decimal amount, uint256 transactionHash)
{
if (paymentMethod.PayJoin?.Enabled is true &&
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;
record.TxSeen = true;
//this is the payjoin output!
return record.ContributedAmount;
}
private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network) private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network)
{ {
return invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike)) return invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike))

View file

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Logging;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Rating; using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
@ -25,7 +26,7 @@ namespace BTCPayServer.Payments
/// <param name="network"></param> /// <param name="network"></param>
/// <param name="preparePaymentObject"></param> /// <param name="preparePaymentObject"></param>
/// <returns></returns> /// <returns></returns>
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod, Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, ISupportedPaymentMethod supportedPaymentMethod,
PaymentMethod paymentMethod, StoreData store, BTCPayNetworkBase network, object preparePaymentObject); PaymentMethod paymentMethod, StoreData store, BTCPayNetworkBase network, object preparePaymentObject);
/// <summary> /// <summary>
@ -53,7 +54,7 @@ namespace BTCPayServer.Payments
where TSupportedPaymentMethod : ISupportedPaymentMethod where TSupportedPaymentMethod : ISupportedPaymentMethod
where TBTCPayNetwork : BTCPayNetworkBase where TBTCPayNetwork : BTCPayNetworkBase
{ {
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(TSupportedPaymentMethod supportedPaymentMethod, Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, TSupportedPaymentMethod supportedPaymentMethod,
PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject); PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject);
} }
@ -65,6 +66,7 @@ namespace BTCPayServer.Payments
public abstract PaymentType PaymentType { get; } public abstract PaymentType PaymentType { get; }
public abstract Task<IPaymentMethodDetails> CreatePaymentMethodDetails( public abstract Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
InvoiceLogs logs,
TSupportedPaymentMethod supportedPaymentMethod, TSupportedPaymentMethod supportedPaymentMethod,
PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject); PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject);
@ -99,12 +101,12 @@ namespace BTCPayServer.Payments
return null; return null;
} }
public Task<IPaymentMethodDetails> CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, public Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
StoreData store, BTCPayNetworkBase network, object preparePaymentObject) StoreData store, BTCPayNetworkBase network, object preparePaymentObject)
{ {
if (supportedPaymentMethod is TSupportedPaymentMethod method && network is TBTCPayNetwork correctNetwork) if (supportedPaymentMethod is TSupportedPaymentMethod method && network is TBTCPayNetwork correctNetwork)
{ {
return CreatePaymentMethodDetails(method, paymentMethod, store, correctNetwork, preparePaymentObject); return CreatePaymentMethodDetails(logs, method, paymentMethod, store, correctNetwork, preparePaymentObject);
} }
throw new NotSupportedException("Invalid supportedPaymentMethod"); throw new NotSupportedException("Invalid supportedPaymentMethod");

View file

@ -14,6 +14,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using NBitcoin; using NBitcoin;
using System.Globalization; using System.Globalization;
using BTCPayServer.Logging;
namespace BTCPayServer.Payments.Lightning namespace BTCPayServer.Payments.Lightning
{ {
@ -40,6 +41,7 @@ namespace BTCPayServer.Payments.Lightning
public override PaymentType PaymentType => PaymentTypes.LightningLike; public override PaymentType PaymentType => PaymentTypes.LightningLike;
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails( public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
InvoiceLogs logs,
LightningSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store, LightningSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store,
BTCPayNetwork network, object preparePaymentObject) BTCPayNetwork network, object preparePaymentObject)
{ {

View file

@ -5,18 +5,26 @@ using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBitcoin.Logging;
using NBXplorer; using NBXplorer;
using NBXplorer.Models; using NBXplorer.Models;
using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits; using NicolasDorier.RateLimits;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Payments.PayJoin namespace BTCPayServer.Payments.PayJoin
{ {
@ -28,374 +36,404 @@ namespace BTCPayServer.Payments.PayJoin
private readonly ExplorerClientProvider _explorerClientProvider; private readonly ExplorerClientProvider _explorerClientProvider;
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private readonly BTCPayWalletProvider _btcPayWalletProvider; private readonly BTCPayWalletProvider _btcPayWalletProvider;
private readonly PayJoinStateProvider _payJoinStateProvider; private readonly PayJoinRepository _payJoinRepository;
private readonly EventAggregator _eventAggregator;
private readonly NBXplorerDashboard _dashboard;
private readonly DelayedTransactionBroadcaster _broadcaster;
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider, public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider, InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider, StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider,
PayJoinStateProvider payJoinStateProvider) PayJoinRepository payJoinRepository,
EventAggregator eventAggregator,
NBXplorerDashboard dashboard,
DelayedTransactionBroadcaster broadcaster)
{ {
_btcPayNetworkProvider = btcPayNetworkProvider; _btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository; _invoiceRepository = invoiceRepository;
_explorerClientProvider = explorerClientProvider; _explorerClientProvider = explorerClientProvider;
_storeRepository = storeRepository; _storeRepository = storeRepository;
_btcPayWalletProvider = btcPayWalletProvider; _btcPayWalletProvider = btcPayWalletProvider;
_payJoinStateProvider = payJoinStateProvider; _payJoinRepository = payJoinRepository;
_eventAggregator = eventAggregator;
_dashboard = dashboard;
_broadcaster = broadcaster;
} }
[HttpPost("{invoice}")] [HttpPost("")]
[IgnoreAntiforgeryToken] [IgnoreAntiforgeryToken]
[EnableCors(CorsPolicies.All)] [EnableCors(CorsPolicies.All)]
[MediaTypeConstraint("text/plain")] [MediaTypeConstraint("text/plain")]
[RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)] [RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> Submit(string cryptoCode, string invoice) public async Task<IActionResult> Submit(string cryptoCode)
{ {
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode); var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network == null) if (network == null)
{ {
return UnprocessableEntity("Incorrect network"); return BadRequest(CreatePayjoinError(400, "invalid-network", "Incorrect network"));
}
var explorer = _explorerClientProvider.GetExplorerClient(network);
if (Request.ContentLength is long length)
{
if (length > 1_000_000)
return this.StatusCode(413,
CreatePayjoinError(413, "payload-too-large", "The transaction is too big to be processed"));
}
else
{
return StatusCode(411,
CreatePayjoinError(411, "missing-content-length",
"The http header Content-Length should be filled"));
} }
string rawBody; string rawBody;
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8)) using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
{ {
rawBody = await reader.ReadToEndAsync(); rawBody = (await reader.ReadToEndAsync()) ?? string.Empty;
} }
if (string.IsNullOrEmpty(rawBody)) Transaction originalTx = null;
FeeRate originalFeeRate = null;
bool psbtFormat = true;
if (!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out var psbt))
{ {
return UnprocessableEntity("raw tx not provided"); psbtFormat = false;
} if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx))
return BadRequest(CreatePayjoinError(400, "invalid-format", "invalid transaction or psbt"));
PSBT psbt = null; originalTx = tx;
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var transaction) && psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork);
!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out psbt)) psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() {PSBT = psbt})).PSBT;
{ for (int i = 0; i < tx.Inputs.Count; i++)
return UnprocessableEntity("invalid raw transaction or psbt");
}
if (psbt != null)
{
try
{ {
transaction = psbt.ExtractTransaction(); psbt.Inputs[i].FinalScriptSig = tx.Inputs[i].ScriptSig;
} psbt.Inputs[i].FinalScriptWitness = tx.Inputs[i].WitScript;
catch (Exception e)
{
return UnprocessableEntity("invalid psbt");
} }
} }
else
if (transaction.Check() != TransactionCheckResult.Success)
{ {
return UnprocessableEntity($"invalid tx: {transaction.Check()}"); if (!psbt.IsAllFinalized())
return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT should be finalized"));
originalTx = psbt.ExtractTransaction();
} }
if (transaction.Inputs.Any(txin => txin.ScriptSig == null || txin.WitScript == null)) if (originalTx.Inputs.Any(i => !(i.GetSigner() is WitKeyId)))
return BadRequest(CreatePayjoinError(400, "not-using-p2wpkh", "Payjoin only support P2WPKH inputs"));
if (psbt.CheckSanity() is var errors && errors.Count != 0)
{ {
return UnprocessableEntity($"all inputs must be segwit and signed"); return BadRequest(CreatePayjoinError(400, "insane-psbt", $"This PSBT is insane ({errors[0]})"));
}
if (!psbt.TryGetEstimatedFeeRate(out originalFeeRate))
{
return BadRequest(CreatePayjoinError(400, "need-utxo-information",
"You need to provide Witness UTXO information to the PSBT."));
} }
var explorerClient = _explorerClientProvider.GetExplorerClient(network); // This is actually not a mandatory check, but we don't want implementers
var mempool = await explorerClient.BroadcastAsync(transaction, true); // to leak global xpubs
if (psbt.GlobalXPubs.Any())
{
return BadRequest(CreatePayjoinError(400, "leaking-data",
"GlobalXPubs should not be included in the PSBT"));
}
if (psbt.Outputs.Any(o => o.HDKeyPaths.Count != 0) || psbt.Inputs.Any(o => o.HDKeyPaths.Count != 0))
{
return BadRequest(CreatePayjoinError(400, "leaking-data",
"Keypath information should not be included in the PSBT"));
}
if (psbt.Inputs.Any(o => !o.IsFinalized()))
{
return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT Should be finalized"));
}
////////////
var mempool = await explorer.BroadcastAsync(originalTx, true);
if (!mempool.Success) if (!mempool.Success)
{ {
return UnprocessableEntity($"provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"); return BadRequest(CreatePayjoinError(400, "invalid-transaction",
$"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"));
} }
var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike); var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
bool paidSomething = false;
//multiple outs could mean a payment being done to multiple invoices to multiple stores in one payjoin tx which makes life unbearable Money due = null;
//UNLESS the request specified an invoice Id, which is mandatory :) Dictionary<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>();
var matchingInvoice = await _invoiceRepository.GetInvoice(invoice); PSBTOutput paymentOutput = null;
if (matchingInvoice == null) BitcoinAddress paymentAddress = null;
InvoiceEntity invoice = null;
int ourOutputIndex = -1;
DerivationSchemeSettings derivationSchemeSettings = null;
foreach (var output in psbt.Outputs)
{ {
return UnprocessableEntity($"invalid invoice"); ourOutputIndex++;
} var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {key})).FirstOrDefault();
var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId); if (invoice is null)
//get outs to our current invoice address continue;
var currentPaymentMethodDetails = derivationSchemeSettings = invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(paymentMethodId)
(BitcoinLikeOnChainPaymentMethod) invoicePaymentMethod.GetPaymentMethodDetails(); .SingleOrDefault();
if (derivationSchemeSettings is null)
if (!currentPaymentMethodDetails.PayJoin?.Enabled is true) continue;
{ var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
return UnprocessableEntity($"cannot handle payjoin tx"); var paymentDetails =
} paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
if (paymentDetails is null || !paymentDetails.PayjoinEnabled)
//the invoice must be active, and the status must be new OR paid if continue;
if (matchingInvoice.IsExpired() || if (invoice.GetAllBitcoinPaymentData().Any())
((matchingInvoice.GetInvoiceState().Status == InvoiceStatus.Paid &&
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash == null) ||
matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New))
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null &&
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash() &&
!transaction.RBF)
{
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
var validity = state.CheckIfTransactionValid(transaction, invoice);
if (validity == PayJoinState.TransactionValidityResult.Invalid_Inputs_Seen || validity == PayJoinState.TransactionValidityResult.Invalid_PartialMatch)
{
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(address) && 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 return UnprocessableEntity(CreatePayjoinError(422, "already-paid",
state.PruneExposedButSpentCoins(availableUtxos); $"The invoice this PSBT is paying has already been partially or completely paid"));
//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 paidSomething = true;
if (!utxosToContributeToThisPayment.Any()) due = paymentMethod.Calculate().TotalDue - output.Value;
{ if (due > Money.Zero)
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; break;
} }
if (!await _payJoinRepository.TryLockInputs(originalTx.Inputs.Select(i => i.PrevOut).ToArray()))
{
return BadRequest(CreatePayjoinError(400, "inputs-already-used",
"Some of those inputs have already been used to make payjoin transaction"));
}
var utxos = (await explorer.GetUTXOsAsync(derivationSchemeSettings.AccountDerivation))
.GetUnspentUTXOs(false);
// In case we are paying ourselves, be need to make sure
// we can't take spent outpoints.
var prevOuts = originalTx.Inputs.Select(o => o.PrevOut).ToHashSet();
utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray();
foreach (var utxo in await SelectUTXO(network, utxos, output.Value,
psbt.Outputs.Where(o => o.Index != output.Index).Select(o => o.Value).ToArray()))
{
selectedUTXOs.Add(utxo.Outpoint, utxo);
}
paymentOutput = output;
paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
break;
} }
newTx.Inputs.AddRange(utxosToContributeToThisPayment.Select(coin => if (!paidSomething)
new TxIn(coin.OutPoint) {Sequence = newTx.Inputs.First().Sequence}));
if (psbt != null)
{ {
psbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork); return BadRequest(CreatePayjoinError(400, "invoice-not-found",
"This transaction does not pay any invoice with payjoin"));
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 (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
{
//if the invoice was rbfed, remove the current record and replace it with the new one
state.RemoveRecord(invoice);
}
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
{
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
invoicePaymentMethod);
}
return Ok(HexEncoder.IsWellFormed(rawBody) ? psbt.ToHex() : psbt.ToBase64());
} }
else
if (due is null || due > Money.Zero)
{ {
// Since we're going to modify the transaction, we're going invalidate all signatures return BadRequest(CreatePayjoinError(400, "invoice-not-fully-paid",
foreach (TxIn newTxInput in newTx.Inputs) "The transaction must pay the whole invoice"));
{
newTxInput.WitScript = WitScript.Empty;
}
newTx.Sign(
utxosToContributeToThisPayment.Select(coin =>
extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)),
utxosToContributeToThisPayment.Select(coin => coin.Coin));
if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
{
//if the invoice was rbfed, remove the current record and replace it with the new one
state.RemoveRecord(invoice);
}
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
{
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
invoicePaymentMethod);
}
return Ok(newTx.ToHex());
} }
}
private async Task AddRecord(string invoice, PayJoinState joinState, Transaction transaction, if (selectedUTXOs.Count == 0)
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, await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx);
Transaction = transaction, return StatusCode(503,
OriginalTransactionHash = transaction.GetHash(), CreatePayjoinError(503, "out-of-utxos",
CoinsExposed = utxosToContributeToThisPayment, "We do not have any UTXO available for making a payjoin for now"));
ContributedAmount = cjOutputContributedAmount, }
TotalOutputAmount = cjOutputSum,
ProposedTransactionHash = newTx.GetHash(), var originalPaymentValue = paymentOutput.Value;
InvoiceId = invoice // Add the original transaction to the payment
}); var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
//we also store a record in the payment method details of the invoice, paymentOutput.Value,
//Tn case the server is shut down and a payjoin payment is made before it is turned back on. new OutPoint(originalTx.GetHash(), paymentOutput.Index),
//Otherwise we would end up marking the invoice as overPaid with our own inputs! originalTx.RBF);
currentPaymentMethodDetails.PayJoin = new PayJoinPaymentState() originalPaymentData.PayjoinInformation = new PayjoinInformation()
{ {
Enabled = true, Type = PayjoinTransactionType.Original, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
CoinsExposed = utxosToContributeToThisPayment,
ContributedAmount = cjOutputContributedAmount,
TotalOutputAmount = cjOutputSum,
ProposedTransactionHash = newTx.GetHash(),
OriginalTransactionHash = transaction.GetHash(),
}; };
invoicePaymentMethod.SetPaymentMethodDetails(currentPaymentMethodDetails); originalPaymentData.ConfirmationCount = -1;
await _invoiceRepository.UpdateInvoicePaymentMethod(invoice, invoicePaymentMethod); var now = DateTimeOffset.UtcNow;
var payment = await _invoiceRepository.AddPayment(invoice.Id, now, originalPaymentData, network, true);
if (payment is null)
{
return UnprocessableEntity(CreatePayjoinError(422, "already-paid",
$"The original transaction has already been accounted"));
}
await _broadcaster.Schedule(now + TimeSpan.FromMinutes(1.0), originalTx, network);
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx);
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment});
//check if wallet of store is configured to be hot wallet
var extKeyStr = await explorer.GetMetadataAsync<string>(
derivationSchemeSettings.AccountDerivation,
WellknownMetadataKeys.AccountHDKey);
if (extKeyStr == null)
{
// This should not happen, as we check the existance of private key before creating invoice with payjoin
return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now"));
}
var newTx = originalTx.Clone();
var ourOutput = newTx.Outputs[ourOutputIndex];
foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value))
{
ourOutput.Value += (Money)selectedUTXO.Value;
newTx.Inputs.Add(selectedUTXO.Outpoint);
}
var rand = new Random();
Utils.Shuffle(newTx.Inputs, rand);
Utils.Shuffle(newTx.Outputs, rand);
ourOutputIndex = newTx.Outputs.IndexOf(ourOutput);
// Remove old signatures as they are not valid anymore
foreach (var input in newTx.Inputs)
{
input.WitScript = WitScript.Empty;
}
Money ourFeeContribution = Money.Zero;
// We need to adjust the fee to keep a constant fee rate
var originalNewTx = newTx.Clone();
bool isSecondPass = false;
recalculateFee:
ourOutput = newTx.Outputs[ourOutputIndex];
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
txBuilder.AddCoins(psbt.Inputs.Select(i => i.GetCoin()));
txBuilder.AddCoins(selectedUTXOs.Select(o => o.Value.AsCoin()));
Money expectedFee = txBuilder.EstimateFees(newTx, originalFeeRate);
Money actualFee = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
Money additionalFee = expectedFee - actualFee;
if (additionalFee > Money.Zero)
{
var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
new FeeRate(1.0m);
// If the user overpaid, taking fee on our output (useful if they dump a full UTXO for privacy)
if (due < Money.Zero)
{
ourFeeContribution = Money.Min(additionalFee, -due);
ourFeeContribution = Money.Min(ourFeeContribution,
ourOutput.Value - ourOutput.GetDustThreshold(minRelayTxFee));
ourOutput.Value -= ourFeeContribution;
additionalFee -= ourFeeContribution;
}
// The rest, we take from user's change
if (additionalFee > Money.Zero)
{
for (int i = 0; i < newTx.Outputs.Count && additionalFee != Money.Zero; i++)
{
if (i != ourOutputIndex)
{
var outputContribution = Money.Min(additionalFee, newTx.Outputs[i].Value);
newTx.Outputs[i].Value -= outputContribution;
additionalFee -= outputContribution;
}
}
}
List<int> dustIndices = new List<int>();
for (int i = 0; i < newTx.Outputs.Count; i++)
{
if (newTx.Outputs[i].IsDust(minRelayTxFee))
{
dustIndices.Insert(0, i);
}
}
if (dustIndices.Count > 0)
{
if (isSecondPass)
{
// This should not happen
return StatusCode(500,
CreatePayjoinError(500, "unavailable",
$"This service is unavailable for now (isSecondPass)"));
}
foreach (var dustIndex in dustIndices)
{
newTx.Outputs.RemoveAt(dustIndex);
}
ourOutputIndex = newTx.Outputs.IndexOf(ourOutput);
newTx = originalNewTx.Clone();
foreach (var dustIndex in dustIndices)
{
newTx.Outputs.RemoveAt(dustIndex);
}
ourFeeContribution = Money.Zero;
isSecondPass = true;
goto recalculateFee;
}
if (additionalFee > Money.Zero)
{
// We could not pay fully the additional fee, however, as long as
// we are not under the relay fee, it should be OK.
var newVSize = txBuilder.EstimateSize(newTx, true);
var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
if (new FeeRate(newFeePaid, newVSize) < minRelayTxFee)
{
await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray());
return UnprocessableEntity(CreatePayjoinError(422, "not-enough-money",
"Not enough money is sent to pay for the additional payjoin inputs"));
}
}
}
var accountKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
var newPsbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork);
foreach (var selectedUtxo in selectedUTXOs.Select(o => o.Value))
{
var signedInput = newPsbt.Inputs.FindIndexedInput(selectedUtxo.Outpoint);
signedInput.UpdateFromCoin(selectedUtxo.AsCoin());
var privateKey = accountKey.Derive(selectedUtxo.KeyPath).PrivateKey;
signedInput.Sign(privateKey);
signedInput.FinalizeInput();
newTx.Inputs[signedInput.Index].WitScript = newPsbt.Inputs[(int)signedInput.Index].FinalScriptWitness;
}
// Add the coinjoin transaction to the payments
var coinjoinPaymentData = new BitcoinLikePaymentData(paymentAddress,
originalPaymentValue - ourFeeContribution,
new OutPoint(newPsbt.GetGlobalTransaction().GetHash(), ourOutputIndex),
originalTx.RBF);
coinjoinPaymentData.PayjoinInformation = new PayjoinInformation()
{
Type = PayjoinTransactionType.Coinjoin,
ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
};
coinjoinPaymentData.ConfirmationCount = -1;
payment = await _invoiceRepository.AddPayment(invoice.Id, now, coinjoinPaymentData, network, false,
payment.NetworkFee);
// We do not publish an event on purpose, this would be confusing for the merchant.
if (psbtFormat)
return Ok(newPsbt.ToBase64());
else
return Ok(newTx.ToHex());
} }
private List<ReceivedCoin> SelectCoins(BTCPayNetwork network, IEnumerable<ReceivedCoin> availableUtxos, private JObject CreatePayjoinError(int httpCode, string errorCode, string friendlyMessage)
decimal paymentAmount, IEnumerable<decimal> otherOutputs)
{ {
var o = new JObject();
o.Add(new JProperty("httpCode", httpCode));
o.Add(new JProperty("errorCode", errorCode));
o.Add(new JProperty("message", friendlyMessage));
return o;
}
private async Task<UTXO[]> SelectUTXO(BTCPayNetwork network, UTXO[] availableUtxos, Money paymentAmount,
Money[] otherOutputs)
{
if (availableUtxos.Length == 0)
return Array.Empty<UTXO>();
// Assume the merchant wants to get rid of the dust
Utils.Shuffle(availableUtxos);
HashSet<OutPoint> locked = new HashSet<OutPoint>();
// We don't want to make too many db roundtrip which would be inconvenient for the sender
int maxTries = 30;
int currentTry = 0;
List<UTXO> utxosByPriority = new List<UTXO>();
// UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario". // 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. // "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.
@ -405,8 +443,10 @@ namespace BTCPayServer.Payments.PayJoin
foreach (var availableUtxo in availableUtxos) foreach (var availableUtxo in availableUtxos)
{ {
if (currentTry >= maxTries)
break;
//we can only check against our input as we dont know the value of the rest. //we can only check against our input as we dont know the value of the rest.
var input = availableUtxo.Value.GetValue(network); var input = (Money)availableUtxo.Value;
var paymentAmountSum = input + paymentAmount; var paymentAmountSum = input + paymentAmount;
if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output)) if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output))
{ {
@ -414,12 +454,24 @@ namespace BTCPayServer.Payments.PayJoin
continue; continue;
} }
return new List<ReceivedCoin> {availableUtxo}; if (await _payJoinRepository.TryLock(availableUtxo.Outpoint))
{
return new UTXO[] { availableUtxo };
}
locked.Add(availableUtxo.Outpoint);
currentTry++;
} }
foreach (var utxo in availableUtxos.Where(u => !locked.Contains(u.Outpoint)))
//For now we just grab a utxo "at random" {
Random r = new Random(); if (currentTry >= maxTries)
return new List<ReceivedCoin>() {availableUtxos.ElementAt(r.Next(0, availableUtxos.Count()))}; break;
if (await _payJoinRepository.TryLock(utxo.Outpoint))
{
return new UTXO[] { utxo };
}
currentTry++;
}
return Array.Empty<UTXO>();
} }
} }
} }

View file

@ -1,13 +1,18 @@
using BTCPayServer.HostedServices;
using BTCPayServer.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace BTCPayServer.Payments.PayJoin namespace BTCPayServer.Payments.PayJoin
{ {
public static class PayJoinExtensions public static class PayJoinExtensions
{ {
public static void AddPayJoinServices(this IServiceCollection serviceCollection) public static void AddPayJoinServices(this IServiceCollection services)
{ {
serviceCollection.AddSingleton<PayJoinStateProvider>(); services.AddSingleton<DelayedTransactionBroadcaster>();
serviceCollection.AddHostedService<PayJoinTransactionBroadcaster>(); services.AddSingleton<IHostedService, HostedServices.DelayedTransactionBroadcasterHostedService>();
services.AddSingleton<PayJoinRepository>();
services.AddSingleton<PayjoinClient>();
} }
} }
} }

View file

@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
using NBXplorer.Models;
namespace BTCPayServer.Payments.PayJoin
{
public class PayJoinRepository
{
HashSet<OutPoint> _Outpoints = new HashSet<OutPoint>();
HashSet<OutPoint> _LockedInputs = new HashSet<OutPoint>();
public Task<bool> TryLock(OutPoint outpoint)
{
lock (_Outpoints)
{
return Task.FromResult(_Outpoints.Add(outpoint));
}
}
public Task<bool> TryUnlock(params OutPoint[] outPoints)
{
if (outPoints.Length == 0)
return Task.FromResult(true);
lock (_Outpoints)
{
bool r = true;
foreach (var outpoint in outPoints)
{
r &= _Outpoints.Remove(outpoint);
}
return Task.FromResult(r);
}
}
public Task<bool> TryLockInputs(OutPoint[] outPoint)
{
lock (_LockedInputs)
{
foreach (var o in outPoint)
if (!_LockedInputs.Add(o))
return Task.FromResult(false);
}
return Task.FromResult(true);
}
}
}

View file

@ -1,176 +0,0 @@
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 enum TransactionValidityResult
{
Valid_ExactMatch,
Invalid_PartialMatch,
Valid_NoMatch,
Invalid_Inputs_Seen,
Valid_SameInputs
}
public TransactionValidityResult CheckIfTransactionValid(Transaction transaction, string invoiceId)
{
if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}"))
{
return TransactionValidityResult.Valid_ExactMatch;
}
var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString());
var matches = RecordedTransactions.Where(record =>
record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) ||
record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase));
if (matches.Any())
{
if(matches.Any(record =>
record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) &&
record.Value.Transaction.RBF &&
record.Value.Transaction.Inputs.All(recordTxIn => hashes.Contains(recordTxIn.PrevOut.ToString()))))
{
return TransactionValidityResult.Valid_SameInputs;
}
return TransactionValidityResult.Invalid_PartialMatch;
}
return RecordedTransactions.Any(record =>
record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString())))
? TransactionValidityResult.Invalid_Inputs_Seen: TransactionValidityResult.Valid_NoMatch;
}
public void AddRecord(PayJoinStateRecordedItem recordedItem)
{
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.SingleOrDefault(pair =>
pair.Value.ProposedTransactionHash == proposedTxHash ||
pair.Value.OriginalTransactionHash == proposedTxHash).Key;
if (id != null)
{
RecordedTransactions.TryRemove(id, out _);
}
}
public void RemoveRecord(string invoiceId)
{
var id = RecordedTransactions.Single(pair =>
pair.Value.InvoiceId == invoiceId).Key;
RecordedTransactions.TryRemove(id, out _);
}
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

@ -1,121 +0,0 @@
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

@ -1,26 +0,0 @@
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 bool TxSeen { get; set; }
public override string ToString()
{
return $"{InvoiceId}_{OriginalTransactionHash}";
}
}
}

View file

@ -1,130 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Hosting;
using NBitcoin.RPC;
using NBXplorer;
namespace BTCPayServer.Payments.PayJoin
{
public class PayJoinTransactionBroadcaster : IHostedService
{
// The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok.
private static readonly TimeSpan BroadcastAfter = TimeSpan.FromMinutes(5);
private readonly EventAggregator _eventAggregator;
private readonly ExplorerClientProvider _explorerClientProvider;
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() ||
(txEvent.NewTransactionEvent.TransactionData.Transaction.RBF &&
txEvent.NewTransactionEvent.TransactionData.Confirmations == 0))
{
return;
}
var relevantStates =
_payJoinStateProvider.Get(txEvent.CryptoCode, txEvent.NewTransactionEvent.DerivationStrategy);
foreach (var relevantState in relevantStates)
{
//if any of the exposed inputs were 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)
{
await BroadcastStaleTransactions(BroadcastAfter, cancellationToken);
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
}
}
public async Task BroadcastStaleTransactions(TimeSpan broadcastAfter, CancellationToken cancellationToken)
{
List<Task> tasks = new List<Task>();
foreach (var state in _payJoinStateProvider.GetAll())
{
var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode);
//broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time.
//This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible.
var staleTxs = state.Value.GetStaleRecords(broadcastAfter)
.Where(item => !item.TxSeen || item.Transaction.RBF);
tasks.AddRange(staleTxs.Select(async staleTx =>
{
//if the transaction signals RBF and was broadcasted, check if it was rbfed out
if (staleTx.TxSeen && staleTx.Transaction.RBF)
{
var proposedTransaction = await explorerClient.GetTransactionAsync(staleTx.ProposedTransactionHash, cancellationToken);
var result = await explorerClient.BroadcastAsync(proposedTransaction.Transaction, cancellationToken);
var accounted = result.Success ||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN ||
!(
// Happen if a blocks mined a replacement
// Or if the tx is a double spend of something already in the mempool without rbf
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
// Happen if RBF is on and fee insufficient
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED);
if (accounted)
{
//if it wasn't replaced just yet, do not attempt to move the exposed coins to the priority list
return;
}
}
else
{
await explorerClient
.BroadcastAsync(staleTx.Transaction, cancellationToken);
}
state.Value.RemoveRecord(staleTx, true);
}));
}
await Task.WhenAll(tasks);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await _payJoinStateProvider.SaveCoins();
leases.Dispose();
}
}
}

View file

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Logging;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Services.Altcoins.Monero.RPC.Models; using BTCPayServer.Services.Altcoins.Monero.RPC.Models;
@ -30,7 +31,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
} }
public override PaymentType PaymentType => MoneroPaymentType.Instance; public override PaymentType PaymentType => MoneroPaymentType.Instance;
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(MoneroSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, MoneroSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
StoreData store, MoneroLikeSpecificBtcPayNetwork network, object preparePaymentObject) StoreData store, MoneroLikeSpecificBtcPayNetwork network, object preparePaymentObject)
{ {

View file

@ -0,0 +1,105 @@
using System;
using Microsoft.Extensions.Logging;
using NBitcoin;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NBXplorer;
using System.Threading.Channels;
using System.Threading;
using BTCPayServer.Logging;
namespace BTCPayServer.Services
{
public class DelayedTransactionBroadcaster
{
class Record
{
public DateTimeOffset Recorded;
public DateTimeOffset BroadcastTime;
public Transaction Transaction;
public BTCPayNetwork Network;
}
Channel<Record> _Records = Channel.CreateUnbounded<Record>();
private readonly ExplorerClientProvider _explorerClientProvider;
public DelayedTransactionBroadcaster(ExplorerClientProvider explorerClientProvider)
{
if (explorerClientProvider == null)
throw new ArgumentNullException(nameof(explorerClientProvider));
_explorerClientProvider = explorerClientProvider;
}
public Task Schedule(DateTimeOffset broadcastTime, Transaction transaction, BTCPayNetwork network)
{
if (transaction == null)
throw new ArgumentNullException(nameof(transaction));
if (network == null)
throw new ArgumentNullException(nameof(network));
var now = DateTimeOffset.UtcNow;
var record = new Record()
{
Recorded = now,
BroadcastTime = broadcastTime,
Transaction = transaction,
Network = network
};
_Records.Writer.TryWrite(record);
// TODO: persist
return Task.CompletedTask;
}
public async Task ProcessAll(CancellationToken cancellationToken = default)
{
if (disabled)
return;
var now = DateTimeOffset.UtcNow;
List<Record> rescheduled = new List<Record>();
List<Record> scheduled = new List<Record>();
List<Record> broadcasted = new List<Record>();
while (_Records.Reader.TryRead(out var r))
{
(r.BroadcastTime > now ? rescheduled : scheduled).Add(r);
}
var broadcasts = scheduled.Select(async (record) =>
{
var explorer = _explorerClientProvider.GetExplorerClient(record.Network);
if (explorer is null)
return false;
try
{
// We don't look the result, this is a best effort basis.
var result = await explorer.BroadcastAsync(record.Transaction, cancellationToken);
if (result.Success)
{
Logs.PayServer.LogInformation($"{record.Network.CryptoCode}: {record.Transaction.GetHash()} has been successfully broadcasted");
}
return false;
}
catch
{
// If this goes here, maybe RPC is down or NBX is down, we should reschedule
return true;
}
}).ToArray();
for (int i = 0; i < scheduled.Count; i++)
{
var needReschedule = await broadcasts[i];
(needReschedule ? rescheduled : broadcasted).Add(scheduled[i]);
}
foreach (var record in rescheduled)
{
_Records.Writer.TryWrite(record);
}
// TODO: Remove everything in broadcasted from DB
}
private bool disabled = false;
public void Disable()
{
disabled = true;
}
}
}

View file

@ -469,9 +469,9 @@ namespace BTCPayServer.Services.Invoices
dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo); dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo);
var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due); var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due);
if (((details as BitcoinLikeOnChainPaymentMethod)?.PayJoin?.Enabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase)) if (((details as BitcoinLikeOnChainPaymentMethod)?.PayjoinEnabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase))
{ {
bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu/{Id}"; bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu";
} }
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
{ {

View file

@ -314,7 +314,11 @@ retry:
if (!context.PendingInvoices.Any(a => a.Id == invoiceId)) if (!context.PendingInvoices.Any(a => a.Id == invoiceId))
{ {
context.PendingInvoices.Add(new PendingInvoiceData() { Id = invoiceId }); context.PendingInvoices.Add(new PendingInvoiceData() { Id = invoiceId });
await context.SaveChangesAsync(); try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException) { } // Already exists
} }
} }
} }
@ -683,7 +687,7 @@ retry:
/// <param name="cryptoCode"></param> /// <param name="cryptoCode"></param>
/// <param name="accounted"></param> /// <param name="accounted"></param>
/// <returns>The PaymentEntity or null if already added</returns> /// <returns>The PaymentEntity or null if already added</returns>
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false) public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false, decimal? networkFee = null)
{ {
using (var context = _ContextFactory.CreateContext()) using (var context = _ContextFactory.CreateContext())
{ {
@ -701,7 +705,7 @@ retry:
#pragma warning restore CS0618 #pragma warning restore CS0618
ReceivedTime = date.UtcDateTime, ReceivedTime = date.UtcDateTime,
Accounted = accounted, Accounted = accounted,
NetworkFee = paymentMethodDetails.GetNextNetworkFee(), NetworkFee = networkFee ?? paymentMethodDetails.GetNextNetworkFee(),
Network = network Network = network
}; };
entity.SetCryptoPaymentData(paymentData); entity.SetCryptoPaymentData(paymentData);

View file

@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Util;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services
{
public class PayjoinClient
{
private readonly ExplorerClientProvider _explorerClientProvider;
private HttpClient _httpClient;
public PayjoinClient(ExplorerClientProvider explorerClientProvider, IHttpClientFactory httpClientFactory)
{
if (httpClientFactory == null) throw new ArgumentNullException(nameof(httpClientFactory));
_explorerClientProvider =
explorerClientProvider ?? throw new ArgumentNullException(nameof(explorerClientProvider));
_httpClient = httpClientFactory.CreateClient("payjoin");
}
public async Task<PSBT> RequestPayjoin(Uri endpoint, DerivationSchemeSettings derivationSchemeSettings,
PSBT originalTx, CancellationToken cancellationToken)
{
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
if (derivationSchemeSettings == null) throw new ArgumentNullException(nameof(derivationSchemeSettings));
if (originalTx == null) throw new ArgumentNullException(nameof(originalTx));
var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings();
var sentBefore = -originalTx.GetBalance(derivationSchemeSettings.AccountDerivation,
signingAccount.AccountKey,
signingAccount.GetRootedKeyPath());
if (!originalTx.TryGetEstimatedFeeRate(out var oldFeeRate))
throw new ArgumentException("originalTx should have utxo information", nameof(originalTx));
var cloned = originalTx.Clone();
if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors))
{
return null;
}
// We make sure we don't send unnecessary information to the receiver
foreach (var finalized in cloned.Inputs.Where(i => i.IsFinalized()))
{
finalized.ClearForFinalize();
}
foreach (var output in cloned.Outputs)
{
output.HDKeyPaths.Clear();
}
cloned.GlobalXPubs.Clear();
var bpuresponse = await _httpClient.PostAsync(endpoint,
new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain"), cancellationToken);
if (!bpuresponse.IsSuccessStatusCode)
{
var errorStr = await bpuresponse.Content.ReadAsStringAsync();
try
{
var error = JObject.Parse(errorStr);
throw new PayjoinReceiverException((int)bpuresponse.StatusCode, error["errorCode"].Value<string>(),
error["message"].Value<string>());
}
catch (JsonReaderException)
{
// will throw
bpuresponse.EnsureSuccessStatusCode();
throw;
}
}
var hex = await bpuresponse.Content.ReadAsStringAsync();
var newPSBT = PSBT.Parse(hex, originalTx.Network);
// Checking that the PSBT of the receiver is clean
if (newPSBT.GlobalXPubs.Any())
{
throw new PayjoinSenderException("GlobalXPubs should not be included in the receiver's PSBT");
}
if (newPSBT.Outputs.Any(o => o.HDKeyPaths.Count != 0) || newPSBT.Inputs.Any(o => o.HDKeyPaths.Count != 0))
{
throw new PayjoinSenderException("Keypath information should not be included in the receiver's PSBT");
}
////////////
newPSBT = await _explorerClientProvider.UpdatePSBT(derivationSchemeSettings, newPSBT);
if (newPSBT.CheckSanity() is IList<PSBTError> errors2 && errors2.Count != 0)
{
throw new PayjoinSenderException($"The PSBT of the receiver is insane ({errors2[0]})");
}
// We make sure we don't sign things what should not be signed
foreach (var finalized in newPSBT.Inputs.Where(i => i.IsFinalized()))
{
finalized.ClearForFinalize();
}
// Make sure only the only our output have any information
foreach (var output in newPSBT.Outputs)
{
output.HDKeyPaths.Clear();
foreach (var originalOutput in originalTx.Outputs)
{
if (output.ScriptPubKey == originalOutput.ScriptPubKey)
output.UpdateFrom(originalOutput);
}
}
// Making sure that our inputs are finalized, and that some of our inputs have not been added
int ourInputCount = 0;
foreach (var input in newPSBT.Inputs.CoinsFor(derivationSchemeSettings.AccountDerivation,
signingAccount.AccountKey, signingAccount.GetRootedKeyPath()))
{
if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is PSBTInput ourInput)
{
ourInputCount++;
if (input.IsFinalized())
throw new PayjoinSenderException("A PSBT input from us should not be finalized");
}
else
{
throw new PayjoinSenderException(
"The payjoin receiver added some of our own inputs in the proposal");
}
}
// Making sure that the receiver's inputs are finalized
foreach (var input in newPSBT.Inputs)
{
if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is null && !input.IsFinalized())
throw new PayjoinSenderException("The payjoin receiver included a non finalized input");
}
if (ourInputCount < originalTx.Inputs.Count)
throw new PayjoinSenderException("The payjoin receiver removed some of our inputs");
// We limit the number of inputs the receiver can add
var addedInputs = newPSBT.Inputs.Count - originalTx.Inputs.Count;
if (originalTx.Inputs.Count < addedInputs)
throw new PayjoinSenderException("The payjoin receiver added too much inputs");
var sentAfter = -newPSBT.GetBalance(derivationSchemeSettings.AccountDerivation,
signingAccount.AccountKey,
signingAccount.GetRootedKeyPath());
if (sentAfter > sentBefore)
{
if (!newPSBT.TryGetEstimatedFeeRate(out var newFeeRate) || !newPSBT.TryGetVirtualSize(out var newVirtualSize))
throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly");
// Let's check the difference is only for the fee and that feerate
// did not changed that much
var expectedFee = oldFeeRate.GetFee(newVirtualSize);
// Signing precisely is hard science, give some breathing room for error.
expectedFee += newPSBT.Inputs.Count * Money.Satoshis(2);
// If the payjoin is removing some dust, we may pay a bit more as a whole output has been removed
var removedOutputs = Math.Max(0, originalTx.Outputs.Count - newPSBT.Outputs.Count);
expectedFee += removedOutputs * oldFeeRate.GetFee(294);
var actualFee = newFeeRate.GetFee(newVirtualSize);
if (actualFee > expectedFee && actualFee - expectedFee > Money.Satoshis(546))
throw new PayjoinSenderException("The payjoin receiver is paying too much fee");
}
return newPSBT;
}
}
public class PayjoinException : Exception
{
public PayjoinException(string message) : base(message)
{
}
}
public class PayjoinReceiverException : PayjoinException
{
public PayjoinReceiverException(int httpCode, string errorCode, string message) : base(FormatMessage(httpCode,
errorCode, message))
{
HttpCode = httpCode;
ErrorCode = errorCode;
ErrorMessage = message;
}
public int HttpCode { get; }
public string ErrorCode { get; }
public string ErrorMessage { get; }
private static string FormatMessage(in int httpCode, string errorCode, string message)
{
return $"{errorCode}: {message} (HTTP: {httpCode})";
}
}
public class PayjoinSenderException : PayjoinException
{
public PayjoinSenderException(string message) : base(message)
{
}
}
}

View file

@ -96,14 +96,44 @@ namespace BTCPayServer.Services.Wallets
await _Client.TrackAsync(derivationStrategy); await _Client.TrackAsync(derivationStrategy);
} }
public async Task<TransactionResult> GetTransactionAsync(uint256 txId, CancellationToken cancellation = default(CancellationToken)) public async Task<TransactionResult> GetTransactionAsync(uint256 txId, bool includeOffchain = false, CancellationToken cancellation = default(CancellationToken))
{ {
if (txId == null) if (txId == null)
throw new ArgumentNullException(nameof(txId)); throw new ArgumentNullException(nameof(txId));
var tx = await _Client.GetTransactionAsync(txId, cancellation); var tx = await _Client.GetTransactionAsync(txId, cancellation);
if (tx is null && includeOffchain)
{
var offchainTx = await GetOffchainTransactionAsync(txId);
if (offchainTx != null)
tx = new TransactionResult()
{
Confirmations = -1,
TransactionHash = offchainTx.GetHash(),
Transaction = offchainTx
};
}
return tx; return tx;
} }
public Task<Transaction> GetOffchainTransactionAsync(uint256 txid)
{
lock (offchain)
{
return Task.FromResult(offchain.TryGet(txid));
}
}
public Task SaveOffchainTransactionAsync(Transaction tx)
{
// TODO: Save in database
lock (offchain)
{
offchain.Add(tx.GetHash(), tx);
return Task.CompletedTask;
}
}
private Dictionary<uint256, Transaction> offchain = new Dictionary<uint256, Transaction>();
public void InvalidateCache(DerivationStrategyBase strategy) public void InvalidateCache(DerivationStrategyBase strategy)
{ {
_MemoryCache.Remove("CACHEDCOINS_" + strategy.ToString()); _MemoryCache.Remove("CACHEDCOINS_" + strategy.ToString());

View file

@ -8,4 +8,3 @@
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="Logs">Logs</a> <a class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="Logs">Logs</a>
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a> <a class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a>
</div> </div>

View file

@ -52,7 +52,7 @@
<tr class="@(payment.Replaced ? "linethrough" : "")" > <tr class="@(payment.Replaced ? "linethrough" : "")" >
<td>@payment.Crypto</td> <td>@payment.Crypto</td>
<td>@payment.DepositAddress</td> <td>@payment.DepositAddress</td>
<td class="payment-value">@payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"<br/>(Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount})")</td> <td class="payment-value">@payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin? string.Empty : $"<br/>(Payjoin)")</td>
<td> <td>
<div class="wraptextAuto"> <div class="wraptextAuto">
<a href="@payment.TransactionLink" target="_blank"> <a href="@payment.TransactionLink" target="_blank">