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 Newtonsoft.Json.Linq;
using OpenQA.Selenium;
using TwentyTwenty.Storage;
using Xunit;
using Xunit.Abstractions;
@ -193,12 +194,11 @@ namespace BTCPayServer.Tests
await receiverUser.EnablePayJoin();
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
var clientShouldError = unsupportedFormats.Contains(senderAddressType);
string errorCode = receiverAddressType == senderAddressType ? null : "unavailable|any UTXO available";
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true });
if (unsupportedFormats.Contains(receiverAddressType))
{
Assert.Null(TestAccount.GetPayjoinEndpoint(invoice, cashCow.Network));
Assert.Null(TestAccount.GetPayjoinBitcoinUrl(invoice, cashCow.Network));
continue;
}
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
@ -210,7 +210,7 @@ namespace BTCPayServer.Tests
txBuilder.SendEstimatedFees(new FeeRate(50m));
var psbt = txBuilder.BuildPSBT(false);
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.SwitchTo().Alert().SendKeys(bip21);
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")));
s.Driver.ScrollTo(By.Id("SendMenu"));
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
@ -298,10 +298,10 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("bip21parse")).Click();
s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept();
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl"))
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinBIP21"))
.GetAttribute("value")));
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.FindElement(By.Id("SendMenu")).ForceClick();
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 notifications.ListenDerivationSchemesAsync(new[] { alice.DerivationScheme });
var address = (await nbx.GetUnusedAsync(alice.DerivationScheme, DerivationFeature.Deposit)).Address;
await tester.ExplorerNode.GenerateAsync(1);
tester.ExplorerNode.SendToAddress(address, Money.Coins(1.0m));
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()
{
Destinations =
@ -381,7 +384,12 @@ namespace BTCPayServer.Tests
new CreatePSBTDestination()
{
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()
@ -389,62 +397,110 @@ namespace BTCPayServer.Tests
ExplicitFee = Money.Satoshis(3000)
}
})).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 signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings();
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();
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();
Assert.Equal("1", request.Request.Query["v"][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");
var originalPSBT = await ParsePSBT(request);
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);
fakeServer.Done();
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");
requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default);
Logs.Tester.LogInformation("The payjoin receiver tries to change one of our output");
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
request = await fakeServer.GetNextRequest();
originalPSBT = await ParsePSBT(request);
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);
await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8);
fakeServer.Done();
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");
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");
Logs.Tester.LogInformation("The payjoin receiver can't use additional fee without adding inputs");
pjClient.MinimumFeeRate = new FeeRate(50m);
requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default);
requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default);
request = await fakeServer.GetNextRequest();
originalPSBT = await ParsePSBT(request);
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);
fakeServer.Done();
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;
Logs.Tester.LogInformation("Make sure the receiver implementation do not take more fee than allowed");
@ -476,7 +532,7 @@ namespace BTCPayServer.Tests
}
})).PSBT;
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);
var proposal = await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default);
Assert.True(proposal.TryGetFee(out var newFee));
@ -507,7 +563,7 @@ namespace BTCPayServer.Tests
}
})).PSBT;
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);
var ex2 = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default));
Assert.Equal(PayjoinReceiverWellknownErrors.NotEnoughMoney, ex2.WellknownError);
@ -732,7 +788,7 @@ namespace BTCPayServer.Tests
var invoice = senderUser.BitPay.CreateInvoice(
new Invoice() { Price = 100, Currency = "USD", FullNotifications = true });
//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),
Money.Coins(0.06m));
@ -767,7 +823,8 @@ namespace BTCPayServer.Tests
invoice = receiverUser.BitPay.CreateInvoice(
new Invoice() { Price = 0.02m, Currency = "BTC", FullNotifications = true });
// 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",
new StringContent("", Encoding.UTF8, "text/plain"));
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)
{
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
var endpoint = GetPayjoinBitcoinUrl(invoice, psbt.Network);
if (endpoint == null)
{
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,
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,
new StringContent(content, Encoding.UTF8, "text/plain"));
if (expectedError != null)
@ -421,12 +422,14 @@ namespace BTCPayServer.Tests
return response;
}
public static Uri GetPayjoinEndpoint(Invoice invoice, Network network)
public static BitcoinUrlBuilder GetPayjoinBitcoinUrl(Invoice invoice, Network network)
{
var parsedBip21 = new BitcoinUrlBuilder(
invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21,
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 Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.Payment;
using NBXplorer;
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();
cloned = cloned.Finalize();
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]
@ -317,7 +316,7 @@ namespace BTCPayServer.Controllers
string error = null;
try
{
var proposedPayjoin = await GetPayjoinProposedTX(vm.SigningContext.PayJoinEndpointUrl, psbt,
var proposedPayjoin = await GetPayjoinProposedTX(new BitcoinUrlBuilder(vm.SigningContext.PayJoinBIP21, network.NBitcoinNetwork), psbt,
derivationSchemeSettings, network, cancellationToken);
try
{

View file

@ -658,7 +658,7 @@ namespace BTCPayServer.Controllers
var signingContext = new SigningContextModel()
{
PayJoinEndpointUrl = vm.PayJoinEndpointUrl,
PayJoinBIP21 = vm.PayJoinBIP21,
EnforceLowR = psbt.Suggestions?.ShouldEnforceLowR,
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}")}"
});
}
uriBuilder.UnknowParameters.TryGetValue(PayjoinClient.BIP21EndpointKey, out var vmPayJoinEndpointUrl);
vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl;
if (uriBuilder.TryGetPayjoinEndpoint(out _))
vm.PayJoinBIP21 = uriBuilder.ToString();
}
catch
{
@ -783,7 +784,7 @@ namespace BTCPayServer.Controllers
return;
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.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.ChangeAddress", signingContext.ChangeAddress));
}

View file

@ -38,11 +38,17 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Newtonsoft.Json.Linq;
using BTCPayServer.Payments.Bitcoin;
using NBitcoin.Payment;
namespace BTCPayServer
{
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)
{
var internalDomain = internalLightning?.BaseUri?.DnsSafeHost;

View file

@ -18,7 +18,7 @@ namespace BTCPayServer.Models.WalletViewModels
}
public string PSBT { get; set; }
public string OriginalPSBT { get; set; }
public string PayJoinEndpointUrl { get; set; }
public string PayJoinBIP21 { get; set; }
public bool? EnforceLowR { get; set; }
public string ChangeAddress { get; set; }
}

View file

@ -57,8 +57,8 @@ namespace BTCPayServer.Models.WalletViewModels
public ThreeStateBool AllowFeeBump { get; set; }
public bool NBXSeedAvailable { get; set; }
[Display(Name = "PayJoin Endpoint Url")]
public string PayJoinEndpointUrl { get; set; }
[Display(Name = "PayJoin BIP21")]
public string PayJoinBIP21 { get; set; }
public bool InputSelection { get; set; }
public InputSelectionOption[] InputsAvailable { get; set; }

View file

@ -122,9 +122,10 @@ namespace BTCPayServer.Payments.PayJoin
[MediaTypeConstraint("text/plain")]
[RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> Submit(string cryptoCode,
long maxadditionalfeecontribution = -1,
int additionalfeeoutputindex = -1,
long? maxadditionalfeecontribution,
int? additionalfeeoutputindex,
decimal minfeerate = -1.0m,
bool disableoutputsubstitution = false,
int v = 1)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
@ -192,9 +193,8 @@ namespace BTCPayServer.Payments.PayJoin
}
}
bool spareChangeCase = psbt.Outputs.Count == 1;
FeeRate senderMinFeeRate = !spareChangeCase && minfeerate >= 0.0m ? new FeeRate(minfeerate) : null;
Money allowedSenderFeeContribution = Money.Satoshis(!spareChangeCase && maxadditionalfeecontribution >= 0 ? maxadditionalfeecontribution : long.MaxValue);
FeeRate senderMinFeeRate = minfeerate >= 0.0m ? new FeeRate(minfeerate) : null;
Money allowedSenderFeeContribution = Money.Satoshis(maxadditionalfeecontribution is long t && t >= 0 ? t : 0);
var sendersInputType = psbt.GetInputsScriptPubKeyType();
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
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");
}
@ -341,10 +341,14 @@ namespace BTCPayServer.Payments.PayJoin
var ourNewOutput = newTx.Outputs[originalPaymentOutput.Index];
HashSet<TxOut> isOurOutput = new HashSet<TxOut>();
isOurOutput.Add(ourNewOutput);
TxOut preferredFeeBumpOutput = additionalfeeoutputindex >= 0
&& additionalfeeoutputindex < newTx.Outputs.Count
&& !isOurOutput.Contains(newTx.Outputs[additionalfeeoutputindex])
? newTx.Outputs[additionalfeeoutputindex] : null;
TxOut feeOutput =
additionalfeeoutputindex is int feeOutputIndex &&
maxadditionalfeecontribution is long v3 &&
v3 >= 0 &&
feeOutputIndex >= 0
&& feeOutputIndex < newTx.Outputs.Count
&& !isOurOutput.Contains(newTx.Outputs[feeOutputIndex])
? newTx.Outputs[feeOutputIndex] : null;
var rand = new Random();
int senderInputCount = newTx.Inputs.Count;
foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value))
@ -356,49 +360,6 @@ namespace BTCPayServer.Payments.PayJoin
ourNewOutput.Value += contributedAmount;
var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
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
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)
for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero; i++)
{
if (disableoutputsubstitution)
break;
if (isOurOutput.Contains(newTx.Outputs[i]))
{
var outputContribution = Money.Min(additionalFee, -due);
@ -434,21 +397,15 @@ namespace BTCPayServer.Payments.PayJoin
}
// 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 &&
preferredFeeBumpOutput != newTx.Outputs[i])
continue;
if (!isOurOutput.Contains(newTx.Outputs[i]))
{
var outputContribution = Money.Min(additionalFee, newTx.Outputs[i].Value);
outputContribution = Money.Min(outputContribution,
newTx.Outputs[i].Value - newTx.Outputs[i].GetDustThreshold(minRelayTxFee));
outputContribution = Money.Min(outputContribution, allowedSenderFeeContribution);
newTx.Outputs[i].Value -= outputContribution;
additionalFee -= outputContribution;
allowedSenderFeeContribution -= outputContribution;
}
var outputContribution = Money.Min(additionalFee, feeOutput.Value);
outputContribution = Money.Min(outputContribution,
feeOutput.Value - feeOutput.GetDustThreshold(minRelayTxFee));
outputContribution = Money.Min(outputContribution, allowedSenderFeeContribution);
feeOutput.Value -= outputContribution;
additionalFee -= outputContribution;
allowedSenderFeeContribution -= outputContribution;
}
if (additionalFee > Money.Zero)

View file

@ -7,11 +7,16 @@ using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Logging;
using BTCPayServer.Payments.Changelly.Models;
using Google.Apis.Http;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Payment;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUglify.Helpers;
using TwentyTwenty.Storage;
using IHttpClientFactory = System.Net.Http.IHttpClientFactory;
namespace BTCPayServer.Services
@ -21,7 +26,7 @@ namespace BTCPayServer.Services
{
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");
var coinsPerTypes = psbt.Inputs.Select(i =>
{
@ -34,11 +39,19 @@ namespace BTCPayServer.Services
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;
if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2SH) &&
if (scriptPubKey.IsScriptType(ScriptType.P2SH) &&
i.FinalScriptWitness is WitScript &&
PayToWitPubKeyHashTemplate.Instance.ExtractWitScriptParameters(i.FinalScriptWitness) is { })
return ScriptPubKeyType.SegwitP2SH;
if (scriptPubKey.IsScriptType(ScriptType.P2SH) &&
i.RedeemScript is Script &&
PayToWitPubKeyHashTemplate.Instance.CheckScriptPubKey(i.RedeemScript))
return ScriptPubKeyType.SegwitP2SH;
return null;
}
}
@ -48,6 +61,7 @@ namespace BTCPayServer.Services
public Money MaxAdditionalFeeContribution { get; set; }
public FeeRate MinFeeRate { get; set; }
public int? AdditionalFeeOutputIndex { get; set; }
public bool? DisableOutputSubstitution { get; set; }
public int Version { get; set; } = 1;
}
@ -77,185 +91,257 @@ namespace BTCPayServer.Services
public Money MaxFeeBumpContribution { get; set; }
public FeeRate MinimumFeeRate { get; set; }
public async Task<PSBT> RequestPayjoin(Uri endpoint, DerivationSchemeSettings derivationSchemeSettings,
PSBT originalTx, CancellationToken cancellationToken)
public async Task<PSBT> RequestPayjoin(BitcoinUrlBuilder bip21, DerivationSchemeSettings derivationSchemeSettings,
PSBT signedPSBT, CancellationToken cancellationToken)
{
if (endpoint == null)
throw new ArgumentNullException(nameof(endpoint));
if (bip21 == null)
throw new ArgumentNullException(nameof(bip21));
if (!bip21.TryGetPayjoinEndpoint(out var endpoint))
throw new InvalidOperationException("This BIP21 does not support payjoin");
if (derivationSchemeSettings == null)
throw new ArgumentNullException(nameof(derivationSchemeSettings));
if (originalTx == null)
throw new ArgumentNullException(nameof(originalTx));
if (originalTx.IsAllFinalized())
if (signedPSBT == null)
throw new ArgumentNullException(nameof(signedPSBT));
if (signedPSBT.IsAllFinalized())
throw new InvalidOperationException("The original PSBT should not be finalized.");
var clientParameters = new PayjoinClientParameters();
var type = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
if (!SupportedFormats.Contains(type))
{
throw new PayjoinSenderException($"The wallet does not support payjoin");
}
var optionalParameters = new PayjoinClientParameters();
var inputScriptType = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
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();
if (changeOutput is PSBTOutput o)
clientParameters.AdditionalFeeOutputIndex = (int)o.Index;
var sentBefore = -originalTx.GetBalance(derivationSchemeSettings.AccountDerivation,
signingAccount.AccountKey,
signingAccount.GetRootedKeyPath());
var oldGlobalTx = originalTx.GetGlobalTransaction();
if (!originalTx.TryGetEstimatedFeeRate(out var originalFeeRate) || !originalTx.TryGetVirtualSize(out var oldVirtualSize))
throw new ArgumentException("originalTx should have utxo information", nameof(originalTx));
var originalFee = originalTx.GetFee();
clientParameters.MaxAdditionalFeeContribution = MaxFeeBumpContribution is null ? originalFee : MaxFeeBumpContribution;
optionalParameters.AdditionalFeeOutputIndex = (int)o.Index;
if (!signedPSBT.TryGetEstimatedFeeRate(out var originalFeeRate))
throw new ArgumentException("signedPSBT should have utxo information", nameof(signedPSBT));
var originalFee = signedPSBT.GetFee();
optionalParameters.MaxAdditionalFeeContribution = MaxFeeBumpContribution is null ?
// By default, we want to keep same fee rate and a single additional input
originalFeeRate.GetFee(GetVirtualSize(inputScriptType)) :
MaxFeeBumpContribution;
if (MinimumFeeRate is FeeRate v)
clientParameters.MinFeeRate = v;
var cloned = originalTx.Clone();
cloned.Finalize();
optionalParameters.MinFeeRate = v;
// We make sure we don't send unnecessary information to the receiver
foreach (var finalized in cloned.Inputs.Where(i => i.IsFinalized()))
bool allowOutputSubstitution = !(optionalParameters.DisableOutputSubstitution is true);
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]));
}
foreach (var output in cloned.Outputs)
var originalOutputs = new Queue<(TxOut OriginalTxOut, PSBTOutput SignedPSBTOutput)>();
for (int i = 0; i < originalGlobalTx.Outputs.Count; i++)
{
output.HDKeyPaths.Clear();
originalOutputs.Enqueue((originalGlobalTx.Outputs[i], signedPSBT.Outputs[i]));
}
cloned.GlobalXPubs.Clear();
endpoint = ApplyOptionalParameters(endpoint, clientParameters);
using HttpClient client = CreateHttpClient(endpoint);
var bpuresponse = await client.PostAsync(endpoint,
new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain"), cancellationToken);
if (!bpuresponse.IsSuccessStatusCode)
{
var errorStr = await bpuresponse.Content.ReadAsStringAsync();
try
{
var error = JObject.Parse(errorStr);
throw new PayjoinReceiverException(error["errorCode"].Value<string>(),
error["message"].Value<string>());
}
catch (JsonReaderException)
{
// will throw
bpuresponse.EnsureSuccessStatusCode();
throw;
}
}
var hex = await bpuresponse.Content.ReadAsStringAsync();
var newPSBT = PSBT.Parse(hex, originalTx.Network);
endpoint = ApplyOptionalParameters(endpoint, optionalParameters);
var proposal = await SendOriginalTransaction(endpoint, originalPSBT, cancellationToken);
// Checking that the PSBT of the receiver is clean
if (newPSBT.GlobalXPubs.Any())
if (proposal.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);
}
}
if (proposal.CheckSanity() is List<PSBTError> errors && errors.Count > 0)
throw new PayjoinSenderException($"The proposal PSBT is not sane ({errors[0]})");
// 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()))
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 (oldGlobalTx.Inputs.FindIndexedInput(input.PrevOut) is IndexedTxIn ourInput)
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)
{
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");
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
{
throw new PayjoinSenderException(
"The payjoin receiver added some of our own inputs in the proposal");
// 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 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");
}
}
}
// 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");
if (ourInputCount < originalTx.Inputs.Count)
throw new PayjoinSenderException("The payjoin receiver removed some of our inputs");
// 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 (!newPSBT.TryGetEstimatedFeeRate(out var newFeeRate) || !newPSBT.TryGetVirtualSize(out var newVirtualSize))
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");
if (clientParameters.MinFeeRate is FeeRate minFeeRate)
// 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;
}
var sentAfter = -newPSBT.GetBalance(derivationSchemeSettings.AccountDerivation,
signingAccount.AccountKey,
signingAccount.GetRootedKeyPath());
if (sentAfter > sentBefore)
private int GetVirtualSize(ScriptPubKeyType? scriptPubKeyType)
{
switch (scriptPubKeyType)
{
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");
case ScriptPubKeyType.Legacy:
return 148;
case ScriptPubKeyType.Segwit:
return 68;
case ScriptPubKeyType.SegwitP2SH:
return 91;
default:
return 110;
}
}
return newPSBT;
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();
}
original.GlobalXPubs.Clear();
return original;
}
private async Task<PSBT> SendOriginalTransaction(Uri endpoint, PSBT originalTx, CancellationToken cancellationToken)
{
using (HttpClient client = CreateHttpClient(endpoint))
{
var bpuresponse = await client.PostAsync(endpoint,
new StringContent(originalTx.ToHex(), Encoding.UTF8, "text/plain"), cancellationToken);
if (!bpuresponse.IsSuccessStatusCode)
{
var errorStr = await bpuresponse.Content.ReadAsStringAsync();
try
{
var error = JObject.Parse(errorStr);
throw new PayjoinReceiverException(error["errorCode"].Value<string>(),
error["message"].Value<string>());
}
catch (JsonReaderException)
{
// will throw
bpuresponse.EnsureSuccessStatusCode();
throw;
}
}
var hex = await bpuresponse.Content.ReadAsStringAsync();
return PSBT.Parse(hex, originalTx.Network);
}
}
private static Uri ApplyOptionalParameters(Uri endpoint, PayjoinClientParameters clientParameters)
@ -267,6 +353,8 @@ namespace BTCPayServer.Services
parameters.Add($"v={clientParameters.Version}");
if (clientParameters.AdditionalFeeOutputIndex is int additionalFeeOutputIndex)
parameters.Add($"additionalfeeoutputindex={additionalFeeOutputIndex.ToString(CultureInfo.InvariantCulture)}");
if (clientParameters.DisableOutputSubstitution is bool disableoutputsubstitution)
parameters.Add($"disableoutputsubstitution={disableoutputsubstitution}");
if (clientParameters.MaxAdditionalFeeContribution is Money maxAdditionalFeeContribution)
parameters.Add($"maxadditionalfeecontribution={maxAdditionalFeeContribution.Satoshi.ToString(CultureInfo.InvariantCulture)}");
if (clientParameters.MinFeeRate is FeeRate minFeeRate)
@ -330,7 +418,7 @@ namespace BTCPayServer.Services
}
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;
ReceiverMessage = receiverMessage;
@ -346,9 +434,9 @@ namespace BTCPayServer.Services
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="OriginalPSBT" />
<input type="hidden" asp-for="PayJoinEndpointUrl" />
<input type="hidden" asp-for="PayJoinBIP21" />
<input type="hidden" asp-for="EnforceLowR" />
<input type="hidden" asp-for="ChangeAddress" />
}

View file

@ -142,7 +142,7 @@
<partial name="SigningContext" for="SigningContext" />
@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>
<span> or </span>

View file

@ -203,12 +203,12 @@
</select>
</div>
}
@if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl))
@if (!string.IsNullOrEmpty(Model.PayJoinBIP21))
{
<div class="form-group">
<label asp-for="PayJoinEndpointUrl" class="control-label"></label>
<input asp-for="PayJoinEndpointUrl" class="form-control" />
<span asp-validation-for="PayJoinEndpointUrl" class="text-danger"></span>
<label asp-for="PayJoinBIP21" class="control-label"></label>
<input asp-for="PayJoinBIP21" class="form-control" />
<span asp-validation-for="PayJoinBIP21" class="text-danger"></span>
</div>
}
<div class="form-group">