btcpayserver/BTCPayServer/Controllers/StoresController.Onchain.cs
d11n f4fa7c927c
Wallet setup redesign (#2164)
* Prepare existing layouts and views

* Add icon view component and sprite svg

* Add wallet setup basics

* Add import method view basics

* Use external sprite file instead of inline svg

* Refactor hardware wallet setup flow

* Manually enter an xpub

* Prepare other views

* Update views and models

* Finalize wallet setup flow

* Updat tests, part 1

* Update tests, part 2

* Vaul: Fix missing retry button

* Add better Scan QR subtext

Still tbd.

* Make wallet account an advanced setting

* Prevent empty xpub

* Use textarea for seed input

* Remove redundant error message for missing file upload

* Confirm store updates after generating a new wallet

* Update wording

* Modify existing wallets

* Fix proposed method name

* Suggest using ColdCard Electrum export option only

Advise the user to use the electrum export of the coldcard instead of saying either electrum or wasabi export file … the electurm one contains more info, e.g. the wasabi one doesn't include the account key path.

* More concise WalletSetupMethod setting

* Test fix

* Update wallet removal code

* Fix back navigation quirk in change wallet case

* Fix behaviour on wallet enable/disable

* Fix initial wallet setup

* Improve modify view and messages

* Test fixes

* Seed import fix

Uses the correct form url for confirming addresses

* Quickfixes from design meeting

* Add enable toggle switch on modify page

* Confirm wallet removal

* Update setup view

* Update import view

* Icon finetuning

* Improve import options page

* Refactor QR code scanner

Allow for usage with and without modal

* Update copy and instructions on import pages

* Split generate options: Hot wallet and watch-only

* Implement hot wallet options correctly

* Minor test changes

* Navbar improvements

* Fix tables

* Fix badge color

* Routing related updates

Thanks @kukks for the suggestions!

* Wording updates

Thanks @kukks for the suggestions!

* Extend address types table for xpub import

Thanks @kukks for the suggestions!

* Rename controller

* Unify precondition checks

* Improve removal warning for hot wallets

* Add tooltip on why seed import is not recommended

* Add tooltip icon

* Add Specter import info
2021-02-11 19:48:54 +09:00

514 lines
21 KiB
C#

using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
namespace BTCPayServer.Controllers
{
public partial class StoresController
{
[HttpGet("{storeId}/onchain/{cryptoCode}")]
public ActionResult SetupWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out _);
if (checkResult != null)
{
return checkResult;
}
var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
vm.DerivationScheme = derivation?.AccountDerivation.ToString();
return View(vm);
}
[HttpGet("{storeId}/onchain/{cryptoCode}/import/{method?}")]
public async Task<IActionResult> ImportWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out _, out var network);
if (checkResult != null)
{
return checkResult;
}
var (hotWallet, rpcImport) = await CanUseHotWallet();
vm.Network = network;
vm.RootKeyPath = network.GetRootKeyPath();
vm.CanUseHotWallet = hotWallet;
vm.CanUseRPCImport = rpcImport;
if (vm.Method == null)
{
vm.Method = WalletSetupMethod.ImportOptions;
}
else if (vm.Method == WalletSetupMethod.Seed)
{
vm.SetupRequest = new GenerateWalletRequest();
}
return View(vm.ViewName, vm);
}
[HttpPost("{storeId}/onchain/{cryptoCode}/modify")]
[HttpPost("{storeId}/onchain/{cryptoCode}/import/{method}")]
public async Task<IActionResult> UpdateWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out var network);
if (checkResult != null)
{
return checkResult;
}
vm.Network = network;
vm.RootKeyPath = network.GetRootKeyPath();
DerivationSchemeSettings strategy = null;
var wallet = _WalletProvider.GetWallet(network);
if (wallet == null)
{
return NotFound();
}
if (!string.IsNullOrEmpty(vm.Config))
{
if (!DerivationSchemeSettings.TryParseFromJson(vm.Config, network, out strategy))
{
ModelState.AddModelError(nameof(vm.Config), "Config file was not in the correct format");
return View(vm.ViewName, vm);
}
}
if (vm.WalletFile != null)
{
if (!DerivationSchemeSettings.TryParseFromWalletFile(await ReadAllText(vm.WalletFile), network, out strategy))
{
ModelState.AddModelError(nameof(vm.WalletFile), "Wallet file was not in the correct format");
return View(vm.ViewName, vm);
}
}
else if (!string.IsNullOrEmpty(vm.WalletFileContent))
{
if (!DerivationSchemeSettings.TryParseFromWalletFile(vm.WalletFileContent, network, out strategy))
{
ModelState.AddModelError(nameof(vm.WalletFileContent), "QR import was not in the correct format");
return View(vm.ViewName, vm);
}
}
else
{
try
{
if (!string.IsNullOrEmpty(vm.DerivationScheme))
{
var newStrategy = ParseDerivationStrategy(vm.DerivationScheme, null, network);
if (newStrategy.AccountDerivation != strategy?.AccountDerivation)
{
var accountKey = string.IsNullOrEmpty(vm.AccountKey)
? null
: new BitcoinExtPubKey(vm.AccountKey, network.NBitcoinNetwork);
if (accountKey != null)
{
var accountSettings =
newStrategy.AccountKeySettings.FirstOrDefault(a => a.AccountKey == accountKey);
if (accountSettings != null)
{
accountSettings.AccountKeyPath =
vm.KeyPath == null ? null : KeyPath.Parse(vm.KeyPath);
accountSettings.RootFingerprint = string.IsNullOrEmpty(vm.RootFingerprint)
? (HDFingerprint?)null
: new HDFingerprint(
NBitcoin.DataEncoders.Encoders.Hex.DecodeData(vm.RootFingerprint));
}
}
strategy = newStrategy;
strategy.Source = vm.Source;
vm.DerivationScheme = strategy.AccountDerivation.ToString();
}
}
else
{
ModelState.AddModelError(nameof(vm.DerivationScheme), "Please provide your extended public key");
return View(vm.ViewName, vm);
}
}
catch
{
ModelState.AddModelError(nameof(vm.DerivationScheme), "Invalid wallet format");
return View(vm.ViewName, vm);
}
}
var oldConfig = vm.Config;
vm.Config = strategy?.ToJson();
var configChanged = oldConfig != vm.Config;
PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
var storeBlob = store.GetStoreBlob();
var wasExcluded = storeBlob.GetExcludedPaymentMethods().Match(paymentMethodId);
var willBeExcluded = !vm.Enabled;
var excludedChanged = willBeExcluded != wasExcluded;
var showAddress = // Show addresses if:
// - If the user is testing the hint address in confirmation screen
(vm.Confirmation && !string.IsNullOrWhiteSpace(vm.HintAddress)) ||
// - The user is clicking on continue after changing the config
(!vm.Confirmation && configChanged);
showAddress = showAddress && strategy != null;
if (!showAddress)
{
try
{
if (strategy != null)
await wallet.TrackAsync(strategy.AccountDerivation);
store.SetSupportedPaymentMethod(paymentMethodId, strategy);
storeBlob.SetExcluded(paymentMethodId, willBeExcluded);
storeBlob.Hints.Wallet = false;
store.SetStoreBlob(storeBlob);
}
catch
{
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)});
if (excludedChanged)
{
var label = willBeExcluded ? "disabled" : "enabled";
TempData[WellKnownTempData.SuccessMessage] =
$"On-Chain payments for {network.CryptoCode} have been {label}.";
}
else
{
TempData[WellKnownTempData.SuccessMessage] =
$"Derivation settings for {network.CryptoCode} have been modified.";
}
// This is success case when derivation scheme is added to the store
return RedirectToAction(nameof(UpdateStore), new {storeId = vm.StoreId});
}
if (!string.IsNullOrEmpty(vm.HintAddress))
{
BitcoinAddress address;
try
{
address = BitcoinAddress.Create(vm.HintAddress, network.NBitcoinNetwork);
}
catch
{
ModelState.AddModelError(nameof(vm.HintAddress), "Invalid hint address");
return ConfirmAddresses(vm, strategy);
}
try
{
var newStrategy = ParseDerivationStrategy(vm.DerivationScheme, address.ScriptPubKey, network);
if (newStrategy.AccountDerivation != strategy.AccountDerivation)
{
strategy.AccountDerivation = newStrategy.AccountDerivation;
strategy.AccountOriginal = null;
}
}
catch
{
ModelState.AddModelError(nameof(vm.HintAddress), "Impossible to find a match with this address. Are you sure the wallet and address provided are correct and from the same source?");
return ConfirmAddresses(vm, strategy);
}
vm.HintAddress = "";
TempData[WellKnownTempData.SuccessMessage] =
"Address successfully found, please verify that the rest is correct and click on \"Confirm\"";
ModelState.Remove(nameof(vm.HintAddress));
ModelState.Remove(nameof(vm.DerivationScheme));
}
return ConfirmAddresses(vm, strategy);
}
[HttpGet("{storeId}/onchain/{cryptoCode}/generate/{method?}")]
public async Task<IActionResult> GenerateWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out var network);
if (checkResult != null)
{
return checkResult;
}
var isHotWallet = vm.Method == WalletSetupMethod.HotWallet;
var (hotWallet, rpcImport) = await CanUseHotWallet();
if (isHotWallet && !hotWallet)
{
return NotFound();
}
var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
if (derivation != null)
{
vm.DerivationScheme = derivation.AccountDerivation.ToString();
vm.Config = derivation.ToJson();
}
vm.Enabled = !store.GetStoreBlob().IsExcluded(new PaymentMethodId(vm.CryptoCode, PaymentTypes.BTCLike));
vm.CanUseHotWallet = hotWallet;
vm.CanUseRPCImport = rpcImport;
vm.RootKeyPath = network.GetRootKeyPath();
vm.Network = network;
if (vm.Method == null)
{
vm.Method = WalletSetupMethod.GenerateOptions;
}
else
{
vm.SetupRequest = new GenerateWalletRequest { SavePrivateKeys = isHotWallet };
}
return View(vm.ViewName, vm);
}
[HttpPost("{storeId}/onchain/{cryptoCode}/generate/{method}")]
public async Task<IActionResult> GenerateWallet(string storeId, string cryptoCode, WalletSetupMethod method, GenerateWalletRequest request)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
if (checkResult != null)
{
return checkResult;
}
var (hotWallet, rpcImport) = await CanUseHotWallet();
if (!hotWallet && request.SavePrivateKeys || !rpcImport && request.ImportKeysToRPC)
{
return NotFound();
}
var client = _ExplorerProvider.GetExplorerClient(cryptoCode);
var isImport = method == WalletSetupMethod.Seed;
var vm = new WalletSetupViewModel
{
StoreId = storeId,
CryptoCode = cryptoCode,
Method = method,
SetupRequest = request,
Confirmation = string.IsNullOrEmpty(request.ExistingMnemonic),
Network = network,
RootKeyPath = network.GetRootKeyPath(),
Enabled = !store.GetStoreBlob().IsExcluded(new PaymentMethodId(cryptoCode, PaymentTypes.BTCLike)),
Source = "NBXplorer",
DerivationSchemeFormat = "BTCPay",
CanUseHotWallet = true,
CanUseRPCImport = rpcImport
};
if (isImport && string.IsNullOrEmpty(request.ExistingMnemonic))
{
ModelState.AddModelError(nameof(request.ExistingMnemonic), "Please provide your existing seed");
return View(vm.ViewName, vm);
}
GenerateWalletResponse response;
try
{
response = await client.GenerateWalletAsync(request);
if (response == null)
{
throw new Exception("Node unavailable");
}
}
catch (Exception e)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = $"There was an error generating your wallet: {e.Message}"
});
return View(vm.ViewName, vm);
}
// Set wallet properties from generate response
vm.RootFingerprint = response.AccountKeyPath.MasterFingerprint.ToString();
vm.DerivationScheme = response.DerivationScheme.ToString();
vm.AccountKey = response.AccountHDKey.Neuter().ToWif();
vm.KeyPath = response.AccountKeyPath.KeyPath.ToString();
var result = await UpdateWallet(vm);
if (!ModelState.IsValid || !(result is RedirectToActionResult))
return result;
if (!isImport)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = "<span class='text-centered'>Your wallet has been generated.</span>"
});
var seedVm = new RecoverySeedBackupViewModel
{
CryptoCode = cryptoCode,
Mnemonic = response.Mnemonic,
Passphrase = response.Passphrase,
IsStored = request.SavePrivateKeys,
ReturnUrl = Url.Action(nameof(GenerateWalletConfirm), new {storeId, cryptoCode})
};
return this.RedirectToRecoverySeedBackup(seedVm);
}
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Html = "Please check your addresses and confirm."
});
return result;
}
// The purpose of this action is to show the user a success message, which confirms
// that the store settings have been updated after generating a new wallet.
[HttpGet("{storeId}/onchain/{cryptoCode}/generate/confirm")]
public ActionResult GenerateWalletConfirm(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out _, out var network);
if (checkResult != null)
{
return checkResult;
}
TempData[WellKnownTempData.SuccessMessage] =
$"Derivation settings for {network.CryptoCode} have been modified.";
return RedirectToAction(nameof(UpdateStore), new {storeId});
}
[HttpGet("{storeId}/onchain/{cryptoCode}/modify")]
public async Task<IActionResult> ModifyWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out var network);
if (checkResult != null)
{
return checkResult;
}
var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
if (derivation == null)
{
return NotFound();
}
var (hotWallet, rpcImport) = await CanUseHotWallet();
vm.CanUseHotWallet = hotWallet;
vm.CanUseRPCImport = rpcImport;
vm.RootKeyPath = network.GetRootKeyPath();
vm.Network = network;
vm.Source = derivation.Source;
vm.RootFingerprint = derivation.GetSigningAccountKeySettings().RootFingerprint.ToString();
vm.DerivationScheme = derivation.AccountDerivation.ToString();
vm.KeyPath = derivation.GetSigningAccountKeySettings().AccountKeyPath?.ToString();
vm.Config = derivation.ToJson();
vm.Enabled = !store.GetStoreBlob().IsExcluded(new PaymentMethodId(vm.CryptoCode, PaymentTypes.BTCLike));
return View(vm);
}
[HttpGet("{storeId}/onchain/{cryptoCode}/delete")]
public IActionResult DeleteWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
if (checkResult != null)
{
return checkResult;
}
var derivation = GetExistingDerivationStrategy(cryptoCode, store);
var description =
(derivation.IsHotWallet ? "<p class=\"text-danger font-weight-bold\">Please note that this is a hot wallet!</p> " : "") +
"<p class=\"text-danger font-weight-bold\">Do not remove the wallet if you have not backed it up!</p>" +
"<p class=\"text-left mb-0\">Removing the wallet will erase the wallet data from the server. " +
$"The store won't be able to receive {network.CryptoCode} onchain payments until a new wallet is set up.</p>";
return View("Confirm", new ConfirmModel
{
Title = $"Remove {network.CryptoCode} wallet",
Description = description,
DescriptionHtml = true,
Action = "Remove"
});
}
[HttpPost("{storeId}/onchain/{cryptoCode}/delete")]
public async Task<IActionResult> ConfirmDeleteWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
if (checkResult != null)
{
return checkResult;
}
var derivation = GetExistingDerivationStrategy(cryptoCode, store);
if (derivation == null)
{
return NotFound();
}
PaymentMethodId paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
store.SetSupportedPaymentMethod(paymentMethodId, null);
await _Repo.UpdateStore(store);
_EventAggregator.Publish(new WalletChangedEvent {WalletId = new WalletId(storeId, cryptoCode)});
TempData[WellKnownTempData.SuccessMessage] =
$"On-Chain payment for {network.CryptoCode} has been removed.";
return RedirectToAction(nameof(UpdateStore), new {storeId});
}
private IActionResult ConfirmAddresses(WalletSetupViewModel vm, DerivationSchemeSettings strategy)
{
vm.DerivationScheme = strategy.AccountDerivation.ToString();
var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit);
if (!string.IsNullOrEmpty(vm.DerivationScheme))
{
var line = strategy.AccountDerivation.GetLineFor(deposit);
for (uint i = 0; i < 10; i++)
{
var keyPath = deposit.GetKeyPath(i);
var rootedKeyPath = vm.GetAccountKeypath()?.Derive(keyPath);
var derivation = line.Derive(i);
var address = strategy.Network.NBXplorerNetwork.CreateAddress(strategy.AccountDerivation,
line.KeyPathTemplate.GetKeyPath(i),
derivation.ScriptPubKey).ToString();
vm.AddressSamples.Add((keyPath.ToString(), address, rootedKeyPath));
}
}
vm.Confirmation = true;
ModelState.Remove(nameof(vm.Config)); // Remove the cached value
return View("ImportWallet/ConfirmAddresses", vm);
}
private ActionResult IsAvailable(string cryptoCode, out StoreData store, out BTCPayNetwork network)
{
store = HttpContext.GetStoreData();
network = cryptoCode == null ? null : _ExplorerProvider.GetNetwork(cryptoCode);
return store == null || network == null ? NotFound() : null;
}
}
}