mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 14:22:40 +01:00
Adapt payjoin for BIP78
This commit is contained in:
parent
0b720768b8
commit
24a88fcfb5
12 changed files with 381 additions and 270 deletions
|
@ -32,6 +32,7 @@ using NBXplorer.DerivationStrategy;
|
||||||
using NBXplorer.Models;
|
using NBXplorer.Models;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using OpenQA.Selenium;
|
using OpenQA.Selenium;
|
||||||
|
using TwentyTwenty.Storage;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
@ -193,12 +194,11 @@ namespace BTCPayServer.Tests
|
||||||
await receiverUser.EnablePayJoin();
|
await receiverUser.EnablePayJoin();
|
||||||
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||||
|
|
||||||
var clientShouldError = unsupportedFormats.Contains(senderAddressType);
|
|
||||||
string errorCode = receiverAddressType == senderAddressType ? null : "unavailable|any UTXO available";
|
string errorCode = receiverAddressType == senderAddressType ? null : "unavailable|any UTXO available";
|
||||||
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true });
|
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true });
|
||||||
if (unsupportedFormats.Contains(receiverAddressType))
|
if (unsupportedFormats.Contains(receiverAddressType))
|
||||||
{
|
{
|
||||||
Assert.Null(TestAccount.GetPayjoinEndpoint(invoice, cashCow.Network));
|
Assert.Null(TestAccount.GetPayjoinBitcoinUrl(invoice, cashCow.Network));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
|
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
|
||||||
|
@ -210,7 +210,7 @@ namespace BTCPayServer.Tests
|
||||||
txBuilder.SendEstimatedFees(new FeeRate(50m));
|
txBuilder.SendEstimatedFees(new FeeRate(50m));
|
||||||
var psbt = txBuilder.BuildPSBT(false);
|
var psbt = txBuilder.BuildPSBT(false);
|
||||||
psbt = await senderUser.Sign(psbt);
|
psbt = await senderUser.Sign(psbt);
|
||||||
var pj = await senderUser.SubmitPayjoin(invoice, psbt, errorCode, clientShouldError);
|
var pj = await senderUser.SubmitPayjoin(invoice, psbt, errorCode, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -263,7 +263,7 @@ 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"))
|
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinBIP21"))
|
||||||
.GetAttribute("value")));
|
.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();
|
||||||
|
@ -298,10 +298,10 @@ 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"))
|
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinBIP21"))
|
||||||
.GetAttribute("value")));
|
.GetAttribute("value")));
|
||||||
s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).Clear();
|
s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).Clear();
|
||||||
s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).SendKeys("1");
|
s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).SendKeys("2");
|
||||||
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();
|
||||||
|
@ -372,8 +372,11 @@ namespace BTCPayServer.Tests
|
||||||
await alice.RegisterDerivationSchemeAsync("BTC", ScriptPubKeyType.Segwit, true);
|
await alice.RegisterDerivationSchemeAsync("BTC", ScriptPubKeyType.Segwit, true);
|
||||||
await notifications.ListenDerivationSchemesAsync(new[] { alice.DerivationScheme });
|
await notifications.ListenDerivationSchemesAsync(new[] { alice.DerivationScheme });
|
||||||
var address = (await nbx.GetUnusedAsync(alice.DerivationScheme, DerivationFeature.Deposit)).Address;
|
var address = (await nbx.GetUnusedAsync(alice.DerivationScheme, DerivationFeature.Deposit)).Address;
|
||||||
|
await tester.ExplorerNode.GenerateAsync(1);
|
||||||
tester.ExplorerNode.SendToAddress(address, Money.Coins(1.0m));
|
tester.ExplorerNode.SendToAddress(address, Money.Coins(1.0m));
|
||||||
await notifications.NextEventAsync();
|
await notifications.NextEventAsync();
|
||||||
|
var paymentAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest);
|
||||||
|
var otherAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest);
|
||||||
var psbt = (await nbx.CreatePSBTAsync(alice.DerivationScheme, new CreatePSBTRequest()
|
var psbt = (await nbx.CreatePSBTAsync(alice.DerivationScheme, new CreatePSBTRequest()
|
||||||
{
|
{
|
||||||
Destinations =
|
Destinations =
|
||||||
|
@ -381,7 +384,12 @@ namespace BTCPayServer.Tests
|
||||||
new CreatePSBTDestination()
|
new CreatePSBTDestination()
|
||||||
{
|
{
|
||||||
Amount = Money.Coins(0.5m),
|
Amount = Money.Coins(0.5m),
|
||||||
Destination = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest)
|
Destination = paymentAddress
|
||||||
|
},
|
||||||
|
new CreatePSBTDestination()
|
||||||
|
{
|
||||||
|
Amount = Money.Coins(0.1m),
|
||||||
|
Destination = otherAddress
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
FeePreference = new FeePreference()
|
FeePreference = new FeePreference()
|
||||||
|
@ -389,62 +397,110 @@ namespace BTCPayServer.Tests
|
||||||
ExplicitFee = Money.Satoshis(3000)
|
ExplicitFee = Money.Satoshis(3000)
|
||||||
}
|
}
|
||||||
})).PSBT;
|
})).PSBT;
|
||||||
|
int paymentIndex = 0;
|
||||||
|
int changeIndex = 0;
|
||||||
|
int otherIndex = 0;
|
||||||
|
for (int i = 0; i < psbt.Outputs.Count; i++)
|
||||||
|
{
|
||||||
|
if (psbt.Outputs[i].Value == Money.Coins(0.5m))
|
||||||
|
paymentIndex = i;
|
||||||
|
else if (psbt.Outputs[i].Value == Money.Coins(0.1m))
|
||||||
|
otherIndex = i;
|
||||||
|
else
|
||||||
|
changeIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
var derivationSchemeSettings = alice.GetController<WalletsController>().GetDerivationSchemeSettings(new WalletId(alice.StoreId, "BTC"));
|
var derivationSchemeSettings = alice.GetController<WalletsController>().GetDerivationSchemeSettings(new WalletId(alice.StoreId, "BTC"));
|
||||||
var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings();
|
var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings();
|
||||||
psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
|
psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
|
||||||
var changeIndex = Array.FindIndex(psbt.Outputs.ToArray(), (PSBTOutput o) => o.ScriptPubKey.IsScriptType(ScriptType.P2WPKH));
|
|
||||||
using var fakeServer = new FakeServer();
|
using var fakeServer = new FakeServer();
|
||||||
await fakeServer.Start();
|
await fakeServer.Start();
|
||||||
var requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default);
|
var bip21 = new BitcoinUrlBuilder($"bitcoin:{paymentAddress}?pj={fakeServer.ServerUri}", Network.RegTest);
|
||||||
|
var requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
|
||||||
var request = await fakeServer.GetNextRequest();
|
var request = await fakeServer.GetNextRequest();
|
||||||
Assert.Equal("1", request.Request.Query["v"][0]);
|
Assert.Equal("1", request.Request.Query["v"][0]);
|
||||||
Assert.Equal(changeIndex.ToString(), request.Request.Query["additionalfeeoutputindex"][0]);
|
Assert.Equal(changeIndex.ToString(), request.Request.Query["additionalfeeoutputindex"][0]);
|
||||||
Assert.Equal("3000", request.Request.Query["maxadditionalfeecontribution"][0]);
|
Assert.Equal("1146", request.Request.Query["maxadditionalfeecontribution"][0]);
|
||||||
|
|
||||||
|
|
||||||
Logs.Tester.LogInformation("The payjoin receiver tries to make us pay lots of fee");
|
Logs.Tester.LogInformation("The payjoin receiver tries to make us pay lots of fee");
|
||||||
var originalPSBT = await ParsePSBT(request);
|
var originalPSBT = await ParsePSBT(request);
|
||||||
var proposalTx = originalPSBT.GetGlobalTransaction();
|
var proposalTx = originalPSBT.GetGlobalTransaction();
|
||||||
proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(3001);
|
proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(1147);
|
||||||
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
||||||
fakeServer.Done();
|
fakeServer.Done();
|
||||||
var ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
var ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
||||||
Assert.Contains("too much fee", ex.Message);
|
Assert.Contains("contribution is more than maxadditionalfeecontribution", ex.Message);
|
||||||
|
|
||||||
Logs.Tester.LogInformation("The payjoin receiver tries to send money to himself");
|
Logs.Tester.LogInformation("The payjoin receiver tries to change one of our output");
|
||||||
requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default);
|
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
|
||||||
request = await fakeServer.GetNextRequest();
|
request = await fakeServer.GetNextRequest();
|
||||||
originalPSBT = await ParsePSBT(request);
|
originalPSBT = await ParsePSBT(request);
|
||||||
proposalTx = originalPSBT.GetGlobalTransaction();
|
proposalTx = originalPSBT.GetGlobalTransaction();
|
||||||
proposalTx.Outputs.Where((o, i) => i != changeIndex).First().Value += Money.Satoshis(1);
|
proposalTx.Outputs[otherIndex].Value -= Money.Satoshis(1);
|
||||||
|
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
||||||
|
fakeServer.Done();
|
||||||
|
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
||||||
|
Assert.Contains("The receiver decreased the value of one", ex.Message);
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("The payjoin receiver tries to pocket the fee");
|
||||||
|
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
|
||||||
|
request = await fakeServer.GetNextRequest();
|
||||||
|
originalPSBT = await ParsePSBT(request);
|
||||||
|
proposalTx = originalPSBT.GetGlobalTransaction();
|
||||||
|
proposalTx.Outputs[paymentIndex].Value += Money.Satoshis(1);
|
||||||
|
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
||||||
|
fakeServer.Done();
|
||||||
|
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
||||||
|
Assert.Contains("The receiver decreased absolute fee", ex.Message);
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("The payjoin receiver tries to remove one of our output");
|
||||||
|
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
|
||||||
|
request = await fakeServer.GetNextRequest();
|
||||||
|
originalPSBT = await ParsePSBT(request);
|
||||||
|
proposalTx = originalPSBT.GetGlobalTransaction();
|
||||||
|
var removedOutput = proposalTx.Outputs.First(o => o.ScriptPubKey == otherAddress.ScriptPubKey);
|
||||||
|
proposalTx.Outputs.Remove(removedOutput);
|
||||||
|
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
||||||
|
fakeServer.Done();
|
||||||
|
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
||||||
|
Assert.Contains("Some of our outputs are not included in the proposal", ex.Message);
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("The payjoin receiver tries to change their own output");
|
||||||
|
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
|
||||||
|
request = await fakeServer.GetNextRequest();
|
||||||
|
originalPSBT = await ParsePSBT(request);
|
||||||
|
proposalTx = originalPSBT.GetGlobalTransaction();
|
||||||
|
proposalTx.Outputs.First(o => o.ScriptPubKey == paymentAddress.ScriptPubKey).Value -= Money.Satoshis(1);
|
||||||
|
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
||||||
|
fakeServer.Done();
|
||||||
|
await requesting;
|
||||||
|
|
||||||
|
|
||||||
|
Logs.Tester.LogInformation("The payjoin receiver tries to send money to himself");
|
||||||
|
pjClient.MaxFeeBumpContribution = Money.Satoshis(1);
|
||||||
|
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
|
||||||
|
request = await fakeServer.GetNextRequest();
|
||||||
|
originalPSBT = await ParsePSBT(request);
|
||||||
|
proposalTx = originalPSBT.GetGlobalTransaction();
|
||||||
|
proposalTx.Outputs[paymentIndex].Value += Money.Satoshis(1);
|
||||||
proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(1);
|
proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(1);
|
||||||
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
||||||
fakeServer.Done();
|
fakeServer.Done();
|
||||||
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
||||||
Assert.Contains("money to himself", ex.Message);
|
Assert.Contains("is not only paying fee", ex.Message);
|
||||||
|
pjClient.MaxFeeBumpContribution = null;
|
||||||
|
|
||||||
Logs.Tester.LogInformation("The payjoin receiver can't increase the fee rate too much");
|
Logs.Tester.LogInformation("The payjoin receiver can't use additional fee without adding inputs");
|
||||||
requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default);
|
|
||||||
request = await fakeServer.GetNextRequest();
|
|
||||||
originalPSBT = await ParsePSBT(request);
|
|
||||||
proposalTx = originalPSBT.GetGlobalTransaction();
|
|
||||||
proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(3000);
|
|
||||||
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
|
||||||
fakeServer.Done();
|
|
||||||
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
|
||||||
Assert.Contains("increased the fee rate", ex.Message);
|
|
||||||
|
|
||||||
Logs.Tester.LogInformation("The payjoin receiver can't decrease the fee rate too much");
|
|
||||||
pjClient.MinimumFeeRate = new FeeRate(50m);
|
pjClient.MinimumFeeRate = new FeeRate(50m);
|
||||||
requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default);
|
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
|
||||||
request = await fakeServer.GetNextRequest();
|
request = await fakeServer.GetNextRequest();
|
||||||
originalPSBT = await ParsePSBT(request);
|
originalPSBT = await ParsePSBT(request);
|
||||||
proposalTx = originalPSBT.GetGlobalTransaction();
|
proposalTx = originalPSBT.GetGlobalTransaction();
|
||||||
proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(3000);
|
proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(1146);
|
||||||
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
|
||||||
fakeServer.Done();
|
fakeServer.Done();
|
||||||
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
ex = await Assert.ThrowsAsync<PayjoinSenderException>(async () => await requesting);
|
||||||
Assert.Contains("a too low fee rate", ex.Message);
|
Assert.Contains("is not only paying for additional inputs", ex.Message);
|
||||||
pjClient.MinimumFeeRate = null;
|
pjClient.MinimumFeeRate = null;
|
||||||
|
|
||||||
Logs.Tester.LogInformation("Make sure the receiver implementation do not take more fee than allowed");
|
Logs.Tester.LogInformation("Make sure the receiver implementation do not take more fee than allowed");
|
||||||
|
@ -476,7 +532,7 @@ namespace BTCPayServer.Tests
|
||||||
}
|
}
|
||||||
})).PSBT;
|
})).PSBT;
|
||||||
psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
|
psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
|
||||||
var endpoint = TestAccount.GetPayjoinEndpoint(invoice, Network.RegTest);
|
var endpoint = TestAccount.GetPayjoinBitcoinUrl(invoice, Network.RegTest);
|
||||||
pjClient.MaxFeeBumpContribution = Money.Satoshis(50);
|
pjClient.MaxFeeBumpContribution = Money.Satoshis(50);
|
||||||
var proposal = await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default);
|
var proposal = await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default);
|
||||||
Assert.True(proposal.TryGetFee(out var newFee));
|
Assert.True(proposal.TryGetFee(out var newFee));
|
||||||
|
@ -507,7 +563,7 @@ namespace BTCPayServer.Tests
|
||||||
}
|
}
|
||||||
})).PSBT;
|
})).PSBT;
|
||||||
psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
|
psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
|
||||||
endpoint = TestAccount.GetPayjoinEndpoint(invoice, Network.RegTest);
|
endpoint = TestAccount.GetPayjoinBitcoinUrl(invoice, Network.RegTest);
|
||||||
pjClient.MinimumFeeRate = new FeeRate(100_000_000.2m);
|
pjClient.MinimumFeeRate = new FeeRate(100_000_000.2m);
|
||||||
var ex2 = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default));
|
var ex2 = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default));
|
||||||
Assert.Equal(PayjoinReceiverWellknownErrors.NotEnoughMoney, ex2.WellknownError);
|
Assert.Equal(PayjoinReceiverWellknownErrors.NotEnoughMoney, ex2.WellknownError);
|
||||||
|
@ -732,7 +788,7 @@ namespace BTCPayServer.Tests
|
||||||
var invoice = senderUser.BitPay.CreateInvoice(
|
var invoice = senderUser.BitPay.CreateInvoice(
|
||||||
new Invoice() { Price = 100, Currency = "USD", FullNotifications = true });
|
new Invoice() { Price = 100, Currency = "USD", FullNotifications = true });
|
||||||
//payjoin is not enabled by default.
|
//payjoin is not enabled by default.
|
||||||
Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}", invoice.CryptoInfo.First().PaymentUrls.BIP21);
|
Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}=", invoice.CryptoInfo.First().PaymentUrls.BIP21);
|
||||||
cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network),
|
cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network),
|
||||||
Money.Coins(0.06m));
|
Money.Coins(0.06m));
|
||||||
|
|
||||||
|
@ -767,7 +823,8 @@ namespace BTCPayServer.Tests
|
||||||
invoice = receiverUser.BitPay.CreateInvoice(
|
invoice = receiverUser.BitPay.CreateInvoice(
|
||||||
new Invoice() { Price = 0.02m, Currency = "BTC", FullNotifications = true });
|
new Invoice() { Price = 0.02m, Currency = "BTC", FullNotifications = true });
|
||||||
// Bad version should throw incorrect version
|
// Bad version should throw incorrect version
|
||||||
var endpoint = TestAccount.GetPayjoinEndpoint(invoice, btcPayNetwork.NBitcoinNetwork);
|
var bip21 = TestAccount.GetPayjoinBitcoinUrl(invoice, btcPayNetwork.NBitcoinNetwork);
|
||||||
|
bip21.TryGetPayjoinEndpoint(out var endpoint);
|
||||||
var response = await tester.PayTester.HttpClient.PostAsync(endpoint.AbsoluteUri + "?v=2",
|
var response = await tester.PayTester.HttpClient.PostAsync(endpoint.AbsoluteUri + "?v=2",
|
||||||
new StringContent("", Encoding.UTF8, "text/plain"));
|
new StringContent("", Encoding.UTF8, "text/plain"));
|
||||||
Assert.False(response.IsSuccessStatusCode);
|
Assert.False(response.IsSuccessStatusCode);
|
||||||
|
|
|
@ -344,7 +344,7 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
public async Task<PSBT> SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null, bool senderError= false)
|
public async Task<PSBT> SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null, bool senderError= false)
|
||||||
{
|
{
|
||||||
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
|
var endpoint = GetPayjoinBitcoinUrl(invoice, psbt.Network);
|
||||||
if (endpoint == null)
|
if (endpoint == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("No payjoin endpoint for the invoice");
|
throw new InvalidOperationException("No payjoin endpoint for the invoice");
|
||||||
|
@ -394,7 +394,8 @@ namespace BTCPayServer.Tests
|
||||||
async Task<HttpResponseMessage> SubmitPayjoinCore(string content, Invoice invoice, Network network,
|
async Task<HttpResponseMessage> SubmitPayjoinCore(string content, Invoice invoice, Network network,
|
||||||
string expectedError)
|
string expectedError)
|
||||||
{
|
{
|
||||||
var endpoint = GetPayjoinEndpoint(invoice, network);
|
var bip21 = GetPayjoinBitcoinUrl(invoice, network);
|
||||||
|
bip21.TryGetPayjoinEndpoint(out var endpoint);
|
||||||
var response = await parent.PayTester.HttpClient.PostAsync(endpoint,
|
var response = await parent.PayTester.HttpClient.PostAsync(endpoint,
|
||||||
new StringContent(content, Encoding.UTF8, "text/plain"));
|
new StringContent(content, Encoding.UTF8, "text/plain"));
|
||||||
if (expectedError != null)
|
if (expectedError != null)
|
||||||
|
@ -421,12 +422,14 @@ namespace BTCPayServer.Tests
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Uri GetPayjoinEndpoint(Invoice invoice, Network network)
|
public static BitcoinUrlBuilder GetPayjoinBitcoinUrl(Invoice invoice, Network network)
|
||||||
{
|
{
|
||||||
var parsedBip21 = new BitcoinUrlBuilder(
|
var parsedBip21 = new BitcoinUrlBuilder(
|
||||||
invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21,
|
invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21,
|
||||||
network);
|
network);
|
||||||
return parsedBip21.UnknowParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null;
|
if (!parsedBip21.TryGetPayjoinEndpoint(out var endpoint))
|
||||||
|
return null;
|
||||||
|
return parsedBip21;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ using BTCPayServer.Models.WalletViewModels;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using NBitcoin.Payment;
|
||||||
using NBXplorer;
|
using NBXplorer;
|
||||||
using NBXplorer.Models;
|
using NBXplorer.Models;
|
||||||
|
|
||||||
|
@ -153,14 +154,12 @@ namespace BTCPayServer.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<PSBT> GetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken)
|
private async Task<PSBT> GetPayjoinProposedTX(BitcoinUrlBuilder bip21, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(bpu) || !Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint))
|
|
||||||
throw new InvalidOperationException("No payjoin url available");
|
|
||||||
var cloned = psbt.Clone();
|
var cloned = psbt.Clone();
|
||||||
cloned = cloned.Finalize();
|
cloned = cloned.Finalize();
|
||||||
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), cloned.ExtractTransaction(), btcPayNetwork);
|
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), cloned.ExtractTransaction(), btcPayNetwork);
|
||||||
return await _payjoinClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, cancellationToken);
|
return await _payjoinClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
@ -317,7 +316,7 @@ namespace BTCPayServer.Controllers
|
||||||
string error = null;
|
string error = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var proposedPayjoin = await GetPayjoinProposedTX(vm.SigningContext.PayJoinEndpointUrl, psbt,
|
var proposedPayjoin = await GetPayjoinProposedTX(new BitcoinUrlBuilder(vm.SigningContext.PayJoinBIP21, network.NBitcoinNetwork), psbt,
|
||||||
derivationSchemeSettings, network, cancellationToken);
|
derivationSchemeSettings, network, cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
@ -658,7 +658,7 @@ namespace BTCPayServer.Controllers
|
||||||
|
|
||||||
var signingContext = new SigningContextModel()
|
var signingContext = new SigningContextModel()
|
||||||
{
|
{
|
||||||
PayJoinEndpointUrl = vm.PayJoinEndpointUrl,
|
PayJoinBIP21 = vm.PayJoinBIP21,
|
||||||
EnforceLowR = psbt.Suggestions?.ShouldEnforceLowR,
|
EnforceLowR = psbt.Suggestions?.ShouldEnforceLowR,
|
||||||
ChangeAddress = psbt.ChangeAddress?.ToString()
|
ChangeAddress = psbt.ChangeAddress?.ToString()
|
||||||
};
|
};
|
||||||
|
@ -713,8 +713,9 @@ namespace BTCPayServer.Controllers
|
||||||
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}"
|
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
uriBuilder.UnknowParameters.TryGetValue(PayjoinClient.BIP21EndpointKey, out var vmPayJoinEndpointUrl);
|
|
||||||
vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl;
|
if (uriBuilder.TryGetPayjoinEndpoint(out _))
|
||||||
|
vm.PayJoinBIP21 = uriBuilder.ToString();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
@ -783,7 +784,7 @@ namespace BTCPayServer.Controllers
|
||||||
return;
|
return;
|
||||||
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.PSBT", signingContext.PSBT));
|
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.PSBT", signingContext.PSBT));
|
||||||
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.OriginalPSBT", signingContext.OriginalPSBT));
|
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.OriginalPSBT", signingContext.OriginalPSBT));
|
||||||
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.PayJoinEndpointUrl", signingContext.PayJoinEndpointUrl));
|
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21));
|
||||||
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)));
|
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)));
|
||||||
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.ChangeAddress", signingContext.ChangeAddress));
|
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.ChangeAddress", signingContext.ChangeAddress));
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,11 +38,17 @@ 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;
|
using BTCPayServer.Payments.Bitcoin;
|
||||||
|
using NBitcoin.Payment;
|
||||||
|
|
||||||
namespace BTCPayServer
|
namespace BTCPayServer
|
||||||
{
|
{
|
||||||
public static class Extensions
|
public static class Extensions
|
||||||
{
|
{
|
||||||
|
public static bool TryGetPayjoinEndpoint(this BitcoinUrlBuilder bip21, out Uri endpoint)
|
||||||
|
{
|
||||||
|
endpoint = bip21.UnknowParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null;
|
||||||
|
return endpoint != null;
|
||||||
|
}
|
||||||
public static bool IsInternalNode(this LightningConnectionString connectionString, LightningConnectionString internalLightning)
|
public static bool IsInternalNode(this LightningConnectionString connectionString, LightningConnectionString internalLightning)
|
||||||
{
|
{
|
||||||
var internalDomain = internalLightning?.BaseUri?.DnsSafeHost;
|
var internalDomain = internalLightning?.BaseUri?.DnsSafeHost;
|
||||||
|
|
|
@ -18,7 +18,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
}
|
}
|
||||||
public string PSBT { get; set; }
|
public string PSBT { get; set; }
|
||||||
public string OriginalPSBT { get; set; }
|
public string OriginalPSBT { get; set; }
|
||||||
public string PayJoinEndpointUrl { get; set; }
|
public string PayJoinBIP21 { get; set; }
|
||||||
public bool? EnforceLowR { get; set; }
|
public bool? EnforceLowR { get; set; }
|
||||||
public string ChangeAddress { get; set; }
|
public string ChangeAddress { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,8 +57,8 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
public ThreeStateBool AllowFeeBump { get; set; }
|
public ThreeStateBool AllowFeeBump { get; set; }
|
||||||
|
|
||||||
public bool NBXSeedAvailable { get; set; }
|
public bool NBXSeedAvailable { get; set; }
|
||||||
[Display(Name = "PayJoin Endpoint Url")]
|
[Display(Name = "PayJoin BIP21")]
|
||||||
public string PayJoinEndpointUrl { get; set; }
|
public string PayJoinBIP21 { get; set; }
|
||||||
public bool InputSelection { get; set; }
|
public bool InputSelection { get; set; }
|
||||||
public InputSelectionOption[] InputsAvailable { get; set; }
|
public InputSelectionOption[] InputsAvailable { get; set; }
|
||||||
|
|
||||||
|
|
|
@ -122,9 +122,10 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
[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,
|
public async Task<IActionResult> Submit(string cryptoCode,
|
||||||
long maxadditionalfeecontribution = -1,
|
long? maxadditionalfeecontribution,
|
||||||
int additionalfeeoutputindex = -1,
|
int? additionalfeeoutputindex,
|
||||||
decimal minfeerate = -1.0m,
|
decimal minfeerate = -1.0m,
|
||||||
|
bool disableoutputsubstitution = false,
|
||||||
int v = 1)
|
int v = 1)
|
||||||
{
|
{
|
||||||
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||||
|
@ -192,9 +193,8 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool spareChangeCase = psbt.Outputs.Count == 1;
|
FeeRate senderMinFeeRate = minfeerate >= 0.0m ? new FeeRate(minfeerate) : null;
|
||||||
FeeRate senderMinFeeRate = !spareChangeCase && minfeerate >= 0.0m ? new FeeRate(minfeerate) : null;
|
Money allowedSenderFeeContribution = Money.Satoshis(maxadditionalfeecontribution is long t && t >= 0 ? t : 0);
|
||||||
Money allowedSenderFeeContribution = Money.Satoshis(!spareChangeCase && maxadditionalfeecontribution >= 0 ? maxadditionalfeecontribution : long.MaxValue);
|
|
||||||
|
|
||||||
var sendersInputType = psbt.GetInputsScriptPubKeyType();
|
var sendersInputType = psbt.GetInputsScriptPubKeyType();
|
||||||
if (psbt.CheckSanity() is var errors && errors.Count != 0)
|
if (psbt.CheckSanity() is var errors && errors.Count != 0)
|
||||||
|
@ -260,7 +260,7 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
//this should never happen, unless the store owner changed the wallet mid way through an invoice
|
//this should never happen, unless the store owner changed the wallet mid way through an invoice
|
||||||
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Our wallet does not support payjoin");
|
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Our wallet does not support payjoin");
|
||||||
}
|
}
|
||||||
if (sendersInputType is ScriptPubKeyType t && t != receiverInputsType)
|
if (sendersInputType is ScriptPubKeyType t1 && t1 != receiverInputsType)
|
||||||
{
|
{
|
||||||
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for making a payjoin with the sender's inputs type");
|
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for making a payjoin with the sender's inputs type");
|
||||||
}
|
}
|
||||||
|
@ -341,10 +341,14 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
var ourNewOutput = newTx.Outputs[originalPaymentOutput.Index];
|
var ourNewOutput = newTx.Outputs[originalPaymentOutput.Index];
|
||||||
HashSet<TxOut> isOurOutput = new HashSet<TxOut>();
|
HashSet<TxOut> isOurOutput = new HashSet<TxOut>();
|
||||||
isOurOutput.Add(ourNewOutput);
|
isOurOutput.Add(ourNewOutput);
|
||||||
TxOut preferredFeeBumpOutput = additionalfeeoutputindex >= 0
|
TxOut feeOutput =
|
||||||
&& additionalfeeoutputindex < newTx.Outputs.Count
|
additionalfeeoutputindex is int feeOutputIndex &&
|
||||||
&& !isOurOutput.Contains(newTx.Outputs[additionalfeeoutputindex])
|
maxadditionalfeecontribution is long v3 &&
|
||||||
? newTx.Outputs[additionalfeeoutputindex] : null;
|
v3 >= 0 &&
|
||||||
|
feeOutputIndex >= 0
|
||||||
|
&& feeOutputIndex < newTx.Outputs.Count
|
||||||
|
&& !isOurOutput.Contains(newTx.Outputs[feeOutputIndex])
|
||||||
|
? newTx.Outputs[feeOutputIndex] : null;
|
||||||
var rand = new Random();
|
var rand = new Random();
|
||||||
int senderInputCount = newTx.Inputs.Count;
|
int senderInputCount = newTx.Inputs.Count;
|
||||||
foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value))
|
foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value))
|
||||||
|
@ -356,49 +360,6 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
ourNewOutput.Value += contributedAmount;
|
ourNewOutput.Value += contributedAmount;
|
||||||
var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
|
var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
|
||||||
new FeeRate(1.0m);
|
new FeeRate(1.0m);
|
||||||
// Probably receiving some spare change, let's add an output to make
|
|
||||||
// it looks more like a normal transaction
|
|
||||||
if (spareChangeCase)
|
|
||||||
{
|
|
||||||
ctx.Logs.Write($"The payjoin receiver sent only a single output");
|
|
||||||
if (RandomUtils.GetUInt64() % 2 == 0)
|
|
||||||
{
|
|
||||||
var change = await explorer.GetUnusedAsync(derivationSchemeSettings.AccountDerivation, DerivationFeature.Change);
|
|
||||||
var randomChangeAmount = RandomUtils.GetUInt64() % (ulong)contributedAmount.Satoshi;
|
|
||||||
|
|
||||||
// Randomly round the amount to make the payment output look like a change output
|
|
||||||
var roundMultiple = (ulong)Math.Pow(10, (ulong)Math.Log10(randomChangeAmount));
|
|
||||||
while (roundMultiple > 1_000UL)
|
|
||||||
{
|
|
||||||
if (RandomUtils.GetUInt32() % 2 == 0)
|
|
||||||
{
|
|
||||||
roundMultiple = roundMultiple / 10;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
randomChangeAmount = (randomChangeAmount / roundMultiple) * roundMultiple;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fakeChange = newTx.Outputs.CreateNewTxOut(randomChangeAmount, change.ScriptPubKey);
|
|
||||||
if (fakeChange.IsDust(minRelayTxFee))
|
|
||||||
{
|
|
||||||
randomChangeAmount = fakeChange.GetDustThreshold(minRelayTxFee);
|
|
||||||
fakeChange.Value = randomChangeAmount;
|
|
||||||
}
|
|
||||||
if (randomChangeAmount < contributedAmount)
|
|
||||||
{
|
|
||||||
ourNewOutput.Value -= fakeChange.Value;
|
|
||||||
newTx.Outputs.Add(fakeChange);
|
|
||||||
isOurOutput.Add(fakeChange);
|
|
||||||
ctx.Logs.Write($"Added a fake change output of {fakeChange.Value} {network.CryptoCode} in the payjoin proposal");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Utils.Shuffle(newTx.Inputs, rand);
|
|
||||||
Utils.Shuffle(newTx.Outputs, rand);
|
|
||||||
|
|
||||||
// Remove old signatures as they are not valid anymore
|
// Remove old signatures as they are not valid anymore
|
||||||
foreach (var input in newTx.Inputs)
|
foreach (var input in newTx.Inputs)
|
||||||
|
@ -421,6 +382,8 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
// If the user overpaid, taking fee on our output (useful if sender dump a full UTXO for privacy)
|
// If the user overpaid, taking fee on our output (useful if sender dump a full UTXO for privacy)
|
||||||
for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero; i++)
|
for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero; i++)
|
||||||
{
|
{
|
||||||
|
if (disableoutputsubstitution)
|
||||||
|
break;
|
||||||
if (isOurOutput.Contains(newTx.Outputs[i]))
|
if (isOurOutput.Contains(newTx.Outputs[i]))
|
||||||
{
|
{
|
||||||
var outputContribution = Money.Min(additionalFee, -due);
|
var outputContribution = Money.Min(additionalFee, -due);
|
||||||
|
@ -434,22 +397,16 @@ namespace BTCPayServer.Payments.PayJoin
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rest, we take from user's change
|
// The rest, we take from user's change
|
||||||
for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && allowedSenderFeeContribution > Money.Zero; i++)
|
if (feeOutput != null)
|
||||||
{
|
{
|
||||||
if (preferredFeeBumpOutput is TxOut &&
|
var outputContribution = Money.Min(additionalFee, feeOutput.Value);
|
||||||
preferredFeeBumpOutput != newTx.Outputs[i])
|
|
||||||
continue;
|
|
||||||
if (!isOurOutput.Contains(newTx.Outputs[i]))
|
|
||||||
{
|
|
||||||
var outputContribution = Money.Min(additionalFee, newTx.Outputs[i].Value);
|
|
||||||
outputContribution = Money.Min(outputContribution,
|
outputContribution = Money.Min(outputContribution,
|
||||||
newTx.Outputs[i].Value - newTx.Outputs[i].GetDustThreshold(minRelayTxFee));
|
feeOutput.Value - feeOutput.GetDustThreshold(minRelayTxFee));
|
||||||
outputContribution = Money.Min(outputContribution, allowedSenderFeeContribution);
|
outputContribution = Money.Min(outputContribution, allowedSenderFeeContribution);
|
||||||
newTx.Outputs[i].Value -= outputContribution;
|
feeOutput.Value -= outputContribution;
|
||||||
additionalFee -= outputContribution;
|
additionalFee -= outputContribution;
|
||||||
allowedSenderFeeContribution -= outputContribution;
|
allowedSenderFeeContribution -= outputContribution;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (additionalFee > Money.Zero)
|
if (additionalFee > Money.Zero)
|
||||||
{
|
{
|
||||||
|
|
|
@ -7,11 +7,16 @@ using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Payments.Changelly.Models;
|
using BTCPayServer.Payments.Changelly.Models;
|
||||||
using Google.Apis.Http;
|
using Google.Apis.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using NBitcoin.Payment;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using NUglify.Helpers;
|
||||||
|
using TwentyTwenty.Storage;
|
||||||
using IHttpClientFactory = System.Net.Http.IHttpClientFactory;
|
using IHttpClientFactory = System.Net.Http.IHttpClientFactory;
|
||||||
|
|
||||||
namespace BTCPayServer.Services
|
namespace BTCPayServer.Services
|
||||||
|
@ -21,7 +26,7 @@ namespace BTCPayServer.Services
|
||||||
{
|
{
|
||||||
public static ScriptPubKeyType? GetInputsScriptPubKeyType(this PSBT psbt)
|
public static ScriptPubKeyType? GetInputsScriptPubKeyType(this PSBT psbt)
|
||||||
{
|
{
|
||||||
if (!psbt.IsAllFinalized() || psbt.Inputs.Any(i => i.WitnessUtxo == null))
|
if (!psbt.IsAllFinalized())
|
||||||
throw new InvalidOperationException("The psbt should be finalized with witness information");
|
throw new InvalidOperationException("The psbt should be finalized with witness information");
|
||||||
var coinsPerTypes = psbt.Inputs.Select(i =>
|
var coinsPerTypes = psbt.Inputs.Select(i =>
|
||||||
{
|
{
|
||||||
|
@ -34,11 +39,19 @@ namespace BTCPayServer.Services
|
||||||
|
|
||||||
public static ScriptPubKeyType? GetInputScriptPubKeyType(this PSBTInput i)
|
public static ScriptPubKeyType? GetInputScriptPubKeyType(this PSBTInput i)
|
||||||
{
|
{
|
||||||
if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2WPKH))
|
var scriptPubKey = i.GetTxOut().ScriptPubKey;
|
||||||
|
if (scriptPubKey.IsScriptType(ScriptType.P2PKH))
|
||||||
|
return ScriptPubKeyType.Legacy;
|
||||||
|
if (scriptPubKey.IsScriptType(ScriptType.P2WPKH))
|
||||||
return ScriptPubKeyType.Segwit;
|
return ScriptPubKeyType.Segwit;
|
||||||
if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2SH) &&
|
if (scriptPubKey.IsScriptType(ScriptType.P2SH) &&
|
||||||
|
i.FinalScriptWitness is WitScript &&
|
||||||
PayToWitPubKeyHashTemplate.Instance.ExtractWitScriptParameters(i.FinalScriptWitness) is { })
|
PayToWitPubKeyHashTemplate.Instance.ExtractWitScriptParameters(i.FinalScriptWitness) is { })
|
||||||
return ScriptPubKeyType.SegwitP2SH;
|
return ScriptPubKeyType.SegwitP2SH;
|
||||||
|
if (scriptPubKey.IsScriptType(ScriptType.P2SH) &&
|
||||||
|
i.RedeemScript is Script &&
|
||||||
|
PayToWitPubKeyHashTemplate.Instance.CheckScriptPubKey(i.RedeemScript))
|
||||||
|
return ScriptPubKeyType.SegwitP2SH;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,6 +61,7 @@ namespace BTCPayServer.Services
|
||||||
public Money MaxAdditionalFeeContribution { get; set; }
|
public Money MaxAdditionalFeeContribution { get; set; }
|
||||||
public FeeRate MinFeeRate { get; set; }
|
public FeeRate MinFeeRate { get; set; }
|
||||||
public int? AdditionalFeeOutputIndex { get; set; }
|
public int? AdditionalFeeOutputIndex { get; set; }
|
||||||
|
public bool? DisableOutputSubstitution { get; set; }
|
||||||
public int Version { get; set; } = 1;
|
public int Version { get; set; } = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,58 +91,237 @@ namespace BTCPayServer.Services
|
||||||
public Money MaxFeeBumpContribution { get; set; }
|
public Money MaxFeeBumpContribution { get; set; }
|
||||||
public FeeRate MinimumFeeRate { get; set; }
|
public FeeRate MinimumFeeRate { get; set; }
|
||||||
|
|
||||||
public async Task<PSBT> RequestPayjoin(Uri endpoint, DerivationSchemeSettings derivationSchemeSettings,
|
public async Task<PSBT> RequestPayjoin(BitcoinUrlBuilder bip21, DerivationSchemeSettings derivationSchemeSettings,
|
||||||
PSBT originalTx, CancellationToken cancellationToken)
|
PSBT signedPSBT, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (endpoint == null)
|
if (bip21 == null)
|
||||||
throw new ArgumentNullException(nameof(endpoint));
|
throw new ArgumentNullException(nameof(bip21));
|
||||||
|
if (!bip21.TryGetPayjoinEndpoint(out var endpoint))
|
||||||
|
throw new InvalidOperationException("This BIP21 does not support payjoin");
|
||||||
if (derivationSchemeSettings == null)
|
if (derivationSchemeSettings == null)
|
||||||
throw new ArgumentNullException(nameof(derivationSchemeSettings));
|
throw new ArgumentNullException(nameof(derivationSchemeSettings));
|
||||||
if (originalTx == null)
|
if (signedPSBT == null)
|
||||||
throw new ArgumentNullException(nameof(originalTx));
|
throw new ArgumentNullException(nameof(signedPSBT));
|
||||||
if (originalTx.IsAllFinalized())
|
if (signedPSBT.IsAllFinalized())
|
||||||
throw new InvalidOperationException("The original PSBT should not be finalized.");
|
throw new InvalidOperationException("The original PSBT should not be finalized.");
|
||||||
var clientParameters = new PayjoinClientParameters();
|
var optionalParameters = new PayjoinClientParameters();
|
||||||
var type = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
|
var inputScriptType = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
|
||||||
if (!SupportedFormats.Contains(type))
|
|
||||||
{
|
|
||||||
throw new PayjoinSenderException($"The wallet does not support payjoin");
|
|
||||||
}
|
|
||||||
var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings();
|
var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings();
|
||||||
var changeOutput = originalTx.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath())
|
var paymentScriptPubKey = bip21.Address?.ScriptPubKey;
|
||||||
|
var changeOutput = signedPSBT.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath())
|
||||||
|
.Where(o => o.ScriptPubKey != paymentScriptPubKey)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
if (changeOutput is PSBTOutput o)
|
if (changeOutput is PSBTOutput o)
|
||||||
clientParameters.AdditionalFeeOutputIndex = (int)o.Index;
|
optionalParameters.AdditionalFeeOutputIndex = (int)o.Index;
|
||||||
var sentBefore = -originalTx.GetBalance(derivationSchemeSettings.AccountDerivation,
|
if (!signedPSBT.TryGetEstimatedFeeRate(out var originalFeeRate))
|
||||||
signingAccount.AccountKey,
|
throw new ArgumentException("signedPSBT should have utxo information", nameof(signedPSBT));
|
||||||
signingAccount.GetRootedKeyPath());
|
var originalFee = signedPSBT.GetFee();
|
||||||
var oldGlobalTx = originalTx.GetGlobalTransaction();
|
optionalParameters.MaxAdditionalFeeContribution = MaxFeeBumpContribution is null ?
|
||||||
if (!originalTx.TryGetEstimatedFeeRate(out var originalFeeRate) || !originalTx.TryGetVirtualSize(out var oldVirtualSize))
|
// By default, we want to keep same fee rate and a single additional input
|
||||||
throw new ArgumentException("originalTx should have utxo information", nameof(originalTx));
|
originalFeeRate.GetFee(GetVirtualSize(inputScriptType)) :
|
||||||
var originalFee = originalTx.GetFee();
|
MaxFeeBumpContribution;
|
||||||
clientParameters.MaxAdditionalFeeContribution = MaxFeeBumpContribution is null ? originalFee : MaxFeeBumpContribution;
|
|
||||||
if (MinimumFeeRate is FeeRate v)
|
if (MinimumFeeRate is FeeRate v)
|
||||||
clientParameters.MinFeeRate = v;
|
optionalParameters.MinFeeRate = v;
|
||||||
var cloned = originalTx.Clone();
|
|
||||||
cloned.Finalize();
|
|
||||||
|
|
||||||
// We make sure we don't send unnecessary information to the receiver
|
bool allowOutputSubstitution = !(optionalParameters.DisableOutputSubstitution is true);
|
||||||
foreach (var finalized in cloned.Inputs.Where(i => i.IsFinalized()))
|
if (bip21.UnknowParameters.TryGetValue("pjos", out var pjos) && pjos == "0")
|
||||||
|
allowOutputSubstitution = false;
|
||||||
|
PSBT originalPSBT = CreateOriginalPSBT(signedPSBT);
|
||||||
|
Transaction originalGlobalTx = signedPSBT.GetGlobalTransaction();
|
||||||
|
TxOut feeOutput = changeOutput == null ? null : originalGlobalTx.Outputs[changeOutput.Index];
|
||||||
|
var originalInputs = new Queue<(TxIn OriginalTxIn, PSBTInput SignedPSBTInput)>();
|
||||||
|
for (int i = 0; i < originalGlobalTx.Inputs.Count; i++)
|
||||||
{
|
{
|
||||||
finalized.ClearForFinalize();
|
originalInputs.Enqueue((originalGlobalTx.Inputs[i], signedPSBT.Inputs[i]));
|
||||||
|
}
|
||||||
|
var originalOutputs = new Queue<(TxOut OriginalTxOut, PSBTOutput SignedPSBTOutput)>();
|
||||||
|
for (int i = 0; i < originalGlobalTx.Outputs.Count; i++)
|
||||||
|
{
|
||||||
|
originalOutputs.Enqueue((originalGlobalTx.Outputs[i], signedPSBT.Outputs[i]));
|
||||||
|
}
|
||||||
|
endpoint = ApplyOptionalParameters(endpoint, optionalParameters);
|
||||||
|
var proposal = await SendOriginalTransaction(endpoint, originalPSBT, cancellationToken);
|
||||||
|
// Checking that the PSBT of the receiver is clean
|
||||||
|
if (proposal.GlobalXPubs.Any())
|
||||||
|
{
|
||||||
|
throw new PayjoinSenderException("GlobalXPubs should not be included in the receiver's PSBT");
|
||||||
|
}
|
||||||
|
////////////
|
||||||
|
|
||||||
|
if (proposal.CheckSanity() is List<PSBTError> errors && errors.Count > 0)
|
||||||
|
throw new PayjoinSenderException($"The proposal PSBT is not sane ({errors[0]})");
|
||||||
|
|
||||||
|
var proposalGlobalTx = proposal.GetGlobalTransaction();
|
||||||
|
// Verify that the transaction version, and nLockTime are unchanged.
|
||||||
|
if (proposalGlobalTx.Version != originalGlobalTx.Version)
|
||||||
|
throw new PayjoinSenderException($"The proposal PSBT changed the transaction version");
|
||||||
|
if (proposalGlobalTx.LockTime != originalGlobalTx.LockTime)
|
||||||
|
throw new PayjoinSenderException($"The proposal PSBT changed the nLocktime");
|
||||||
|
|
||||||
|
HashSet<Sequence> sequences = new HashSet<Sequence>();
|
||||||
|
// For each inputs in the proposal:
|
||||||
|
foreach (var proposedPSBTInput in proposal.Inputs)
|
||||||
|
{
|
||||||
|
if (proposedPSBTInput.HDKeyPaths.Count != 0)
|
||||||
|
throw new PayjoinSenderException("The receiver added keypaths to an input");
|
||||||
|
if (proposedPSBTInput.PartialSigs.Count != 0)
|
||||||
|
throw new PayjoinSenderException("The receiver added partial signatures to an input");
|
||||||
|
var proposedTxIn = proposalGlobalTx.Inputs.FindIndexedInput(proposedPSBTInput.PrevOut).TxIn;
|
||||||
|
bool isOurInput = originalInputs.Count > 0 && originalInputs.Peek().OriginalTxIn.PrevOut == proposedPSBTInput.PrevOut;
|
||||||
|
// If it is one of our input
|
||||||
|
if (isOurInput)
|
||||||
|
{
|
||||||
|
var input = originalInputs.Dequeue();
|
||||||
|
// Verify that sequence is unchanged.
|
||||||
|
if (input.OriginalTxIn.Sequence != proposedTxIn.Sequence)
|
||||||
|
throw new PayjoinSenderException("The proposedTxIn modified the sequence of one of our inputs");
|
||||||
|
// Verify the PSBT input is not finalized
|
||||||
|
if (proposedPSBTInput.IsFinalized())
|
||||||
|
throw new PayjoinSenderException("The receiver finalized one of our inputs");
|
||||||
|
// Verify that <code>non_witness_utxo</code> and <code>witness_utxo</code> are not specified.
|
||||||
|
if (proposedPSBTInput.NonWitnessUtxo != null || proposedPSBTInput.WitnessUtxo != null)
|
||||||
|
throw new PayjoinSenderException("The receiver added non_witness_utxo or witness_utxo to one of our inputs");
|
||||||
|
sequences.Add(proposedTxIn.Sequence);
|
||||||
|
|
||||||
|
// Fill up the info from the original PSBT input so we can sign and get fees.
|
||||||
|
proposedPSBTInput.NonWitnessUtxo = input.SignedPSBTInput.NonWitnessUtxo;
|
||||||
|
proposedPSBTInput.WitnessUtxo = input.SignedPSBTInput.WitnessUtxo;
|
||||||
|
// We fill up information we had on the signed PSBT, so we can sign it.
|
||||||
|
foreach (var hdKey in input.SignedPSBTInput.HDKeyPaths)
|
||||||
|
proposedPSBTInput.HDKeyPaths.Add(hdKey.Key, hdKey.Value);
|
||||||
|
proposedPSBTInput.RedeemScript = input.SignedPSBTInput.RedeemScript;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Verify the PSBT input is finalized
|
||||||
|
if (!proposedPSBTInput.IsFinalized())
|
||||||
|
throw new PayjoinSenderException("The receiver did not finalized one of their input");
|
||||||
|
// Verify that non_witness_utxo or witness_utxo are filled in.
|
||||||
|
if (proposedPSBTInput.NonWitnessUtxo == null && proposedPSBTInput.WitnessUtxo == null)
|
||||||
|
throw new PayjoinSenderException("The receiver did not specify non_witness_utxo or witness_utxo for one of their inputs");
|
||||||
|
sequences.Add(proposedTxIn.Sequence);
|
||||||
|
// Verify that the payjoin proposal did not introduced mixed input's type.
|
||||||
|
if (inputScriptType != proposedPSBTInput.GetInputScriptPubKeyType())
|
||||||
|
throw new PayjoinSenderException("Mixed input type detected in the proposal");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var output in cloned.Outputs)
|
// Verify that all of sender's inputs from the original PSBT are in the proposal.
|
||||||
|
if (originalInputs.Count != 0)
|
||||||
|
throw new PayjoinSenderException("Some of our inputs are not included in the proposal");
|
||||||
|
|
||||||
|
// Verify that the payjoin proposal did not introduced mixed input's sequence.
|
||||||
|
if (sequences.Count != 1)
|
||||||
|
throw new PayjoinSenderException("Mixed sequence detected in the proposal");
|
||||||
|
|
||||||
|
if (!proposal.TryGetFee(out var newFee))
|
||||||
|
throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly");
|
||||||
|
var additionalFee = newFee - originalFee;
|
||||||
|
if (additionalFee < Money.Zero)
|
||||||
|
throw new PayjoinSenderException("The receiver decreased absolute fee");
|
||||||
|
|
||||||
|
// For each outputs in the proposal:
|
||||||
|
foreach (var proposedPSBTOutput in proposal.Outputs)
|
||||||
{
|
{
|
||||||
|
// Verify that no keypaths is in the PSBT output
|
||||||
|
if (proposedPSBTOutput.HDKeyPaths.Count != 0)
|
||||||
|
throw new PayjoinSenderException("The receiver added keypaths to an output");
|
||||||
|
bool isOriginalOutput = originalOutputs.Count > 0 && originalOutputs.Peek().OriginalTxOut.ScriptPubKey == proposedPSBTOutput.ScriptPubKey;
|
||||||
|
if (isOriginalOutput)
|
||||||
|
{
|
||||||
|
var originalOutput = originalOutputs.Dequeue();
|
||||||
|
if (originalOutput.OriginalTxOut == feeOutput)
|
||||||
|
{
|
||||||
|
var actualContribution = feeOutput.Value - proposedPSBTOutput.Value;
|
||||||
|
// The amount that was substracted from the output's value is less or equal to maxadditionalfeecontribution
|
||||||
|
if (actualContribution > optionalParameters.MaxAdditionalFeeContribution)
|
||||||
|
throw new PayjoinSenderException("The actual contribution is more than maxadditionalfeecontribution");
|
||||||
|
// Make sure the actual contribution is only paying fee
|
||||||
|
if (actualContribution > additionalFee)
|
||||||
|
throw new PayjoinSenderException("The actual contribution is not only paying fee");
|
||||||
|
// Make sure the actual contribution is only paying for fee incurred by additional inputs
|
||||||
|
var additionalInputsCount = proposalGlobalTx.Inputs.Count - originalGlobalTx.Inputs.Count;
|
||||||
|
if (actualContribution > originalFeeRate.GetFee(GetVirtualSize(inputScriptType)) * additionalInputsCount)
|
||||||
|
throw new PayjoinSenderException("The actual contribution is not only paying for additional inputs");
|
||||||
|
}
|
||||||
|
else if (allowOutputSubstitution &&
|
||||||
|
originalOutput.OriginalTxOut.ScriptPubKey == paymentScriptPubKey)
|
||||||
|
{
|
||||||
|
// That's the payment output, the receiver may have changed it.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (originalOutput.OriginalTxOut.Value > proposedPSBTOutput.Value)
|
||||||
|
throw new PayjoinSenderException("The receiver decreased the value of one of the outputs");
|
||||||
|
}
|
||||||
|
// We fill up information we had on the signed PSBT, so we can sign it.
|
||||||
|
foreach (var hdKey in originalOutput.SignedPSBTOutput.HDKeyPaths)
|
||||||
|
proposedPSBTOutput.HDKeyPaths.Add(hdKey.Key, hdKey.Value);
|
||||||
|
proposedPSBTOutput.RedeemScript = originalOutput.SignedPSBTOutput.RedeemScript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Verify that all of sender's outputs from the original PSBT are in the proposal.
|
||||||
|
if (originalOutputs.Count != 0)
|
||||||
|
{
|
||||||
|
if (!allowOutputSubstitution ||
|
||||||
|
originalOutputs.Count != 1 ||
|
||||||
|
originalOutputs.Dequeue().OriginalTxOut.ScriptPubKey != paymentScriptPubKey)
|
||||||
|
{
|
||||||
|
throw new PayjoinSenderException("Some of our outputs are not included in the proposal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If minfeerate was specified, check that the fee rate of the payjoin transaction is not less than this value.
|
||||||
|
if (optionalParameters.MinFeeRate is FeeRate minFeeRate)
|
||||||
|
{
|
||||||
|
if (!proposal.TryGetEstimatedFeeRate(out var newFeeRate))
|
||||||
|
throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly");
|
||||||
|
if (newFeeRate < minFeeRate)
|
||||||
|
throw new PayjoinSenderException("The payjoin receiver created a payjoin with a too low fee rate");
|
||||||
|
}
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetVirtualSize(ScriptPubKeyType? scriptPubKeyType)
|
||||||
|
{
|
||||||
|
switch (scriptPubKeyType)
|
||||||
|
{
|
||||||
|
case ScriptPubKeyType.Legacy:
|
||||||
|
return 148;
|
||||||
|
case ScriptPubKeyType.Segwit:
|
||||||
|
return 68;
|
||||||
|
case ScriptPubKeyType.SegwitP2SH:
|
||||||
|
return 91;
|
||||||
|
default:
|
||||||
|
return 110;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PSBT CreateOriginalPSBT(PSBT signedPSBT)
|
||||||
|
{
|
||||||
|
var original = signedPSBT.Clone();
|
||||||
|
original = original.Finalize();
|
||||||
|
foreach (var input in original.Inputs)
|
||||||
|
{
|
||||||
|
input.HDKeyPaths.Clear();
|
||||||
|
input.PartialSigs.Clear();
|
||||||
|
input.Unknown.Clear();
|
||||||
|
}
|
||||||
|
foreach (var output in original.Outputs)
|
||||||
|
{
|
||||||
|
output.Unknown.Clear();
|
||||||
output.HDKeyPaths.Clear();
|
output.HDKeyPaths.Clear();
|
||||||
}
|
}
|
||||||
|
original.GlobalXPubs.Clear();
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
cloned.GlobalXPubs.Clear();
|
private async Task<PSBT> SendOriginalTransaction(Uri endpoint, PSBT originalTx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
endpoint = ApplyOptionalParameters(endpoint, clientParameters);
|
using (HttpClient client = CreateHttpClient(endpoint))
|
||||||
using HttpClient client = CreateHttpClient(endpoint);
|
{
|
||||||
var bpuresponse = await client.PostAsync(endpoint,
|
var bpuresponse = await client.PostAsync(endpoint,
|
||||||
new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain"), cancellationToken);
|
new StringContent(originalTx.ToHex(), Encoding.UTF8, "text/plain"), cancellationToken);
|
||||||
if (!bpuresponse.IsSuccessStatusCode)
|
if (!bpuresponse.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorStr = await bpuresponse.Content.ReadAsStringAsync();
|
var errorStr = await bpuresponse.Content.ReadAsStringAsync();
|
||||||
|
@ -147,115 +340,8 @@ namespace BTCPayServer.Services
|
||||||
}
|
}
|
||||||
|
|
||||||
var hex = await bpuresponse.Content.ReadAsStringAsync();
|
var hex = await bpuresponse.Content.ReadAsStringAsync();
|
||||||
var newPSBT = PSBT.Parse(hex, originalTx.Network);
|
return 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
|
|
||||||
var newGlobalTx = newPSBT.GetGlobalTransaction();
|
|
||||||
int ourInputCount = 0;
|
|
||||||
if (newGlobalTx.Version != oldGlobalTx.Version)
|
|
||||||
throw new PayjoinSenderException("The version field of the transaction has been modified");
|
|
||||||
if (newGlobalTx.LockTime != oldGlobalTx.LockTime)
|
|
||||||
throw new PayjoinSenderException("The LockTime field of the transaction has been modified");
|
|
||||||
foreach (var input in newPSBT.Inputs.CoinsFor(derivationSchemeSettings.AccountDerivation,
|
|
||||||
signingAccount.AccountKey, signingAccount.GetRootedKeyPath()))
|
|
||||||
{
|
|
||||||
if (oldGlobalTx.Inputs.FindIndexedInput(input.PrevOut) is IndexedTxIn ourInput)
|
|
||||||
{
|
|
||||||
ourInputCount++;
|
|
||||||
if (input.IsFinalized())
|
|
||||||
throw new PayjoinSenderException("A PSBT input from us should not be finalized");
|
|
||||||
if (newGlobalTx.Inputs[input.Index].Sequence != ourInput.TxIn.Sequence)
|
|
||||||
throw new PayjoinSenderException("The sequence of one of our input has been modified");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new PayjoinSenderException(
|
|
||||||
"The payjoin receiver added some of our own inputs in the proposal");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var input in newPSBT.Inputs)
|
|
||||||
{
|
|
||||||
if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is null)
|
|
||||||
{
|
|
||||||
if (!input.IsFinalized())
|
|
||||||
throw new PayjoinSenderException("The payjoin receiver included a non finalized input");
|
|
||||||
// Making sure that the receiver's inputs are finalized and match format
|
|
||||||
var payjoinInputType = input.GetInputScriptPubKeyType();
|
|
||||||
if (payjoinInputType is null || payjoinInputType.Value != type)
|
|
||||||
{
|
|
||||||
throw new PayjoinSenderException("The payjoin receiver included an input that is not the same segwit input type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ourInputCount < originalTx.Inputs.Count)
|
|
||||||
throw new PayjoinSenderException("The payjoin receiver removed some of our inputs");
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
if (clientParameters.MinFeeRate is FeeRate minFeeRate)
|
|
||||||
{
|
|
||||||
if (newFeeRate < minFeeRate)
|
|
||||||
throw new PayjoinSenderException("The payjoin receiver created a payjoin with a too low fee rate");
|
|
||||||
}
|
|
||||||
|
|
||||||
var sentAfter = -newPSBT.GetBalance(derivationSchemeSettings.AccountDerivation,
|
|
||||||
signingAccount.AccountKey,
|
|
||||||
signingAccount.GetRootedKeyPath());
|
|
||||||
if (sentAfter > sentBefore)
|
|
||||||
{
|
|
||||||
var overPaying = sentAfter - sentBefore;
|
|
||||||
var additionalFee = newPSBT.GetFee() - originalFee;
|
|
||||||
if (overPaying > additionalFee)
|
|
||||||
throw new PayjoinSenderException("The payjoin receiver is sending more money to himself");
|
|
||||||
if (overPaying > clientParameters.MaxAdditionalFeeContribution)
|
|
||||||
throw new PayjoinSenderException("The payjoin receiver is making us pay too much fee");
|
|
||||||
|
|
||||||
// Let's check the difference is only for the fee and that feerate
|
|
||||||
// did not changed that much
|
|
||||||
var expectedFee = originalFeeRate.GetFee(newVirtualSize);
|
|
||||||
// Signing precisely is hard science, give some breathing room for error.
|
|
||||||
expectedFee += originalFeeRate.GetFee(newPSBT.Inputs.Count * 2);
|
|
||||||
if (overPaying > (expectedFee - originalFee))
|
|
||||||
throw new PayjoinSenderException("The payjoin receiver increased the fee rate we are paying too much");
|
|
||||||
}
|
|
||||||
|
|
||||||
return newPSBT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Uri ApplyOptionalParameters(Uri endpoint, PayjoinClientParameters clientParameters)
|
private static Uri ApplyOptionalParameters(Uri endpoint, PayjoinClientParameters clientParameters)
|
||||||
|
@ -267,6 +353,8 @@ namespace BTCPayServer.Services
|
||||||
parameters.Add($"v={clientParameters.Version}");
|
parameters.Add($"v={clientParameters.Version}");
|
||||||
if (clientParameters.AdditionalFeeOutputIndex is int additionalFeeOutputIndex)
|
if (clientParameters.AdditionalFeeOutputIndex is int additionalFeeOutputIndex)
|
||||||
parameters.Add($"additionalfeeoutputindex={additionalFeeOutputIndex.ToString(CultureInfo.InvariantCulture)}");
|
parameters.Add($"additionalfeeoutputindex={additionalFeeOutputIndex.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
if (clientParameters.DisableOutputSubstitution is bool disableoutputsubstitution)
|
||||||
|
parameters.Add($"disableoutputsubstitution={disableoutputsubstitution}");
|
||||||
if (clientParameters.MaxAdditionalFeeContribution is Money maxAdditionalFeeContribution)
|
if (clientParameters.MaxAdditionalFeeContribution is Money maxAdditionalFeeContribution)
|
||||||
parameters.Add($"maxadditionalfeecontribution={maxAdditionalFeeContribution.Satoshi.ToString(CultureInfo.InvariantCulture)}");
|
parameters.Add($"maxadditionalfeecontribution={maxAdditionalFeeContribution.Satoshi.ToString(CultureInfo.InvariantCulture)}");
|
||||||
if (clientParameters.MinFeeRate is FeeRate minFeeRate)
|
if (clientParameters.MinFeeRate is FeeRate minFeeRate)
|
||||||
|
@ -330,7 +418,7 @@ namespace BTCPayServer.Services
|
||||||
}
|
}
|
||||||
public class PayjoinReceiverException : PayjoinException
|
public class PayjoinReceiverException : PayjoinException
|
||||||
{
|
{
|
||||||
public PayjoinReceiverException(string errorCode, string receiverMessage) : base(FormatMessage(errorCode))
|
public PayjoinReceiverException(string errorCode, string receiverMessage) : base(FormatMessage(errorCode, receiverMessage))
|
||||||
{
|
{
|
||||||
ErrorCode = errorCode;
|
ErrorCode = errorCode;
|
||||||
ReceiverMessage = receiverMessage;
|
ReceiverMessage = receiverMessage;
|
||||||
|
@ -346,9 +434,9 @@ namespace BTCPayServer.Services
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatMessage(string errorCode)
|
private static string FormatMessage(string errorCode, string receiverMessage)
|
||||||
{
|
{
|
||||||
return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}";
|
return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}. (Receiver message: {receiverMessage})";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
{
|
{
|
||||||
<input type="hidden" asp-for="PSBT" />
|
<input type="hidden" asp-for="PSBT" />
|
||||||
<input type="hidden" asp-for="OriginalPSBT" />
|
<input type="hidden" asp-for="OriginalPSBT" />
|
||||||
<input type="hidden" asp-for="PayJoinEndpointUrl" />
|
<input type="hidden" asp-for="PayJoinBIP21" />
|
||||||
<input type="hidden" asp-for="EnforceLowR" />
|
<input type="hidden" asp-for="EnforceLowR" />
|
||||||
<input type="hidden" asp-for="ChangeAddress" />
|
<input type="hidden" asp-for="ChangeAddress" />
|
||||||
}
|
}
|
||||||
|
|
|
@ -142,7 +142,7 @@
|
||||||
<partial name="SigningContext" for="SigningContext" />
|
<partial name="SigningContext" for="SigningContext" />
|
||||||
@if (!Model.HasErrors)
|
@if (!Model.HasErrors)
|
||||||
{
|
{
|
||||||
@if (!string.IsNullOrEmpty(Model.SigningContext?.PayJoinEndpointUrl))
|
@if (!string.IsNullOrEmpty(Model.SigningContext?.PayJoinBIP21))
|
||||||
{
|
{
|
||||||
<button type="submit" class="btn btn-primary" name="command" value="payjoin">Broadcast (Payjoin)</button>
|
<button type="submit" class="btn btn-primary" name="command" value="payjoin">Broadcast (Payjoin)</button>
|
||||||
<span> or </span>
|
<span> or </span>
|
||||||
|
|
|
@ -203,12 +203,12 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl))
|
@if (!string.IsNullOrEmpty(Model.PayJoinBIP21))
|
||||||
{
|
{
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="PayJoinEndpointUrl" class="control-label"></label>
|
<label asp-for="PayJoinBIP21" class="control-label"></label>
|
||||||
<input asp-for="PayJoinEndpointUrl" class="form-control" />
|
<input asp-for="PayJoinBIP21" class="form-control" />
|
||||||
<span asp-validation-for="PayJoinEndpointUrl" class="text-danger"></span>
|
<span asp-validation-for="PayJoinBIP21" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
Loading…
Add table
Reference in a new issue