Adapt payjoin for BIP78

This commit is contained in:
nicolas.dorier 2020-06-17 21:43:56 +09:00
parent 0b720768b8
commit 24a88fcfb5
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
12 changed files with 381 additions and 270 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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