Add payjoin option to hot wallet setup (#2450)

* Add payjoin option to hot wallet setup

Enables payjoin by default when creating a hot wallet and offers the user an opt-out.

Test fix

* Display PayJoin option only if it is available

* Test fixes

* Update hot wallet checks

* Test fix after rebase

* Use toggle buttons for enabling options
This commit is contained in:
d11n 2021-06-18 03:25:17 +02:00 committed by GitHub
parent 6b4ff4ce2c
commit 3c80621dac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 108 additions and 92 deletions

View file

@ -77,7 +77,7 @@ namespace BTCPayServer.Tests
// Get enabled state from overview action
StoreViewModel storeModel;
response = controller.UpdateStore();
response = await controller.UpdateStore();
storeModel = (StoreViewModel)Assert.IsType<ViewResult>(response).Model;
var lnNode = storeModel.LightningNodes.Find(node => node.CryptoCode == cryptoCode);
Assert.NotNull(lnNode);
@ -85,11 +85,11 @@ namespace BTCPayServer.Tests
WalletSetupViewModel setupVm;
var storeId = user.StoreId;
response = await controller.GenerateWallet(storeId, cryptoCode, WalletSetupMethod.GenerateOptions, new GenerateWalletRequest());
response = await controller.GenerateWallet(storeId, cryptoCode, WalletSetupMethod.GenerateOptions, new WalletSetupRequest());
Assert.IsType<ViewResult>(response);
// Get enabled state from overview action
response = controller.UpdateStore();
response = await controller.UpdateStore();
storeModel = (StoreViewModel)Assert.IsType<ViewResult>(response).Model;
var derivationScheme = storeModel.DerivationSchemes.Find(scheme => scheme.Crypto == cryptoCode);
Assert.NotNull(derivationScheme);
@ -98,7 +98,7 @@ namespace BTCPayServer.Tests
// Disable wallet
response = controller.SetWalletEnabled(storeId, cryptoCode, false).GetAwaiter().GetResult();
Assert.IsType<RedirectToActionResult>(response);
response = controller.UpdateStore();
response = await controller.UpdateStore();
storeModel = (StoreViewModel)Assert.IsType<ViewResult>(response).Model;
derivationScheme = storeModel.DerivationSchemes.Find(scheme => scheme.Crypto == cryptoCode);
Assert.NotNull(derivationScheme);

View file

@ -165,7 +165,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
user.ModifyStore(s => s.NetworkFeeMode = NetworkFeeMode.Never);
await user.ModifyStore(s => s.NetworkFeeMode = NetworkFeeMode.Never);
var apps = user.GetController<AppsController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp().Result).Model);
vm.Name = "test";

View file

@ -238,19 +238,15 @@ namespace BTCPayServer.Tests
var receiverSeed = s.GenerateWallet("BTC", "", true, true, format);
var receiverWalletId = new WalletId(receiver.storeId, "BTC");
//payjoin is not enabled by default.
//payjoin is enabled by default.
var invoiceId = s.CreateInvoice(receiver.storeName);
s.GoToInvoiceCheckout(invoiceId);
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}=", bip21);
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}=", bip21);
s.GoToHome();
s.GoToStore(receiver.storeId);
//payjoin is not enabled by default.
Assert.False(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected);
s.Driver.SetCheckbox(By.Id("PayJoinEnabled"), true);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.True(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected);
var sender = s.CreateNewStore();
@ -519,7 +515,7 @@ namespace BTCPayServer.Tests
address = (await nbx.GetUnusedAsync(bob.DerivationScheme, DerivationFeature.Deposit)).Address;
tester.ExplorerNode.SendToAddress(address, Money.Coins(1.1m));
await notifications.NextEventAsync();
bob.ModifyStore(s => s.PayJoinEnabled = true);
await bob.ModifyStore(s => s.PayJoinEnabled = true);
var invoice = bob.BitPay.CreateInvoice(
new Invoice() { Price = 0.1m, Currency = "BTC", FullNotifications = true });
var invoiceBIP21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21,

View file

@ -125,28 +125,22 @@ namespace BTCPayServer.Tests
CreateStoreAsync().GetAwaiter().GetResult();
}
public void SetNetworkFeeMode(NetworkFeeMode mode)
public async Task SetNetworkFeeMode(NetworkFeeMode mode)
{
ModifyStore((store) =>
await ModifyStore(store =>
{
store.NetworkFeeMode = mode;
});
}
public void ModifyStore(Action<StoreViewModel> modify)
public async Task ModifyStore(Action<StoreViewModel> modify)
{
var storeController = GetController<StoresController>();
StoreViewModel store = (StoreViewModel)((ViewResult)storeController.UpdateStore()).Model;
var response = await storeController.UpdateStore();
StoreViewModel store = (StoreViewModel)((ViewResult)response).Model;
modify(store);
storeController.UpdateStore(store).GetAwaiter().GetResult();
}
public Task ModifyStoreAsync(Action<StoreViewModel> modify)
{
var storeController = GetController<StoresController>();
StoreViewModel store = (StoreViewModel)((ViewResult)storeController.UpdateStore()).Model;
modify(store);
return storeController.UpdateStore(store);
}
public T GetController<T>(bool setImplicitStore = true) where T : Controller
{
@ -181,7 +175,7 @@ namespace BTCPayServer.Tests
SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId, true);
var generateRequest = new GenerateWalletRequest()
var generateRequest = new WalletSetupRequest
{
ScriptPubKeyType = segwit,
SavePrivateKeys = importKeysToNBX,
@ -196,7 +190,7 @@ namespace BTCPayServer.Tests
public Task EnablePayJoin()
{
return ModifyStoreAsync(s => s.PayJoinEnabled = true);
return ModifyStore(s => s.PayJoinEnabled = true);
}
public GenerateWalletResponse GenerateWalletResponseV { get; set; }

View file

@ -750,7 +750,8 @@ namespace BTCPayServer.Tests
// Set tolerance to 50%
var stores = user.GetController<StoresController>();
var vm = Assert.IsType<StoreViewModel>(Assert.IsType<ViewResult>(stores.UpdateStore()).Model);
var response = await stores.UpdateStore();
var vm = Assert.IsType<StoreViewModel>(Assert.IsType<ViewResult>(response).Model);
Assert.Equal(0.0, vm.PaymentTolerance);
vm.PaymentTolerance = 50.0;
Assert.IsType<RedirectToActionResult>(stores.UpdateStore(vm).Result);
@ -941,8 +942,8 @@ namespace BTCPayServer.Tests
await user.GrantAccessAsync(true);
await user.RegisterDerivationSchemeAsync("BTC");
await user.RegisterLightningNodeAsync("BTC", LightningConnectionType.CLightning);
user.SetNetworkFeeMode(NetworkFeeMode.Never);
await user.ModifyStoreAsync(model => model.SpeedPolicy = SpeedPolicy.HighSpeed);
await user.SetNetworkFeeMode(NetworkFeeMode.Never);
await user.ModifyStore(model => model.SpeedPolicy = SpeedPolicy.HighSpeed);
var invoice = await user.BitPay.CreateInvoiceAsync(new Invoice(0.0001m, "BTC"));
await tester.WaitForEvent<InvoiceNewPaymentDetailsEvent>(async () =>
{
@ -990,7 +991,8 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess(true);
var storeController = user.GetController<StoresController>();
Assert.IsType<ViewResult>(storeController.UpdateStore());
var storeResponse = await storeController.UpdateStore();
Assert.IsType<ViewResult>(storeResponse);
Assert.IsType<ViewResult>(storeController.SetupLightningNode(user.StoreId, "BTC"));
var testResult = storeController.SetupLightningNode(user.StoreId, new LightningNodeViewModel
@ -1013,9 +1015,10 @@ namespace BTCPayServer.Tests
new LightningNodeViewModel { ConnectionString = tester.MerchantCharge.Client.Uri.AbsoluteUri },
"save", "BTC").GetAwaiter().GetResult());
storeResponse = await storeController.UpdateStore();
var storeVm =
Assert.IsType<StoreViewModel>(Assert
.IsType<ViewResult>(storeController.UpdateStore()).Model);
.IsType<ViewResult>(storeResponse).Model);
Assert.Single(storeVm.LightningNodes.Where(l => !string.IsNullOrEmpty(l.Address)));
}
}
@ -1128,8 +1131,8 @@ namespace BTCPayServer.Tests
var acc = tester.NewAccount();
acc.GrantAccess();
acc.RegisterDerivationScheme("BTC");
acc.ModifyStore(s => s.SpeedPolicy = SpeedPolicy.LowSpeed);
var invoice = acc.BitPay.CreateInvoice(new Invoice()
await acc.ModifyStore(s => s.SpeedPolicy = SpeedPolicy.LowSpeed);
var invoice = acc.BitPay.CreateInvoice(new Invoice
{
Price = 5.0m,
Currency = "USD",
@ -1545,7 +1548,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
user.SetNetworkFeeMode(NetworkFeeMode.Always);
await user.SetNetworkFeeMode(NetworkFeeMode.Always);
var invoice =
user.BitPay.CreateInvoice(new Invoice() { Price = 5000.0m, Currency = "USD" }, Facade.Merchant);
var payment1 = invoice.BtcDue + Money.Coins(0.0001m);
@ -1646,7 +1649,7 @@ namespace BTCPayServer.Tests
Logs.Tester.LogInformation(
$"Let's test if we can RBF a normal payment without adding fees to the invoice");
user.SetNetworkFeeMode(NetworkFeeMode.MultiplePaymentsOnly);
await user.SetNetworkFeeMode(NetworkFeeMode.MultiplePaymentsOnly);
invoice = user.BitPay.CreateInvoice(new Invoice() { Price = 5000.0m, Currency = "USD" }, Facade.Merchant);
payment1 = invoice.BtcDue;
tx1 = new uint256(tester.ExplorerNode.SendCommand("sendtoaddress", new object[]
@ -1952,7 +1955,7 @@ namespace BTCPayServer.Tests
});
Assert.Equal(404, (int)response.StatusCode);
user.ModifyStore(s => s.AnyoneCanCreateInvoice = true);
await user.ModifyStore(s => s.AnyoneCanCreateInvoice = true);
Logs.Tester.LogInformation("Bad store with anyone can create invoice = 403");
response = await tester.PayTester.HttpClient.SendAsync(
@ -2449,7 +2452,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
user.SetNetworkFeeMode(NetworkFeeMode.Always);
await user.SetNetworkFeeMode(NetworkFeeMode.Always);
var invoice = user.BitPay.CreateInvoice(
new Invoice()
{
@ -2525,7 +2528,7 @@ namespace BTCPayServer.Tests
foreach (var networkFeeMode in Enum.GetValues(typeof(NetworkFeeMode)).Cast<NetworkFeeMode>())
{
Logs.Tester.LogInformation($"Trying with {nameof(networkFeeMode)}={networkFeeMode}");
user.SetNetworkFeeMode(networkFeeMode);
await user.SetNetworkFeeMode(networkFeeMode);
var invoice = user.BitPay.CreateInvoice(
new Invoice()
{
@ -2612,7 +2615,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
user.SetNetworkFeeMode(NetworkFeeMode.Always);
await user.SetNetworkFeeMode(NetworkFeeMode.Always);
var invoice = user.BitPay.CreateInvoice(
new Invoice()
{

View file

@ -62,7 +62,7 @@ namespace BTCPayServer.Controllers
}
else if (vm.Method == WalletSetupMethod.Seed)
{
vm.SetupRequest = new GenerateWalletRequest();
vm.SetupRequest = new WalletSetupRequest();
}
return View(vm.ViewName, vm);
@ -162,6 +162,7 @@ namespace BTCPayServer.Controllers
store.SetSupportedPaymentMethod(paymentMethodId, strategy);
storeBlob.SetExcluded(paymentMethodId, false);
storeBlob.Hints.Wallet = false;
storeBlob.PayJoinEnabled = vm.IsHotWallet && vm.SetupRequest.PayJoinEnabled;
store.SetStoreBlob(storeBlob);
}
catch
@ -169,7 +170,6 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid derivation scheme");
return View(vm.ViewName, vm);
}
await _Repo.UpdateStore(store);
_EventAggregator.Publish(new WalletChangedEvent { WalletId = new WalletId(vm.StoreId, vm.CryptoCode) });
@ -217,14 +217,20 @@ namespace BTCPayServer.Controllers
}
else
{
vm.SetupRequest = new GenerateWalletRequest { SavePrivateKeys = isHotWallet };
var canUsePayJoin = hotWallet && isHotWallet && network.SupportPayJoin;
vm.SetupRequest = new WalletSetupRequest
{
SavePrivateKeys = isHotWallet,
CanUsePayJoin = canUsePayJoin,
PayJoinEnabled = canUsePayJoin
};
}
return View(vm.ViewName, vm);
}
internal GenerateWalletResponse GenerateWalletResponse;
[HttpPost("{storeId}/onchain/{cryptoCode}/generate/{method}")]
public async Task<IActionResult> GenerateWallet(string storeId, string cryptoCode, WalletSetupMethod method, GenerateWalletRequest request)
public async Task<IActionResult> GenerateWallet(string storeId, string cryptoCode, WalletSetupMethod method, WalletSetupRequest request)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
if (checkResult != null)
@ -240,7 +246,6 @@ namespace BTCPayServer.Controllers
var client = _ExplorerProvider.GetExplorerClient(cryptoCode);
var isImport = method == WalletSetupMethod.Seed;
var vm = new WalletSetupViewModel
{
StoreId = storeId,
@ -253,7 +258,7 @@ namespace BTCPayServer.Controllers
Source = isImport ? "SeedImported" : "NBXplorerGenerated",
IsHotWallet = isImport ? request.SavePrivateKeys : method == WalletSetupMethod.HotWallet,
DerivationSchemeFormat = "BTCPay",
CanUseHotWallet = true,
CanUseHotWallet = hotWallet,
CanUseRPCImport = rpcImport
};
@ -370,7 +375,6 @@ namespace BTCPayServer.Controllers
}
var (hotWallet, rpcImport) = await CanUseHotWallet();
var isHotWallet = await IsHotWallet(vm.CryptoCode, derivation);
vm.CanUseHotWallet = hotWallet;
vm.CanUseRPCImport = rpcImport;
@ -381,13 +385,13 @@ namespace BTCPayServer.Controllers
vm.DerivationScheme = derivation.AccountDerivation.ToString();
vm.KeyPath = derivation.GetSigningAccountKeySettings().AccountKeyPath?.ToString();
vm.Config = ProtectString(derivation.ToJson());
vm.IsHotWallet = isHotWallet;
vm.IsHotWallet = derivation.IsHotWallet;
return View(vm);
}
[HttpGet("{storeId}/onchain/{cryptoCode}/replace")]
public async Task<IActionResult> ReplaceWallet(string storeId, string cryptoCode)
public ActionResult ReplaceWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
if (checkResult != null)
@ -396,9 +400,8 @@ namespace BTCPayServer.Controllers
}
var derivation = GetExistingDerivationStrategy(cryptoCode, store);
var isHotWallet = await IsHotWallet(cryptoCode, derivation);
var walletType = isHotWallet ? "hot" : "watch-only";
var additionalText = isHotWallet
var walletType = derivation.IsHotWallet ? "hot" : "watch-only";
var additionalText = derivation.IsHotWallet
? ""
: " or imported into an external wallet. If you no longer have access to your private key (recovery seed), immediately replace the wallet";
var description =
@ -435,7 +438,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/onchain/{cryptoCode}/delete")]
public async Task<IActionResult> DeleteWallet(string storeId, string cryptoCode)
public ActionResult DeleteWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
if (checkResult != null)
@ -444,9 +447,8 @@ namespace BTCPayServer.Controllers
}
var derivation = GetExistingDerivationStrategy(cryptoCode, store);
var isHotWallet = await IsHotWallet(cryptoCode, derivation);
var walletType = isHotWallet ? "hot" : "watch-only";
var additionalText = isHotWallet
var walletType = derivation.IsHotWallet ? "hot" : "watch-only";
var additionalText = derivation.IsHotWallet
? ""
: " or imported into an external wallet. If you no longer have access to your private key (recovery seed), immediately replace the wallet";
var description =
@ -582,11 +584,5 @@ namespace BTCPayServer.Controllers
return await stream.ReadToEndAsync();
}
}
private async Task<bool> IsHotWallet(string cryptoCode, DerivationSchemeSettings derivation)
{
return derivation.IsHotWallet && await _ExplorerProvider.GetExplorerClient(cryptoCode)
.GetMetadataAsync<string>(derivation.AccountDerivation, WellknownMetadataKeys.MasterHDKey) != null;
}
}
}

View file

@ -466,7 +466,6 @@ namespace BTCPayServer.Controllers
}
}
if (!ModelState.IsValid)
{
return View(model);
@ -558,12 +557,9 @@ namespace BTCPayServer.Controllers
}
}
}
[HttpGet]
[Route("{storeId}")]
public IActionResult UpdateStore()
[HttpGet("{storeId}")]
public async Task<IActionResult> UpdateStore()
{
var store = HttpContext.GetStoreData();
if (store == null)
@ -586,12 +582,17 @@ namespace BTCPayServer.Controllers
vm.PayJoinEnabled = storeBlob.PayJoinEnabled;
vm.HintWallet = storeBlob.Hints.Wallet;
vm.HintLightning = storeBlob.Hints.Lightning;
(bool canUseHotWallet, _) = await CanUseHotWallet();
vm.CanUsePayJoin = canUseHotWallet && store
.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<DerivationSchemeSettings>()
.Any(settings => settings.Network.SupportPayJoin && settings.IsHotWallet);
return View(vm);
}
[HttpPost]
[Route("{storeId}")]
[HttpPost("{storeId}")]
public async Task<IActionResult> UpdateStore(StoreViewModel model, string command = null)
{
bool needUpdate = false;
@ -635,11 +636,7 @@ namespace BTCPayServer.Controllers
{
var problematicPayjoinEnabledMethods = CurrentStore.GetSupportedPaymentMethods(_NetworkProvider)
.OfType<DerivationSchemeSettings>()
.Where(settings =>
settings.Network.SupportPayJoin &&
string.IsNullOrEmpty(_ExplorerProvider.GetExplorerClient(settings.Network)
.GetMetadata<string>(settings.AccountDerivation,
WellknownMetadataKeys.Mnemonic)))
.Where(settings => settings.Network.SupportPayJoin && !settings.IsHotWallet)
.Select(settings => settings.PaymentId.CryptoCode)
.ToArray();

View file

@ -88,6 +88,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Enable Payjoin/P2EP")]
public bool PayJoinEnabled { get; set; }
public bool CanUsePayJoin { get; set; }
public bool HintWallet { get; set; }
public bool HintLightning { get; set; }

View file

@ -0,0 +1,10 @@
using NBXplorer.Models;
namespace BTCPayServer.Models.StoreViewModels
{
public class WalletSetupRequest : GenerateWalletRequest
{
public bool PayJoinEnabled { get; set; }
public bool CanUsePayJoin { get; set; }
}
}

View file

@ -1,5 +1,3 @@
using NBXplorer.Models;
namespace BTCPayServer.Models.StoreViewModels
{
public enum WalletSetupMethod
@ -18,7 +16,7 @@ namespace BTCPayServer.Models.StoreViewModels
public class WalletSetupViewModel : DerivationSchemeViewModel
{
public WalletSetupMethod? Method { get; set; }
public GenerateWalletRequest SetupRequest { get; set; }
public WalletSetupRequest SetupRequest { get; set; }
public string StoreId { get; set; }
public bool IsHotWallet { get; set; }

View file

@ -175,22 +175,27 @@
</div>
<h4 class="mt-5 mb-3">Payment</h4>
<div class="form-group form-check">
<input asp-for="AnyoneCanCreateInvoice" type="checkbox" class="form-check-input" />
<label asp-for="AnyoneCanCreateInvoice" class="form-check-label"></label>
<div class="form-group d-flex align-items-center">
<input asp-for="AnyoneCanCreateInvoice" type="checkbox" class="btcpay-toggle me-2" />
<label asp-for="AnyoneCanCreateInvoice" class="form-label mb-0 me-1"></label>
<a href="https://docs.btcpayserver.org/FAQ/FAQ-Stores/#allow-anyone-to-create-invoice" target="_blank">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</div>
<div class="form-group form-check">
<input asp-for="PayJoinEnabled" type="checkbox" class="form-check-input" />
<label asp-for="PayJoinEnabled" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/Payjoin/" target="_blank">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
</div>
<div class="form-group">
@if (Model.CanUsePayJoin)
{
<div class="form-group">
<div class="d-flex align-items-center">
<input asp-for="PayJoinEnabled" type="checkbox" class="btcpay-toggle me-2"/>
<label asp-for="PayJoinEnabled" class="form-label mb-0 me-1"></label>
<a href="https://docs.btcpayserver.org/Payjoin/" target="_blank">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</div>
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
</div>
}
<div class="form-group mt-4">
<label asp-for="NetworkFeeMode" class="form-label"></label>
<a href="https://docs.btcpayserver.org/FAQ/FAQ-Stores/#add-network-fee-to-invoice-vary-with-mining-fees" target="_blank">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>

View file

@ -1,5 +1,5 @@
@using NBitcoin
@model NBXplorer.Models.GenerateWalletRequest
@model WalletSetupRequest
@{
var method = ViewData["Method"];
@ -55,6 +55,22 @@
else
{
<input asp-for="SavePrivateKeys" type="hidden" value="@isHotWallet" />
@if (Model.CanUsePayJoin)
{
<div class="form-group mt-4">
<label asp-for="PayJoinEnabled">Enable PayJoin</label>
<input type="checkbox" asp-for="PayJoinEnabled" class="btcpay-toggle ml-2" />
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
<p class="text-muted pt-2">
PayJoin enhances the privacy for you and your customers.
Enabling it gives your customers the option to use PayJoin during checkout.
<a href="https://docs.btcpayserver.org/Payjoin/" target="_blank">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</p>
</div>
}
}
<div class="mb-4">