mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-21 14:04:12 +01:00
Adapt payjoin for BIP78
This commit is contained in:
parent
0b720768b8
commit
24a88fcfb5
12 changed files with 381 additions and 270 deletions
|
@ -32,6 +32,7 @@ using NBXplorer.DerivationStrategy;
|
|||
using NBXplorer.Models;
|
||||
using 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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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; }
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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" />
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Add table
Reference in a new issue