mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-15 12:20:16 +01:00
Refactor server-side
This commit is contained in:
parent
9e1ae29600
commit
fd026a9733
36 changed files with 1381 additions and 1204 deletions
|
@ -1,11 +1,11 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="NBitcoin" Version="5.0.20" />
|
<PackageReference Include="NBitcoin" Version="5.0.27" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ namespace BTCPayServer
|
||||||
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
|
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
|
||||||
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("0'") : new KeyPath("1'"),
|
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("0'") : new KeyPath("1'"),
|
||||||
SupportRBF = true,
|
SupportRBF = true,
|
||||||
|
SupportPayJoin = true,
|
||||||
//https://github.com/spesmilo/electrum/blob/11733d6bc271646a00b69ff07657119598874da4/electrum/constants.py
|
//https://github.com/spesmilo/electrum/blob/11733d6bc271646a00b69ff07657119598874da4/electrum/constants.py
|
||||||
ElectrumMapping = NetworkType == NetworkType.Mainnet
|
ElectrumMapping = NetworkType == NetworkType.Mainnet
|
||||||
? new Dictionary<uint, DerivationType>()
|
? new Dictionary<uint, DerivationType>()
|
||||||
|
|
|
@ -61,6 +61,8 @@ namespace BTCPayServer
|
||||||
|
|
||||||
public int MaxTrackedConfirmation { get; internal set; } = 6;
|
public int MaxTrackedConfirmation { get; internal set; } = 6;
|
||||||
public string UriScheme { get; internal set; }
|
public string UriScheme { get; internal set; }
|
||||||
|
public bool SupportPayJoin { get; set; } = false;
|
||||||
|
|
||||||
public KeyPath GetRootKeyPath(DerivationType type)
|
public KeyPath GetRootKeyPath(DerivationType type)
|
||||||
{
|
{
|
||||||
KeyPath baseKey;
|
KeyPath baseKey;
|
||||||
|
|
|
@ -4,6 +4,6 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
<PackageReference Include="NBXplorer.Client" Version="3.0.5" />
|
<PackageReference Include="NBXplorer.Client" Version="3.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -13,12 +13,14 @@ using BTCPayServer.Models.WalletViewModels;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
using BTCPayServer.Payments.Bitcoin;
|
||||||
using BTCPayServer.Payments.PayJoin;
|
using BTCPayServer.Payments.PayJoin;
|
||||||
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
using BTCPayServer.Services.Wallets;
|
using BTCPayServer.Services.Wallets;
|
||||||
using BTCPayServer.Tests.Logging;
|
using BTCPayServer.Tests.Logging;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.Payment;
|
using NBitcoin.Payment;
|
||||||
using NBitpayClient;
|
using NBitpayClient;
|
||||||
|
@ -45,17 +47,20 @@ namespace BTCPayServer.Tests
|
||||||
using (var s = SeleniumTester.Create())
|
using (var s = SeleniumTester.Create())
|
||||||
{
|
{
|
||||||
await s.StartAsync();
|
await s.StartAsync();
|
||||||
|
var invoiceRepository = s.Server.PayTester.GetService<InvoiceRepository>();
|
||||||
|
// var payjoinRepository = s.Server.PayTester.GetService<PayJoinRepository>();
|
||||||
|
// var broadcaster = s.Server.PayTester.GetService<DelayedTransactionBroadcaster>();
|
||||||
s.RegisterNewUser(true);
|
s.RegisterNewUser(true);
|
||||||
var receiver = s.CreateNewStore();
|
var receiver = s.CreateNewStore();
|
||||||
var receiverSeed = s.GenerateWallet("BTC", "", true, true);
|
var receiverSeed = s.GenerateWallet("BTC", "", true, true);
|
||||||
var receiverWalletId = new WalletId(receiver.storeId, "BTC");
|
var receiverWalletId = new WalletId(receiver.storeId, "BTC");
|
||||||
var payJoinStateProvider = s.Server.PayTester.GetService<PayJoinStateProvider>();
|
|
||||||
//payjoin is not enabled by default.
|
//payjoin is not enabled by default.
|
||||||
var invoiceId = s.CreateInvoice(receiver.storeId);
|
var invoiceId = s.CreateInvoice(receiver.storeId);
|
||||||
s.GoToInvoiceCheckout(invoiceId);
|
s.GoToInvoiceCheckout(invoiceId);
|
||||||
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
||||||
.GetAttribute("href");
|
.GetAttribute("href");
|
||||||
Assert.DoesNotContain("bpu", bip21);
|
Assert.DoesNotContain("bpu=", bip21);
|
||||||
|
|
||||||
s.GoToHome();
|
s.GoToHome();
|
||||||
s.GoToStore(receiver.storeId);
|
s.GoToStore(receiver.storeId);
|
||||||
|
@ -74,19 +79,20 @@ namespace BTCPayServer.Tests
|
||||||
s.GoToInvoiceCheckout(invoiceId);
|
s.GoToInvoiceCheckout(invoiceId);
|
||||||
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
||||||
.GetAttribute("href");
|
.GetAttribute("href");
|
||||||
Assert.Contains("bpu", bip21);
|
Assert.Contains("bpu=", bip21);
|
||||||
|
|
||||||
s.GoToWalletSend(senderWalletId);
|
s.GoToWalletSend(senderWalletId);
|
||||||
s.Driver.FindElement(By.Id("bip21parse")).Click();
|
s.Driver.FindElement(By.Id("bip21parse")).Click();
|
||||||
s.Driver.SwitchTo().Alert().SendKeys(bip21);
|
s.Driver.SwitchTo().Alert().SendKeys(bip21);
|
||||||
s.Driver.SwitchTo().Alert().Accept();
|
s.Driver.SwitchTo().Alert().Accept();
|
||||||
Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
|
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
|
||||||
s.Driver.ScrollTo(By.Id("SendMenu"));
|
s.Driver.ScrollTo(By.Id("SendMenu"));
|
||||||
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
||||||
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
|
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
|
||||||
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(async () =>
|
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
|
||||||
{
|
{
|
||||||
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
|
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
|
||||||
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
//no funds in receiver wallet to do payjoin
|
//no funds in receiver wallet to do payjoin
|
||||||
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning);
|
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning);
|
||||||
|
@ -111,32 +117,181 @@ namespace BTCPayServer.Tests
|
||||||
s.Driver.FindElement(By.Id("bip21parse")).Click();
|
s.Driver.FindElement(By.Id("bip21parse")).Click();
|
||||||
s.Driver.SwitchTo().Alert().SendKeys(bip21);
|
s.Driver.SwitchTo().Alert().SendKeys(bip21);
|
||||||
s.Driver.SwitchTo().Alert().Accept();
|
s.Driver.SwitchTo().Alert().Accept();
|
||||||
Assert.False(string.IsNullOrEmpty( s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
|
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
|
||||||
s.Driver.ScrollTo(By.Id("SendMenu"));
|
s.Driver.ScrollTo(By.Id("SendMenu"));
|
||||||
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
||||||
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
|
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
|
||||||
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(async () =>
|
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
|
||||||
{
|
{
|
||||||
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
|
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
|
||||||
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Success);
|
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Success);
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var invoice = await invoiceRepository.GetInvoice(invoiceId);
|
||||||
|
var payments = invoice.GetPayments().ToArray();
|
||||||
|
var originalPayment = payments
|
||||||
|
.Single(p =>
|
||||||
|
p.GetCryptoPaymentData() is BitcoinLikePaymentData pd &&
|
||||||
|
pd.PayjoinInformation?.Type is PayjoinTransactionType.Original);
|
||||||
|
var coinjoinPayment = payments
|
||||||
|
.Single(p =>
|
||||||
|
p.GetCryptoPaymentData() is BitcoinLikePaymentData pd &&
|
||||||
|
pd.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin);
|
||||||
|
Assert.Equal(-1, ((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).ConfirmationCount);
|
||||||
|
Assert.Equal(0, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).ConfirmationCount);
|
||||||
|
Assert.False(originalPayment.Accounted);
|
||||||
|
Assert.True(coinjoinPayment.Accounted);
|
||||||
|
Assert.Equal(((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).Value,
|
||||||
|
((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).Value);
|
||||||
|
});
|
||||||
|
|
||||||
await TestUtils.EventuallyAsync(async () =>
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
{
|
{
|
||||||
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
|
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
|
||||||
|
var dto = invoice.EntityToDTO();
|
||||||
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
|
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
|
||||||
});
|
});
|
||||||
s.GoToInvoices();
|
s.GoToInvoices();
|
||||||
paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value"));
|
paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value"));
|
||||||
Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase));
|
Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Integration", "Integration")]
|
||||||
|
public async Task CanUseBIP79FeeCornerCase()
|
||||||
|
{
|
||||||
|
using (var tester = ServerTester.Create())
|
||||||
|
{
|
||||||
|
await tester.StartAsync();
|
||||||
|
var broadcaster = tester.PayTester.GetService<DelayedTransactionBroadcaster>();
|
||||||
|
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
|
||||||
|
broadcaster.Disable();
|
||||||
|
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||||
|
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||||
|
var cashCow = tester.ExplorerNode;
|
||||||
|
cashCow.Generate(2); // get some money in case
|
||||||
|
|
||||||
|
var senderUser = tester.NewAccount();
|
||||||
|
senderUser.GrantAccess(true);
|
||||||
|
senderUser.RegisterDerivationScheme("BTC", true);
|
||||||
|
|
||||||
|
var receiverUser = tester.NewAccount();
|
||||||
|
receiverUser.GrantAccess(true);
|
||||||
|
receiverUser.RegisterDerivationScheme("BTC", true, true);
|
||||||
|
await receiverUser.EnablePayJoin();
|
||||||
|
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||||
|
string lastInvoiceId = null;
|
||||||
|
|
||||||
|
var vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "not-enough-money");
|
||||||
|
async Task<PSBT> RunVector()
|
||||||
|
{
|
||||||
|
var coin = await senderUser.ReceiveUTXO(vector.SpentCoin, network);
|
||||||
|
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() {Price = vector.InvoiceAmount.ToDecimal(MoneyUnit.BTC), Currency = "BTC", FullNotifications = true});
|
||||||
|
lastInvoiceId = invoice.Id;
|
||||||
|
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
|
||||||
|
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
|
||||||
|
txBuilder.AddCoins(coin);
|
||||||
|
txBuilder.Send(invoiceAddress, vector.Paid);
|
||||||
|
txBuilder.SendFees(vector.Fee);
|
||||||
|
txBuilder.SetChange(await senderUser.GetNewAddress(network));
|
||||||
|
var psbt = txBuilder.BuildPSBT(false);
|
||||||
|
psbt = await senderUser.Sign(psbt);
|
||||||
|
var pj = await senderUser.SubmitPayjoin(invoice, psbt, vector.ExpectedError);
|
||||||
|
if (vector.ExpectLocked)
|
||||||
|
{
|
||||||
|
Assert.True(await payjoinRepository.TryUnlock(receiverCoin.Outpoint));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.False(await payjoinRepository.TryUnlock(receiverCoin.Outpoint));
|
||||||
|
}
|
||||||
|
return pj;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("Here we send exactly the right amount. This should fails as\n" +
|
||||||
|
"there is not enough to pay the additional payjoin input. (going below the min relay fee");
|
||||||
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "not-enough-money");
|
||||||
|
await RunVector();
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("We don't pay enough");
|
||||||
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), ExpectLocked: false, ExpectedError: "invoice-not-fully-paid");
|
||||||
|
await RunVector();
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("We pay correctly");
|
||||||
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), ExpectLocked: true, ExpectedError: null as string);
|
||||||
|
await RunVector();
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("We pay correctly, but no utxo\n" +
|
||||||
|
"However, this has the side effect of having the receiver broadcasting the original tx");
|
||||||
|
await payjoinRepository.TryLock(receiverCoin.Outpoint);
|
||||||
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), ExpectLocked: true, ExpectedError: "out-of-utxos");
|
||||||
|
await RunVector();
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme);
|
||||||
|
Assert.Equal(2, coins.Length);
|
||||||
|
var newCoin = coins.First(c => (Money)c.Value == Money.Satoshis(500));
|
||||||
|
await payjoinRepository.TryLock(newCoin.OutPoint);
|
||||||
|
});
|
||||||
|
var originalSenderUser = senderUser;
|
||||||
|
retry:
|
||||||
|
// Additional fee is 96 , minrelaytx is 294
|
||||||
|
// We pay correctly, fees partially taken from what is overpaid
|
||||||
|
// We paid 510, the receiver pay 10 sat
|
||||||
|
// The send pay remaining 86 sat from his pocket
|
||||||
|
// So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back)
|
||||||
|
Logs.Tester.LogInformation($"Check if we can take fee on overpaid utxo{(senderUser == receiverUser ? " (to self)" : "")}");
|
||||||
|
vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), ExpectLocked: true, ExpectedError: null as string);
|
||||||
|
var proposedPSBT = await RunVector();
|
||||||
|
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
||||||
|
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(500) + receiverCoin.Amount);
|
||||||
|
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(294));
|
||||||
|
proposedPSBT = await senderUser.Sign(proposedPSBT);
|
||||||
|
proposedPSBT = proposedPSBT.Finalize();
|
||||||
|
var explorerClient = tester.PayTester.GetService<ExplorerClientProvider>().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode);
|
||||||
|
var result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction());
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Logs.Tester.LogInformation($"We broadcasted the payjoin {proposedPSBT.ExtractTransaction().GetHash()}");
|
||||||
|
Logs.Tester.LogInformation($"Let's make sure that the coinjoin is not over paying, since the 10 overpaid sats have gone to fee");
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var invoice = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(lastInvoiceId);
|
||||||
|
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
|
||||||
|
Assert.Equal(InvoiceExceptionStatus.None, invoice.ExceptionStatus);
|
||||||
|
var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme);
|
||||||
|
foreach (var coin in coins)
|
||||||
|
await payjoinRepository.TryLock(coin.OutPoint);
|
||||||
|
});
|
||||||
|
tester.ExplorerNode.Generate(1);
|
||||||
|
receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||||
|
|
||||||
|
if (senderUser != receiverUser)
|
||||||
|
{
|
||||||
|
Logs.Tester.LogInformation("Let's do the same, this time paying to ourselves");
|
||||||
|
senderUser = receiverUser;
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
senderUser = originalSenderUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//the state should now hold that there is an ongoing utxo
|
// Same as above. Except the sender send one satoshi less, so the change
|
||||||
var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
|
// output get below dust and should be removed completely.
|
||||||
Assert.NotNull(receiverWalletPayJoinState);
|
vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), ExpectLocked: true, ExpectedError: null as string);
|
||||||
Assert.Single(receiverWalletPayJoinState.GetRecords());
|
proposedPSBT = await RunVector();
|
||||||
Assert.Equal(0.02m, receiverWalletPayJoinState.GetRecords().First().ContributedAmount);
|
var output = Assert.Single(proposedPSBT.Outputs);
|
||||||
Assert.Single(receiverWalletPayJoinState.GetRecords().First().CoinsExposed);
|
// With the output removed, the user should have largely pay all the needed fee
|
||||||
|
Assert.Equal(Money.Satoshis(510) + receiverCoin.Amount, output.Value);
|
||||||
|
proposedPSBT = await senderUser.Sign(proposedPSBT);
|
||||||
|
proposedPSBT = proposedPSBT.Finalize();
|
||||||
|
explorerClient = tester.PayTester.GetService<ExplorerClientProvider>().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode);
|
||||||
|
result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction(), true);
|
||||||
|
Assert.True(result.Success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,7 +304,7 @@ namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
await tester.StartAsync();
|
await tester.StartAsync();
|
||||||
|
|
||||||
var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>();
|
////var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>();
|
||||||
var btcPayNetwork = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
var btcPayNetwork = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||||
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(btcPayNetwork);
|
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(btcPayNetwork);
|
||||||
var cashCow = tester.ExplorerNode;
|
var cashCow = tester.ExplorerNode;
|
||||||
|
@ -181,40 +336,17 @@ namespace BTCPayServer.Tests
|
||||||
//give the cow some cash
|
//give the cow some cash
|
||||||
await cashCow.GenerateAsync(1);
|
await cashCow.GenerateAsync(1);
|
||||||
//let's get some more utxos first
|
//let's get some more utxos first
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
await receiverUser.ReceiveUTXO(Money.Coins(0.011m), btcPayNetwork);
|
||||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
await receiverUser.ReceiveUTXO(Money.Coins(0.012m), btcPayNetwork);
|
||||||
new Money(0.011m, MoneyUnit.BTC)));
|
await receiverUser.ReceiveUTXO(Money.Coins(0.013m), btcPayNetwork);
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
await receiverUser.ReceiveUTXO(Money.Coins(0.014m), btcPayNetwork);
|
||||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
await senderUser.ReceiveUTXO(Money.Coins(0.021m), btcPayNetwork);
|
||||||
new Money(0.012m, MoneyUnit.BTC)));
|
await senderUser.ReceiveUTXO(Money.Coins(0.022m), btcPayNetwork);
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
await senderUser.ReceiveUTXO(Money.Coins(0.023m), btcPayNetwork);
|
||||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
await senderUser.ReceiveUTXO(Money.Coins(0.024m), btcPayNetwork);
|
||||||
new Money(0.013m, MoneyUnit.BTC)));
|
await senderUser.ReceiveUTXO(Money.Coins(0.025m), btcPayNetwork);
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
await senderUser.ReceiveUTXO(Money.Coins(0.026m), btcPayNetwork);
|
||||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
var senderChange = await senderUser.GetNewAddress(btcPayNetwork);
|
||||||
new Money(0.021m, MoneyUnit.BTC)));
|
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
|
||||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
|
||||||
new Money(0.022m, MoneyUnit.BTC)));
|
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
|
||||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
|
||||||
new Money(0.023m, MoneyUnit.BTC)));
|
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
|
||||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
|
||||||
new Money(0.024m, MoneyUnit.BTC)));
|
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
|
||||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
|
||||||
new Money(0.025m, MoneyUnit.BTC)));
|
|
||||||
Assert.NotNull(await cashCow.SendToAddressAsync(
|
|
||||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
|
||||||
new Money(0.026m, MoneyUnit.BTC)));
|
|
||||||
|
|
||||||
await cashCow.SendToAddressAsync(
|
|
||||||
(await btcPayWallet.ReserveAddressAsync(senderUser.DerivationScheme)).Address,
|
|
||||||
new Money(0.014m, MoneyUnit.BTC));
|
|
||||||
|
|
||||||
|
|
||||||
var senderChange = (await btcPayWallet.GetChangeAddressAsync(senderUser.DerivationScheme)).Item1;
|
|
||||||
|
|
||||||
//Let's start the harassment
|
//Let's start the harassment
|
||||||
invoice = receiverUser.BitPay.CreateInvoice(
|
invoice = receiverUser.BitPay.CreateInvoice(
|
||||||
|
@ -222,13 +354,11 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21,
|
var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21,
|
||||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||||
var endpoint = parsedBip21.UnknowParameters["bpu"];
|
|
||||||
|
|
||||||
var invoice2 = receiverUser.BitPay.CreateInvoice(
|
var invoice2 = receiverUser.BitPay.CreateInvoice(
|
||||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||||
var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21,
|
var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21,
|
||||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||||
var endpoint2 = secondInvoiceParsedBip21.UnknowParameters["bpu"];
|
|
||||||
|
|
||||||
var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId);
|
var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId);
|
||||||
var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
|
var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
|
||||||
|
@ -293,39 +423,22 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
//Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all
|
//Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all
|
||||||
//Result: reject
|
//Result: reject
|
||||||
Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint,
|
// Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint,
|
||||||
new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
// new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
||||||
|
|
||||||
//Attempt 2: Create two transactions using different inputs and send them to the same invoice.
|
//Attempt 2: Create two transactions using different inputs and send them to the same invoice.
|
||||||
//Result: Second Tx should be rejected.
|
//Result: Second Tx should be rejected.
|
||||||
var Invoice1Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint,
|
var Invoice1Coin1ResponseTx = await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork);
|
||||||
new StringContent(Invoice1Coin1.ToHex(), Encoding.UTF8, "text/plain"));
|
await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork, "already-paid");
|
||||||
|
|
||||||
var Invoice1Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint,
|
|
||||||
new StringContent(Invoice1Coin2.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
|
|
||||||
Assert.True(Invoice1Coin1Response.IsSuccessStatusCode);
|
|
||||||
Assert.False(Invoice1Coin2Response.IsSuccessStatusCode);
|
|
||||||
var Invoice1Coin1ResponseTx =
|
|
||||||
Transaction.Parse(await Invoice1Coin1Response.Content.ReadAsStringAsync(), n);
|
|
||||||
var contributedInputsInvoice1Coin1ResponseTx =
|
var contributedInputsInvoice1Coin1ResponseTx =
|
||||||
Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut);
|
Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut);
|
||||||
Assert.Single(contributedInputsInvoice1Coin1ResponseTx);
|
Assert.Single(contributedInputsInvoice1Coin1ResponseTx);
|
||||||
|
|
||||||
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted
|
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted
|
||||||
//Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
|
//Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
|
||||||
|
await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, "inputs-already-used");
|
||||||
|
var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork);
|
||||||
|
|
||||||
var Invoice2Coin1Response = await tester.PayTester.HttpClient.PostAsync(endpoint2,
|
|
||||||
new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
|
|
||||||
var Invoice2Coin2Response = await tester.PayTester.HttpClient.PostAsync(endpoint2,
|
|
||||||
new StringContent(Invoice2Coin2.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
|
|
||||||
Assert.False(Invoice2Coin1Response.IsSuccessStatusCode);
|
|
||||||
Assert.True(Invoice2Coin2Response.IsSuccessStatusCode);
|
|
||||||
|
|
||||||
var Invoice2Coin2ResponseTx =
|
|
||||||
Transaction.Parse(await Invoice2Coin2Response.Content.ReadAsStringAsync(), n);
|
|
||||||
var contributedInputsInvoice2Coin2ResponseTx =
|
var contributedInputsInvoice2Coin2ResponseTx =
|
||||||
Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut);
|
Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut);
|
||||||
Assert.Single(contributedInputsInvoice2Coin2ResponseTx);
|
Assert.Single(contributedInputsInvoice2Coin2ResponseTx);
|
||||||
|
@ -337,14 +450,12 @@ namespace BTCPayServer.Tests
|
||||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||||
var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21,
|
var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21,
|
||||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||||
var invoice3Endpoint = invoice3ParsedBip21.UnknowParameters["bpu"];
|
|
||||||
|
|
||||||
|
|
||||||
var invoice4 = receiverUser.BitPay.CreateInvoice(
|
var invoice4 = receiverUser.BitPay.CreateInvoice(
|
||||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||||
var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21,
|
var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21,
|
||||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||||
var invoice4Endpoint = invoice4ParsedBip21.UnknowParameters["bpu"];
|
|
||||||
|
|
||||||
|
|
||||||
var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||||
|
@ -356,14 +467,8 @@ namespace BTCPayServer.Tests
|
||||||
.SendEstimatedFees(new FeeRate(100m))
|
.SendEstimatedFees(new FeeRate(100m))
|
||||||
.BuildTransaction(true);
|
.BuildTransaction(true);
|
||||||
|
|
||||||
var Invoice3Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice3Endpoint,
|
await senderUser.SubmitPayjoin(invoice3, Invoice3AndInvoice4Coin3, btcPayNetwork);
|
||||||
new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain"));
|
await senderUser.SubmitPayjoin(invoice4, Invoice3AndInvoice4Coin3, btcPayNetwork, "already-paid");
|
||||||
|
|
||||||
var Invoice4Coin3Response = await tester.PayTester.HttpClient.PostAsync(invoice4Endpoint,
|
|
||||||
new StringContent(Invoice3AndInvoice4Coin3.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
|
|
||||||
Assert.True(Invoice3Coin3Response.IsSuccessStatusCode);
|
|
||||||
Assert.False(Invoice4Coin3Response.IsSuccessStatusCode);
|
|
||||||
|
|
||||||
//Attempt 5: Make tx that pays invoice 5 with 2 outputs
|
//Attempt 5: Make tx that pays invoice 5 with 2 outputs
|
||||||
//Result: proposed tx consolidates the outputs
|
//Result: proposed tx consolidates the outputs
|
||||||
|
@ -372,7 +477,6 @@ namespace BTCPayServer.Tests
|
||||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||||
var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21,
|
var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21,
|
||||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||||
var invoice5Endpoint = invoice5ParsedBip21.UnknowParameters["bpu"];
|
|
||||||
|
|
||||||
var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||||
.SetChange(senderChange)
|
.SetChange(senderChange)
|
||||||
|
@ -383,60 +487,9 @@ namespace BTCPayServer.Tests
|
||||||
.SendEstimatedFees(new FeeRate(100m));
|
.SendEstimatedFees(new FeeRate(100m));
|
||||||
|
|
||||||
var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true);
|
var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true);
|
||||||
|
var Invoice5Coin4ResponseTx = await senderUser.SubmitPayjoin(invoice5, Invoice5Coin4, btcPayNetwork);
|
||||||
var Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
|
||||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
|
|
||||||
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
|
|
||||||
var Invoice5Coin4ResponseTx =
|
|
||||||
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n);
|
|
||||||
Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address));
|
Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address));
|
||||||
|
|
||||||
//Attempt 6: submit the same tx over and over in the hopes of getting new utxos
|
|
||||||
//Result: same tx gets sent back
|
|
||||||
for (int i = 0; i < 5; i++)
|
|
||||||
{
|
|
||||||
var Invoice5Coin4Response2 = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
|
||||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
if (!Invoice5Coin4Response2.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Logs.Tester.LogInformation(
|
|
||||||
$"Failed on try {i + 1} with {await Invoice5Coin4Response2.Content.ReadAsStringAsync()}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.True(Invoice5Coin4Response2.IsSuccessStatusCode);
|
|
||||||
var Invoice5Coin4Response2Tx =
|
|
||||||
Transaction.Parse(await Invoice5Coin4Response2.Content.ReadAsStringAsync(), n);
|
|
||||||
Assert.Equal(Invoice5Coin4ResponseTx.GetHash(), Invoice5Coin4Response2Tx.GetHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
//Attempt 7: send the payjoin porposed tx to the endpoint
|
|
||||||
//Result: get same tx sent back as is
|
|
||||||
Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
|
||||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
|
|
||||||
Assert.Equal(Invoice5Coin4ResponseTx.GetHash(),
|
|
||||||
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash());
|
|
||||||
|
|
||||||
//Attempt 8: sign the payjoin and send it back to the endpoint
|
|
||||||
//Result: get same tx sent back as is
|
|
||||||
var Invoice5Coin4ResponseTxSigned = Invoice5Coin4TxBuilder.SignTransaction(Invoice5Coin4ResponseTx);
|
|
||||||
Invoice5Coin4Response = await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
|
||||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
Assert.True(Invoice5Coin4Response.IsSuccessStatusCode);
|
|
||||||
Assert.Equal(Invoice5Coin4ResponseTxSigned.GetHash(),
|
|
||||||
Transaction.Parse(await Invoice5Coin4Response.Content.ReadAsStringAsync(), n).GetHash());
|
|
||||||
|
|
||||||
//Attempt 9: broadcast a payjoin tx, then try to submit both original tx and the payjoin itself again
|
|
||||||
//Result: fails
|
|
||||||
await tester.ExplorerClient.BroadcastAsync(Invoice5Coin4ResponseTxSigned);
|
|
||||||
|
|
||||||
Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
|
||||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
|
||||||
|
|
||||||
Assert.False((await tester.PayTester.HttpClient.PostAsync(invoice5Endpoint,
|
|
||||||
new StringContent(Invoice5Coin4.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
|
||||||
|
|
||||||
//Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again
|
//Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again
|
||||||
//Result: same tx gets sent back
|
//Result: same tx gets sent back
|
||||||
|
|
||||||
|
@ -449,7 +502,6 @@ namespace BTCPayServer.Tests
|
||||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||||
var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21,
|
var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21,
|
||||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||||
var invoice6Endpoint = invoice6ParsedBip21.UnknowParameters["bpu"];
|
|
||||||
|
|
||||||
var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||||
.SetChange(senderChange)
|
.SetChange(senderChange)
|
||||||
|
@ -462,26 +514,22 @@ namespace BTCPayServer.Tests
|
||||||
var invoice6Coin5 = invoice6Coin5TxBuilder
|
var invoice6Coin5 = invoice6Coin5TxBuilder
|
||||||
.BuildTransaction(true);
|
.BuildTransaction(true);
|
||||||
|
|
||||||
var Invoice6Coin5Response1 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
|
var Invoice6Coin5Response1Tx =await senderUser.SubmitPayjoin(invoice6, invoice6Coin5, btcPayNetwork);
|
||||||
new StringContent(invoice6Coin5.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
Assert.True(Invoice6Coin5Response1.IsSuccessStatusCode);
|
|
||||||
var Invoice6Coin5Response1Tx =
|
|
||||||
Transaction.Parse(await Invoice6Coin5Response1.Content.ReadAsStringAsync(), n);
|
|
||||||
var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx);
|
var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx);
|
||||||
//broadcast the first payjoin
|
//broadcast the first payjoin
|
||||||
await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned);
|
await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned);
|
||||||
|
|
||||||
invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m));
|
// invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m));
|
||||||
var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder
|
// var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder
|
||||||
.BuildTransaction(true);
|
// .BuildTransaction(true);
|
||||||
|
//
|
||||||
var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
|
// var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
|
||||||
new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain"));
|
// new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain"));
|
||||||
Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode);
|
// Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode);
|
||||||
var Invoice6Coin5Response3Tx =
|
// var Invoice6Coin5Response3Tx =
|
||||||
Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n);
|
// Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n);
|
||||||
Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin =>
|
// Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin =>
|
||||||
Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut)));
|
// Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut)));
|
||||||
|
|
||||||
//Attempt 11:
|
//Attempt 11:
|
||||||
//send tx with rbt, broadcast payjoin,
|
//send tx with rbt, broadcast payjoin,
|
||||||
|
@ -497,7 +545,6 @@ namespace BTCPayServer.Tests
|
||||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||||
var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21,
|
var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21,
|
||||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||||
var invoice7Endpoint = invoice7ParsedBip21.UnknowParameters["bpu"];
|
|
||||||
|
|
||||||
var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||||
.SetChange(senderChange)
|
.SetChange(senderChange)
|
||||||
|
@ -510,26 +557,26 @@ namespace BTCPayServer.Tests
|
||||||
var invoice7Coin6Tx = invoice7Coin6TxBuilder
|
var invoice7Coin6Tx = invoice7Coin6TxBuilder
|
||||||
.BuildTransaction(true);
|
.BuildTransaction(true);
|
||||||
|
|
||||||
var invoice7Coin6Response1 = await tester.PayTester.HttpClient.PostAsync(invoice7Endpoint,
|
var invoice7Coin6Response1Tx = await senderUser.SubmitPayjoin(invoice7, invoice7Coin6Tx, btcPayNetwork);
|
||||||
new StringContent(invoice7Coin6Tx.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
Assert.True(invoice7Coin6Response1.IsSuccessStatusCode);
|
|
||||||
var invoice7Coin6Response1Tx =
|
|
||||||
Transaction.Parse(await invoice7Coin6Response1.Content.ReadAsStringAsync(), n);
|
|
||||||
var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx);
|
var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx);
|
||||||
var contributedInputsInvoice7Coin6Response1TxSigned =
|
var contributedInputsInvoice7Coin6Response1TxSigned =
|
||||||
Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut);
|
Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut);
|
||||||
|
|
||||||
|
|
||||||
var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
|
////var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
|
||||||
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
|
////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
|
||||||
//broadcast the payjoin
|
//broadcast the payjoin
|
||||||
await tester.WaitForEvent<InvoiceEvent>(async () =>
|
var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned));
|
||||||
{
|
Assert.True(res.Success);
|
||||||
var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned));
|
|
||||||
Assert.True(res.Success);
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);
|
// Paid with coinjoin
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
|
||||||
|
Assert.Equal(InvoiceStatus.Paid, invoiceEntity.Status);
|
||||||
|
Assert.Contains(invoiceEntity.GetPayments(), p => p.Accounted && ((BitcoinLikePaymentData)p.GetCryptoPaymentData()).PayjoinInformation.Type is PayjoinTransactionType.Coinjoin);
|
||||||
|
});
|
||||||
|
////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);
|
||||||
|
|
||||||
var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||||
.SetChange(senderChange)
|
.SetChange(senderChange)
|
||||||
|
@ -542,23 +589,25 @@ namespace BTCPayServer.Tests
|
||||||
.BuildTransaction(true);
|
.BuildTransaction(true);
|
||||||
|
|
||||||
//broadcast the "rbf cancel" tx
|
//broadcast the "rbf cancel" tx
|
||||||
await tester.WaitForEvent<InvoiceEvent>(async () =>
|
res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2));
|
||||||
|
Assert.True(res.Success);
|
||||||
|
|
||||||
|
// Make a block, this should put back the invoice to new
|
||||||
|
var blockhash = tester.ExplorerNode.Generate(1)[0];
|
||||||
|
Assert.NotNull(await tester.ExplorerNode.GetRawTransactionAsync(invoice7Coin6Tx2.GetHash(), blockhash));
|
||||||
|
Assert.Null(await tester.ExplorerNode.GetRawTransactionAsync(Invoice7Coin6Response1TxSigned.GetHash(), blockhash, false));
|
||||||
|
// Now we should return to New
|
||||||
|
OutPoint ourOutpoint = null;
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
{
|
{
|
||||||
var res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2));
|
var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
|
||||||
Assert.True(res.Success);
|
Assert.Equal(InvoiceStatus.New, invoiceEntity.Status);
|
||||||
|
Assert.True(invoiceEntity.GetPayments().All(p => !p.Accounted));
|
||||||
|
ourOutpoint = invoiceEntity.GetAllBitcoinPaymentData().First().PayjoinInformation.ContributedOutPoints[0];
|
||||||
});
|
});
|
||||||
//btcpay does not know of replaced txs where the outputs do not pay it(double spends using RBF to "cancel" a payment)
|
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
|
||||||
Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);
|
// The outpoint should now be available for next pj selection
|
||||||
|
Assert.False(await payjoinRepository.TryUnlock(ourOutpoint));
|
||||||
//hijack our automated payjoin original broadcaster and force it to broadcast all, now
|
|
||||||
var payJoinTransactionBroadcaster = tester.PayTester.ServiceProvider.GetServices<IHostedService>()
|
|
||||||
.OfType<PayJoinTransactionBroadcaster>().First();
|
|
||||||
await payJoinTransactionBroadcaster.BroadcastStaleTransactions(TimeSpan.Zero, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.DoesNotContain(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
|
|
||||||
//all our failed payjoins are clear and any exposed utxo has been moved to the prioritized list
|
|
||||||
Assert.Contains(receiverWalletPayJoinState.GetExposedCoins(), receivedCoin =>
|
|
||||||
receivedCoin.OutPoint == contributedInputsInvoice7Coin6Response1TxSigned.PrevOut);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,12 @@ using BTCPayServer.Data;
|
||||||
using BTCPayServer.Services.Rates;
|
using BTCPayServer.Services.Rates;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
using BTCPayServer.Payments.Bitcoin;
|
||||||
using BTCPayServer.Payments.Lightning;
|
using BTCPayServer.Payments.Lightning;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
|
using Logs = BTCPayServer.Tests.Logging.Logs;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
|
@ -41,8 +43,8 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
currencyPairRateResult.Add(new CurrencyPair("USD", "BTC"), Task.FromResult(rateResultUSDBTC));
|
currencyPairRateResult.Add(new CurrencyPair("USD", "BTC"), Task.FromResult(rateResultUSDBTC));
|
||||||
currencyPairRateResult.Add(new CurrencyPair("BTC", "USD"), Task.FromResult(rateResultBTCUSD));
|
currencyPairRateResult.Add(new CurrencyPair("BTC", "USD"), Task.FromResult(rateResultBTCUSD));
|
||||||
|
InvoiceLogs logs = new InvoiceLogs();
|
||||||
handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null);
|
handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null, null);
|
||||||
handlerLN = new LightningLikePaymentHandler(null, null, networkProvider, null);
|
handlerLN = new LightningLikePaymentHandler(null, null, networkProvider, null);
|
||||||
|
|
||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
|
|
|
@ -144,16 +144,17 @@ namespace BTCPayServer.Tests
|
||||||
await CustomerLightningD.Pay(bolt11);
|
await CustomerLightningD.Pay(bolt11);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task WaitForEvent<T>(Func<Task> action)
|
public async Task<T> WaitForEvent<T>(Func<Task> action)
|
||||||
{
|
{
|
||||||
var tcs = new TaskCompletionSource<bool>();
|
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt =>
|
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt =>
|
||||||
{
|
{
|
||||||
tcs.SetResult(true);
|
tcs.TrySetResult(evt);
|
||||||
});
|
});
|
||||||
await action.Invoke();
|
await action.Invoke();
|
||||||
await tcs.Task;
|
var result = await tcs.Task;
|
||||||
sub.Dispose();
|
sub.Dispose();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ILightningClient CustomerLightningD { get; set; }
|
public ILightningClient CustomerLightningD { get; set; }
|
||||||
|
|
|
@ -8,8 +8,10 @@ using NBitcoin;
|
||||||
using NBitpayClient;
|
using NBitpayClient;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Amazon.S3.Model;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using NBXplorer.DerivationStrategy;
|
using NBXplorer.DerivationStrategy;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
|
@ -21,12 +23,18 @@ using BTCPayServer.Data;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using NBXplorer.Models;
|
using NBXplorer.Models;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Stores;
|
||||||
|
using BTCPayServer.Services.Wallets;
|
||||||
|
using NBitcoin.Payment;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
public class TestAccount
|
public class TestAccount
|
||||||
{
|
{
|
||||||
ServerTester parent;
|
ServerTester parent;
|
||||||
|
|
||||||
public TestAccount(ServerTester parent)
|
public TestAccount(ServerTester parent)
|
||||||
{
|
{
|
||||||
this.parent = parent;
|
this.parent = parent;
|
||||||
|
@ -51,7 +59,8 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
public Task<BTCPayServerClient> CreateClient()
|
public Task<BTCPayServerClient> CreateClient()
|
||||||
{
|
{
|
||||||
return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email, RegisterDetails.Password));
|
return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email,
|
||||||
|
RegisterDetails.Password));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BTCPayServerClient> CreateClient(params string[] permissions)
|
public async Task<BTCPayServerClient> CreateClient(params string[] permissions)
|
||||||
|
@ -60,11 +69,12 @@ namespace BTCPayServer.Tests
|
||||||
var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey(
|
var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey(
|
||||||
new ManageController.AddApiKeyViewModel()
|
new ManageController.AddApiKeyViewModel()
|
||||||
{
|
{
|
||||||
PermissionValues = permissions.Select(s => new ManageController.AddApiKeyViewModel.PermissionValueItem()
|
PermissionValues =
|
||||||
{
|
permissions.Select(s =>
|
||||||
Permission = s,
|
new ManageController.AddApiKeyViewModel.PermissionValueItem()
|
||||||
Value = true
|
{
|
||||||
}).ToList(),
|
Permission = s, Value = true
|
||||||
|
}).ToList(),
|
||||||
StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores
|
StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores
|
||||||
}));
|
}));
|
||||||
var statusMessage = manageController.TempData.GetStatusMessageModel();
|
var statusMessage = manageController.TempData.GetStatusMessageModel();
|
||||||
|
@ -78,6 +88,7 @@ namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
RegisterAsync(isAdmin).GetAwaiter().GetResult();
|
RegisterAsync(isAdmin).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task GrantAccessAsync(bool isAdmin = false)
|
public async Task GrantAccessAsync(bool isAdmin = false)
|
||||||
{
|
{
|
||||||
await RegisterAsync(isAdmin);
|
await RegisterAsync(isAdmin);
|
||||||
|
@ -105,6 +116,7 @@ namespace BTCPayServer.Tests
|
||||||
store.NetworkFeeMode = mode;
|
store.NetworkFeeMode = mode;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ModifyStore(Action<StoreViewModel> modify)
|
public void ModifyStore(Action<StoreViewModel> modify)
|
||||||
{
|
{
|
||||||
var storeController = GetController<StoresController>();
|
var storeController = GetController<StoresController>();
|
||||||
|
@ -122,7 +134,7 @@ namespace BTCPayServer.Tests
|
||||||
public async Task CreateStoreAsync()
|
public async Task CreateStoreAsync()
|
||||||
{
|
{
|
||||||
var store = this.GetController<UserStoresController>();
|
var store = this.GetController<UserStoresController>();
|
||||||
await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" });
|
await store.CreateStore(new CreateStoreViewModel() {Name = "Test Store"});
|
||||||
StoreId = store.CreatedStoreId;
|
StoreId = store.CreatedStoreId;
|
||||||
parent.Stores.Add(StoreId);
|
parent.Stores.Add(StoreId);
|
||||||
}
|
}
|
||||||
|
@ -133,7 +145,9 @@ namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult();
|
return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false, bool importKeysToNBX = false)
|
|
||||||
|
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false,
|
||||||
|
bool importKeysToNBX = false)
|
||||||
{
|
{
|
||||||
SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||||
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId);
|
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId);
|
||||||
|
@ -143,20 +157,21 @@ namespace BTCPayServer.Tests
|
||||||
SavePrivateKeys = importKeysToNBX
|
SavePrivateKeys = importKeysToNBX
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel()
|
await store.AddDerivationScheme(StoreId,
|
||||||
{
|
new DerivationSchemeViewModel()
|
||||||
Enabled = true,
|
{
|
||||||
CryptoCode = cryptoCode,
|
Enabled = true,
|
||||||
Network = SupportedNetwork,
|
CryptoCode = cryptoCode,
|
||||||
RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(),
|
Network = SupportedNetwork,
|
||||||
RootKeyPath = SupportedNetwork.GetRootKeyPath(),
|
RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(),
|
||||||
Source = "NBXplorer",
|
RootKeyPath = SupportedNetwork.GetRootKeyPath(),
|
||||||
AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(),
|
Source = "NBXplorer",
|
||||||
DerivationSchemeFormat = "BTCPay",
|
AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(),
|
||||||
KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(),
|
DerivationSchemeFormat = "BTCPay",
|
||||||
DerivationScheme = DerivationScheme.ToString(),
|
KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(),
|
||||||
Confirmation = true
|
DerivationScheme = DerivationScheme.ToString(),
|
||||||
}, cryptoCode);
|
Confirmation = true
|
||||||
|
}, cryptoCode);
|
||||||
return new WalletId(StoreId, cryptoCode);
|
return new WalletId(StoreId, cryptoCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,21 +214,26 @@ namespace BTCPayServer.Tests
|
||||||
IsAdmin = account.RegisteredAdmin;
|
IsAdmin = account.RegisteredAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
public RegisterViewModel RegisterDetails{ get; set; }
|
public RegisterViewModel RegisterDetails { get; set; }
|
||||||
|
|
||||||
public Bitpay BitPay
|
public Bitpay BitPay
|
||||||
{
|
{
|
||||||
get; set;
|
get;
|
||||||
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string UserId
|
public string UserId
|
||||||
{
|
{
|
||||||
get; set;
|
get;
|
||||||
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string StoreId
|
public string StoreId
|
||||||
{
|
{
|
||||||
get; set;
|
get;
|
||||||
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsAdmin { get; internal set; }
|
public bool IsAdmin { get; internal set; }
|
||||||
|
|
||||||
public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType)
|
public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType)
|
||||||
|
@ -229,19 +249,116 @@ namespace BTCPayServer.Tests
|
||||||
if (connectionType == LightningConnectionType.Charge)
|
if (connectionType == LightningConnectionType.Charge)
|
||||||
connectionString = "type=charge;server=" + parent.MerchantCharge.Client.Uri.AbsoluteUri;
|
connectionString = "type=charge;server=" + parent.MerchantCharge.Client.Uri.AbsoluteUri;
|
||||||
else if (connectionType == LightningConnectionType.CLightning)
|
else if (connectionType == LightningConnectionType.CLightning)
|
||||||
connectionString = "type=clightning;server=" + ((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri;
|
connectionString = "type=clightning;server=" +
|
||||||
|
((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri;
|
||||||
else if (connectionType == LightningConnectionType.LndREST)
|
else if (connectionType == LightningConnectionType.LndREST)
|
||||||
connectionString = $"type=lnd-rest;server={parent.MerchantLnd.Swagger.BaseUrl};allowinsecure=true";
|
connectionString = $"type=lnd-rest;server={parent.MerchantLnd.Swagger.BaseUrl};allowinsecure=true";
|
||||||
else
|
else
|
||||||
throw new NotSupportedException(connectionType.ToString());
|
throw new NotSupportedException(connectionType.ToString());
|
||||||
|
|
||||||
await storeController.AddLightningNode(StoreId, new LightningNodeViewModel()
|
await storeController.AddLightningNode(StoreId,
|
||||||
{
|
new LightningNodeViewModel() {ConnectionString = connectionString, SkipPortTest = true}, "save", "BTC");
|
||||||
ConnectionString = connectionString,
|
|
||||||
SkipPortTest = true
|
|
||||||
}, "save", "BTC");
|
|
||||||
if (storeController.ModelState.ErrorCount != 0)
|
if (storeController.ModelState.ErrorCount != 0)
|
||||||
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
|
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network)
|
||||||
|
{
|
||||||
|
var cashCow = parent.ExplorerNode;
|
||||||
|
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||||
|
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
|
||||||
|
var txid = await cashCow.SendToAddressAsync(address, value);
|
||||||
|
var tx = await cashCow.GetRawTransactionAsync(txid);
|
||||||
|
return tx.Outputs.AsCoins().First(c => c.ScriptPubKey == address.ScriptPubKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BitcoinAddress> GetNewAddress(BTCPayNetwork network)
|
||||||
|
{
|
||||||
|
var cashCow = parent.ExplorerNode;
|
||||||
|
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||||
|
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PSBT> Sign(PSBT psbt)
|
||||||
|
{
|
||||||
|
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>()
|
||||||
|
.GetWallet(psbt.Network.NetworkSet.CryptoCode);
|
||||||
|
var explorerClient = parent.PayTester.GetService<ExplorerClientProvider>()
|
||||||
|
.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode);
|
||||||
|
psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest()
|
||||||
|
{
|
||||||
|
DerivationScheme = DerivationScheme, PSBT = psbt
|
||||||
|
})).PSBT;
|
||||||
|
return psbt.SignAll(this.DerivationScheme, GenerateWalletResponseV.AccountHDKey,
|
||||||
|
GenerateWalletResponseV.AccountKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PSBT> SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null)
|
||||||
|
{
|
||||||
|
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
|
||||||
|
var pjClient = parent.PayTester.GetService<PayjoinClient>();
|
||||||
|
var storeRepository = parent.PayTester.GetService<StoreRepository>();
|
||||||
|
var store = await storeRepository.FindStore(StoreId);
|
||||||
|
var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType<DerivationSchemeSettings>()
|
||||||
|
.First();
|
||||||
|
if (expectedError is null)
|
||||||
|
{
|
||||||
|
var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default);
|
||||||
|
Assert.NotNull(proposed);
|
||||||
|
return proposed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ex = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
|
||||||
|
Assert.Equal(expectedError, ex.ErrorCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Transaction> SubmitPayjoin(Invoice invoice, Transaction transaction, BTCPayNetwork network,
|
||||||
|
string expectedError = null)
|
||||||
|
{
|
||||||
|
var response =
|
||||||
|
await SubmitPayjoinCore(transaction.ToHex(), invoice, network.NBitcoinNetwork, expectedError);
|
||||||
|
if (response == null)
|
||||||
|
return null;
|
||||||
|
var signed = Transaction.Parse(await response.Content.ReadAsStringAsync(), network.NBitcoinNetwork);
|
||||||
|
return signed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<HttpResponseMessage> SubmitPayjoinCore(string content, Invoice invoice, Network network,
|
||||||
|
string expectedError)
|
||||||
|
{
|
||||||
|
var endpoint = GetPayjoinEndpoint(invoice, network);
|
||||||
|
var response = await parent.PayTester.HttpClient.PostAsync(endpoint,
|
||||||
|
new StringContent(content, Encoding.UTF8, "text/plain"));
|
||||||
|
if (expectedError != null)
|
||||||
|
{
|
||||||
|
Assert.False(response.IsSuccessStatusCode);
|
||||||
|
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(expectedError, error["errorCode"].Value<string>());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
Assert.True(false,
|
||||||
|
$"Error: {error["errorCode"].Value<string>()}: {error["message"].Value<string>()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri GetPayjoinEndpoint(Invoice invoice, Network network)
|
||||||
|
{
|
||||||
|
var parsedBip21 = new BitcoinUrlBuilder(
|
||||||
|
invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21,
|
||||||
|
network);
|
||||||
|
return new Uri(parsedBip21.UnknowParameters["bpu"], UriKind.Absolute);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -173,7 +173,7 @@ namespace BTCPayServer.Tests
|
||||||
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
|
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
|
||||||
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
|
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
|
||||||
{
|
{
|
||||||
new BitcoinLikePaymentHandler(null, networkProvider, null, null),
|
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
|
||||||
new LightningLikePaymentHandler(null, null, networkProvider, null),
|
new LightningLikePaymentHandler(null, null, networkProvider, null),
|
||||||
});
|
});
|
||||||
var networkBTC = networkProvider.GetNetwork("BTC");
|
var networkBTC = networkProvider.GetNetwork("BTC");
|
||||||
|
@ -288,7 +288,7 @@ namespace BTCPayServer.Tests
|
||||||
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
|
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
|
||||||
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
|
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
|
||||||
{
|
{
|
||||||
new BitcoinLikePaymentHandler(null, networkProvider, null, null),
|
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
|
||||||
new LightningLikePaymentHandler(null, null, networkProvider, null),
|
new LightningLikePaymentHandler(null, null, networkProvider, null),
|
||||||
});
|
});
|
||||||
var entity = new InvoiceEntity();
|
var entity = new InvoiceEntity();
|
||||||
|
@ -477,7 +477,7 @@ namespace BTCPayServer.Tests
|
||||||
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
|
var networkProvider = new BTCPayNetworkProvider(NetworkType.Regtest);
|
||||||
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
|
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
|
||||||
{
|
{
|
||||||
new BitcoinLikePaymentHandler(null, networkProvider, null, null),
|
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
|
||||||
new LightningLikePaymentHandler(null, null, networkProvider, null),
|
new LightningLikePaymentHandler(null, null, networkProvider, null),
|
||||||
});
|
});
|
||||||
var entity = new InvoiceEntity();
|
var entity = new InvoiceEntity();
|
||||||
|
|
|
@ -76,7 +76,7 @@ services:
|
||||||
- customer_lnd
|
- customer_lnd
|
||||||
- merchant_lnd
|
- merchant_lnd
|
||||||
nbxplorer:
|
nbxplorer:
|
||||||
image: nicolasdorier/nbxplorer:2.1.14
|
image: nicolasdorier/nbxplorer:2.1.21
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "32838:32838"
|
- "32838:32838"
|
||||||
|
|
|
@ -259,7 +259,7 @@ namespace BTCPayServer.Controllers
|
||||||
|
|
||||||
using (logs.Measure($"{logPrefix} Payment method details creation"))
|
using (logs.Measure($"{logPrefix} Payment method details creation"))
|
||||||
{
|
{
|
||||||
var paymentDetails = await handler.CreatePaymentMethodDetails(supportedPaymentMethod, paymentMethod, store, network, preparePayment);
|
var paymentDetails = await handler.CreatePaymentMethodDetails(logs, supportedPaymentMethod, paymentMethod, store, network, preparePayment);
|
||||||
paymentMethod.SetPaymentMethodDetails(paymentDetails);
|
paymentMethod.SetPaymentMethodDetails(paymentDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,7 +107,7 @@ namespace BTCPayServer.Controllers
|
||||||
return ViewWalletSendLedger(walletId, psbt);
|
return ViewWalletSendLedger(walletId, psbt);
|
||||||
case "update":
|
case "update":
|
||||||
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
|
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
|
||||||
psbt = await UpdatePSBT(derivationSchemeSettings, psbt, network);
|
psbt = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbt);
|
||||||
if (psbt == null)
|
if (psbt == null)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(vm.PSBT), "You need to update your version of NBXplorer");
|
ModelState.AddModelError(nameof(vm.PSBT), "You need to update your version of NBXplorer");
|
||||||
|
@ -144,84 +144,23 @@ namespace BTCPayServer.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<PSBT> UpdatePSBT(DerivationSchemeSettings derivationSchemeSettings, PSBT psbt, BTCPayNetwork network)
|
private async Task<PSBT> TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
var result = await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(new UpdatePSBTRequest()
|
|
||||||
{
|
|
||||||
PSBT = psbt,
|
|
||||||
DerivationScheme = derivationSchemeSettings.AccountDerivation,
|
|
||||||
});
|
|
||||||
if (result == null)
|
|
||||||
return null;
|
|
||||||
derivationSchemeSettings.RebaseKeyPaths(result.PSBT);
|
|
||||||
return result.PSBT;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private async Task<PSBT> TryGetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork)
|
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint))
|
if (!string.IsNullOrEmpty(bpu) && Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint))
|
||||||
{
|
{
|
||||||
var httpClient = _httpClientFactory.CreateClient("payjoin");
|
|
||||||
|
|
||||||
var cloned = psbt.Clone();
|
var cloned = psbt.Clone();
|
||||||
|
cloned = cloned.Finalize();
|
||||||
if (!cloned.IsAllFinalized() && !cloned.TryFinalize(out var errors))
|
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1.0), cloned.ExtractTransaction(), btcPayNetwork);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _payjoinClient.RequestPayjoin(endpoint, derivationSchemeSettings, cloned, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var bpuresponse = await httpClient.PostAsync(endpoint, new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain"));
|
|
||||||
if (bpuresponse.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var hex = await bpuresponse.Content.ReadAsStringAsync();
|
|
||||||
if (PSBT.TryParse(hex, btcPayNetwork.NBitcoinNetwork, out var newPSBT))
|
|
||||||
{
|
|
||||||
//check that all the inputs we provided are still there and that there is at least one new(signed) input.
|
|
||||||
bool valid = false;
|
|
||||||
var existingInputs = psbt.Inputs.Select(input => input.PrevOut).ToHashSet();
|
|
||||||
foreach (var input in newPSBT.Inputs)
|
|
||||||
{
|
|
||||||
var existingInput = existingInputs.SingleOrDefault(point => point == input.PrevOut);
|
|
||||||
if (existingInput != null)
|
|
||||||
{
|
|
||||||
existingInputs.Remove(existingInput);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!input.TryFinalizeInput(out _))
|
|
||||||
{
|
|
||||||
//a new input was provided but was invalid.
|
|
||||||
valid = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// a new signed input was provided
|
|
||||||
valid = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!valid || existingInputs.Any())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
//check if output sum to self is the same.
|
|
||||||
var signingAccountKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
|
|
||||||
var newOutputSumToSelfSum = newPSBT.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccountKeySettings.AccountKey,
|
|
||||||
signingAccountKeySettings.GetRootedKeyPath()).Sum(output => output.Value);
|
|
||||||
|
|
||||||
var originalOutputSumToSelf = psbt.Outputs.Sum(output => output.Value);
|
|
||||||
if (originalOutputSumToSelf < newOutputSumToSelfSum)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
newPSBT = await UpdatePSBT(derivationSchemeSettings, newPSBT, btcPayNetwork);
|
|
||||||
return newPSBT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -256,7 +195,7 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
|
var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
|
||||||
if (!psbtObject.IsAllFinalized())
|
if (!psbtObject.IsAllFinalized())
|
||||||
psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject;
|
psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject;
|
||||||
IHDKey signingKey = null;
|
IHDKey signingKey = null;
|
||||||
RootedKeyPath signingKeyPath = null;
|
RootedKeyPath signingKeyPath = null;
|
||||||
try
|
try
|
||||||
|
@ -358,7 +297,7 @@ namespace BTCPayServer.Controllers
|
||||||
[Route("{walletId}/psbt/ready")]
|
[Route("{walletId}/psbt/ready")]
|
||||||
public async Task<IActionResult> WalletPSBTReady(
|
public async Task<IActionResult> WalletPSBTReady(
|
||||||
[ModelBinder(typeof(WalletIdModelBinder))]
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
||||||
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null)
|
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (command == null)
|
if (command == null)
|
||||||
return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl);
|
return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl);
|
||||||
|
@ -383,7 +322,7 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
case "payjoin":
|
case "payjoin":
|
||||||
var proposedPayjoin =await
|
var proposedPayjoin =await
|
||||||
TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network);
|
TryGetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt, derivationSchemeSettings, network, cancellationToken);
|
||||||
if (proposedPayjoin == null)
|
if (proposedPayjoin == null)
|
||||||
{
|
{
|
||||||
//we possibly exposed the tx to the receiver, so we need to broadcast straight away
|
//we possibly exposed the tx to the receiver, so we need to broadcast straight away
|
||||||
|
@ -401,25 +340,14 @@ namespace BTCPayServer.Controllers
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
|
var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
|
||||||
var payjoinSigned = PSBTChanged(proposedPayjoin,
|
proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
|
||||||
() => proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
|
extKey,
|
||||||
extKey,
|
RootedKeyPath.Parse(vm.SigningKeyPath));
|
||||||
RootedKeyPath.Parse(vm.SigningKeyPath)));
|
|
||||||
if (!payjoinSigned)
|
|
||||||
{
|
|
||||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
|
||||||
{
|
|
||||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
|
||||||
AllowDismiss = false,
|
|
||||||
Html = $"The payjoin transaction could not be signed. The original transaction was broadcast instead."
|
|
||||||
});
|
|
||||||
return await WalletPSBTReady(walletId, vm, "broadcast");
|
|
||||||
}
|
|
||||||
vm.PSBT = proposedPayjoin.ToBase64();
|
vm.PSBT = proposedPayjoin.ToBase64();
|
||||||
vm.OriginalPSBT = psbt.ToBase64();
|
vm.OriginalPSBT = psbt.ToBase64();
|
||||||
return await WalletPSBTReady(walletId, vm, "broadcast");
|
return await WalletPSBTReady(walletId, vm, "broadcast");
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||||
{
|
{
|
||||||
|
|
|
@ -50,7 +50,8 @@ namespace BTCPayServer.Controllers
|
||||||
private readonly WalletReceiveStateService _WalletReceiveStateService;
|
private readonly WalletReceiveStateService _WalletReceiveStateService;
|
||||||
private readonly EventAggregator _EventAggregator;
|
private readonly EventAggregator _EventAggregator;
|
||||||
private readonly SettingsRepository _settingsRepository;
|
private readonly SettingsRepository _settingsRepository;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly DelayedTransactionBroadcaster _broadcaster;
|
||||||
|
private readonly PayjoinClient _payjoinClient;
|
||||||
public RateFetcher RateFetcher { get; }
|
public RateFetcher RateFetcher { get; }
|
||||||
|
|
||||||
CurrencyNameTable _currencyTable;
|
CurrencyNameTable _currencyTable;
|
||||||
|
@ -69,7 +70,8 @@ namespace BTCPayServer.Controllers
|
||||||
WalletReceiveStateService walletReceiveStateService,
|
WalletReceiveStateService walletReceiveStateService,
|
||||||
EventAggregator eventAggregator,
|
EventAggregator eventAggregator,
|
||||||
SettingsRepository settingsRepository,
|
SettingsRepository settingsRepository,
|
||||||
IHttpClientFactory httpClientFactory)
|
DelayedTransactionBroadcaster broadcaster,
|
||||||
|
PayjoinClient payjoinClient)
|
||||||
{
|
{
|
||||||
_currencyTable = currencyTable;
|
_currencyTable = currencyTable;
|
||||||
Repository = repo;
|
Repository = repo;
|
||||||
|
@ -86,7 +88,8 @@ namespace BTCPayServer.Controllers
|
||||||
_WalletReceiveStateService = walletReceiveStateService;
|
_WalletReceiveStateService = walletReceiveStateService;
|
||||||
_EventAggregator = eventAggregator;
|
_EventAggregator = eventAggregator;
|
||||||
_settingsRepository = settingsRepository;
|
_settingsRepository = settingsRepository;
|
||||||
_httpClientFactory = httpClientFactory;
|
_broadcaster = broadcaster;
|
||||||
|
_payjoinClient = payjoinClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
|
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
|
||||||
|
@ -844,6 +847,7 @@ namespace BTCPayServer.Controllers
|
||||||
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl);
|
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private bool PSBTChanged(PSBT psbt, Action act)
|
private bool PSBTChanged(PSBT psbt, Action act)
|
||||||
{
|
{
|
||||||
var before = psbt.ToBase64();
|
var before = psbt.ToBase64();
|
||||||
|
|
|
@ -36,6 +36,7 @@ using System.Net;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using BTCPayServer.Payments.Bitcoin;
|
||||||
|
|
||||||
namespace BTCPayServer
|
namespace BTCPayServer
|
||||||
{
|
{
|
||||||
|
@ -136,15 +137,37 @@ namespace BTCPayServer
|
||||||
catch { }
|
catch { }
|
||||||
finally { try { webSocket.Dispose(); } catch { } }
|
finally { try { webSocket.Dispose(); } catch { } }
|
||||||
}
|
}
|
||||||
public static async Task<Dictionary<uint256, TransactionResult>> GetTransactions(this BTCPayWallet client, uint256[] hashes, CancellationToken cts = default(CancellationToken))
|
|
||||||
|
public static IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(this InvoiceEntity invoice)
|
||||||
|
{
|
||||||
|
return invoice.GetPayments()
|
||||||
|
.Where(p => p.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike)
|
||||||
|
.Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Dictionary<uint256, TransactionResult>> GetTransactions(this BTCPayWallet client, uint256[] hashes, bool includeOffchain = false, CancellationToken cts = default(CancellationToken))
|
||||||
{
|
{
|
||||||
hashes = hashes.Distinct().ToArray();
|
hashes = hashes.Distinct().ToArray();
|
||||||
var transactions = hashes
|
var transactions = hashes
|
||||||
.Select(async o => await client.GetTransactionAsync(o, cts))
|
.Select(async o => await client.GetTransactionAsync(o, includeOffchain, cts))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
await Task.WhenAll(transactions).ConfigureAwait(false);
|
await Task.WhenAll(transactions).ConfigureAwait(false);
|
||||||
return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash());
|
return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<PSBT> UpdatePSBT(this ExplorerClientProvider explorerClientProvider, DerivationSchemeSettings derivationSchemeSettings, PSBT psbt)
|
||||||
|
{
|
||||||
|
var result = await explorerClientProvider.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode).UpdatePSBTAsync(new UpdatePSBTRequest()
|
||||||
|
{
|
||||||
|
PSBT = psbt,
|
||||||
|
DerivationScheme = derivationSchemeSettings.AccountDerivation
|
||||||
|
});
|
||||||
|
if (result == null)
|
||||||
|
return null;
|
||||||
|
derivationSchemeSettings.RebaseKeyPaths(result.PSBT);
|
||||||
|
return result.PSBT;
|
||||||
|
}
|
||||||
|
|
||||||
public static string WithTrailingSlash(this string str)
|
public static string WithTrailingSlash(this string str)
|
||||||
{
|
{
|
||||||
if (str.EndsWith("/", StringComparison.InvariantCulture))
|
if (str.EndsWith("/", StringComparison.InvariantCulture))
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -49,7 +49,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
_NetworkFeeRate = value;
|
_NetworkFeeRate = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public bool PayjoinEnabled { get; set; }
|
||||||
// Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason
|
// Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public FeeRate FeeRate { get; set; }
|
public FeeRate FeeRate { get; set; }
|
||||||
|
@ -58,24 +58,10 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public String DepositAddress { get; set; }
|
public String DepositAddress { get; set; }
|
||||||
|
|
||||||
public PayJoinPaymentState PayJoin { get; set; } = new PayJoinPaymentState();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public BitcoinAddress GetDepositAddress(Network network)
|
public BitcoinAddress GetDepositAddress(Network network)
|
||||||
{
|
{
|
||||||
return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network);
|
return string.IsNullOrEmpty(DepositAddress) ? null : BitcoinAddress.Create(DepositAddress, network);
|
||||||
}
|
}
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PayJoinPaymentState
|
|
||||||
{
|
|
||||||
public bool Enabled { get; set; } = false;
|
|
||||||
public uint256 ProposedTransactionHash { get; set; }
|
|
||||||
public List<ReceivedCoin> CoinsExposed { get; set; }
|
|
||||||
public decimal TotalOutputAmount { get; set; }
|
|
||||||
public decimal ContributedAmount { get; set; }
|
|
||||||
public uint256 OriginalTransactionHash { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,14 +21,13 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf, decimal payJoinSelfContributedAmount)
|
public BitcoinLikePaymentData(BitcoinAddress address, IMoney value, OutPoint outpoint, bool rbf)
|
||||||
{
|
{
|
||||||
Address = address;
|
Address = address;
|
||||||
Value = value;
|
Value = value;
|
||||||
Outpoint = outpoint;
|
Outpoint = outpoint;
|
||||||
ConfirmationCount = 0;
|
ConfirmationCount = 0;
|
||||||
RBF = rbf;
|
RBF = rbf;
|
||||||
PayJoinSelfContributedAmount = payJoinSelfContributedAmount;
|
|
||||||
}
|
}
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public BTCPayNetworkBase Network { get; set; }
|
public BTCPayNetworkBase Network { get; set; }
|
||||||
|
@ -38,10 +37,10 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
public TxOut Output { get; set; }
|
public TxOut Output { get; set; }
|
||||||
public int ConfirmationCount { get; set; }
|
public int ConfirmationCount { get; set; }
|
||||||
public bool RBF { get; set; }
|
public bool RBF { get; set; }
|
||||||
public decimal NetworkFee { get; set; }
|
|
||||||
public BitcoinAddress Address { get; set; }
|
public BitcoinAddress Address { get; set; }
|
||||||
public IMoney Value { get; set; }
|
public IMoney Value { get; set; }
|
||||||
public decimal PayJoinSelfContributedAmount { get; set; } = 0;
|
|
||||||
|
public PayjoinInformation PayjoinInformation { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Script ScriptPubKey
|
public Script ScriptPubKey
|
||||||
|
@ -69,8 +68,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
|
|
||||||
public decimal GetValue()
|
public decimal GetValue()
|
||||||
{
|
{
|
||||||
return (Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC)) -
|
return Value?.GetValue(Network as BTCPayNetwork) ?? Output.Value.ToDecimal(MoneyUnit.BTC);
|
||||||
PayJoinSelfContributedAmount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool PaymentCompleted(PaymentEntity entity)
|
public bool PaymentCompleted(PaymentEntity entity)
|
||||||
|
@ -109,4 +107,17 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
return GetDestination().ToString();
|
return GetDestination().ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class PayjoinInformation
|
||||||
|
{
|
||||||
|
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
|
||||||
|
public PayjoinTransactionType Type { get; set; }
|
||||||
|
public OutPoint[] ContributedOutPoints { get; set; }
|
||||||
|
}
|
||||||
|
public enum PayjoinTransactionType
|
||||||
|
{
|
||||||
|
Original,
|
||||||
|
Coinjoin
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Models;
|
using BTCPayServer.Models;
|
||||||
using BTCPayServer.Models.InvoicingModels;
|
using BTCPayServer.Models.InvoicingModels;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
|
@ -19,16 +21,19 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
ExplorerClientProvider _ExplorerProvider;
|
ExplorerClientProvider _ExplorerProvider;
|
||||||
private readonly BTCPayNetworkProvider _networkProvider;
|
private readonly BTCPayNetworkProvider _networkProvider;
|
||||||
private IFeeProviderFactory _FeeRateProviderFactory;
|
private IFeeProviderFactory _FeeRateProviderFactory;
|
||||||
|
private readonly NBXplorerDashboard _dashboard;
|
||||||
private Services.Wallets.BTCPayWalletProvider _WalletProvider;
|
private Services.Wallets.BTCPayWalletProvider _WalletProvider;
|
||||||
|
|
||||||
public BitcoinLikePaymentHandler(ExplorerClientProvider provider,
|
public BitcoinLikePaymentHandler(ExplorerClientProvider provider,
|
||||||
BTCPayNetworkProvider networkProvider,
|
BTCPayNetworkProvider networkProvider,
|
||||||
IFeeProviderFactory feeRateProviderFactory,
|
IFeeProviderFactory feeRateProviderFactory,
|
||||||
|
NBXplorerDashboard dashboard,
|
||||||
Services.Wallets.BTCPayWalletProvider walletProvider)
|
Services.Wallets.BTCPayWalletProvider walletProvider)
|
||||||
{
|
{
|
||||||
_ExplorerProvider = provider;
|
_ExplorerProvider = provider;
|
||||||
_networkProvider = networkProvider;
|
_networkProvider = networkProvider;
|
||||||
_FeeRateProviderFactory = feeRateProviderFactory;
|
_FeeRateProviderFactory = feeRateProviderFactory;
|
||||||
|
_dashboard = dashboard;
|
||||||
_WalletProvider = walletProvider;
|
_WalletProvider = walletProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,16 +79,19 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
{
|
{
|
||||||
if (storeBlob.OnChainMinValue != null)
|
if (storeBlob.OnChainMinValue != null)
|
||||||
{
|
{
|
||||||
var currentRateToCrypto = await rate[new CurrencyPair(paymentMethodId.CryptoCode, storeBlob.OnChainMinValue.Currency)];
|
var currentRateToCrypto =
|
||||||
|
await rate[new CurrencyPair(paymentMethodId.CryptoCode, storeBlob.OnChainMinValue.Currency)];
|
||||||
if (currentRateToCrypto?.BidAsk != null)
|
if (currentRateToCrypto?.BidAsk != null)
|
||||||
{
|
{
|
||||||
var limitValueCrypto = Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid);
|
var limitValueCrypto =
|
||||||
|
Money.Coins(storeBlob.OnChainMinValue.Value / currentRateToCrypto.BidAsk.Bid);
|
||||||
if (amount < limitValueCrypto)
|
if (amount < limitValueCrypto)
|
||||||
{
|
{
|
||||||
return "The amount of the invoice is too low to be paid on chain";
|
return "The amount of the invoice is too low to be paid on chain";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,9 +114,12 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
var storeBlob = store.GetStoreBlob();
|
var storeBlob = store.GetStoreBlob();
|
||||||
return new Prepare()
|
return new Prepare()
|
||||||
{
|
{
|
||||||
GetFeeRate = _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget),
|
GetFeeRate =
|
||||||
GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never ? null
|
_FeeRateProviderFactory.CreateFeeProvider(network)
|
||||||
: _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(),
|
.GetFeeRateAsync(storeBlob.RecommendedFeeBlockTarget),
|
||||||
|
GetNetworkFeeRate = storeBlob.NetworkFeeMode == NetworkFeeMode.Never
|
||||||
|
? null
|
||||||
|
: _FeeRateProviderFactory.CreateFeeProvider(network).GetFeeRateAsync(),
|
||||||
ReserveAddress = _WalletProvider.GetWallet(network)
|
ReserveAddress = _WalletProvider.GetWallet(network)
|
||||||
.ReserveAddressAsync(supportedPaymentMethod.AccountDerivation)
|
.ReserveAddressAsync(supportedPaymentMethod.AccountDerivation)
|
||||||
};
|
};
|
||||||
|
@ -117,6 +128,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
public override PaymentType PaymentType => PaymentTypes.BTCLike;
|
public override PaymentType PaymentType => PaymentTypes.BTCLike;
|
||||||
|
|
||||||
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
|
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
|
||||||
|
InvoiceLogs logs,
|
||||||
DerivationSchemeSettings supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store,
|
DerivationSchemeSettings supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store,
|
||||||
BTCPayNetwork network, object preparePaymentObject)
|
BTCPayNetwork network, object preparePaymentObject)
|
||||||
{
|
{
|
||||||
|
@ -132,7 +144,8 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
{
|
{
|
||||||
case NetworkFeeMode.Always:
|
case NetworkFeeMode.Always:
|
||||||
onchainMethod.NetworkFeeRate = (await prepare.GetNetworkFeeRate);
|
onchainMethod.NetworkFeeRate = (await prepare.GetNetworkFeeRate);
|
||||||
onchainMethod.NextNetworkFee = onchainMethod.NetworkFeeRate.GetFee(100); // assume price for 100 bytes
|
onchainMethod.NextNetworkFee =
|
||||||
|
onchainMethod.NetworkFeeRate.GetFee(100); // assume price for 100 bytes
|
||||||
break;
|
break;
|
||||||
case NetworkFeeMode.Never:
|
case NetworkFeeMode.Never:
|
||||||
onchainMethod.NetworkFeeRate = FeeRate.Zero;
|
onchainMethod.NetworkFeeRate = FeeRate.Zero;
|
||||||
|
@ -145,12 +158,24 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
}
|
}
|
||||||
|
|
||||||
onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString();
|
onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString();
|
||||||
onchainMethod.PayJoin = new PayJoinPaymentState()
|
onchainMethod.PayjoinEnabled = blob.PayJoinEnabled &&
|
||||||
|
supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() ==
|
||||||
|
ScriptPubKeyType.Segwit &&
|
||||||
|
network.SupportPayJoin;
|
||||||
|
if (onchainMethod.PayjoinEnabled)
|
||||||
{
|
{
|
||||||
Enabled = blob.PayJoinEnabled &&
|
var nodeSupport = _dashboard?.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities
|
||||||
supportedPaymentMethod.AccountDerivation.ScriptPubKeyType() !=
|
?.CanSupportTransactionCheck is true;
|
||||||
ScriptPubKeyType.Legacy
|
bool isHotwallet = supportedPaymentMethod.Source == "NBXplorer";
|
||||||
};
|
onchainMethod.PayjoinEnabled &= isHotwallet && nodeSupport;
|
||||||
|
if (!isHotwallet)
|
||||||
|
logs.Write("Payjoin should have been enabled, but your store is not a hotwallet");
|
||||||
|
if (!nodeSupport)
|
||||||
|
logs.Write("Payjoin should have been enabled, but your version of NBXplorer or full node does not support it.");
|
||||||
|
if (onchainMethod.PayjoinEnabled)
|
||||||
|
logs.Write("Payjoin is enabled for this invoice.");
|
||||||
|
}
|
||||||
|
|
||||||
return onchainMethod;
|
return onchainMethod;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.Payments.PayJoin;
|
using BTCPayServer.Payments.PayJoin;
|
||||||
using NBitcoin.Altcoins.Elements;
|
using NBitcoin.Altcoins.Elements;
|
||||||
using NBitcoin.RPC;
|
using NBitcoin.RPC;
|
||||||
|
using BTCPayServer;
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.Bitcoin
|
namespace BTCPayServer.Payments.Bitcoin
|
||||||
{
|
{
|
||||||
|
@ -31,19 +32,18 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
public class NBXplorerListener : IHostedService
|
public class NBXplorerListener : IHostedService
|
||||||
{
|
{
|
||||||
EventAggregator _Aggregator;
|
EventAggregator _Aggregator;
|
||||||
private readonly PayJoinStateProvider _payJoinStateProvider;
|
private readonly PayJoinRepository _payJoinRepository;
|
||||||
ExplorerClientProvider _ExplorerClients;
|
ExplorerClientProvider _ExplorerClients;
|
||||||
IHostApplicationLifetime _Lifetime;
|
IHostApplicationLifetime _Lifetime;
|
||||||
InvoiceRepository _InvoiceRepository;
|
InvoiceRepository _InvoiceRepository;
|
||||||
private TaskCompletionSource<bool> _RunningTask;
|
private TaskCompletionSource<bool> _RunningTask;
|
||||||
private CancellationTokenSource _Cts;
|
private CancellationTokenSource _Cts;
|
||||||
BTCPayWalletProvider _Wallets;
|
BTCPayWalletProvider _Wallets;
|
||||||
|
|
||||||
public NBXplorerListener(ExplorerClientProvider explorerClients,
|
public NBXplorerListener(ExplorerClientProvider explorerClients,
|
||||||
BTCPayWalletProvider wallets,
|
BTCPayWalletProvider wallets,
|
||||||
InvoiceRepository invoiceRepository,
|
InvoiceRepository invoiceRepository,
|
||||||
EventAggregator aggregator,
|
EventAggregator aggregator,
|
||||||
PayJoinStateProvider payJoinStateProvider,
|
PayJoinRepository payjoinRepository,
|
||||||
IHostApplicationLifetime lifetime)
|
IHostApplicationLifetime lifetime)
|
||||||
{
|
{
|
||||||
PollInterval = TimeSpan.FromMinutes(1.0);
|
PollInterval = TimeSpan.FromMinutes(1.0);
|
||||||
|
@ -51,7 +51,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
_InvoiceRepository = invoiceRepository;
|
_InvoiceRepository = invoiceRepository;
|
||||||
_ExplorerClients = explorerClients;
|
_ExplorerClients = explorerClients;
|
||||||
_Aggregator = aggregator;
|
_Aggregator = aggregator;
|
||||||
_payJoinStateProvider = payJoinStateProvider;
|
_payJoinRepository = payjoinRepository;
|
||||||
_Lifetime = lifetime;
|
_Lifetime = lifetime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,16 +160,11 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy,
|
var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy,
|
||||||
output.Item1.KeyPath, output.Item1.ScriptPubKey);
|
output.Item1.KeyPath, output.Item1.ScriptPubKey);
|
||||||
|
|
||||||
var payJoinSelfContributedAmount = GetPayJoinContributedAmount(
|
|
||||||
new WalletId(invoice.StoreId, network.CryptoCode),
|
|
||||||
output.matchedOutput.Value.GetValue(network),
|
|
||||||
evt.TransactionData.TransactionHash);
|
|
||||||
|
|
||||||
var paymentData = new BitcoinLikePaymentData(address,
|
var paymentData = new BitcoinLikePaymentData(address,
|
||||||
output.matchedOutput.Value, output.outPoint,
|
output.matchedOutput.Value, output.outPoint,
|
||||||
evt.TransactionData.Transaction.RBF, payJoinSelfContributedAmount);
|
evt.TransactionData.Transaction.RBF);
|
||||||
|
|
||||||
var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
|
var alreadyExist = invoice.GetAllBitcoinPaymentData().Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
|
||||||
if (!alreadyExist)
|
if (!alreadyExist)
|
||||||
{
|
{
|
||||||
var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network);
|
var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network);
|
||||||
|
@ -216,23 +211,19 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(InvoiceEntity invoice)
|
|
||||||
{
|
|
||||||
return invoice.GetPayments()
|
|
||||||
.Where(p => p.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike)
|
|
||||||
.Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData());
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<InvoiceEntity> UpdatePaymentStates(BTCPayWallet wallet, string invoiceId)
|
async Task<InvoiceEntity> UpdatePaymentStates(BTCPayWallet wallet, string invoiceId)
|
||||||
{
|
{
|
||||||
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, false);
|
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, false);
|
||||||
if (invoice == null)
|
if (invoice == null)
|
||||||
return null;
|
return null;
|
||||||
List<PaymentEntity> updatedPaymentEntities = new List<PaymentEntity>();
|
List<PaymentEntity> updatedPaymentEntities = new List<PaymentEntity>();
|
||||||
var transactions = await wallet.GetTransactions(GetAllBitcoinPaymentData(invoice)
|
var transactions = await wallet.GetTransactions(invoice.GetAllBitcoinPaymentData()
|
||||||
.Select(p => p.Outpoint.Hash)
|
.Select(p => p.Outpoint.Hash)
|
||||||
.ToArray());
|
.ToArray(), true);
|
||||||
var payJoinState = _payJoinStateProvider.Get(new WalletId(invoice.StoreId, wallet.Network.CryptoCode));
|
bool? originalPJBroadcasted = null;
|
||||||
|
bool? originalPJBroadcastable = null;
|
||||||
|
bool? cjPJBroadcasted = null;
|
||||||
|
OutPoint[] ourPJOutpoints = null;
|
||||||
foreach (var payment in invoice.GetPayments(wallet.Network))
|
foreach (var payment in invoice.GetPayments(wallet.Network))
|
||||||
{
|
{
|
||||||
if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike)
|
if (payment.GetPaymentMethodId().PaymentType != PaymentTypes.BTCLike)
|
||||||
|
@ -240,15 +231,16 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData();
|
var paymentData = (BitcoinLikePaymentData)payment.GetCryptoPaymentData();
|
||||||
if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx))
|
if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx))
|
||||||
continue;
|
continue;
|
||||||
var txId = tx.Transaction.GetHash();
|
|
||||||
bool accounted = true;
|
bool accounted = true;
|
||||||
if (tx.Confirmations == 0)
|
|
||||||
|
if (tx.Confirmations == 0 || tx.Confirmations == -1)
|
||||||
{
|
{
|
||||||
// Let's check if it was orphaned by broadcasting it again
|
// Let's check if it was orphaned by broadcasting it again
|
||||||
var explorerClient = _ExplorerClients.GetExplorerClient(wallet.Network);
|
var explorerClient = _ExplorerClients.GetExplorerClient(wallet.Network);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await explorerClient.BroadcastAsync(tx.Transaction, _Cts.Token);
|
var result = await explorerClient.BroadcastAsync(tx.Transaction, testMempoolAccept: tx.Confirmations == -1, _Cts.Token);
|
||||||
accounted = result.Success ||
|
accounted = result.Success ||
|
||||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN ||
|
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN ||
|
||||||
!(
|
!(
|
||||||
|
@ -257,10 +249,24 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
|
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
|
||||||
// Happen if RBF is on and fee insufficient
|
// Happen if RBF is on and fee insufficient
|
||||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED);
|
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED);
|
||||||
if (!accounted && payment.Accounted)
|
if (!accounted && payment.Accounted && tx.Confirmations != -1)
|
||||||
{
|
{
|
||||||
Logs.PayServer.LogInformation($"{wallet.Network.CryptoCode}: The transaction {tx.TransactionHash} has been replaced.");
|
Logs.PayServer.LogInformation($"{wallet.Network.CryptoCode}: The transaction {tx.TransactionHash} has been replaced.");
|
||||||
}
|
}
|
||||||
|
if (paymentData.PayjoinInformation is PayjoinInformation pj)
|
||||||
|
{
|
||||||
|
ourPJOutpoints = pj.ContributedOutPoints;
|
||||||
|
switch (pj.Type)
|
||||||
|
{
|
||||||
|
case PayjoinTransactionType.Original:
|
||||||
|
originalPJBroadcasted = accounted && tx.Confirmations >= 0;
|
||||||
|
originalPJBroadcastable = accounted;
|
||||||
|
break;
|
||||||
|
case PayjoinTransactionType.Coinjoin:
|
||||||
|
cjPJBroadcasted = accounted && tx.Confirmations >= 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// RPC might be unavailable, we can't check double spend so let's assume there is none
|
// RPC might be unavailable, we can't check double spend so let's assume there is none
|
||||||
catch
|
catch
|
||||||
|
@ -286,11 +292,6 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we keep the state of the payjoin tx until it is confirmed in case of rbf situations where the tx is cancelled
|
|
||||||
if (paymentData.PayJoinSelfContributedAmount> 0 && accounted && paymentData.PaymentConfirmed(payment, invoice.SpeedPolicy))
|
|
||||||
{
|
|
||||||
payJoinState?.RemoveRecord(paymentData.Outpoint.Hash);
|
|
||||||
}
|
|
||||||
// if needed add invoice back to pending to track number of confirmations
|
// if needed add invoice back to pending to track number of confirmations
|
||||||
if (paymentData.ConfirmationCount < wallet.Network.MaxTrackedConfirmation)
|
if (paymentData.ConfirmationCount < wallet.Network.MaxTrackedConfirmation)
|
||||||
await _InvoiceRepository.AddPendingInvoiceIfNotPresent(invoice.Id);
|
await _InvoiceRepository.AddPendingInvoiceIfNotPresent(invoice.Id);
|
||||||
|
@ -298,6 +299,17 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
if (updated)
|
if (updated)
|
||||||
updatedPaymentEntities.Add(payment);
|
updatedPaymentEntities.Add(payment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the origin tx of a payjoin has been broadcasted, then we know we can
|
||||||
|
// reuse our outpoint for another PJ
|
||||||
|
if (originalPJBroadcasted is true ||
|
||||||
|
// If the original tx is not broadcastable anymore and nor does the coinjoin
|
||||||
|
// reuse our outpoint for another PJ
|
||||||
|
(originalPJBroadcastable is false && !(cjPJBroadcasted is true)))
|
||||||
|
{
|
||||||
|
await _payJoinRepository.TryUnlock(ourPJOutpoints);
|
||||||
|
}
|
||||||
|
|
||||||
await _InvoiceRepository.UpdatePayments(updatedPaymentEntities);
|
await _InvoiceRepository.UpdatePayments(updatedPaymentEntities);
|
||||||
if (updatedPaymentEntities.Count != 0)
|
if (updatedPaymentEntities.Count != 0)
|
||||||
_Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id));
|
_Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id));
|
||||||
|
@ -313,7 +325,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, true);
|
var invoice = await _InvoiceRepository.GetInvoice(invoiceId, true);
|
||||||
if (invoice == null)
|
if (invoice == null)
|
||||||
continue;
|
continue;
|
||||||
var alreadyAccounted = GetAllBitcoinPaymentData(invoice).Select(p => p.Outpoint).ToHashSet();
|
var alreadyAccounted = invoice.GetAllBitcoinPaymentData().Select(p => p.Outpoint).ToHashSet();
|
||||||
var strategy = GetDerivationStrategy(invoice, network);
|
var strategy = GetDerivationStrategy(invoice, network);
|
||||||
if (strategy == null)
|
if (strategy == null)
|
||||||
continue;
|
continue;
|
||||||
|
@ -330,11 +342,9 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash);
|
var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash);
|
||||||
|
|
||||||
var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey);
|
var address = network.NBXplorerNetwork.CreateAddress(strategy, coin.KeyPath, coin.ScriptPubKey);
|
||||||
var payJoinSelfContributedAmount =
|
|
||||||
GetPayJoinContributedAmount(paymentMethod, coin.Value.GetValue(network), transaction.TransactionHash);
|
|
||||||
|
|
||||||
var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint,
|
var paymentData = new BitcoinLikePaymentData(address, coin.Value, coin.OutPoint,
|
||||||
transaction.Transaction.RBF, payJoinSelfContributedAmount);
|
transaction.Transaction.RBF);
|
||||||
|
|
||||||
var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false);
|
var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false);
|
||||||
alreadyAccounted.Add(coin.OutPoint);
|
alreadyAccounted.Add(coin.OutPoint);
|
||||||
|
@ -350,31 +360,6 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||||
return totalPayment;
|
return totalPayment;
|
||||||
}
|
}
|
||||||
|
|
||||||
private decimal GetPayJoinContributedAmount(BitcoinLikeOnChainPaymentMethod paymentMethod, decimal amount, uint256 transactionHash)
|
|
||||||
{
|
|
||||||
if (paymentMethod.PayJoin?.Enabled is true &&
|
|
||||||
paymentMethod.PayJoin.ProposedTransactionHash == transactionHash &&
|
|
||||||
paymentMethod.PayJoin.TotalOutputAmount == amount)
|
|
||||||
{
|
|
||||||
//this is the payjoin output!
|
|
||||||
return paymentMethod.PayJoin.ContributedAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
private decimal GetPayJoinContributedAmount(WalletId walletId, decimal amount, uint256 transactionHash)
|
|
||||||
{
|
|
||||||
var payJoinState =
|
|
||||||
_payJoinStateProvider.Get(walletId);
|
|
||||||
|
|
||||||
if (payJoinState == null || !payJoinState.TryGetWithProposedHash(transactionHash, out var record) ||
|
|
||||||
record.TotalOutputAmount != amount) return 0;
|
|
||||||
record.TxSeen = true;
|
|
||||||
//this is the payjoin output!
|
|
||||||
return record.ContributedAmount;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network)
|
private DerivationStrategyBase GetDerivationStrategy(InvoiceEntity invoice, BTCPayNetworkBase network)
|
||||||
{
|
{
|
||||||
return invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike))
|
return invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike))
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Models.InvoicingModels;
|
using BTCPayServer.Models.InvoicingModels;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
|
@ -25,7 +26,7 @@ namespace BTCPayServer.Payments
|
||||||
/// <param name="network"></param>
|
/// <param name="network"></param>
|
||||||
/// <param name="preparePaymentObject"></param>
|
/// <param name="preparePaymentObject"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod,
|
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, ISupportedPaymentMethod supportedPaymentMethod,
|
||||||
PaymentMethod paymentMethod, StoreData store, BTCPayNetworkBase network, object preparePaymentObject);
|
PaymentMethod paymentMethod, StoreData store, BTCPayNetworkBase network, object preparePaymentObject);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -53,7 +54,7 @@ namespace BTCPayServer.Payments
|
||||||
where TSupportedPaymentMethod : ISupportedPaymentMethod
|
where TSupportedPaymentMethod : ISupportedPaymentMethod
|
||||||
where TBTCPayNetwork : BTCPayNetworkBase
|
where TBTCPayNetwork : BTCPayNetworkBase
|
||||||
{
|
{
|
||||||
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(TSupportedPaymentMethod supportedPaymentMethod,
|
Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, TSupportedPaymentMethod supportedPaymentMethod,
|
||||||
PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject);
|
PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,6 +66,7 @@ namespace BTCPayServer.Payments
|
||||||
public abstract PaymentType PaymentType { get; }
|
public abstract PaymentType PaymentType { get; }
|
||||||
|
|
||||||
public abstract Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
|
public abstract Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
|
||||||
|
InvoiceLogs logs,
|
||||||
TSupportedPaymentMethod supportedPaymentMethod,
|
TSupportedPaymentMethod supportedPaymentMethod,
|
||||||
PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject);
|
PaymentMethod paymentMethod, StoreData store, TBTCPayNetwork network, object preparePaymentObject);
|
||||||
|
|
||||||
|
@ -99,12 +101,12 @@ namespace BTCPayServer.Payments
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IPaymentMethodDetails> CreatePaymentMethodDetails(ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
|
public Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, ISupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
|
||||||
StoreData store, BTCPayNetworkBase network, object preparePaymentObject)
|
StoreData store, BTCPayNetworkBase network, object preparePaymentObject)
|
||||||
{
|
{
|
||||||
if (supportedPaymentMethod is TSupportedPaymentMethod method && network is TBTCPayNetwork correctNetwork)
|
if (supportedPaymentMethod is TSupportedPaymentMethod method && network is TBTCPayNetwork correctNetwork)
|
||||||
{
|
{
|
||||||
return CreatePaymentMethodDetails(method, paymentMethod, store, correctNetwork, preparePaymentObject);
|
return CreatePaymentMethodDetails(logs, method, paymentMethod, store, correctNetwork, preparePaymentObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NotSupportedException("Invalid supportedPaymentMethod");
|
throw new NotSupportedException("Invalid supportedPaymentMethod");
|
||||||
|
|
|
@ -14,6 +14,7 @@ using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Rates;
|
using BTCPayServer.Services.Rates;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.Lightning
|
namespace BTCPayServer.Payments.Lightning
|
||||||
{
|
{
|
||||||
|
@ -40,6 +41,7 @@ namespace BTCPayServer.Payments.Lightning
|
||||||
|
|
||||||
public override PaymentType PaymentType => PaymentTypes.LightningLike;
|
public override PaymentType PaymentType => PaymentTypes.LightningLike;
|
||||||
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
|
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(
|
||||||
|
InvoiceLogs logs,
|
||||||
LightningSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store,
|
LightningSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, StoreData store,
|
||||||
BTCPayNetwork network, object preparePaymentObject)
|
BTCPayNetwork network, object preparePaymentObject)
|
||||||
{
|
{
|
||||||
|
|
|
@ -5,18 +5,26 @@ using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.Filters;
|
using BTCPayServer.Filters;
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
using BTCPayServer.Payments.Bitcoin;
|
||||||
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using BTCPayServer.Services.Wallets;
|
using BTCPayServer.Services.Wallets;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.DataEncoders;
|
using NBitcoin.DataEncoders;
|
||||||
|
using NBitcoin.Logging;
|
||||||
using NBXplorer;
|
using NBXplorer;
|
||||||
using NBXplorer.Models;
|
using NBXplorer.Models;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using NicolasDorier.RateLimits;
|
using NicolasDorier.RateLimits;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.PayJoin
|
namespace BTCPayServer.Payments.PayJoin
|
||||||
{
|
{
|
||||||
|
@ -28,374 +36,404 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
private readonly ExplorerClientProvider _explorerClientProvider;
|
private readonly ExplorerClientProvider _explorerClientProvider;
|
||||||
private readonly StoreRepository _storeRepository;
|
private readonly StoreRepository _storeRepository;
|
||||||
private readonly BTCPayWalletProvider _btcPayWalletProvider;
|
private readonly BTCPayWalletProvider _btcPayWalletProvider;
|
||||||
private readonly PayJoinStateProvider _payJoinStateProvider;
|
private readonly PayJoinRepository _payJoinRepository;
|
||||||
|
private readonly EventAggregator _eventAggregator;
|
||||||
|
private readonly NBXplorerDashboard _dashboard;
|
||||||
|
private readonly DelayedTransactionBroadcaster _broadcaster;
|
||||||
|
|
||||||
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
|
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
|
||||||
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
|
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
|
||||||
StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider,
|
StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider,
|
||||||
PayJoinStateProvider payJoinStateProvider)
|
PayJoinRepository payJoinRepository,
|
||||||
|
EventAggregator eventAggregator,
|
||||||
|
NBXplorerDashboard dashboard,
|
||||||
|
DelayedTransactionBroadcaster broadcaster)
|
||||||
{
|
{
|
||||||
_btcPayNetworkProvider = btcPayNetworkProvider;
|
_btcPayNetworkProvider = btcPayNetworkProvider;
|
||||||
_invoiceRepository = invoiceRepository;
|
_invoiceRepository = invoiceRepository;
|
||||||
_explorerClientProvider = explorerClientProvider;
|
_explorerClientProvider = explorerClientProvider;
|
||||||
_storeRepository = storeRepository;
|
_storeRepository = storeRepository;
|
||||||
_btcPayWalletProvider = btcPayWalletProvider;
|
_btcPayWalletProvider = btcPayWalletProvider;
|
||||||
_payJoinStateProvider = payJoinStateProvider;
|
_payJoinRepository = payJoinRepository;
|
||||||
|
_eventAggregator = eventAggregator;
|
||||||
|
_dashboard = dashboard;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{invoice}")]
|
[HttpPost("")]
|
||||||
[IgnoreAntiforgeryToken]
|
[IgnoreAntiforgeryToken]
|
||||||
[EnableCors(CorsPolicies.All)]
|
[EnableCors(CorsPolicies.All)]
|
||||||
[MediaTypeConstraint("text/plain")]
|
[MediaTypeConstraint("text/plain")]
|
||||||
[RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)]
|
[RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)]
|
||||||
public async Task<IActionResult> Submit(string cryptoCode, string invoice)
|
public async Task<IActionResult> Submit(string cryptoCode)
|
||||||
{
|
{
|
||||||
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||||
if (network == null)
|
if (network == null)
|
||||||
{
|
{
|
||||||
return UnprocessableEntity("Incorrect network");
|
return BadRequest(CreatePayjoinError(400, "invalid-network", "Incorrect network"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var explorer = _explorerClientProvider.GetExplorerClient(network);
|
||||||
|
if (Request.ContentLength is long length)
|
||||||
|
{
|
||||||
|
if (length > 1_000_000)
|
||||||
|
return this.StatusCode(413,
|
||||||
|
CreatePayjoinError(413, "payload-too-large", "The transaction is too big to be processed"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return StatusCode(411,
|
||||||
|
CreatePayjoinError(411, "missing-content-length",
|
||||||
|
"The http header Content-Length should be filled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
string rawBody;
|
string rawBody;
|
||||||
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
|
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
|
||||||
{
|
{
|
||||||
rawBody = await reader.ReadToEndAsync();
|
rawBody = (await reader.ReadToEndAsync()) ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(rawBody))
|
Transaction originalTx = null;
|
||||||
|
FeeRate originalFeeRate = null;
|
||||||
|
bool psbtFormat = true;
|
||||||
|
if (!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out var psbt))
|
||||||
{
|
{
|
||||||
return UnprocessableEntity("raw tx not provided");
|
psbtFormat = false;
|
||||||
}
|
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx))
|
||||||
|
return BadRequest(CreatePayjoinError(400, "invalid-format", "invalid transaction or psbt"));
|
||||||
PSBT psbt = null;
|
originalTx = tx;
|
||||||
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var transaction) &&
|
psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork);
|
||||||
!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out psbt))
|
psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() {PSBT = psbt})).PSBT;
|
||||||
{
|
for (int i = 0; i < tx.Inputs.Count; i++)
|
||||||
return UnprocessableEntity("invalid raw transaction or psbt");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (psbt != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
transaction = psbt.ExtractTransaction();
|
psbt.Inputs[i].FinalScriptSig = tx.Inputs[i].ScriptSig;
|
||||||
}
|
psbt.Inputs[i].FinalScriptWitness = tx.Inputs[i].WitScript;
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
return UnprocessableEntity("invalid psbt");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (transaction.Check() != TransactionCheckResult.Success)
|
|
||||||
{
|
{
|
||||||
return UnprocessableEntity($"invalid tx: {transaction.Check()}");
|
if (!psbt.IsAllFinalized())
|
||||||
|
return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT should be finalized"));
|
||||||
|
originalTx = psbt.ExtractTransaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.Inputs.Any(txin => txin.ScriptSig == null || txin.WitScript == null))
|
if (originalTx.Inputs.Any(i => !(i.GetSigner() is WitKeyId)))
|
||||||
|
return BadRequest(CreatePayjoinError(400, "not-using-p2wpkh", "Payjoin only support P2WPKH inputs"));
|
||||||
|
if (psbt.CheckSanity() is var errors && errors.Count != 0)
|
||||||
{
|
{
|
||||||
return UnprocessableEntity($"all inputs must be segwit and signed");
|
return BadRequest(CreatePayjoinError(400, "insane-psbt", $"This PSBT is insane ({errors[0]})"));
|
||||||
|
}
|
||||||
|
if (!psbt.TryGetEstimatedFeeRate(out originalFeeRate))
|
||||||
|
{
|
||||||
|
return BadRequest(CreatePayjoinError(400, "need-utxo-information",
|
||||||
|
"You need to provide Witness UTXO information to the PSBT."));
|
||||||
}
|
}
|
||||||
|
|
||||||
var explorerClient = _explorerClientProvider.GetExplorerClient(network);
|
// This is actually not a mandatory check, but we don't want implementers
|
||||||
var mempool = await explorerClient.BroadcastAsync(transaction, true);
|
// to leak global xpubs
|
||||||
|
if (psbt.GlobalXPubs.Any())
|
||||||
|
{
|
||||||
|
return BadRequest(CreatePayjoinError(400, "leaking-data",
|
||||||
|
"GlobalXPubs should not be included in the PSBT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (psbt.Outputs.Any(o => o.HDKeyPaths.Count != 0) || psbt.Inputs.Any(o => o.HDKeyPaths.Count != 0))
|
||||||
|
{
|
||||||
|
return BadRequest(CreatePayjoinError(400, "leaking-data",
|
||||||
|
"Keypath information should not be included in the PSBT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (psbt.Inputs.Any(o => !o.IsFinalized()))
|
||||||
|
{
|
||||||
|
return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT Should be finalized"));
|
||||||
|
}
|
||||||
|
////////////
|
||||||
|
|
||||||
|
var mempool = await explorer.BroadcastAsync(originalTx, true);
|
||||||
if (!mempool.Success)
|
if (!mempool.Success)
|
||||||
{
|
{
|
||||||
return UnprocessableEntity($"provided transaction isn't mempool eligible {mempool.RPCCodeMessage}");
|
return BadRequest(CreatePayjoinError(400, "invalid-transaction",
|
||||||
|
$"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
|
var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
|
||||||
|
bool paidSomething = false;
|
||||||
//multiple outs could mean a payment being done to multiple invoices to multiple stores in one payjoin tx which makes life unbearable
|
Money due = null;
|
||||||
//UNLESS the request specified an invoice Id, which is mandatory :)
|
Dictionary<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>();
|
||||||
var matchingInvoice = await _invoiceRepository.GetInvoice(invoice);
|
PSBTOutput paymentOutput = null;
|
||||||
if (matchingInvoice == null)
|
BitcoinAddress paymentAddress = null;
|
||||||
|
InvoiceEntity invoice = null;
|
||||||
|
int ourOutputIndex = -1;
|
||||||
|
DerivationSchemeSettings derivationSchemeSettings = null;
|
||||||
|
foreach (var output in psbt.Outputs)
|
||||||
{
|
{
|
||||||
return UnprocessableEntity($"invalid invoice");
|
ourOutputIndex++;
|
||||||
}
|
var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
|
||||||
|
invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {key})).FirstOrDefault();
|
||||||
var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId);
|
if (invoice is null)
|
||||||
//get outs to our current invoice address
|
continue;
|
||||||
var currentPaymentMethodDetails =
|
derivationSchemeSettings = invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(paymentMethodId)
|
||||||
(BitcoinLikeOnChainPaymentMethod) invoicePaymentMethod.GetPaymentMethodDetails();
|
.SingleOrDefault();
|
||||||
|
if (derivationSchemeSettings is null)
|
||||||
if (!currentPaymentMethodDetails.PayJoin?.Enabled is true)
|
continue;
|
||||||
{
|
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
var paymentDetails =
|
||||||
}
|
paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
|
||||||
|
if (paymentDetails is null || !paymentDetails.PayjoinEnabled)
|
||||||
//the invoice must be active, and the status must be new OR paid if
|
continue;
|
||||||
if (matchingInvoice.IsExpired() ||
|
if (invoice.GetAllBitcoinPaymentData().Any())
|
||||||
((matchingInvoice.GetInvoiceState().Status == InvoiceStatus.Paid &&
|
|
||||||
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash == null) ||
|
|
||||||
matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New))
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null &&
|
|
||||||
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash() &&
|
|
||||||
!transaction.RBF)
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
var address = currentPaymentMethodDetails.GetDepositAddress(network.NBitcoinNetwork);
|
|
||||||
var matchingTXOuts = transaction.Outputs.Where(txout => txout.IsTo(address));
|
|
||||||
var nonMatchingTXOuts = transaction.Outputs.Where(txout => !txout.IsTo(address));
|
|
||||||
if (!matchingTXOuts.Any())
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"tx does not pay invoice");
|
|
||||||
}
|
|
||||||
|
|
||||||
var store = await _storeRepository.FindStore(matchingInvoice.StoreId);
|
|
||||||
|
|
||||||
//check if store is enabled
|
|
||||||
var derivationSchemeSettings = store.GetSupportedPaymentMethods(_btcPayNetworkProvider)
|
|
||||||
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
|
|
||||||
settings.PaymentId == paymentMethodId && store.GetEnabledPaymentIds(_btcPayNetworkProvider)
|
|
||||||
.Contains(settings.PaymentId));
|
|
||||||
if (derivationSchemeSettings == null)
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = _payJoinStateProvider.GetOrAdd(new WalletId(matchingInvoice.StoreId, cryptoCode),
|
|
||||||
derivationSchemeSettings.AccountDerivation);
|
|
||||||
|
|
||||||
//check if any of the inputs have been spotted in other txs sent our way..Reject anything but the original
|
|
||||||
//also reject if the invoice being payjoined to already has a record
|
|
||||||
var validity = state.CheckIfTransactionValid(transaction, invoice);
|
|
||||||
if (validity == PayJoinState.TransactionValidityResult.Invalid_Inputs_Seen || validity == PayJoinState.TransactionValidityResult.Invalid_PartialMatch)
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
//check if wallet of store is configured to be hot wallet
|
|
||||||
var extKeyStr = await explorerClient.GetMetadataAsync<string>(
|
|
||||||
derivationSchemeSettings.AccountDerivation,
|
|
||||||
WellknownMetadataKeys.MasterHDKey);
|
|
||||||
if (extKeyStr == null)
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
var extKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
|
|
||||||
|
|
||||||
var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
|
|
||||||
if (signingKeySettings.RootFingerprint is null)
|
|
||||||
signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint();
|
|
||||||
|
|
||||||
RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath();
|
|
||||||
if (rootedKeyPath == null)
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
// The master fingerprint and/or account key path of your seed are not set in the wallet settings
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user gave the root key, let's try to rebase the PSBT, and derive the account private key
|
|
||||||
if (rootedKeyPath.MasterFingerprint == extKey.GetPublicKey().GetHDFingerPrint())
|
|
||||||
{
|
|
||||||
extKey = extKey.Derive(rootedKeyPath.KeyPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
//check if the store uses segwit -- mixing inputs of different types is suspicious
|
|
||||||
if (derivationSchemeSettings.AccountDerivation.ScriptPubKeyType() == ScriptPubKeyType.Legacy)
|
|
||||||
{
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
//get previous payments so that we can check if their address is also used in the txouts)
|
|
||||||
var previousPayments = matchingInvoice.GetPayments(network)
|
|
||||||
.Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData);
|
|
||||||
|
|
||||||
if (transaction.Outputs.Any(
|
|
||||||
txout => previousPayments.Any(data => !txout.IsTo(address) && txout.IsTo(data.GetDestination()))))
|
|
||||||
{
|
|
||||||
//Meh, address reuse from the customer would be happening with this tx, skip
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
//get any utxos we exposed already that match any of the inputs sent to us.
|
|
||||||
var utxosToContributeToThisPayment = state.GetExposed(transaction);
|
|
||||||
|
|
||||||
var invoicePaymentMethodAccounting = invoicePaymentMethod.Calculate();
|
|
||||||
if (invoicePaymentMethodAccounting.Due != matchingTXOuts.Sum(txout => txout.Value) &&
|
|
||||||
!utxosToContributeToThisPayment.Any())
|
|
||||||
{
|
|
||||||
//the invoice would be under/overpaid with this tx and we have not exposed utxos so no worries
|
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
//if we have not exposed any utxos to any of the inputs
|
|
||||||
if (!utxosToContributeToThisPayment.Any())
|
|
||||||
{
|
|
||||||
var wallet = _btcPayWalletProvider.GetWallet(network);
|
|
||||||
//get all utxos we have so far exposed
|
|
||||||
var coins = state.GetRecords().SelectMany(list =>
|
|
||||||
list.CoinsExposed.Select(coin => coin.OutPoint.Hash));
|
|
||||||
|
|
||||||
//get all utxos we have NOT so far exposed
|
|
||||||
var availableUtxos = (await wallet.GetUnspentCoins(derivationSchemeSettings.AccountDerivation)).Where(
|
|
||||||
coin =>
|
|
||||||
!coins.Contains(coin.OutPoint.Hash));
|
|
||||||
if (availableUtxos.Any())
|
|
||||||
{
|
{
|
||||||
//clean up the state by removing utxos from the exposed list that we no longer have
|
return UnprocessableEntity(CreatePayjoinError(422, "already-paid",
|
||||||
state.PruneExposedButSpentCoins(availableUtxos);
|
$"The invoice this PSBT is paying has already been partially or completely paid"));
|
||||||
//if we have coins that were exposed before but were not spent, prioritize them
|
|
||||||
var exposedAlready = state.GetExposedCoins();
|
|
||||||
if (exposedAlready.Any())
|
|
||||||
{
|
|
||||||
utxosToContributeToThisPayment = SelectCoins(network, exposedAlready,
|
|
||||||
invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC),
|
|
||||||
nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
|
|
||||||
state.PruneExposedBySpentCoins(utxosToContributeToThisPayment.Select(coin => coin.OutPoint));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
utxosToContributeToThisPayment = SelectCoins(network, availableUtxos,
|
|
||||||
invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC),
|
|
||||||
nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
//we don't have any utxos to provide to this tx
|
paidSomething = true;
|
||||||
if (!utxosToContributeToThisPayment.Any())
|
due = paymentMethod.Calculate().TotalDue - output.Value;
|
||||||
{
|
if (due > Money.Zero)
|
||||||
return UnprocessableEntity($"cannot handle payjoin tx");
|
|
||||||
}
|
|
||||||
|
|
||||||
//we rebuild the tx using 1 output to the invoice designed address
|
|
||||||
var cjOutputContributedAmount = utxosToContributeToThisPayment.Sum(coin => coin.Value.GetValue(network));
|
|
||||||
var cjOutputSum = matchingTXOuts.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)) +
|
|
||||||
cjOutputContributedAmount;
|
|
||||||
|
|
||||||
var newTx = transaction.Clone();
|
|
||||||
|
|
||||||
|
|
||||||
if (matchingTXOuts.Count() > 1)
|
|
||||||
{
|
|
||||||
//if there are more than 1 outputs to our address, consolidate them to 1 + coinjoined amount to avoid unnecessary utxos
|
|
||||||
newTx.Outputs.Clear();
|
|
||||||
newTx.Outputs.Add(new Money(cjOutputSum, MoneyUnit.BTC), address.ScriptPubKey);
|
|
||||||
foreach (var nonmatchingTxOut in nonMatchingTXOuts)
|
|
||||||
{
|
{
|
||||||
newTx.Outputs.Add(nonmatchingTxOut.Value, nonmatchingTxOut.ScriptPubKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
//set the value of the out to our address to the sum of the coinjoined amount
|
|
||||||
foreach (var txOutput in newTx.Outputs.Where(txOutput =>
|
|
||||||
txOutput.Value == matchingTXOuts.First().Value &&
|
|
||||||
txOutput.ScriptPubKey == matchingTXOuts.First().ScriptPubKey))
|
|
||||||
{
|
|
||||||
txOutput.Value = new Money(cjOutputSum, MoneyUnit.BTC);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!await _payJoinRepository.TryLockInputs(originalTx.Inputs.Select(i => i.PrevOut).ToArray()))
|
||||||
|
{
|
||||||
|
return BadRequest(CreatePayjoinError(400, "inputs-already-used",
|
||||||
|
"Some of those inputs have already been used to make payjoin transaction"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var utxos = (await explorer.GetUTXOsAsync(derivationSchemeSettings.AccountDerivation))
|
||||||
|
.GetUnspentUTXOs(false);
|
||||||
|
// In case we are paying ourselves, be need to make sure
|
||||||
|
// we can't take spent outpoints.
|
||||||
|
var prevOuts = originalTx.Inputs.Select(o => o.PrevOut).ToHashSet();
|
||||||
|
utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray();
|
||||||
|
foreach (var utxo in await SelectUTXO(network, utxos, output.Value,
|
||||||
|
psbt.Outputs.Where(o => o.Index != output.Index).Select(o => o.Value).ToArray()))
|
||||||
|
{
|
||||||
|
selectedUTXOs.Add(utxo.Outpoint, utxo);
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentOutput = output;
|
||||||
|
paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
newTx.Inputs.AddRange(utxosToContributeToThisPayment.Select(coin =>
|
if (!paidSomething)
|
||||||
new TxIn(coin.OutPoint) {Sequence = newTx.Inputs.First().Sequence}));
|
|
||||||
|
|
||||||
if (psbt != null)
|
|
||||||
{
|
{
|
||||||
psbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork);
|
return BadRequest(CreatePayjoinError(400, "invoice-not-found",
|
||||||
|
"This transaction does not pay any invoice with payjoin"));
|
||||||
psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest()
|
|
||||||
{
|
|
||||||
DerivationScheme = derivationSchemeSettings.AccountDerivation,
|
|
||||||
PSBT = psbt,
|
|
||||||
RebaseKeyPaths = derivationSchemeSettings.GetPSBTRebaseKeyRules().ToList()
|
|
||||||
})).PSBT;
|
|
||||||
|
|
||||||
|
|
||||||
psbt = psbt.SignWithKeys(utxosToContributeToThisPayment
|
|
||||||
.Select(coin => extKey.Derive(coin.KeyPath).PrivateKey)
|
|
||||||
.ToArray());
|
|
||||||
|
|
||||||
if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
|
|
||||||
{
|
|
||||||
//if the invoice was rbfed, remove the current record and replace it with the new one
|
|
||||||
state.RemoveRecord(invoice);
|
|
||||||
}
|
|
||||||
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
|
|
||||||
{
|
|
||||||
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
|
|
||||||
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
|
|
||||||
invoicePaymentMethod);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(HexEncoder.IsWellFormed(rawBody) ? psbt.ToHex() : psbt.ToBase64());
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
if (due is null || due > Money.Zero)
|
||||||
{
|
{
|
||||||
// Since we're going to modify the transaction, we're going invalidate all signatures
|
return BadRequest(CreatePayjoinError(400, "invoice-not-fully-paid",
|
||||||
foreach (TxIn newTxInput in newTx.Inputs)
|
"The transaction must pay the whole invoice"));
|
||||||
{
|
|
||||||
newTxInput.WitScript = WitScript.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
newTx.Sign(
|
|
||||||
utxosToContributeToThisPayment.Select(coin =>
|
|
||||||
extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)),
|
|
||||||
utxosToContributeToThisPayment.Select(coin => coin.Coin));
|
|
||||||
|
|
||||||
if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
|
|
||||||
{
|
|
||||||
//if the invoice was rbfed, remove the current record and replace it with the new one
|
|
||||||
state.RemoveRecord(invoice);
|
|
||||||
}
|
|
||||||
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
|
|
||||||
{
|
|
||||||
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
|
|
||||||
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
|
|
||||||
invoicePaymentMethod);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(newTx.ToHex());
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddRecord(string invoice, PayJoinState joinState, Transaction transaction,
|
if (selectedUTXOs.Count == 0)
|
||||||
List<ReceivedCoin> utxosToContributeToThisPayment, decimal cjOutputContributedAmount, decimal cjOutputSum,
|
|
||||||
Transaction newTx,
|
|
||||||
BitcoinLikeOnChainPaymentMethod currentPaymentMethodDetails, PaymentMethod invoicePaymentMethod)
|
|
||||||
{
|
|
||||||
//keep a record of the tx and check if we have seen the tx before or any of its inputs
|
|
||||||
//on a timer service: if x amount of times passes, broadcast this tx
|
|
||||||
joinState.AddRecord(new PayJoinStateRecordedItem()
|
|
||||||
{
|
{
|
||||||
Timestamp = DateTimeOffset.Now,
|
await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx);
|
||||||
Transaction = transaction,
|
return StatusCode(503,
|
||||||
OriginalTransactionHash = transaction.GetHash(),
|
CreatePayjoinError(503, "out-of-utxos",
|
||||||
CoinsExposed = utxosToContributeToThisPayment,
|
"We do not have any UTXO available for making a payjoin for now"));
|
||||||
ContributedAmount = cjOutputContributedAmount,
|
}
|
||||||
TotalOutputAmount = cjOutputSum,
|
|
||||||
ProposedTransactionHash = newTx.GetHash(),
|
var originalPaymentValue = paymentOutput.Value;
|
||||||
InvoiceId = invoice
|
// Add the original transaction to the payment
|
||||||
});
|
var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
|
||||||
//we also store a record in the payment method details of the invoice,
|
paymentOutput.Value,
|
||||||
//Tn case the server is shut down and a payjoin payment is made before it is turned back on.
|
new OutPoint(originalTx.GetHash(), paymentOutput.Index),
|
||||||
//Otherwise we would end up marking the invoice as overPaid with our own inputs!
|
originalTx.RBF);
|
||||||
currentPaymentMethodDetails.PayJoin = new PayJoinPaymentState()
|
originalPaymentData.PayjoinInformation = new PayjoinInformation()
|
||||||
{
|
{
|
||||||
Enabled = true,
|
Type = PayjoinTransactionType.Original, ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
|
||||||
CoinsExposed = utxosToContributeToThisPayment,
|
|
||||||
ContributedAmount = cjOutputContributedAmount,
|
|
||||||
TotalOutputAmount = cjOutputSum,
|
|
||||||
ProposedTransactionHash = newTx.GetHash(),
|
|
||||||
OriginalTransactionHash = transaction.GetHash(),
|
|
||||||
};
|
};
|
||||||
invoicePaymentMethod.SetPaymentMethodDetails(currentPaymentMethodDetails);
|
originalPaymentData.ConfirmationCount = -1;
|
||||||
await _invoiceRepository.UpdateInvoicePaymentMethod(invoice, invoicePaymentMethod);
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var payment = await _invoiceRepository.AddPayment(invoice.Id, now, originalPaymentData, network, true);
|
||||||
|
if (payment is null)
|
||||||
|
{
|
||||||
|
return UnprocessableEntity(CreatePayjoinError(422, "already-paid",
|
||||||
|
$"The original transaction has already been accounted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _broadcaster.Schedule(now + TimeSpan.FromMinutes(1.0), originalTx, network);
|
||||||
|
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx);
|
||||||
|
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment});
|
||||||
|
|
||||||
|
//check if wallet of store is configured to be hot wallet
|
||||||
|
var extKeyStr = await explorer.GetMetadataAsync<string>(
|
||||||
|
derivationSchemeSettings.AccountDerivation,
|
||||||
|
WellknownMetadataKeys.AccountHDKey);
|
||||||
|
if (extKeyStr == null)
|
||||||
|
{
|
||||||
|
// This should not happen, as we check the existance of private key before creating invoice with payjoin
|
||||||
|
return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var newTx = originalTx.Clone();
|
||||||
|
var ourOutput = newTx.Outputs[ourOutputIndex];
|
||||||
|
foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value))
|
||||||
|
{
|
||||||
|
ourOutput.Value += (Money)selectedUTXO.Value;
|
||||||
|
newTx.Inputs.Add(selectedUTXO.Outpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rand = new Random();
|
||||||
|
Utils.Shuffle(newTx.Inputs, rand);
|
||||||
|
Utils.Shuffle(newTx.Outputs, rand);
|
||||||
|
ourOutputIndex = newTx.Outputs.IndexOf(ourOutput);
|
||||||
|
|
||||||
|
// Remove old signatures as they are not valid anymore
|
||||||
|
foreach (var input in newTx.Inputs)
|
||||||
|
{
|
||||||
|
input.WitScript = WitScript.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Money ourFeeContribution = Money.Zero;
|
||||||
|
// We need to adjust the fee to keep a constant fee rate
|
||||||
|
var originalNewTx = newTx.Clone();
|
||||||
|
bool isSecondPass = false;
|
||||||
|
recalculateFee:
|
||||||
|
ourOutput = newTx.Outputs[ourOutputIndex];
|
||||||
|
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
|
||||||
|
txBuilder.AddCoins(psbt.Inputs.Select(i => i.GetCoin()));
|
||||||
|
txBuilder.AddCoins(selectedUTXOs.Select(o => o.Value.AsCoin()));
|
||||||
|
Money expectedFee = txBuilder.EstimateFees(newTx, originalFeeRate);
|
||||||
|
Money actualFee = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
|
||||||
|
Money additionalFee = expectedFee - actualFee;
|
||||||
|
if (additionalFee > Money.Zero)
|
||||||
|
{
|
||||||
|
var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
|
||||||
|
new FeeRate(1.0m);
|
||||||
|
|
||||||
|
// If the user overpaid, taking fee on our output (useful if they dump a full UTXO for privacy)
|
||||||
|
if (due < Money.Zero)
|
||||||
|
{
|
||||||
|
ourFeeContribution = Money.Min(additionalFee, -due);
|
||||||
|
ourFeeContribution = Money.Min(ourFeeContribution,
|
||||||
|
ourOutput.Value - ourOutput.GetDustThreshold(minRelayTxFee));
|
||||||
|
ourOutput.Value -= ourFeeContribution;
|
||||||
|
additionalFee -= ourFeeContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest, we take from user's change
|
||||||
|
if (additionalFee > Money.Zero)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < newTx.Outputs.Count && additionalFee != Money.Zero; i++)
|
||||||
|
{
|
||||||
|
if (i != ourOutputIndex)
|
||||||
|
{
|
||||||
|
var outputContribution = Money.Min(additionalFee, newTx.Outputs[i].Value);
|
||||||
|
newTx.Outputs[i].Value -= outputContribution;
|
||||||
|
additionalFee -= outputContribution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> dustIndices = new List<int>();
|
||||||
|
for (int i = 0; i < newTx.Outputs.Count; i++)
|
||||||
|
{
|
||||||
|
if (newTx.Outputs[i].IsDust(minRelayTxFee))
|
||||||
|
{
|
||||||
|
dustIndices.Insert(0, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dustIndices.Count > 0)
|
||||||
|
{
|
||||||
|
if (isSecondPass)
|
||||||
|
{
|
||||||
|
// This should not happen
|
||||||
|
return StatusCode(500,
|
||||||
|
CreatePayjoinError(500, "unavailable",
|
||||||
|
$"This service is unavailable for now (isSecondPass)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dustIndex in dustIndices)
|
||||||
|
{
|
||||||
|
newTx.Outputs.RemoveAt(dustIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ourOutputIndex = newTx.Outputs.IndexOf(ourOutput);
|
||||||
|
newTx = originalNewTx.Clone();
|
||||||
|
foreach (var dustIndex in dustIndices)
|
||||||
|
{
|
||||||
|
newTx.Outputs.RemoveAt(dustIndex);
|
||||||
|
}
|
||||||
|
ourFeeContribution = Money.Zero;
|
||||||
|
isSecondPass = true;
|
||||||
|
goto recalculateFee;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalFee > Money.Zero)
|
||||||
|
{
|
||||||
|
// We could not pay fully the additional fee, however, as long as
|
||||||
|
// we are not under the relay fee, it should be OK.
|
||||||
|
var newVSize = txBuilder.EstimateSize(newTx, true);
|
||||||
|
var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
|
||||||
|
if (new FeeRate(newFeePaid, newVSize) < minRelayTxFee)
|
||||||
|
{
|
||||||
|
await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray());
|
||||||
|
return UnprocessableEntity(CreatePayjoinError(422, "not-enough-money",
|
||||||
|
"Not enough money is sent to pay for the additional payjoin inputs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
|
||||||
|
var newPsbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork);
|
||||||
|
foreach (var selectedUtxo in selectedUTXOs.Select(o => o.Value))
|
||||||
|
{
|
||||||
|
var signedInput = newPsbt.Inputs.FindIndexedInput(selectedUtxo.Outpoint);
|
||||||
|
signedInput.UpdateFromCoin(selectedUtxo.AsCoin());
|
||||||
|
var privateKey = accountKey.Derive(selectedUtxo.KeyPath).PrivateKey;
|
||||||
|
signedInput.Sign(privateKey);
|
||||||
|
signedInput.FinalizeInput();
|
||||||
|
newTx.Inputs[signedInput.Index].WitScript = newPsbt.Inputs[(int)signedInput.Index].FinalScriptWitness;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the coinjoin transaction to the payments
|
||||||
|
var coinjoinPaymentData = new BitcoinLikePaymentData(paymentAddress,
|
||||||
|
originalPaymentValue - ourFeeContribution,
|
||||||
|
new OutPoint(newPsbt.GetGlobalTransaction().GetHash(), ourOutputIndex),
|
||||||
|
originalTx.RBF);
|
||||||
|
coinjoinPaymentData.PayjoinInformation = new PayjoinInformation()
|
||||||
|
{
|
||||||
|
Type = PayjoinTransactionType.Coinjoin,
|
||||||
|
ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
|
||||||
|
};
|
||||||
|
coinjoinPaymentData.ConfirmationCount = -1;
|
||||||
|
payment = await _invoiceRepository.AddPayment(invoice.Id, now, coinjoinPaymentData, network, false,
|
||||||
|
payment.NetworkFee);
|
||||||
|
// We do not publish an event on purpose, this would be confusing for the merchant.
|
||||||
|
|
||||||
|
if (psbtFormat)
|
||||||
|
return Ok(newPsbt.ToBase64());
|
||||||
|
else
|
||||||
|
return Ok(newTx.ToHex());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ReceivedCoin> SelectCoins(BTCPayNetwork network, IEnumerable<ReceivedCoin> availableUtxos,
|
private JObject CreatePayjoinError(int httpCode, string errorCode, string friendlyMessage)
|
||||||
decimal paymentAmount, IEnumerable<decimal> otherOutputs)
|
|
||||||
{
|
{
|
||||||
|
var o = new JObject();
|
||||||
|
o.Add(new JProperty("httpCode", httpCode));
|
||||||
|
o.Add(new JProperty("errorCode", errorCode));
|
||||||
|
o.Add(new JProperty("message", friendlyMessage));
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UTXO[]> SelectUTXO(BTCPayNetwork network, UTXO[] availableUtxos, Money paymentAmount,
|
||||||
|
Money[] otherOutputs)
|
||||||
|
{
|
||||||
|
if (availableUtxos.Length == 0)
|
||||||
|
return Array.Empty<UTXO>();
|
||||||
|
// Assume the merchant wants to get rid of the dust
|
||||||
|
Utils.Shuffle(availableUtxos);
|
||||||
|
HashSet<OutPoint> locked = new HashSet<OutPoint>();
|
||||||
|
// We don't want to make too many db roundtrip which would be inconvenient for the sender
|
||||||
|
int maxTries = 30;
|
||||||
|
int currentTry = 0;
|
||||||
|
List<UTXO> utxosByPriority = new List<UTXO>();
|
||||||
// UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario".
|
// UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario".
|
||||||
//
|
//
|
||||||
// "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output.
|
// "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output.
|
||||||
|
@ -405,8 +443,10 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
|
|
||||||
foreach (var availableUtxo in availableUtxos)
|
foreach (var availableUtxo in availableUtxos)
|
||||||
{
|
{
|
||||||
|
if (currentTry >= maxTries)
|
||||||
|
break;
|
||||||
//we can only check against our input as we dont know the value of the rest.
|
//we can only check against our input as we dont know the value of the rest.
|
||||||
var input = availableUtxo.Value.GetValue(network);
|
var input = (Money)availableUtxo.Value;
|
||||||
var paymentAmountSum = input + paymentAmount;
|
var paymentAmountSum = input + paymentAmount;
|
||||||
if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output))
|
if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output))
|
||||||
{
|
{
|
||||||
|
@ -414,12 +454,24 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new List<ReceivedCoin> {availableUtxo};
|
if (await _payJoinRepository.TryLock(availableUtxo.Outpoint))
|
||||||
|
{
|
||||||
|
return new UTXO[] { availableUtxo };
|
||||||
|
}
|
||||||
|
locked.Add(availableUtxo.Outpoint);
|
||||||
|
currentTry++;
|
||||||
}
|
}
|
||||||
|
foreach (var utxo in availableUtxos.Where(u => !locked.Contains(u.Outpoint)))
|
||||||
//For now we just grab a utxo "at random"
|
{
|
||||||
Random r = new Random();
|
if (currentTry >= maxTries)
|
||||||
return new List<ReceivedCoin>() {availableUtxos.ElementAt(r.Next(0, availableUtxos.Count()))};
|
break;
|
||||||
|
if (await _payJoinRepository.TryLock(utxo.Outpoint))
|
||||||
|
{
|
||||||
|
return new UTXO[] { utxo };
|
||||||
|
}
|
||||||
|
currentTry++;
|
||||||
|
}
|
||||||
|
return Array.Empty<UTXO>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
|
using BTCPayServer.Services;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.PayJoin
|
namespace BTCPayServer.Payments.PayJoin
|
||||||
{
|
{
|
||||||
public static class PayJoinExtensions
|
public static class PayJoinExtensions
|
||||||
{
|
{
|
||||||
public static void AddPayJoinServices(this IServiceCollection serviceCollection)
|
public static void AddPayJoinServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
serviceCollection.AddSingleton<PayJoinStateProvider>();
|
services.AddSingleton<DelayedTransactionBroadcaster>();
|
||||||
serviceCollection.AddHostedService<PayJoinTransactionBroadcaster>();
|
services.AddSingleton<IHostedService, HostedServices.DelayedTransactionBroadcasterHostedService>();
|
||||||
|
services.AddSingleton<PayJoinRepository>();
|
||||||
|
services.AddSingleton<PayjoinClient>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
48
BTCPayServer/Payments/PayJoin/PayJoinRepository.cs
Normal file
48
BTCPayServer/Payments/PayJoin/PayJoinRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,176 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using BTCPayServer.Services.Wallets;
|
|
||||||
using NBitcoin;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.PayJoin
|
|
||||||
{
|
|
||||||
public class PayJoinState
|
|
||||||
{
|
|
||||||
//keep track of all transactions sent to us via this protocol
|
|
||||||
private readonly ConcurrentDictionary<string, PayJoinStateRecordedItem> RecordedTransactions =
|
|
||||||
new ConcurrentDictionary<string, PayJoinStateRecordedItem>();
|
|
||||||
|
|
||||||
//utxos that have been exposed but the original tx was broadcasted instead.
|
|
||||||
private readonly ConcurrentDictionary<string, ReceivedCoin> ExposedCoins;
|
|
||||||
|
|
||||||
public PayJoinState(ConcurrentDictionary<string, ReceivedCoin> exposedCoins = null)
|
|
||||||
{
|
|
||||||
ExposedCoins = exposedCoins ?? new ConcurrentDictionary<string, ReceivedCoin>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<PayJoinStateRecordedItem> GetRecords()
|
|
||||||
{
|
|
||||||
return RecordedTransactions.Values;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<PayJoinStateRecordedItem> GetStaleRecords(TimeSpan cutoff)
|
|
||||||
{
|
|
||||||
return GetRecords().Where(pair =>
|
|
||||||
DateTimeOffset.Now.Subtract(pair.Timestamp).TotalMilliseconds >=
|
|
||||||
cutoff.TotalMilliseconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum TransactionValidityResult
|
|
||||||
{
|
|
||||||
Valid_ExactMatch,
|
|
||||||
Invalid_PartialMatch,
|
|
||||||
Valid_NoMatch,
|
|
||||||
Invalid_Inputs_Seen,
|
|
||||||
Valid_SameInputs
|
|
||||||
}
|
|
||||||
|
|
||||||
public TransactionValidityResult CheckIfTransactionValid(Transaction transaction, string invoiceId)
|
|
||||||
{
|
|
||||||
if (RecordedTransactions.ContainsKey($"{invoiceId}_{transaction.GetHash()}"))
|
|
||||||
{
|
|
||||||
return TransactionValidityResult.Valid_ExactMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
var hashes = transaction.Inputs.Select(txIn => txIn.PrevOut.ToString());
|
|
||||||
|
|
||||||
var matches = RecordedTransactions.Where(record =>
|
|
||||||
record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) ||
|
|
||||||
record.Key.Contains(transaction.GetHash().ToString(), StringComparison.InvariantCultureIgnoreCase));
|
|
||||||
|
|
||||||
if (matches.Any())
|
|
||||||
{
|
|
||||||
if(matches.Any(record =>
|
|
||||||
record.Key.Contains(invoiceId, StringComparison.InvariantCultureIgnoreCase) &&
|
|
||||||
record.Value.Transaction.RBF &&
|
|
||||||
record.Value.Transaction.Inputs.All(recordTxIn => hashes.Contains(recordTxIn.PrevOut.ToString()))))
|
|
||||||
{
|
|
||||||
return TransactionValidityResult.Valid_SameInputs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TransactionValidityResult.Invalid_PartialMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
return RecordedTransactions.Any(record =>
|
|
||||||
record.Value.Transaction.Inputs.Any(txIn => hashes.Contains(txIn.PrevOut.ToString())))
|
|
||||||
? TransactionValidityResult.Invalid_Inputs_Seen: TransactionValidityResult.Valid_NoMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddRecord(PayJoinStateRecordedItem recordedItem)
|
|
||||||
{
|
|
||||||
RecordedTransactions.TryAdd(recordedItem.ToString(), recordedItem);
|
|
||||||
foreach (var receivedCoin in recordedItem.CoinsExposed)
|
|
||||||
{
|
|
||||||
ExposedCoins.TryRemove(receivedCoin.OutPoint.ToString(), out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveRecord(PayJoinStateRecordedItem item, bool keepExposed)
|
|
||||||
{
|
|
||||||
if (keepExposed)
|
|
||||||
{
|
|
||||||
foreach (var receivedCoin in item.CoinsExposed)
|
|
||||||
{
|
|
||||||
ExposedCoins.AddOrReplace(receivedCoin.OutPoint.ToString(), receivedCoin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordedTransactions.TryRemove(item.ToString(), out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveRecord(uint256 proposedTxHash)
|
|
||||||
{
|
|
||||||
var id = RecordedTransactions.SingleOrDefault(pair =>
|
|
||||||
pair.Value.ProposedTransactionHash == proposedTxHash ||
|
|
||||||
pair.Value.OriginalTransactionHash == proposedTxHash).Key;
|
|
||||||
if (id != null)
|
|
||||||
{
|
|
||||||
RecordedTransactions.TryRemove(id, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveRecord(string invoiceId)
|
|
||||||
{
|
|
||||||
var id = RecordedTransactions.Single(pair =>
|
|
||||||
pair.Value.InvoiceId == invoiceId).Key;
|
|
||||||
RecordedTransactions.TryRemove(id, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ReceivedCoin> GetExposed(Transaction transaction)
|
|
||||||
{
|
|
||||||
return RecordedTransactions.Values
|
|
||||||
.Where(pair =>
|
|
||||||
pair.Transaction.Inputs.Any(txIn =>
|
|
||||||
transaction.Inputs.Any(txIn2 => txIn.PrevOut == txIn2.PrevOut)))
|
|
||||||
.SelectMany(pair => pair.CoinsExposed).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool TryGetWithProposedHash(uint256 hash, out PayJoinStateRecordedItem item)
|
|
||||||
{
|
|
||||||
item =
|
|
||||||
RecordedTransactions.Values.SingleOrDefault(
|
|
||||||
recordedItem => recordedItem.ProposedTransactionHash == hash);
|
|
||||||
return item != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<ReceivedCoin> GetExposedCoins(bool includeOnesInOngoingBPUs = false)
|
|
||||||
{
|
|
||||||
var result = ExposedCoins.Values;
|
|
||||||
return includeOnesInOngoingBPUs
|
|
||||||
? result.Concat(RecordedTransactions.Values.SelectMany(item => item.CoinsExposed))
|
|
||||||
: result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void PruneExposedButSpentCoins(IEnumerable<ReceivedCoin> stillAvailable)
|
|
||||||
{
|
|
||||||
var keys = stillAvailable.Select(coin => coin.OutPoint.ToString());
|
|
||||||
var keysToRemove = ExposedCoins.Keys.Where(s => !keys.Contains(s));
|
|
||||||
foreach (var key in keysToRemove)
|
|
||||||
{
|
|
||||||
ExposedCoins.TryRemove(key, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public void PruneExposedBySpentCoins(IEnumerable<OutPoint> taken)
|
|
||||||
{
|
|
||||||
var keys = taken.Select(coin => coin.ToString());
|
|
||||||
var keysToRemove = ExposedCoins.Keys.Where(s => keys.Contains(s));
|
|
||||||
foreach (var key in keysToRemove)
|
|
||||||
{
|
|
||||||
ExposedCoins.TryRemove(key, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void PruneRecordsOfUsedInputs(TxInList transactionInputs)
|
|
||||||
{
|
|
||||||
foreach (PayJoinStateRecordedItem payJoinStateRecordedItem in RecordedTransactions.Values)
|
|
||||||
{
|
|
||||||
if (payJoinStateRecordedItem.CoinsExposed.Any(coin =>
|
|
||||||
transactionInputs.Any(txin => txin.PrevOut == coin.OutPoint)))
|
|
||||||
{
|
|
||||||
RemoveRecord(payJoinStateRecordedItem, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PruneExposedBySpentCoins(transactionInputs.Select(coin => coin.PrevOut));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using BTCPayServer.Data;
|
|
||||||
using BTCPayServer.Services;
|
|
||||||
using BTCPayServer.Services.Stores;
|
|
||||||
using BTCPayServer.Services.Wallets;
|
|
||||||
using NBitcoin;
|
|
||||||
using NBXplorer.DerivationStrategy;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.PayJoin
|
|
||||||
{
|
|
||||||
public class PayJoinStateProvider
|
|
||||||
{
|
|
||||||
private readonly SettingsRepository _settingsRepository;
|
|
||||||
private readonly StoreRepository _storeRepository;
|
|
||||||
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
|
|
||||||
private readonly BTCPayWalletProvider _btcPayWalletProvider;
|
|
||||||
|
|
||||||
private MultiValueDictionary<DerivationStrategyBase, WalletId> Lookup =
|
|
||||||
new MultiValueDictionary<DerivationStrategyBase, WalletId>();
|
|
||||||
|
|
||||||
private ConcurrentDictionary<WalletId, PayJoinState> States =
|
|
||||||
new ConcurrentDictionary<WalletId, PayJoinState>();
|
|
||||||
|
|
||||||
public PayJoinStateProvider(SettingsRepository settingsRepository, StoreRepository storeRepository,
|
|
||||||
BTCPayNetworkProvider btcPayNetworkProvider, BTCPayWalletProvider btcPayWalletProvider)
|
|
||||||
{
|
|
||||||
_settingsRepository = settingsRepository;
|
|
||||||
_storeRepository = storeRepository;
|
|
||||||
_btcPayNetworkProvider = btcPayNetworkProvider;
|
|
||||||
_btcPayWalletProvider = btcPayWalletProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<PayJoinState> Get(string cryptoCode, DerivationStrategyBase derivationStrategyBase)
|
|
||||||
{
|
|
||||||
if (Lookup.TryGetValue(derivationStrategyBase, out var walletIds))
|
|
||||||
{
|
|
||||||
var matchedWalletKeys = walletIds.Where(id =>
|
|
||||||
id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase));
|
|
||||||
|
|
||||||
return matchedWalletKeys.Select(id => States.TryGet(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.Empty<PayJoinState>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public PayJoinState Get(WalletId walletId)
|
|
||||||
{
|
|
||||||
return States.TryGet(walletId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConcurrentDictionary<WalletId, PayJoinState> GetAll()
|
|
||||||
{
|
|
||||||
return States;
|
|
||||||
}
|
|
||||||
|
|
||||||
public PayJoinState GetOrAdd(WalletId key, DerivationStrategyBase derivationStrategyBase,
|
|
||||||
IEnumerable<ReceivedCoin> exposedCoins = null)
|
|
||||||
{
|
|
||||||
return States.GetOrAdd(key, id =>
|
|
||||||
{
|
|
||||||
Lookup.Add(derivationStrategyBase, id);
|
|
||||||
return new PayJoinState(exposedCoins == null
|
|
||||||
? null
|
|
||||||
: new ConcurrentDictionary<string, ReceivedCoin>(exposedCoins.Select(coin =>
|
|
||||||
new KeyValuePair<string, ReceivedCoin>(coin.OutPoint.ToString(), coin))));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveState(WalletId walletId)
|
|
||||||
{
|
|
||||||
States.TryRemove(walletId, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SaveCoins()
|
|
||||||
{
|
|
||||||
Dictionary<string, IEnumerable<OutPoint>> saved =
|
|
||||||
new Dictionary<string, IEnumerable<OutPoint>>();
|
|
||||||
foreach (var payState in GetAll())
|
|
||||||
{
|
|
||||||
saved.Add(payState.Key.ToString(),
|
|
||||||
payState.Value.GetExposedCoins(true).Select(coin => coin.OutPoint));
|
|
||||||
}
|
|
||||||
|
|
||||||
await _settingsRepository.UpdateSetting(saved, "bpu-state");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LoadCoins()
|
|
||||||
{
|
|
||||||
Dictionary<string, IEnumerable<OutPoint>> saved =
|
|
||||||
await _settingsRepository.GetSettingAsync<Dictionary<string, IEnumerable<OutPoint>>>("bpu-state");
|
|
||||||
if (saved == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (KeyValuePair<string, IEnumerable<OutPoint>> keyValuePair in saved)
|
|
||||||
{
|
|
||||||
var walletId = WalletId.Parse(keyValuePair.Key);
|
|
||||||
var store = await _storeRepository.FindStore(walletId.StoreId);
|
|
||||||
var derivationSchemeSettings = store?.GetSupportedPaymentMethods(_btcPayNetworkProvider)
|
|
||||||
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
|
|
||||||
settings.PaymentId.CryptoCode.Equals(walletId.CryptoCode,
|
|
||||||
StringComparison.InvariantCultureIgnoreCase));
|
|
||||||
if (derivationSchemeSettings == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var utxos = await _btcPayWalletProvider.GetWallet(walletId.CryptoCode)
|
|
||||||
.GetUnspentCoins(derivationSchemeSettings.AccountDerivation);
|
|
||||||
|
|
||||||
_ = GetOrAdd(walletId, derivationSchemeSettings.AccountDerivation,
|
|
||||||
utxos.Where(coin => keyValuePair.Value.Contains(coin.OutPoint)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using BTCPayServer.Services.Wallets;
|
|
||||||
using NBitcoin;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.PayJoin
|
|
||||||
{
|
|
||||||
public class PayJoinStateRecordedItem
|
|
||||||
{
|
|
||||||
public Transaction Transaction { get; set; }
|
|
||||||
public DateTimeOffset Timestamp { get; set; }
|
|
||||||
public uint256 ProposedTransactionHash { get; set; }
|
|
||||||
public List<ReceivedCoin> CoinsExposed { get; set; }
|
|
||||||
public decimal TotalOutputAmount { get; set; }
|
|
||||||
public decimal ContributedAmount { get; set ; }
|
|
||||||
public uint256 OriginalTransactionHash { get; set; }
|
|
||||||
|
|
||||||
public string InvoiceId { get; set; }
|
|
||||||
public bool TxSeen { get; set; }
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return $"{InvoiceId}_{OriginalTransactionHash}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,130 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using BTCPayServer.Data;
|
|
||||||
using BTCPayServer.Events;
|
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
|
||||||
using BTCPayServer.Services.Invoices;
|
|
||||||
using BTCPayServer.Services.Stores;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using NBitcoin.RPC;
|
|
||||||
using NBXplorer;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.PayJoin
|
|
||||||
{
|
|
||||||
public class PayJoinTransactionBroadcaster : IHostedService
|
|
||||||
{
|
|
||||||
// The spec mentioned to give a few mins(1-2), but i don't think it took under consideration the time taken to re-sign inputs with interactive methods( multisig, Hardware wallets, etc). I think 5 mins might be ok.
|
|
||||||
private static readonly TimeSpan BroadcastAfter = TimeSpan.FromMinutes(5);
|
|
||||||
|
|
||||||
private readonly EventAggregator _eventAggregator;
|
|
||||||
private readonly ExplorerClientProvider _explorerClientProvider;
|
|
||||||
private readonly PayJoinStateProvider _payJoinStateProvider;
|
|
||||||
|
|
||||||
private CompositeDisposable leases = new CompositeDisposable();
|
|
||||||
|
|
||||||
public PayJoinTransactionBroadcaster(
|
|
||||||
EventAggregator eventAggregator,
|
|
||||||
ExplorerClientProvider explorerClientProvider,
|
|
||||||
PayJoinStateProvider payJoinStateProvider)
|
|
||||||
{
|
|
||||||
_eventAggregator = eventAggregator;
|
|
||||||
_explorerClientProvider = explorerClientProvider;
|
|
||||||
_payJoinStateProvider = payJoinStateProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var loadCoins = _payJoinStateProvider.LoadCoins();
|
|
||||||
//if the wallet was updated, we need to remove the state as the utxos no longer fit
|
|
||||||
leases.Add(_eventAggregator.Subscribe<WalletChangedEvent>(evt =>
|
|
||||||
_payJoinStateProvider.RemoveState(evt.WalletId)));
|
|
||||||
|
|
||||||
leases.Add(_eventAggregator.Subscribe<NewOnChainTransactionEvent>(txEvent =>
|
|
||||||
{
|
|
||||||
if (!txEvent.NewTransactionEvent.Outputs.Any() ||
|
|
||||||
(txEvent.NewTransactionEvent.TransactionData.Transaction.RBF &&
|
|
||||||
txEvent.NewTransactionEvent.TransactionData.Confirmations == 0))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var relevantStates =
|
|
||||||
_payJoinStateProvider.Get(txEvent.CryptoCode, txEvent.NewTransactionEvent.DerivationStrategy);
|
|
||||||
|
|
||||||
foreach (var relevantState in relevantStates)
|
|
||||||
{
|
|
||||||
//if any of the exposed inputs were spent, remove them from our state
|
|
||||||
relevantState.PruneRecordsOfUsedInputs(txEvent.NewTransactionEvent.TransactionData.Transaction
|
|
||||||
.Inputs);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
_ = BroadcastTransactionsPeriodically(cancellationToken);
|
|
||||||
await loadCoins;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task BroadcastTransactionsPeriodically(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
await BroadcastStaleTransactions(BroadcastAfter, cancellationToken);
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task BroadcastStaleTransactions(TimeSpan broadcastAfter, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
List<Task> tasks = new List<Task>();
|
|
||||||
foreach (var state in _payJoinStateProvider.GetAll())
|
|
||||||
{
|
|
||||||
var explorerClient = _explorerClientProvider.GetExplorerClient(state.Key.CryptoCode);
|
|
||||||
//broadcast any transaction sent to us that we have proposed a payjoin tx for but has not been broadcasted after x amount of time.
|
|
||||||
//This is imperative to preventing users from attempting to get as many utxos exposed from the merchant as possible.
|
|
||||||
var staleTxs = state.Value.GetStaleRecords(broadcastAfter)
|
|
||||||
.Where(item => !item.TxSeen || item.Transaction.RBF);
|
|
||||||
|
|
||||||
tasks.AddRange(staleTxs.Select(async staleTx =>
|
|
||||||
{
|
|
||||||
//if the transaction signals RBF and was broadcasted, check if it was rbfed out
|
|
||||||
if (staleTx.TxSeen && staleTx.Transaction.RBF)
|
|
||||||
{
|
|
||||||
var proposedTransaction = await explorerClient.GetTransactionAsync(staleTx.ProposedTransactionHash, cancellationToken);
|
|
||||||
var result = await explorerClient.BroadcastAsync(proposedTransaction.Transaction, cancellationToken);
|
|
||||||
var accounted = result.Success ||
|
|
||||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN ||
|
|
||||||
!(
|
|
||||||
// Happen if a blocks mined a replacement
|
|
||||||
// Or if the tx is a double spend of something already in the mempool without rbf
|
|
||||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
|
|
||||||
// Happen if RBF is on and fee insufficient
|
|
||||||
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED);
|
|
||||||
|
|
||||||
if (accounted)
|
|
||||||
{
|
|
||||||
//if it wasn't replaced just yet, do not attempt to move the exposed coins to the priority list
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await explorerClient
|
|
||||||
.BroadcastAsync(staleTx.Transaction, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
state.Value.RemoveRecord(staleTx, true);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.WhenAll(tasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await _payJoinStateProvider.SaveCoins();
|
|
||||||
leases.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Lightning;
|
using BTCPayServer.Lightning;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Models;
|
using BTCPayServer.Models;
|
||||||
using BTCPayServer.Models.InvoicingModels;
|
using BTCPayServer.Models.InvoicingModels;
|
||||||
using BTCPayServer.Services.Altcoins.Monero.RPC.Models;
|
using BTCPayServer.Services.Altcoins.Monero.RPC.Models;
|
||||||
|
@ -30,7 +31,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
|
||||||
}
|
}
|
||||||
public override PaymentType PaymentType => MoneroPaymentType.Instance;
|
public override PaymentType PaymentType => MoneroPaymentType.Instance;
|
||||||
|
|
||||||
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(MoneroSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
|
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(InvoiceLogs logs, MoneroSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
|
||||||
StoreData store, MoneroLikeSpecificBtcPayNetwork network, object preparePaymentObject)
|
StoreData store, MoneroLikeSpecificBtcPayNetwork network, object preparePaymentObject)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
105
BTCPayServer/Services/DelayedTransactionBroadcaster.cs
Normal file
105
BTCPayServer/Services/DelayedTransactionBroadcaster.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -469,9 +469,9 @@ namespace BTCPayServer.Services.Invoices
|
||||||
dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo);
|
dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo);
|
||||||
var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due);
|
var bip21 = ((BTCPayNetwork)info.Network).GenerateBIP21(cryptoInfo.Address, cryptoInfo.Due);
|
||||||
|
|
||||||
if (((details as BitcoinLikeOnChainPaymentMethod)?.PayJoin?.Enabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase))
|
if (((details as BitcoinLikeOnChainPaymentMethod)?.PayjoinEnabled??false) && cryptoInfo.CryptoCode.Equals("BTC", StringComparison.InvariantCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu/{Id}";
|
bip21 += $"&bpu={ServerUrl.WithTrailingSlash()}{cryptoCode}/bpu";
|
||||||
}
|
}
|
||||||
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
|
cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls()
|
||||||
{
|
{
|
||||||
|
|
|
@ -314,7 +314,11 @@ retry:
|
||||||
if (!context.PendingInvoices.Any(a => a.Id == invoiceId))
|
if (!context.PendingInvoices.Any(a => a.Id == invoiceId))
|
||||||
{
|
{
|
||||||
context.PendingInvoices.Add(new PendingInvoiceData() { Id = invoiceId });
|
context.PendingInvoices.Add(new PendingInvoiceData() { Id = invoiceId });
|
||||||
await context.SaveChangesAsync();
|
try
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException) { } // Already exists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -683,7 +687,7 @@ retry:
|
||||||
/// <param name="cryptoCode"></param>
|
/// <param name="cryptoCode"></param>
|
||||||
/// <param name="accounted"></param>
|
/// <param name="accounted"></param>
|
||||||
/// <returns>The PaymentEntity or null if already added</returns>
|
/// <returns>The PaymentEntity or null if already added</returns>
|
||||||
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false)
|
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetworkBase network, bool accounted = false, decimal? networkFee = null)
|
||||||
{
|
{
|
||||||
using (var context = _ContextFactory.CreateContext())
|
using (var context = _ContextFactory.CreateContext())
|
||||||
{
|
{
|
||||||
|
@ -701,7 +705,7 @@ retry:
|
||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
ReceivedTime = date.UtcDateTime,
|
ReceivedTime = date.UtcDateTime,
|
||||||
Accounted = accounted,
|
Accounted = accounted,
|
||||||
NetworkFee = paymentMethodDetails.GetNextNetworkFee(),
|
NetworkFee = networkFee ?? paymentMethodDetails.GetNextNetworkFee(),
|
||||||
Network = network
|
Network = network
|
||||||
};
|
};
|
||||||
entity.SetCryptoPaymentData(paymentData);
|
entity.SetCryptoPaymentData(paymentData);
|
||||||
|
|
208
BTCPayServer/Services/PayjoinClient.cs
Normal file
208
BTCPayServer/Services/PayjoinClient.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -96,14 +96,44 @@ namespace BTCPayServer.Services.Wallets
|
||||||
await _Client.TrackAsync(derivationStrategy);
|
await _Client.TrackAsync(derivationStrategy);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TransactionResult> GetTransactionAsync(uint256 txId, CancellationToken cancellation = default(CancellationToken))
|
public async Task<TransactionResult> GetTransactionAsync(uint256 txId, bool includeOffchain = false, CancellationToken cancellation = default(CancellationToken))
|
||||||
{
|
{
|
||||||
if (txId == null)
|
if (txId == null)
|
||||||
throw new ArgumentNullException(nameof(txId));
|
throw new ArgumentNullException(nameof(txId));
|
||||||
var tx = await _Client.GetTransactionAsync(txId, cancellation);
|
var tx = await _Client.GetTransactionAsync(txId, cancellation);
|
||||||
|
if (tx is null && includeOffchain)
|
||||||
|
{
|
||||||
|
var offchainTx = await GetOffchainTransactionAsync(txId);
|
||||||
|
if (offchainTx != null)
|
||||||
|
tx = new TransactionResult()
|
||||||
|
{
|
||||||
|
Confirmations = -1,
|
||||||
|
TransactionHash = offchainTx.GetHash(),
|
||||||
|
Transaction = offchainTx
|
||||||
|
};
|
||||||
|
}
|
||||||
return tx;
|
return tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<Transaction> GetOffchainTransactionAsync(uint256 txid)
|
||||||
|
{
|
||||||
|
lock (offchain)
|
||||||
|
{
|
||||||
|
return Task.FromResult(offchain.TryGet(txid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public Task SaveOffchainTransactionAsync(Transaction tx)
|
||||||
|
{
|
||||||
|
// TODO: Save in database
|
||||||
|
lock (offchain)
|
||||||
|
{
|
||||||
|
offchain.Add(tx.GetHash(), tx);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<uint256, Transaction> offchain = new Dictionary<uint256, Transaction>();
|
||||||
|
|
||||||
public void InvalidateCache(DerivationStrategyBase strategy)
|
public void InvalidateCache(DerivationStrategyBase strategy)
|
||||||
{
|
{
|
||||||
_MemoryCache.Remove("CACHEDCOINS_" + strategy.ToString());
|
_MemoryCache.Remove("CACHEDCOINS_" + strategy.ToString());
|
||||||
|
|
|
@ -8,4 +8,3 @@
|
||||||
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="Logs">Logs</a>
|
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="Logs">Logs</a>
|
||||||
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a>
|
<a class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,7 @@
|
||||||
<tr class="@(payment.Replaced ? "linethrough" : "")" >
|
<tr class="@(payment.Replaced ? "linethrough" : "")" >
|
||||||
<td>@payment.Crypto</td>
|
<td>@payment.Crypto</td>
|
||||||
<td>@payment.DepositAddress</td>
|
<td>@payment.DepositAddress</td>
|
||||||
<td class="payment-value">@payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayJoinSelfContributedAmount == 0? string.Empty : $"<br/>(Payjoin {payment.CryptoPaymentData.PayJoinSelfContributedAmount})")</td>
|
<td class="payment-value">@payment.CryptoPaymentData.GetValue() @Safe.Raw(payment.CryptoPaymentData.PayjoinInformation?.Type is PayjoinTransactionType.Coinjoin? string.Empty : $"<br/>(Payjoin)")</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="wraptextAuto">
|
<div class="wraptextAuto">
|
||||||
<a href="@payment.TransactionLink" target="_blank">
|
<a href="@payment.TransactionLink" target="_blank">
|
||||||
|
|
Loading…
Add table
Reference in a new issue