Merge pull request #1441 from Kukks/payjoin/p2sh

Payjoin: P2SH support
This commit is contained in:
Nicolas Dorier 2020-04-08 22:00:07 +09:00 committed by GitHub
commit 111feeb673
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 160 additions and 38 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
@ -40,6 +41,64 @@ namespace BTCPayServer.Tests
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanOnlyUseCorrectAddressFormatsForPayjoin()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var broadcaster = tester.PayTester.GetService<DelayedTransactionBroadcaster>();
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
broadcaster.Disable();
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var cashCow = tester.ExplorerNode;
cashCow.Generate(2); // get some money in case
var unsupportedFormats = Enum.GetValues(typeof(ScriptPubKeyType))
.AssertType<ScriptPubKeyType[]>()
.Where(type => !PayjoinClient.SupportedFormats.Contains(type));
foreach (ScriptPubKeyType senderAddressType in Enum.GetValues(typeof(ScriptPubKeyType)))
{
var senderUser = tester.NewAccount();
senderUser.GrantAccess(true);
senderUser.RegisterDerivationScheme("BTC", senderAddressType);
foreach (ScriptPubKeyType receiverAddressType in Enum.GetValues(typeof(ScriptPubKeyType)))
{
var senderCoin = await senderUser.ReceiveUTXO(Money.Satoshis(100000), network);
Logs.Tester.LogInformation($"Testing payjoin with sender: {senderAddressType} receiver: {receiverAddressType}");
var receiverUser = tester.NewAccount();
receiverUser.GrantAccess(true);
receiverUser.RegisterDerivationScheme("BTC", receiverAddressType, true);
await receiverUser.EnablePayJoin();
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
var clientShouldError = unsupportedFormats.Contains(senderAddressType);
var errorCode = ( unsupportedFormats.Contains( receiverAddressType) || receiverAddressType != senderAddressType)? "unsupported-inputs" : null;
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() {Price = 50000, Currency = "sats", FullNotifications = true});
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
txBuilder.AddCoins(senderCoin);
txBuilder.Send(invoiceAddress, invoice.BtcDue);
txBuilder.SetChange(await senderUser.GetNewAddress(network));
txBuilder.SendEstimatedFees(new FeeRate(50m));
var psbt = txBuilder.BuildPSBT(false);
psbt = await senderUser.Sign(psbt);
var pj = await senderUser.SubmitPayjoin(invoice, psbt, errorCode, clientShouldError);
}
}
}
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUseBIP79Client()
@ -60,7 +119,7 @@ namespace BTCPayServer.Tests
s.GoToInvoiceCheckout(invoiceId);
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.DoesNotContain("bpu=", bip21);
Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}=", bip21);
s.GoToHome();
s.GoToStore(receiver.storeId);
@ -79,7 +138,7 @@ namespace BTCPayServer.Tests
s.GoToInvoiceCheckout(invoiceId);
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains("bpu=", bip21);
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}=", bip21);
s.GoToWalletSend(senderWalletId);
s.Driver.FindElement(By.Id("bip21parse")).Click();
@ -111,7 +170,7 @@ namespace BTCPayServer.Tests
s.GoToInvoiceCheckout(invoiceId);
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains("bpu", bip21);
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
s.GoToWalletSend(senderWalletId);
s.Driver.FindElement(By.Id("bip21parse")).Click();
@ -179,11 +238,11 @@ namespace BTCPayServer.Tests
var senderUser = tester.NewAccount();
senderUser.GrantAccess(true);
senderUser.RegisterDerivationScheme("BTC", true);
senderUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit);
var receiverUser = tester.NewAccount();
receiverUser.GrantAccess(true);
receiverUser.RegisterDerivationScheme("BTC", true, true);
receiverUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit, true);
await receiverUser.EnablePayJoin();
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
string lastInvoiceId = null;
@ -330,9 +389,8 @@ namespace BTCPayServer.Tests
Assert.True(result.Success);
}
}
[Fact]
// [Fact(Timeout = TestTimeout)]
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUseBIP79()
{
@ -348,18 +406,18 @@ namespace BTCPayServer.Tests
var senderUser = tester.NewAccount();
senderUser.GrantAccess(true);
senderUser.RegisterDerivationScheme("BTC", true, true);
senderUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit, true);
var invoice = senderUser.BitPay.CreateInvoice(
new Invoice() {Price = 100, Currency = "USD", FullNotifications = true});
//payjoin is not enabled by default.
Assert.DoesNotContain("bpu", 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));
var receiverUser = tester.NewAccount();
receiverUser.GrantAccess(true);
receiverUser.RegisterDerivationScheme("BTC", true, true);
receiverUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit, true);
await receiverUser.EnablePayJoin();
// payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer

View file

@ -19,6 +19,7 @@ using System.Threading.Tasks;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Models;
using BTCPayServer.Services;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Stores;
using Newtonsoft.Json;
@ -332,7 +333,7 @@ namespace BTCPayServer.Tests
GoToInvoiceCheckout(invoiceId);
var bip21 = Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains("bpu", bip21);
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
GoToWalletSend(walletId);
Driver.FindElement(By.Id("bip21parse")).Click();

View file

@ -23,6 +23,7 @@ using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity;
using NBXplorer.Models;
using BTCPayServer.Client;
using BTCPayServer.Events;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
@ -141,19 +142,19 @@ namespace BTCPayServer.Tests
public BTCPayNetwork SupportedNetwork { get; set; }
public WalletId RegisterDerivationScheme(string crytoCode, bool segwit = false, bool importKeysToNBX = false)
public WalletId RegisterDerivationScheme(string crytoCode, ScriptPubKeyType segwit = ScriptPubKeyType.Legacy, bool importKeysToNBX = false)
{
return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult();
}
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false,
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, ScriptPubKeyType segwit = ScriptPubKeyType.Legacy,
bool importKeysToNBX = false)
{
SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId);
GenerateWalletResponseV = await parent.ExplorerClient.GenerateWalletAsync(new GenerateWalletRequest()
{
ScriptPubKeyType = segwit ? ScriptPubKeyType.Segwit : ScriptPubKeyType.Legacy,
ScriptPubKeyType = segwit,
SavePrivateKeys = importKeysToNBX,
});
await store.AddDerivationScheme(StoreId,
@ -266,9 +267,25 @@ namespace BTCPayServer.Tests
var cashCow = parent.ExplorerNode;
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
var txid = await cashCow.SendToAddressAsync(address, value);
var tx = await cashCow.GetRawTransactionAsync(txid);
return tx.Outputs.AsCoins().First(c => c.ScriptPubKey == address.ScriptPubKey);
await parent.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
await cashCow.SendToAddressAsync(address, value);
});
int i = 0;
while (i <30)
{
var result = (await btcPayWallet.GetUnspentCoins(DerivationScheme))
.FirstOrDefault(c => c.ScriptPubKey == address.ScriptPubKey)?.Coin;
if (result != null)
{
return result;
}
await Task.Delay(1000);
i++;
}
Assert.False(true);
return null;
}
public async Task<BitcoinAddress> GetNewAddress(BTCPayNetwork network)
@ -293,16 +310,20 @@ namespace BTCPayServer.Tests
GenerateWalletResponseV.AccountKeyPath);
}
public async Task<PSBT> SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null)
public async Task<PSBT> SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null, bool senderError= false)
{
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
if (endpoint == null)
{
return null;
}
var pjClient = parent.PayTester.GetService<PayjoinClient>();
var storeRepository = parent.PayTester.GetService<StoreRepository>();
var store = await storeRepository.FindStore(StoreId);
var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType<DerivationSchemeSettings>()
.First();
Logs.Tester.LogInformation($"Proposing {psbt.GetGlobalTransaction().GetHash()}");
if (expectedError is null)
if (expectedError is null && !senderError)
{
var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default);
Logs.Tester.LogInformation($"Proposed payjoin is {proposed.GetGlobalTransaction().GetHash()}");
@ -311,8 +332,15 @@ namespace BTCPayServer.Tests
}
else
{
var ex = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
Assert.Equal(expectedError, ex.ErrorCode);
if (senderError)
{
await Assert.ThrowsAsync<PayjoinSenderException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
}
else
{
var ex = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
Assert.Equal(expectedError, ex.ErrorCode);
}
return null;
}
}
@ -359,7 +387,7 @@ namespace BTCPayServer.Tests
var parsedBip21 = new BitcoinUrlBuilder(
invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21,
network);
return new Uri(parsedBip21.UnknowParameters["bpu"], UriKind.Absolute);
return parsedBip21.UnknowParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null;
}
}
}

View file

@ -897,7 +897,7 @@ namespace BTCPayServer.Tests
await tester.StartAsync();
var acc = tester.NewAccount();
acc.GrantAccess();
acc.RegisterDerivationScheme("BTC", true);
acc.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit);
var btcDerivationScheme = acc.DerivationScheme;
var walletController = acc.GetController<WalletsController>();

View file

@ -4,7 +4,6 @@ using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
@ -14,12 +13,8 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitcoin.Logging;
using NBXplorer;
using NBXplorer.Models;
using Newtonsoft.Json.Linq;
@ -30,7 +25,7 @@ using System.Diagnostics.CodeAnalysis;
namespace BTCPayServer.Payments.PayJoin
{
[Route("{cryptoCode}/bpu")]
[Route("{cryptoCode}/" + PayjoinClient.BIP21EndpointKey)]
public class PayJoinEndpointController : ControllerBase
{
/// <summary>
@ -172,9 +167,11 @@ namespace BTCPayServer.Payments.PayJoin
{
await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx);
}
if (originalTx.Inputs.Any(i => !(i.GetSigner() is WitKeyId)))
return BadRequest(CreatePayjoinError(400, "unsupported-inputs", "Payjoin only support P2WPKH inputs"));
var allNativeSegwit = psbt.Inputs.All(i => i.ScriptPubKeyType() == ScriptPubKeyType.Segwit);
var allScript = psbt.Inputs.All(i => i.ScriptPubKeyType() == ScriptPubKeyType.SegwitP2SH);
if (!allNativeSegwit && !allScript)
return BadRequest(CreatePayjoinError(400, "unsupported-inputs", "Payjoin only support segwit inputs (of the same type)"));
if (psbt.CheckSanity() is var errors && errors.Count != 0)
{
return BadRequest(CreatePayjoinError(400, "insane-psbt", $"This PSBT is insane ({errors[0]})"));
@ -235,6 +232,17 @@ namespace BTCPayServer.Payments.PayJoin
.SingleOrDefault();
if (derivationSchemeSettings is null)
continue;
var type = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
if (!PayjoinClient.SupportedFormats.Contains(type))
{
//this should never happen, unless the store owner changed the wallet mid way through an invoice
return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now"));
}
else if ((type == ScriptPubKeyType.Segwit && !allNativeSegwit) ||
(type == ScriptPubKeyType.SegwitP2SH && allScript))
return BadRequest(CreatePayjoinError(400, "unsupported-inputs",
"Payjoin only support segwit inputs (of the same type)"));
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
var paymentDetails =
paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;

View file

@ -2,19 +2,38 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Util;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services
{
public static class PSBTExtensions
{
public static ScriptPubKeyType? ScriptPubKeyType(this PSBTInput i)
{
if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2WPKH))
return NBitcoin.ScriptPubKeyType.Segwit;
if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2SH) &&
i.FinalScriptWitness.ToScript().IsScriptType(ScriptType.P2WPKH))
return NBitcoin.ScriptPubKeyType.SegwitP2SH;
return null;
}
}
public class PayjoinClient
{
public static readonly ScriptPubKeyType[] SupportedFormats = {
ScriptPubKeyType.Segwit,
ScriptPubKeyType.SegwitP2SH
};
public const string BIP21EndpointKey = "bpu";
private readonly ExplorerClientProvider _explorerClientProvider;
private HttpClient _httpClient;
@ -35,6 +54,11 @@ namespace BTCPayServer.Services
if (originalTx.IsAllFinalized())
throw new InvalidOperationException("The original PSBT should not be finalized.");
var type = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
if (!SupportedFormats.Contains(type))
{
throw new PayjoinSenderException($"The wallet does not support payjoin");
}
var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings();
var sentBefore = -originalTx.GetBalance(derivationSchemeSettings.AccountDerivation,
signingAccount.AccountKey,
@ -141,15 +165,18 @@ namespace BTCPayServer.Services
}
}
// Making sure that the receiver's inputs are finalized and P2PWKH
// Making sure that the receiver's inputs are finalized and match format
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");
if (!(input.FinalScriptWitness.GetSigner() is WitKeyId))
throw new PayjoinSenderException("The payjoin receiver included an input that is not P2PWKH");
if (type != input.ScriptPubKeyType())
{
throw new PayjoinSenderException("The payjoin receiver included an input that is not the same segwit input type");
}
}
}