Merge pull request #1321 from Kukks/bpu

BIP79
This commit is contained in:
Nicolas Dorier
2020-04-05 12:10:55 +09:00
committed by GitHub
53 changed files with 2184 additions and 180 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,614 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel;
using NBitcoin;
using NBitcoin.Payment;
using NBitpayClient;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
public class PayJoinTests
{
public const int TestTimeout = 60_000;
public PayJoinTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUseBIP79Client()
{
using (var s = SeleniumTester.Create())
{
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);
var receiver = s.CreateNewStore();
var receiverSeed = s.GenerateWallet("BTC", "", true, true);
var receiverWalletId = new WalletId(receiver.storeId, "BTC");
//payjoin is not enabled by default.
var invoiceId = s.CreateInvoice(receiver.storeId);
s.GoToInvoiceCheckout(invoiceId);
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.DoesNotContain("bpu=", bip21);
s.GoToHome();
s.GoToStore(receiver.storeId);
//payjoin is not enabled by default.
Assert.False(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected);
s.SetCheckbox(s,"PayJoinEnabled", true);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.True(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected);
var sender = s.CreateNewStore();
var senderSeed = s.GenerateWallet("BTC", "", true, true);
var senderWalletId = new WalletId(sender.storeId, "BTC");
await s.Server.ExplorerNode.GenerateAsync(1);
await s.FundStoreWallet(senderWalletId);
invoiceId = s.CreateInvoice(receiver.storeId);
s.GoToInvoiceCheckout(invoiceId);
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains("bpu=", bip21);
s.GoToWalletSend(senderWalletId);
s.Driver.FindElement(By.Id("bip21parse")).Click();
s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept();
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
s.Driver.ScrollTo(By.Id("SendMenu"));
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
{
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
return Task.CompletedTask;
});
//no funds in receiver wallet to do payjoin
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning);
await TestUtils.EventuallyAsync(async () =>
{
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
});
s.GoToInvoices();
var paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value"));
Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase));
//let's do it all again, except now the receiver has funds and is able to payjoin
invoiceId = s.CreateInvoice(receiver.storeId);
s.GoToInvoiceCheckout(invoiceId);
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains("bpu", bip21);
s.GoToWalletSend(senderWalletId);
s.Driver.FindElement(By.Id("bip21parse")).Click();
s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept();
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
s.Driver.ScrollTo(By.Id("SendMenu"));
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
{
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
return Task.CompletedTask;
});
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 () =>
{
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
var dto = invoice.EntityToDTO();
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
});
s.GoToInvoices();
paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value"));
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;
}
// Same as above. Except the sender send one satoshi less, so the change
// output get below dust and should be removed completely.
vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), ExpectLocked: true, ExpectedError: null as string);
proposedPSBT = await RunVector();
var output = Assert.Single(proposedPSBT.Outputs);
// 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);
}
}
[Fact]
// [Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUseBIP79()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
////var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>();
var btcPayNetwork = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(btcPayNetwork);
var cashCow = tester.ExplorerNode;
cashCow.Generate(2); // get some money in case
var senderUser = tester.NewAccount();
senderUser.GrantAccess(true);
senderUser.RegisterDerivationScheme("BTC", true, true);
var invoice = senderUser.BitPay.CreateInvoice(
new Invoice() {Price = 100, Currency = "USD", FullNotifications = true});
//payjoin is not enabled by default.
Assert.DoesNotContain("bpu", invoice.CryptoInfo.First().PaymentUrls.BIP21);
cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network),
Money.Coins(0.06m));
var receiverUser = tester.NewAccount();
receiverUser.GrantAccess(true);
receiverUser.RegisterDerivationScheme("BTC", true, true);
await receiverUser.EnablePayJoin();
// payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer
invoice = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network),
Money.Coins(0.06m));
var receiverWalletId = new WalletId(receiverUser.StoreId, "BTC");
//give the cow some cash
await cashCow.GenerateAsync(1);
//let's get some more utxos first
await receiverUser.ReceiveUTXO(Money.Coins(0.011m), btcPayNetwork);
await receiverUser.ReceiveUTXO(Money.Coins(0.012m), btcPayNetwork);
await receiverUser.ReceiveUTXO(Money.Coins(0.013m), btcPayNetwork);
await receiverUser.ReceiveUTXO(Money.Coins(0.014m), btcPayNetwork);
await senderUser.ReceiveUTXO(Money.Coins(0.021m), btcPayNetwork);
await senderUser.ReceiveUTXO(Money.Coins(0.022m), btcPayNetwork);
await senderUser.ReceiveUTXO(Money.Coins(0.023m), btcPayNetwork);
await senderUser.ReceiveUTXO(Money.Coins(0.024m), btcPayNetwork);
await senderUser.ReceiveUTXO(Money.Coins(0.025m), btcPayNetwork);
await senderUser.ReceiveUTXO(Money.Coins(0.026m), btcPayNetwork);
var senderChange = await senderUser.GetNewAddress(btcPayNetwork);
//Let's start the harassment
invoice = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice2 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork);
var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId);
var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
var derivationSchemeSettings = senderStore.GetSupportedPaymentMethods(tester.NetworkProvider)
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
settings.PaymentId == paymentMethodId);
ReceivedCoin[] senderCoins = null;
await TestUtils.EventuallyAsync(async () =>
{
senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme);
Assert.Contains(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.026m);
});
var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m);
var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m);
var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m);
var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m);
var coin5 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.025m);
var coin6 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.026m);
var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
signingKeySettings.RootFingerprint =
senderUser.GenerateWalletResponseV.MasterHDKey.GetPublicKey().GetHDFingerPrint();
var extKey =
senderUser.GenerateWalletResponseV.MasterHDKey.Derive(signingKeySettings.GetRootedKeyPath()
.KeyPath);
var n = tester.ExplorerClient.Network.NBitcoinNetwork;
var Invoice1Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(parsedBip21.Address, parsedBip21.Amount)
.AddCoins(coin.Coin)
.AddKeys(extKey.Derive(coin.KeyPath))
.SendEstimatedFees(new FeeRate(100m))
.BuildTransaction(true);
var Invoice1Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(parsedBip21.Address, parsedBip21.Amount)
.AddCoins(coin2.Coin)
.AddKeys(extKey.Derive(coin2.KeyPath))
.SendEstimatedFees(new FeeRate(100m))
.BuildTransaction(true);
var Invoice2Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount)
.AddCoins(coin.Coin)
.AddKeys(extKey.Derive(coin.KeyPath))
.SendEstimatedFees(new FeeRate(100m))
.BuildTransaction(true);
var Invoice2Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount)
.AddCoins(coin2.Coin)
.AddKeys(extKey.Derive(coin2.KeyPath))
.SendEstimatedFees(new FeeRate(100m))
.BuildTransaction(true);
//Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all
//Result: reject
// Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint,
// new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
//Attempt 2: Create two transactions using different inputs and send them to the same invoice.
//Result: Second Tx should be rejected.
var Invoice1Coin1ResponseTx = await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork);
await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork, "already-paid");
var contributedInputsInvoice1Coin1ResponseTx =
Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut);
Assert.Single(contributedInputsInvoice1Coin1ResponseTx);
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted
//Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, "inputs-already-used");
var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork);
var contributedInputsInvoice2Coin2ResponseTx =
Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut);
Assert.Single(contributedInputsInvoice2Coin2ResponseTx);
//Attempt 4: Make tx that pays invoice 3 and 4 and submit to both
//Result: reject on 4: the protocol should not worry about this complexity
var invoice3 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice4 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork);
var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(invoice3ParsedBip21.Address, invoice3ParsedBip21.Amount)
.Send(invoice4ParsedBip21.Address, invoice4ParsedBip21.Amount)
.AddCoins(coin3.Coin)
.AddKeys(extKey.Derive(coin3.KeyPath))
.SendEstimatedFees(new FeeRate(100m))
.BuildTransaction(true);
await senderUser.SubmitPayjoin(invoice3, Invoice3AndInvoice4Coin3, btcPayNetwork);
await senderUser.SubmitPayjoin(invoice4, Invoice3AndInvoice4Coin3, btcPayNetwork, "already-paid");
//Attempt 5: Make tx that pays invoice 5 with 2 outputs
//Result: proposed tx consolidates the outputs
var invoice5 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork);
var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2)
.Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2)
.AddCoins(coin4.Coin)
.AddKeys(extKey.Derive(coin4.KeyPath))
.SendEstimatedFees(new FeeRate(100m));
var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true);
var Invoice5Coin4ResponseTx = await senderUser.SubmitPayjoin(invoice5, Invoice5Coin4, btcPayNetwork);
Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address));
//Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again
//Result: same tx gets sent back
//give the receiver some more utxos
Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync(
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
new Money(0.1m, MoneyUnit.BTC)));
var invoice6 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(invoice6ParsedBip21.Address, invoice6ParsedBip21.Amount)
.AddCoins(coin5.Coin)
.AddKeys(extKey.Derive(coin5.KeyPath))
.SendEstimatedFees(new FeeRate(100m))
.SetLockTime(0);
var invoice6Coin5 = invoice6Coin5TxBuilder
.BuildTransaction(true);
var Invoice6Coin5Response1Tx =await senderUser.SubmitPayjoin(invoice6, invoice6Coin5, btcPayNetwork);
var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx);
//broadcast the first payjoin
await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned);
// invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m));
// var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder
// .BuildTransaction(true);
//
// var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
// new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain"));
// Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode);
// var Invoice6Coin5Response3Tx =
// Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n);
// Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin =>
// Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut)));
//Attempt 11:
//send tx with rbt, broadcast payjoin,
//create tx spending the original tx inputs with rbf to self,
//Result: the exposed utxos are priorized in the next p2ep
//give the receiver some more utxos
Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync(
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
new Money(0.1m, MoneyUnit.BTC)));
var invoice7 = receiverUser.BitPay.CreateInvoice(
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21,
tester.ExplorerClient.Network.NBitcoinNetwork);
var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
.SetChange(senderChange)
.Send(invoice7ParsedBip21.Address, invoice7ParsedBip21.Amount)
.AddCoins(coin6.Coin)
.AddKeys(extKey.Derive(coin6.KeyPath))
.SendEstimatedFees(new FeeRate(100m))
.SetLockTime(0);
var invoice7Coin6Tx = invoice7Coin6TxBuilder
.BuildTransaction(true);
var invoice7Coin6Response1Tx = await senderUser.SubmitPayjoin(invoice7, invoice7Coin6Tx, btcPayNetwork);
var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx);
var contributedInputsInvoice7Coin6Response1TxSigned =
Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut);
////var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
//broadcast the payjoin
var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned));
Assert.True(res.Success);
// 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()
.SetChange(senderChange)
.AddCoins(coin6.Coin)
.SendAll(senderChange)
.SubtractFees()
.AddKeys(extKey.Derive(coin6.KeyPath))
.SendEstimatedFees(new FeeRate(200m))
.SetLockTime(0)
.BuildTransaction(true);
//broadcast the "rbf cancel" tx
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 invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
Assert.Equal(InvoiceStatus.New, invoiceEntity.Status);
Assert.True(invoiceEntity.GetPayments().All(p => !p.Accounted));
ourOutpoint = invoiceEntity.GetAllBitcoinPaymentData().First().PayjoinInformation.ContributedOutPoints[0];
});
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
// The outpoint should now be available for next pj selection
Assert.False(await payjoinRepository.TryUnlock(ourOutpoint));
}
}
}
}

View File

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

View File

@@ -120,6 +120,7 @@ namespace BTCPayServer.Tests
return (usr, Driver.FindElement(By.Id("Id")).GetAttribute("value"));
}
public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false)
{
@@ -314,8 +315,38 @@ namespace BTCPayServer.Tests
return id;
}
public async Task FundStoreWallet(WalletId walletId, int coins = 1, decimal denomination = 1m)
{
GoToWalletReceive(walletId);
Driver.FindElement(By.Id("generateButton")).Click();
var addressStr = Driver.FindElement(By.Id("vue-address")).GetProperty("value");
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork);
for (int i = 0; i < coins; i++)
{
await Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(denomination));
}
}
public void PayInvoice(WalletId walletId, string invoiceId)
{
GoToInvoiceCheckout(invoiceId);
var bip21 = Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains("bpu", bip21);
GoToWalletSend(walletId);
Driver.FindElement(By.Id("bip21parse")).Click();
Driver.SwitchTo().Alert().SendKeys(bip21);
Driver.SwitchTo().Alert().Accept();
Driver.ScrollTo(By.Id("SendMenu"));
Driver.FindElement(By.Id("SendMenu")).ForceClick();
Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick();
}
private void CheckForJSErrors()
{
//wait for seleniun update: https://stackoverflow.com/questions/57520296/selenium-webdriver-3-141-0-driver-manage-logs-availablelogtypes-throwing-syste

View File

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

View File

@@ -8,8 +8,10 @@ using NBitcoin;
using NBitpayClient;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Amazon.S3.Model;
using Xunit;
using NBXplorer.DerivationStrategy;
using BTCPayServer.Payments;
@@ -21,21 +23,27 @@ using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity;
using NBXplorer.Models;
using BTCPayServer.Client;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using NBitcoin.Payment;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Tests
{
public class TestAccount
{
ServerTester parent;
public TestAccount(ServerTester parent)
{
this.parent = parent;
BitPay = new Bitpay(new Key(), parent.PayTester.ServerUri);
}
public void GrantAccess()
public void GrantAccess(bool isAdmin = false)
{
GrantAccessAsync().GetAwaiter().GetResult();
GrantAccessAsync(isAdmin).GetAwaiter().GetResult();
}
public async Task MakeAdmin(bool isAdmin = true)
@@ -51,7 +59,8 @@ namespace BTCPayServer.Tests
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)
@@ -60,11 +69,12 @@ namespace BTCPayServer.Tests
var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey(
new ManageController.AddApiKeyViewModel()
{
PermissionValues = permissions.Select(s => new ManageController.AddApiKeyViewModel.PermissionValueItem()
{
Permission = s,
Value = true
}).ToList(),
PermissionValues =
permissions.Select(s =>
new ManageController.AddApiKeyViewModel.PermissionValueItem()
{
Permission = s, Value = true
}).ToList(),
StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores
}));
var statusMessage = manageController.TempData.GetStatusMessageModel();
@@ -74,13 +84,14 @@ namespace BTCPayServer.Tests
return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey);
}
public void Register()
public void Register(bool isAdmin = false)
{
RegisterAsync().GetAwaiter().GetResult();
RegisterAsync(isAdmin).GetAwaiter().GetResult();
}
public async Task GrantAccessAsync()
public async Task GrantAccessAsync(bool isAdmin = false)
{
await RegisterAsync();
await RegisterAsync(isAdmin);
await CreateStoreAsync();
var store = this.GetController<StoresController>();
var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant);
@@ -105,6 +116,7 @@ namespace BTCPayServer.Tests
store.NetworkFeeMode = mode;
});
}
public void ModifyStore(Action<StoreViewModel> modify)
{
var storeController = GetController<StoresController>();
@@ -122,7 +134,7 @@ namespace BTCPayServer.Tests
public async Task CreateStoreAsync()
{
var store = this.GetController<UserStoresController>();
await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" });
await store.CreateStore(new CreateStoreViewModel() {Name = "Test Store"});
StoreId = store.CreatedStoreId;
parent.Stores.Add(StoreId);
}
@@ -133,7 +145,9 @@ namespace BTCPayServer.Tests
{
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);
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId);
@@ -143,23 +157,38 @@ namespace BTCPayServer.Tests
SavePrivateKeys = importKeysToNBX
});
await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel()
{
Enabled = true,
CryptoCode = cryptoCode,
Network = SupportedNetwork,
RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(),
RootKeyPath = SupportedNetwork.GetRootKeyPath(),
Source = "NBXplorer",
AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(),
DerivationSchemeFormat = "BTCPay",
KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(),
DerivationScheme = DerivationScheme.ToString(),
Confirmation = true
}, cryptoCode);
await store.AddDerivationScheme(StoreId,
new DerivationSchemeViewModel()
{
Enabled = true,
CryptoCode = cryptoCode,
Network = SupportedNetwork,
RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(),
RootKeyPath = SupportedNetwork.GetRootKeyPath(),
Source = "NBXplorer",
AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(),
DerivationSchemeFormat = "BTCPay",
KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(),
DerivationScheme = DerivationScheme.ToString(),
Confirmation = true
}, cryptoCode);
return new WalletId(StoreId, cryptoCode);
}
public async Task EnablePayJoin()
{
var storeController = parent.PayTester.GetController<StoresController>(UserId, StoreId);
var storeVM =
Assert.IsType<StoreViewModel>(Assert
.IsType<ViewResult>(storeController.UpdateStore()).Model);
storeVM.PayJoinEnabled = true;
Assert.Equal(nameof(storeController.UpdateStore),
Assert.IsType<RedirectToActionResult>(
await storeController.UpdateStore(storeVM)).ActionName);
}
public GenerateWalletResponse GenerateWalletResponseV { get; set; }
public DerivationStrategyBase DerivationScheme
@@ -170,7 +199,7 @@ namespace BTCPayServer.Tests
}
}
private async Task RegisterAsync()
private async Task RegisterAsync(bool isAdmin = false)
{
var account = parent.PayTester.GetController<AccountController>();
RegisterDetails = new RegisterViewModel()
@@ -178,27 +207,33 @@ namespace BTCPayServer.Tests
Email = Guid.NewGuid() + "@toto.com",
ConfirmPassword = "Kitten0@",
Password = "Kitten0@",
IsAdmin = isAdmin
};
await account.Register(RegisterDetails);
UserId = account.RegisteredUserId;
IsAdmin = account.RegisteredAdmin;
}
public RegisterViewModel RegisterDetails{ get; set; }
public RegisterViewModel RegisterDetails { get; set; }
public Bitpay BitPay
{
get; set;
get;
set;
}
public string UserId
{
get; set;
get;
set;
}
public string StoreId
{
get; set;
get;
set;
}
public bool IsAdmin { get; internal set; }
public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType)
@@ -214,19 +249,116 @@ namespace BTCPayServer.Tests
if (connectionType == LightningConnectionType.Charge)
connectionString = "type=charge;server=" + parent.MerchantCharge.Client.Uri.AbsoluteUri;
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)
connectionString = $"type=lnd-rest;server={parent.MerchantLnd.Swagger.BaseUrl};allowinsecure=true";
else
throw new NotSupportedException(connectionType.ToString());
await storeController.AddLightningNode(StoreId, new LightningNodeViewModel()
{
ConnectionString = connectionString,
SkipPortTest = true
}, "save", "BTC");
await storeController.AddLightningNode(StoreId,
new LightningNodeViewModel() {ConnectionString = connectionString, SkipPortTest = true}, "save", "BTC");
if (storeController.ModelState.ErrorCount != 0)
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

@@ -183,7 +183,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
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),
});
var networkBTC = networkProvider.GetNetwork("BTC");
@@ -298,7 +298,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
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),
});
var entity = new InvoiceEntity();
@@ -487,7 +487,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
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),
});
var entity = new InvoiceEntity();

View File

@@ -76,7 +76,7 @@ services:
- customer_lnd
- merchant_lnd
nbxplorer:
image: nicolasdorier/nbxplorer:2.1.14
image: nicolasdorier/nbxplorer:2.1.21
restart: unless-stopped
ports:
- "32838:32838"
@@ -318,7 +318,6 @@ services:
- "bitcoin_datadir:/deps/.bitcoin"
links:
- bitcoind
volumes:
sshd_datadir:
bitcoin_datadir:

View File

@@ -259,7 +259,7 @@ namespace BTCPayServer.Controllers
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);
}

View File

@@ -479,6 +479,7 @@ namespace BTCPayServer.Controllers
vm.InvoiceExpiration = storeBlob.InvoiceExpiration;
vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate;
vm.PaymentTolerance = storeBlob.PaymentTolerance;
vm.PayJoinEnabled = storeBlob.PayJoinEnabled;
return View(vm);
}
@@ -573,7 +574,7 @@ namespace BTCPayServer.Controllers
blob.InvoiceExpiration = model.InvoiceExpiration;
blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty;
blob.PaymentTolerance = model.PaymentTolerance;
blob.PayJoinEnabled = model.PayJoinEnabled;
if (CurrentStore.SetStoreBlob(blob))
{
needUpdate = true;

View File

@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Models.WalletViewModels;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
@@ -68,6 +71,7 @@ namespace BTCPayServer.Controllers
vm.Decoded = psbt.ToString();
vm.PSBT = psbt.ToBase64();
}
return View(nameof(WalletPSBT), vm ?? new WalletPSBTViewModel() { CryptoCode = walletId.CryptoCode });
}
[HttpPost]
@@ -98,12 +102,12 @@ namespace BTCPayServer.Controllers
vm.FileName = vm.UploadedPSBTFile?.FileName;
return View(vm);
case "vault":
return ViewVault(walletId, psbt);
return ViewVault(walletId, psbt, vm.PayJoinEndpointUrl);
case "ledger":
return ViewWalletSendLedger(walletId, psbt);
case "update":
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
psbt = await UpdatePSBT(derivationSchemeSettings, psbt, network);
psbt = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbt);
if (psbt == null)
{
ModelState.AddModelError(nameof(vm.PSBT), "You need to update your version of NBXplorer");
@@ -112,7 +116,7 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = "PSBT updated!";
return RedirectToWalletPSBT(psbt, vm.FileName);
case "seed":
return SignWithSeed(walletId, psbt.ToBase64());
return SignWithSeed(walletId, psbt.ToBase64(), vm.PayJoinEndpointUrl);
case "nbx-seed":
if (await CanUseHotWallet())
{
@@ -121,8 +125,8 @@ namespace BTCPayServer.Controllers
.GetMetadataAsync<string>(derivationScheme.AccountDerivation,
WellknownMetadataKeys.MasterHDKey);
return SignWithSeed(walletId,
new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64()});
return await SignWithSeed(walletId,
new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64(), PayJoinEndpointUrl = vm.PayJoinEndpointUrl});
}
return View(vm);
@@ -140,31 +144,42 @@ 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()
if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint))
{
PSBT = psbt,
DerivationScheme = derivationSchemeSettings.AccountDerivation,
});
if (result == null)
return null;
derivationSchemeSettings.RebaseKeyPaths(result.PSBT);
return result.PSBT;
var cloned = psbt.Clone();
cloned = cloned.Finalize();
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;
}
[HttpGet]
[Route("{walletId}/psbt/ready")]
public async Task<IActionResult> WalletPSBTReady(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string psbt = null,
string signingKey = null,
string signingKeyPath = null)
string signingKeyPath = null,
string originalPsbt = null,
string payJoinEndpointUrl = null)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var vm = new WalletPSBTReadyViewModel() { PSBT = psbt };
vm.SigningKey = signingKey;
vm.SigningKeyPath = signingKeyPath;
vm.OriginalPSBT = originalPsbt;
vm.PayJoinEndpointUrl = payJoinEndpointUrl;
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
if (derivationSchemeSettings == null)
return NotFound();
@@ -180,7 +195,7 @@ namespace BTCPayServer.Controllers
{
var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
if (!psbtObject.IsAllFinalized())
psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject;
psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject;
IHDKey signingKey = null;
RootedKeyPath signingKeyPath = null;
try
@@ -282,16 +297,17 @@ namespace BTCPayServer.Controllers
[Route("{walletId}/psbt/ready")]
public async Task<IActionResult> WalletPSBTReady(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null)
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null, CancellationToken cancellationToken = default)
{
if (command == null)
return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath);
return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl);
PSBT psbt = null;
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
DerivationSchemeSettings derivationSchemeSettings = null;
try
{
psbt = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
if (derivationSchemeSettings == null)
return NotFound();
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
@@ -301,38 +317,88 @@ namespace BTCPayServer.Controllers
vm.GlobalError = "Invalid PSBT";
return View(nameof(WalletPSBTReady),vm);
}
if (command == "broadcast")
switch (command)
{
if (!psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors))
{
case "payjoin":
var proposedPayjoin =await
TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network, cancellationToken);
if (proposedPayjoin == null)
{
//we possibly exposed the tx to the receiver, so we need to broadcast straight away
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Warning,
AllowDismiss = false,
Html = $"The payjoin transaction could not be created. The original transaction was broadcast instead."
});
return await WalletPSBTReady(walletId, vm, "broadcast");
}
else
{
try
{
var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
extKey,
RootedKeyPath.Parse(vm.SigningKeyPath));
vm.PSBT = proposedPayjoin.ToBase64();
vm.OriginalPSBT = psbt.ToBase64();
return await WalletPSBTReady(walletId, vm, "broadcast");
}
catch (Exception)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Info,
AllowDismiss = false,
Html =
$"This transaction has been coordinated between the receiver and you to create a <a href='https://en.bitcoin.it/wiki/PayJoin' target='_blank'>payjoin transaction</a> by adding inputs from the receiver. The amount being sent may appear higher but is in fact the same"
});
return ViewVault(walletId, proposedPayjoin, vm.PayJoinEndpointUrl, psbt);
}
}
case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors):
vm.SetErrors(errors);
return View(nameof(WalletPSBTReady),vm);
}
var transaction = psbt.ExtractTransaction();
try
case "broadcast":
{
var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);
if (!broadcastResult.Success)
var transaction = psbt.ExtractTransaction();
try
{
vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}";
var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);
if (!broadcastResult.Success)
{
if (!string.IsNullOrEmpty(vm.OriginalPSBT))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Warning,
AllowDismiss = false,
Html = $"The payjoin transaction could not be broadcast.<br/>({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).<br/>The transaction has been reverted back to its original format and has been broadcast."
});
vm.PSBT = vm.OriginalPSBT;
vm.OriginalPSBT = null;
return await WalletPSBTReady(walletId, vm, "broadcast");
}
vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}";
return View(nameof(WalletPSBTReady),vm);
}
}
catch (Exception ex)
{
vm.GlobalError = "Error while broadcasting: " + ex.Message;
return View(nameof(WalletPSBTReady),vm);
}
return RedirectToWalletTransaction(walletId, transaction);
}
catch (Exception ex)
{
vm.GlobalError = "Error while broadcasting: " + ex.Message;
case "analyze-psbt":
return RedirectToWalletPSBT(psbt);
default:
vm.GlobalError = "Unknown command";
return View(nameof(WalletPSBTReady),vm);
}
return RedirectToWalletTransaction(walletId, transaction);
}
else if (command == "analyze-psbt")
{
return RedirectToWalletPSBT(psbt);
}
else
{
vm.GlobalError = "Unknown command";
return View(nameof(WalletPSBTReady),vm);
}
}

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
@@ -49,6 +50,8 @@ namespace BTCPayServer.Controllers
private readonly WalletReceiveStateService _WalletReceiveStateService;
private readonly EventAggregator _EventAggregator;
private readonly SettingsRepository _settingsRepository;
private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly PayjoinClient _payjoinClient;
public RateFetcher RateFetcher { get; }
CurrencyNameTable _currencyTable;
@@ -66,7 +69,9 @@ namespace BTCPayServer.Controllers
BTCPayWalletProvider walletProvider,
WalletReceiveStateService walletReceiveStateService,
EventAggregator eventAggregator,
SettingsRepository settingsRepository)
SettingsRepository settingsRepository,
DelayedTransactionBroadcaster broadcaster,
PayjoinClient payjoinClient)
{
_currencyTable = currencyTable;
Repository = repo;
@@ -83,6 +88,8 @@ namespace BTCPayServer.Controllers
_WalletReceiveStateService = walletReceiveStateService;
_EventAggregator = eventAggregator;
_settingsRepository = settingsRepository;
_broadcaster = broadcaster;
_payjoinClient = payjoinClient;
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
@@ -456,7 +463,9 @@ namespace BTCPayServer.Controllers
if (network == null || network.ReadonlyWallet)
return NotFound();
vm.SupportRBF = network.SupportRBF;
vm.NBXSeedAvailable = await CanUseHotWallet() && !string.IsNullOrEmpty(await ExplorerClientProvider.GetExplorerClient(network)
.GetMetadataAsync<string>(GetDerivationSchemeSettings(walletId).AccountDerivation,
WellknownMetadataKeys.MasterHDKey, cancellation));
if (!string.IsNullOrEmpty(bip21))
{
LoadFromBIP21(vm, bip21, network);
@@ -594,28 +603,28 @@ namespace BTCPayServer.Controllers
return View(vm);
}
derivationScheme.RebaseKeyPaths(psbt.PSBT);
switch (command)
{
case "vault":
return ViewVault(walletId, psbt.PSBT);
return ViewVault(walletId, psbt.PSBT, vm.PayJoinEndpointUrl);
case "nbx-seed":
var extKey = await ExplorerClientProvider.GetExplorerClient(network)
.GetMetadataAsync<string>(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey, cancellation);
return SignWithSeed(walletId, new SignWithSeedViewModel()
return await SignWithSeed(walletId, new SignWithSeedViewModel()
{
PayJoinEndpointUrl = vm.PayJoinEndpointUrl,
SeedOrKey = extKey,
PSBT = psbt.PSBT.ToBase64()
});
case "ledger":
return ViewWalletSendLedger(walletId, psbt.PSBT, psbt.ChangeAddress);
case "seed":
return SignWithSeed(walletId, psbt.PSBT.ToBase64());
return SignWithSeed(walletId, psbt.PSBT.ToBase64(), vm.PayJoinEndpointUrl);
case "analyze-psbt":
var name =
$"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
return RedirectToWalletPSBT(psbt.PSBT, name);
return RedirectToWalletPSBT(psbt.PSBT, name, vm.PayJoinEndpointUrl);
default:
return View(vm);
}
@@ -650,6 +659,8 @@ namespace BTCPayServer.Controllers
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}"
});
}
uriBuilder.UnknowParameters.TryGetValue("bpu", out var vmPayJoinEndpointUrl);
vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl;
}
catch (Exception)
{
@@ -663,11 +674,13 @@ namespace BTCPayServer.Controllers
ModelState.Clear();
}
private IActionResult ViewVault(WalletId walletId, PSBT psbt)
private IActionResult ViewVault(WalletId walletId, PSBT psbt, string payJoinEndpointUrl, PSBT originalPSBT = null)
{
return View("WalletSendVault", new WalletSendVaultModel()
return View(nameof(WalletSendVault), new WalletSendVaultModel()
{
PayJoinEndpointUrl = payJoinEndpointUrl,
WalletId = walletId.ToString(),
OriginalPSBT = originalPSBT?.ToBase64(),
PSBT = psbt.ToBase64(),
WebsocketPath = this.Url.Action(nameof(VaultController.VaultBridgeConnection), "Vault", new { walletId = walletId.ToString() })
});
@@ -675,12 +688,12 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("{walletId}/vault")]
public IActionResult SubmitVault([ModelBinder(typeof(WalletIdModelBinder))]
public async Task<IActionResult> WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendVaultModel model)
{
return RedirectToWalletPSBTReady(model.PSBT);
return RedirectToWalletPSBTReady(model.PSBT, originalPsbt: model.OriginalPSBT, payJoinEndpointUrl: model.PayJoinEndpointUrl);
}
private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null)
private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null, string originalPsbt = null, string payJoinEndpointUrl = null)
{
var vm = new PostRedirectViewModel()
{
@@ -689,6 +702,8 @@ namespace BTCPayServer.Controllers
Parameters =
{
new KeyValuePair<string, string>("psbt", psbt),
new KeyValuePair<string, string>("originalPsbt", originalPsbt),
new KeyValuePair<string, string>("payJoinEndpointUrl", payJoinEndpointUrl),
new KeyValuePair<string, string>("SigningKey", signingKey),
new KeyValuePair<string, string>("SigningKeyPath", signingKeyPath)
}
@@ -696,7 +711,7 @@ namespace BTCPayServer.Controllers
return View("PostRedirect", vm);
}
private IActionResult RedirectToWalletPSBT(PSBT psbt, string fileName = null)
private IActionResult RedirectToWalletPSBT(PSBT psbt, string fileName = null, string payJoinEndpointUrl = null)
{
var vm = new PostRedirectViewModel()
{
@@ -709,6 +724,8 @@ namespace BTCPayServer.Controllers
};
if (!string.IsNullOrEmpty(fileName))
vm.Parameters.Add(new KeyValuePair<string, string>("fileName", fileName));
if (!string.IsNullOrEmpty(payJoinEndpointUrl))
vm.Parameters.Add(new KeyValuePair<string, string>("payJoinEndpointUrl", payJoinEndpointUrl));
return View("PostRedirect", vm);
}
@@ -747,7 +764,7 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("{walletId}/ledger")]
public IActionResult SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))]
public async Task<IActionResult> SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendLedgerModel model)
{
return RedirectToWalletPSBTReady(model.PSBT);
@@ -755,16 +772,17 @@ namespace BTCPayServer.Controllers
[HttpGet("{walletId}/psbt/seed")]
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId,string psbt)
WalletId walletId,string psbt, string payJoinEndpointUrl)
{
return View(nameof(SignWithSeed), new SignWithSeedViewModel()
{
PayJoinEndpointUrl = payJoinEndpointUrl,
PSBT = psbt
});
}
[HttpPost("{walletId}/psbt/seed")]
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, SignWithSeedViewModel viewModel)
{
if (!ModelState.IsValid)
@@ -826,9 +844,10 @@ namespace BTCPayServer.Controllers
return View(viewModel);
}
ModelState.Remove(nameof(viewModel.PSBT));
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString());
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl);
}
private bool PSBTChanged(PSBT psbt, Action act)
{
var before = psbt.ToBase64();
@@ -850,7 +869,17 @@ namespace BTCPayServer.Controllers
var wallet = _walletProvider.GetWallet(network);
var derivationSettings = GetDerivationSchemeSettings(walletId);
wallet.InvalidateCache(derivationSettings.AccountDerivation);
TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash().ToString()})";
if (TempData.GetStatusMessageModel() == null)
{
TempData[WellKnownTempData.SuccessMessage] =
$"Transaction broadcasted successfully ({transaction.GetHash()})";
}
else
{
var statusMessageModel = TempData.GetStatusMessageModel();
statusMessageModel.Message += $" ({transaction.GetHash()})";
TempData.SetStatusMessageModel(statusMessageModel);
}
}
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}

View File

@@ -172,6 +172,7 @@ namespace BTCPayServer.Data
public EmailSettings EmailSettings { get; set; }
public bool RedirectAutomatically { get; set; }
public bool PayJoinEnabled { get; set; }
public IPaymentFilter GetExcludedPaymentMethods()
{

View File

@@ -36,6 +36,7 @@ using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Newtonsoft.Json.Linq;
using BTCPayServer.Payments.Bitcoin;
namespace BTCPayServer
{
@@ -136,15 +137,37 @@ namespace BTCPayServer
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();
var transactions = hashes
.Select(async o => await client.GetTransactionAsync(o, cts))
.Select(async o => await client.GetTransactionAsync(o, includeOffchain, cts))
.ToArray();
await Task.WhenAll(transactions).ConfigureAwait(false);
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)
{
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

@@ -31,6 +31,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Security;
using BTCPayServer.Services.PaymentRequests;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -62,6 +63,7 @@ namespace BTCPayServer.Hosting
{
httpClient.Timeout = Timeout.InfiniteTimeSpan;
});
services.AddPayJoinServices();
services.AddMoneroLike();
services.TryAddSingleton<SettingsRepository>();
services.TryAddSingleton<TorServices>();
@@ -257,11 +259,13 @@ namespace BTCPayServer.Hosting
{
rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay");
rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay");
rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=1000r/min burst=100 nodelay");
}
else
{
rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay");
rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay");
rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst=3 nodelay");
}
return rateLimits;
});

View File

@@ -86,6 +86,9 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Description template of the lightning invoice")]
public string LightningDescriptionTemplate { get; set; }
[Display(Name = "Enable BIP79 Payjoin/P2EP")]
public bool PayJoinEnabled { get; set; }
public class LightningNode
{

View File

@@ -7,6 +7,8 @@ namespace BTCPayServer.Models.WalletViewModels
{
public class SignWithSeedViewModel
{
public string OriginalPSBT { get; set; }
public string PayJoinEndpointUrl { get; set; }
[Required]
public string PSBT { get; set; }
[Required][Display(Name = "BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)")]

View File

@@ -8,6 +8,8 @@ namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTReadyViewModel
{
public string PayJoinEndpointUrl { get; set; }
public string OriginalPSBT { get; set; }
public string PSBT { get; set; }
public string SigningKey { get; set; }
public string SigningKeyPath { get; set; }

View File

@@ -10,6 +10,7 @@ namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTViewModel
{
public string PayJoinEndpointUrl { get; set; }
public string CryptoCode { get; set; }
public string Decoded { get; set; }
string _FileName;

View File

@@ -10,7 +10,6 @@ namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSendModel
{
public List<TransactionOutput> Outputs { get; set; } = new List<TransactionOutput>();
public class TransactionOutput
@@ -49,6 +48,8 @@ namespace BTCPayServer.Models.WalletViewModels
public bool DisableRBF { get; set; }
public bool NBXSeedAvailable { get; set; }
[Display(Name = "PayJoin Endpoint Url (BIP79)")]
public string PayJoinEndpointUrl { get; set; }
public bool InputSelection { get; set; }
public InputSelectionOption[] InputsAvailable { get; set; }

View File

@@ -7,8 +7,10 @@ namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSendVaultModel
{
public string OriginalPSBT { get; set; }
public string WalletId { get; set; }
public string PSBT { get; set; }
public string WebsocketPath { get; set; }
public string PayJoinEndpointUrl { get; set; }
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
using NBitcoin;
using NBXplorer.JsonConverters;
using Newtonsoft.Json;
@@ -48,7 +49,7 @@ namespace BTCPayServer.Payments.Bitcoin
_NetworkFeeRate = value;
}
}
public bool PayjoinEnabled { get; set; }
// Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason
[JsonIgnore]
public FeeRate FeeRate { get; set; }
@@ -56,6 +57,7 @@ namespace BTCPayServer.Payments.Bitcoin
public Money NextNetworkFee { get; set; }
[JsonIgnore]
public String DepositAddress { get; set; }
public BitcoinAddress GetDepositAddress(Network network)
{
return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network);

View File

@@ -37,10 +37,11 @@ namespace BTCPayServer.Payments.Bitcoin
public TxOut Output { get; set; }
public int ConfirmationCount { get; set; }
public bool RBF { get; set; }
public decimal NetworkFee { get; set; }
public BitcoinAddress Address { get; set; }
public IMoney Value { get; set; }
public PayjoinInformation PayjoinInformation { get; set; }
[JsonIgnore]
public Script ScriptPubKey
{
@@ -67,7 +68,7 @@ namespace BTCPayServer.Payments.Bitcoin
public decimal GetValue()
{
return Value?.GetValue(Network as BTCPayNetwork)??Output.Value.ToDecimal(MoneyUnit.BTC);
return Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC);
}
public bool PaymentCompleted(PaymentEntity entity)
@@ -106,4 +107,17 @@ namespace BTCPayServer.Payments.Bitcoin
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.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Rating;
@@ -19,16 +21,19 @@ namespace BTCPayServer.Payments.Bitcoin
ExplorerClientProvider _ExplorerProvider;
private readonly BTCPayNetworkProvider _networkProvider;
private IFeeProviderFactory _FeeRateProviderFactory;
private readonly NBXplorerDashboard _dashboard;
private Services.Wallets.BTCPayWalletProvider _WalletProvider;
public BitcoinLikePaymentHandler(ExplorerClientProvider provider,
BTCPayNetworkProvider networkProvider,
IFeeProviderFactory feeRateProviderFactory,
NBXplorerDashboard dashboard,
Services.Wallets.BTCPayWalletProvider walletProvider)
{
_ExplorerProvider = provider;
_networkProvider = networkProvider;
_FeeRateProviderFactory = feeRateProviderFactory;
_dashboard = dashboard;
_WalletProvider = walletProvider;
}
@@ -74,16 +79,19 @@ namespace BTCPayServer.Payments.Bitcoin
{
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)
{
var limitValueCrypto = Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid);
var limitValueCrypto =
Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid);
if (amount < limitValueCrypto)
{
return "The amount of the invoice is too low to be paid on chain";
}
}
}
return string.Empty;
}
@@ -106,9 +114,12 @@ namespace BTCPayServer.Payments.Bitcoin
var storeBlob = store.GetStoreBlob();
return new Prepare()
{
GetFeeRate = _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget),
GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never ? null
: _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(),
GetFeeRate =
_FeeRateProviderFactory.CreateFeeProvider(network)
.GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget),
GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never
? null
: _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(),
ReserveAddress = _WalletProvider.GetWallet(network)
.ReserveAddressAsync(supportedPaymentMethod.AccountDerivation)
};
@@ -117,6 +128,7 @@ namespace BTCPayServer.Payments.Bitcoin
public override PaymentType PaymentType => PaymentTypes.BTCLike;
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
InvoiceLogs logs,
DerivationSchemeSettings supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store,
BTCPayNetwork network, object preparePaymentObject)
{
@@ -125,13 +137,15 @@ namespace BTCPayServer.Payments.Bitcoin
var prepare = (Prepare)preparePaymentObject;
Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod onchainMethod =
new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod();
onchainMethod.NetworkFeeMode = store.GetStoreBlob().NetworkFeeMode;
var blob = store.GetStoreBlob();
onchainMethod.NetworkFeeMode = blob.NetworkFeeMode;
onchainMethod.FeeRate = await prepare.GetFeeRate;
switch (onchainMethod.NetworkFeeMode)
{
case NetworkFeeMode.Always:
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;
case NetworkFeeMode.Never:
onchainMethod.NetworkFeeRate = FeeRate.Zero;
@@ -139,10 +153,29 @@ namespace BTCPayServer.Payments.Bitcoin
break;
case NetworkFeeMode.MultiplePaymentsOnly:
onchainMethod.NetworkFeeRate = (await prepare.GetNetworkFeeRate);
onchainMethod.NextNetworkFee = Money.Zero;
onchainMethod.NextNetworkFee = Money.Zero;
break;
}
onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString();
onchainMethod.PayjoinEnabled = blob.PayJoinEnabled &&
supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() ==
ScriptPubKeyType.Segwit &&
network.SupportPayJoin;
if (onchainMethod.PayjoinEnabled)
{
var nodeSupport = _dashboard?.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities
?.CanSupportTransactionCheck is true;
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;
}
}

View File

@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using NBXplorer;
using System.Collections.Concurrent;
using BTCPayServer.Controllers;
using NBXplorer.DerivationStrategy;
using BTCPayServer.Events;
using BTCPayServer.Services;
@@ -18,8 +19,10 @@ using NBitcoin;
using NBXplorer.Models;
using BTCPayServer.Payments;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments.PayJoin;
using NBitcoin.Altcoins.Elements;
using NBitcoin.RPC;
using BTCPayServer;
namespace BTCPayServer.Payments.Bitcoin
{
@@ -29,17 +32,18 @@ namespace BTCPayServer.Payments.Bitcoin
public class NBXplorerListener : IHostedService
{
EventAggregator _Aggregator;
private readonly PayJoinRepository _payJoinRepository;
ExplorerClientProvider _ExplorerClients;
IHostApplicationLifetime _Lifetime;
InvoiceRepository _InvoiceRepository;
private TaskCompletionSource<bool> _RunningTask;
private CancellationTokenSource _Cts;
BTCPayWalletProvider _Wallets;
public NBXplorerListener(ExplorerClientProvider explorerClients,
BTCPayWalletProvider wallets,
InvoiceRepository invoiceRepository,
EventAggregator aggregator,
PayJoinRepository payjoinRepository,
IHostApplicationLifetime lifetime)
{
PollInterval = TimeSpan.FromMinutes(1.0);
@@ -47,6 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin
_InvoiceRepository = invoiceRepository;
_ExplorerClients = explorerClients;
_Aggregator = aggregator;
_payJoinRepository = payjoinRepository;
_Lifetime = lifetime;
}
@@ -138,7 +143,6 @@ namespace BTCPayServer.Payments.Bitcoin
switch (newEvent)
{
case NBXplorer.Models.NewBlockEvent evt:
await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices())
.Select(invoiceId => UpdatePaymentStates(wallet, invoiceId))
.ToArray());
@@ -146,11 +150,7 @@ namespace BTCPayServer.Payments.Bitcoin
break;
case NBXplorer.Models.NewTransactionEvent evt:
wallet.InvalidateCache(evt.DerivationStrategy);
_Aggregator.Publish(new NewOnChainTransactionEvent()
{
CryptoCode = wallet.Network.CryptoCode,
NewTransactionEvent = evt
});
foreach (var output in network.GetValidOutputs(evt))
{
var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
@@ -159,8 +159,12 @@ namespace BTCPayServer.Payments.Bitcoin
{
var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy,
output.Item1.KeyPath, output.Item1.ScriptPubKey);
var paymentData = new BitcoinLikePaymentData(address, output.matchedOutput.Value, output.outPoint, evt.TransactionData.Transaction.RBF);
var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
var paymentData = new BitcoinLikePaymentData(address,
output.matchedOutput.Value, output.outPoint,
evt.TransactionData.Transaction.RBF);
var alreadyExist = invoice.GetAllBitcoinPaymentData().Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
if (!alreadyExist)
{
var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network);
@@ -174,6 +178,12 @@ namespace BTCPayServer.Payments.Bitcoin
}
}
_Aggregator.Publish(new NewOnChainTransactionEvent()
{
CryptoCode = wallet.Network.CryptoCode,
NewTransactionEvent = evt
});
break;
default:
Logs.PayServer.LogWarning("Received unknown message from NBXplorer");
@@ -201,22 +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)
{
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, false);
if (invoice == null)
return null;
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)
.ToArray());
.ToArray(), true);
bool? originalPJBroadcasted = null;
bool? originalPJBroadcastable = null;
bool? cjPJBroadcasted = null;
OutPoint[] ourPJOutpoints = null;
foreach (var payment in invoice.GetPayments(wallet.Network))
{
if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike)
@@ -224,15 +231,16 @@ namespace BTCPayServer.Payments.Bitcoin
var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData();
if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx))
continue;
var txId = tx.Transaction.GetHash();
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
var explorerClient = _ExplorerClients.GetExplorerClient(wallet.Network);
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 ||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN ||
!(
@@ -241,10 +249,24 @@ namespace BTCPayServer.Payments.Bitcoin
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
// Happen if RBF is on and fee insufficient
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.");
}
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
catch
@@ -277,6 +299,17 @@ namespace BTCPayServer.Payments.Bitcoin
if (updated)
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);
if (updatedPaymentEntities.Count != 0)
_Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id));
@@ -292,11 +325,13 @@ namespace BTCPayServer.Payments.Bitcoin
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, true);
if (invoice == null)
continue;
var alreadyAccounted = GetAllBitcoinPaymentData(invoice).Select(p => p.Outpoint).ToHashSet();
var alreadyAccounted = invoice.GetAllBitcoinPaymentData().Select(p => p.Outpoint).ToHashSet();
var strategy = GetDerivationStrategy(invoice, network);
if (strategy == null)
continue;
var cryptoId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
var paymentMethod = invoice.GetPaymentMethod(cryptoId).GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod;
if (!invoice.Support(cryptoId))
continue;
var coins = (await wallet.GetUnspentCoins(strategy))
@@ -307,9 +342,10 @@ namespace BTCPayServer.Payments.Bitcoin
var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash);
var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey);
var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint,
transaction.Transaction.RBF);
var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false);
alreadyAccounted.Add(coin.OutPoint);
if (payment != null)

View File

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

View File

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

View File

@@ -0,0 +1,477 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitcoin.Logging;
using NBXplorer;
using NBXplorer.Models;
using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Payments.PayJoin
{
[Route("{cryptoCode}/bpu")]
public class PayJoinEndpointController : ControllerBase
{
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly InvoiceRepository _invoiceRepository;
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly StoreRepository _storeRepository;
private readonly BTCPayWalletProvider _btcPayWalletProvider;
private readonly PayJoinRepository _payJoinRepository;
private readonly EventAggregator _eventAggregator;
private readonly NBXplorerDashboard _dashboard;
private readonly DelayedTransactionBroadcaster _broadcaster;
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider,
PayJoinRepository payJoinRepository,
EventAggregator eventAggregator,
NBXplorerDashboard dashboard,
DelayedTransactionBroadcaster broadcaster)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository;
_explorerClientProvider = explorerClientProvider;
_storeRepository = storeRepository;
_btcPayWalletProvider = btcPayWalletProvider;
_payJoinRepository = payJoinRepository;
_eventAggregator = eventAggregator;
_dashboard = dashboard;
_broadcaster = broadcaster;
}
[HttpPost("")]
[IgnoreAntiforgeryToken]
[EnableCors(CorsPolicies.All)]
[MediaTypeConstraint("text/plain")]
[RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> Submit(string cryptoCode)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network == null)
{
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;
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
{
rawBody = (await reader.ReadToEndAsync()) ?? string.Empty;
}
Transaction originalTx = null;
FeeRate originalFeeRate = null;
bool psbtFormat = true;
if (!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out var psbt))
{
psbtFormat = false;
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx))
return BadRequest(CreatePayjoinError(400, "invalid-format", "invalid transaction or psbt"));
originalTx = tx;
psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork);
psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() {PSBT = psbt})).PSBT;
for (int i = 0; i < tx.Inputs.Count; i++)
{
psbt.Inputs[i].FinalScriptSig = tx.Inputs[i].ScriptSig;
psbt.Inputs[i].FinalScriptWitness = tx.Inputs[i].WitScript;
}
}
else
{
if (!psbt.IsAllFinalized())
return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT should be finalized"));
originalTx = psbt.ExtractTransaction();
}
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 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."));
}
// This is actually not a mandatory check, but we don't want implementers
// 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)
{
return BadRequest(CreatePayjoinError(400, "invalid-transaction",
$"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"));
}
var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
bool paidSomething = false;
Money due = null;
Dictionary<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>();
PSBTOutput paymentOutput = null;
BitcoinAddress paymentAddress = null;
InvoiceEntity invoice = null;
int ourOutputIndex = -1;
DerivationSchemeSettings derivationSchemeSettings = null;
foreach (var output in psbt.Outputs)
{
ourOutputIndex++;
var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {key})).FirstOrDefault();
if (invoice is null)
continue;
derivationSchemeSettings = invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(paymentMethodId)
.SingleOrDefault();
if (derivationSchemeSettings is null)
continue;
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
var paymentDetails =
paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
if (paymentDetails is null || !paymentDetails.PayjoinEnabled)
continue;
if (invoice.GetAllBitcoinPaymentData().Any())
{
return UnprocessableEntity(CreatePayjoinError(422, "already-paid",
$"The invoice this PSBT is paying has already been partially or completely paid"));
}
paidSomething = true;
due = paymentMethod.Calculate().TotalDue - output.Value;
if (due > Money.Zero)
{
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;
}
if (!paidSomething)
{
return BadRequest(CreatePayjoinError(400, "invoice-not-found",
"This transaction does not pay any invoice with payjoin"));
}
if (due is null || due > Money.Zero)
{
return BadRequest(CreatePayjoinError(400, "invoice-not-fully-paid",
"The transaction must pay the whole invoice"));
}
if (selectedUTXOs.Count == 0)
{
await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx);
return StatusCode(503,
CreatePayjoinError(503, "out-of-utxos",
"We do not have any UTXO available for making a payjoin for now"));
}
var originalPaymentValue = paymentOutput.Value;
// Add the original transaction to the payment
var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
paymentOutput.Value,
new OutPoint(originalTx.GetHash(), paymentOutput.Index),
originalTx.RBF);
originalPaymentData.PayjoinInformation = new PayjoinInformation()
{
Type = PayjoinTransactionType.Original, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
};
originalPaymentData.ConfirmationCount = -1;
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 JObject CreatePayjoinError(int httpCode, string errorCode, string friendlyMessage)
{
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".
//
// "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output.
//
// "UIH2": one input is larger than any output. This heuristically implies that no output is a payment, or, to say it better, it implies that this is not a normal wallet-created payment, it's something strange/exotic.
//src: https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2796539
foreach (var availableUtxo in availableUtxos)
{
if (currentTry >= maxTries)
break;
//we can only check against our input as we dont know the value of the rest.
var input = (Money)availableUtxo.Value;
var paymentAmountSum = input + paymentAmount;
if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output))
{
//UIH 1 & 2
continue;
}
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)))
{
if (currentTry >= maxTries)
break;
if (await _payJoinRepository.TryLock(utxo.Outpoint))
{
return new UTXO[] { utxo };
}
currentTry++;
}
return Array.Empty<UTXO>();
}
}
}

View File

@@ -0,0 +1,18 @@
using BTCPayServer.HostedServices;
using BTCPayServer.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace BTCPayServer.Payments.PayJoin
{
public static class PayJoinExtensions
{
public static void AddPayJoinServices(this IServiceCollection services)
{
services.AddSingleton<DelayedTransactionBroadcaster>();
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

@@ -33,12 +33,12 @@ namespace BTCPayServer.Payments
public override IPaymentMethodDetails DeserializePaymentMethodDetails(BTCPayNetworkBase network, string str)
{
return JsonConvert.DeserializeObject<BitcoinLikeOnChainPaymentMethod>(str);
return ((BTCPayNetwork) network).ToObject<BitcoinLikeOnChainPaymentMethod>(str);
}
public override string SerializePaymentMethodDetails(BTCPayNetworkBase network, IPaymentMethodDetails details)
{
return JsonConvert.SerializeObject(details);
return ((BTCPayNetwork) network).ToString((BitcoinLikeOnChainPaymentMethod)details);
}
public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value)

View File

@@ -51,7 +51,8 @@
"BTCPAY_SSHCONNECTION": "root@127.0.0.1:21622",
"BTCPAY_SSHPASSWORD": "opD3i2282D",
"BTCPAY_DEBUGLOG": "debug.log",
"BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc"
"BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc",
"BTCPAY_SOCKSENDPOINT": "localhost:9050"
},
"applicationUrl": "https://localhost:14142/"
}

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
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 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)
{

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

@@ -411,7 +411,8 @@ namespace BTCPayServer.Services.Invoices
var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo();
var subtotalPrice = accounting.TotalDue - accounting.NetworkFee;
var cryptoCode = info.GetId().CryptoCode;
var address = info.GetPaymentMethodDetails()?.GetPaymentDestination();
var details = info.GetPaymentMethodDetails();
var address = details?.GetPaymentDestination();
var exrates = new Dictionary<string, decimal>
{
{ ProductInformation.Currency, cryptoInfo.Rate }
@@ -463,12 +464,18 @@ namespace BTCPayServer.Services.Invoices
{
var minerInfo = new MinerFeeInfo();
minerInfo.TotalFee = accounting.NetworkFee.Satoshi;
minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)info.GetPaymentMethodDetails()).FeeRate
minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)details).FeeRate
.GetFee(1).Satoshi;
dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo);
var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due);
if (((details as BitcoinLikeOnChainPaymentMethod)?.PayjoinEnabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase))
{
bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu";
}
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
{
BIP21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due),
BIP21 = bip21,
};
#pragma warning disable 618

View File

@@ -292,6 +292,21 @@ retry:
}
}
public async Task UpdateInvoicePaymentMethod(string invoiceId, PaymentMethod paymentMethod)
{
using (var context = _ContextFactory.CreateContext())
{
var invoice = await context.Invoices.FindAsync(invoiceId);
if (invoice == null)
return;
var network = paymentMethod.Network;
var invoiceEntity = ToObject(invoice.Blob);
invoiceEntity.SetPaymentMethod(paymentMethod);
invoice.Blob = ToBytes(invoiceEntity, network);
await context.SaveChangesAsync();
}
}
public async Task AddPendingInvoiceIfNotPresent(string invoiceId)
{
using (var context = _ContextFactory.CreateContext())
@@ -299,7 +314,11 @@ retry:
if (!context.PendingInvoices.Any(a => a.Id == invoiceId))
{
context.PendingInvoices.Add(new PendingInvoiceData() { Id = invoiceId });
await context.SaveChangesAsync();
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException) { } // Already exists
}
}
}
@@ -668,7 +687,7 @@ retry:
/// <param name="cryptoCode"></param>
/// <param name="accounted"></param>
/// <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())
{
@@ -686,7 +705,7 @@ retry:
#pragma warning restore CS0618
ReceivedTime = date.UtcDateTime,
Accounted = accounted,
NetworkFee = paymentMethodDetails.GetNextNetworkFee(),
NetworkFee = networkFee ?? paymentMethodDetails.GetNextNetworkFee(),
Network = network
};
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

@@ -1,10 +1,5 @@
using System;
using NBitcoin;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
@@ -16,10 +11,12 @@ namespace BTCPayServer.Services
public class SocketFactory
{
private readonly BTCPayServerOptions _options;
public SocketFactory(BTCPayServerOptions options)
{
_options = options;
}
public async Task<Socket> ConnectAsync(EndPoint endPoint, CancellationToken cancellationToken)
{
DefaultEndpointConnector connector = new DefaultEndpointConnector();
@@ -31,6 +28,7 @@ namespace BTCPayServer.Services
SocksEndpoint = _options.SocksEndpoint
});
}
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
try
{
@@ -40,6 +38,7 @@ namespace BTCPayServer.Services
{
SafeCloseSocket(socket);
}
return socket;
}
@@ -52,6 +51,7 @@ namespace BTCPayServer.Services
catch
{
}
try
{
socket.Dispose();

View File

@@ -23,7 +23,7 @@ namespace BTCPayServer.Services.Wallets
public DateTimeOffset Timestamp { get; set; }
public KeyPath KeyPath { get; set; }
public IMoney Value { get; set; }
public Coin Coin { get; set; }
}
public class NetworkCoins
{
@@ -96,14 +96,44 @@ namespace BTCPayServer.Services.Wallets
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)
throw new ArgumentNullException(nameof(txId));
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;
}
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)
{
_MemoryCache.Remove("CACHEDCOINS_" + strategy.ToString());
@@ -180,7 +210,8 @@ namespace BTCPayServer.Services.Wallets
Value = c.Value,
Timestamp = c.Timestamp,
OutPoint = c.Outpoint,
ScriptPubKey = c.ScriptPubKey
ScriptPubKey = c.ScriptPubKey,
Coin = c.AsCoin(derivationStrategy)
}).ToArray();
}

View File

@@ -8,4 +8,3 @@
<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>
</div>

View File

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

View File

@@ -81,6 +81,11 @@
</select>
<span asp-validation-for="SpeedPolicy" class="text-danger"></span>
</div>
<div class="form-check">
<input asp-for="PayJoinEnabled" type="checkbox" class="form-check-input"/>
<label asp-for="PayJoinEnabled" class="form-check-label"></label>
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
</div>
<div class="form-group">
<h5>Derivation Scheme</h5>
<span>The DerivationScheme represents the destination of the funds received by your invoice on chain.</span>

View File

@@ -27,7 +27,9 @@
<div class="col-md-10">
<div asp-validation-summary="All" class="text-danger"></div>
<form method="post" asp-action="SignWithSeed" asp-route-walletId="@this.Context.GetRouteValue("walletId")">
<input type="hidden" asp-for="OriginalPSBT" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="PayJoinEndpointUrl" />
<div class="form-group">
<label asp-for="SeedOrKey"></label>
<input asp-for="SeedOrKey" class="form-control" />

View File

@@ -30,6 +30,7 @@
<div class="form-group">
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@this.Context.GetRouteValue("walletId")">
<input type="hidden" asp-for="CryptoCode" />
<input type="hidden" asp-for="PayJoinEndpointUrl" />
<input type="hidden" asp-for="NBXSeedAvailable" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="FileName" />

View File

@@ -137,15 +137,28 @@
<div class="row">
<div class="col-lg-12 text-center">
<form method="post" asp-action="WalletPSBTReady" asp-route-walletId="@this.Context.GetRouteValue("walletId")">
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="PSBT" value="@Model.PSBT"/>
<input type="hidden" asp-for="OriginalPSBT"/>
<input type="hidden" asp-for="SigningKey"/>
<input type="hidden" asp-for="SigningKeyPath"/>
<input type="hidden" asp-for="PayJoinEndpointUrl"/>
@if (!Model.HasErrors)
{
<button type="submit" class="btn btn-primary" name="command" value="broadcast">Broadcast it</button>
@if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl))
{
<button type="submit" class="btn btn-primary" name="command" value="payjoin">Broadcast (Payjoin)</button>
<span> or </span>
<button type="submit" class="btn btn-secondary" name="command" value="broadcast">Broadcast (Simple)</button>
}
else
{
<button type="submit" class="btn btn-primary" name="command" value="broadcast">Broadcast it</button>
}
<span> or </span>
}
<button type="submit" class="btn btn-secondary" name="command" value="analyze-psbt">Export as PSBT</button>
</form>
</div>
</div>

View File

@@ -166,6 +166,14 @@
</a>
</div>
}
@if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl))
{
<div class="form-group">
<label asp-for="PayJoinEndpointUrl" class="control-label"></label>
<input asp-for="PayJoinEndpointUrl" class="form-control"/>
<span asp-validation-for="PayJoinEndpointUrl" class="text-danger"></span>
</div>
}
<div class="form-group">
<button id="toggleInputSelection" type="submit" name="command" value="toggle-input-selection" class="btn btn-sm btn-secondary">Toggle coin selection</button>
</div>

View File

@@ -20,10 +20,12 @@
</div>
<div class="row">
<div id="body" class="col-md-10">
<form id="broadcastForm" asp-action="SubmitVault" asp-route-walletId="@this.Context.GetRouteValue("walletId")" method="post" style="display:none;">
<form id="broadcastForm" asp-action="WalletSendVault" asp-route-walletId="@this.Context.GetRouteValue("walletId")" method="post" style="display:none;">
<input type="hidden" id="WalletId" asp-for="WalletId" />
<input type="hidden" id="PSBT" asp-for="PSBT" value="@Model.PSBT"/>
<input type="hidden" id="OriginalPSBT" asp-for="OriginalPSBT" value="@Model.OriginalPSBT"/>
<input type="hidden" asp-for="WebsocketPath" />
<input type="hidden" asp-for="PayJoinEndpointUrl" />
</form>
<div id="vaultPlaceholder"></div>
<button id="vault-confirm" class="btn btn-primary" style="display:none;"></button>

View File

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