Add Wallet settings menu, do not rebase keypaths when create the PSBT

This commit is contained in:
nicolas.dorier 2019-05-13 00:13:55 +09:00
parent 698033b0cf
commit bf37f44795
No known key found for this signature in database
GPG Key ID: 6618763EF09186FE
14 changed files with 338 additions and 37 deletions

View File

@ -1644,12 +1644,13 @@ namespace BTCPayServer.Tests
Assert.NotNull(psbt);
var root = new Mnemonic("usage fever hen zero slide mammal silent heavy donate budget pulse say brain thank sausage brand craft about save attract muffin advance illegal cabbage").DeriveExtKey().AsHDKeyCache();
var account = root.Derive(new KeyPath("m/49'/0'/0'"));
Assert.All(psbt.PSBT.Inputs, input =>
{
var keyPath = input.HDKeyPaths.Single();
Assert.StartsWith(onchainBTC.AccountKeyPath.ToString(), keyPath.Value.Item2.ToString());
Assert.Equal(root.Derive(keyPath.Value.Item2).GetPublicKey(), keyPath.Key);
Assert.Equal(keyPath.Value.Item1, onchainBTC.RootFingerprint.Value);
Assert.False(keyPath.Value.Item2.IsHardened);
Assert.Equal(account.Derive(keyPath.Value.Item2).GetPublicKey(), keyPath.Key);
Assert.Equal(keyPath.Value.Item1, onchainBTC.AccountKeySettings[0].AccountKey.GetPublicKey().GetHDFingerPrint());
});
}
}

View File

@ -47,7 +47,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="NBitcoin" Version="4.1.2.20" />
<PackageReference Include="NBitcoin" Version="4.1.2.21" />
<PackageReference Include="NBitpayClient" Version="1.0.0.34" />
<PackageReference Include="DBriize" Version="1.0.0.4" />
<PackageReference Include="NBXplorer.Client" Version="2.0.0.12" />

View File

@ -202,10 +202,17 @@ namespace BTCPayServer.Controllers
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.AccountKeyPath = vm.KeyPath == null ? null : KeyPath.Parse(vm.KeyPath);
strategy.RootFingerprint = string.IsNullOrEmpty(vm.RootFingerprint) ? (HDFingerprint?)null : new HDFingerprint(NBitcoin.DataEncoders.Encoders.Hex.DecodeData(vm.RootFingerprint));
strategy.ExplicitAccountKey = string.IsNullOrEmpty(vm.AccountKey) ? null : new BitcoinExtPubKey(vm.AccountKey, network.NBitcoinNetwork);
strategy.Source = vm.Source;
vm.DerivationScheme = strategy.AccountDerivation.ToString();
}

View File

@ -34,7 +34,6 @@ namespace BTCPayServer.Controllers
psbtRequest.ExplicitChangeAddress = psbtDestination.Destination;
}
psbtDestination.SubstractFees = sendModel.SubstractFees;
psbtRequest.RebaseKeyPaths = derivationSettings.GetPSBTRebaseKeyRules().ToList();
var psbt = (await nbx.CreatePSBTAsync(derivationSettings.AccountDerivation, psbtRequest, cancellationToken));
if (psbt == null)
throw new NotSupportedException("You need to update your version of NBXplorer");

View File

@ -23,6 +23,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
@ -452,14 +453,16 @@ namespace BTCPayServer.Controllers
if (!_dashboard.IsFullySynched(network.CryptoCode, out var summary))
throw new Exception($"{network.CryptoCode}: not started or fully synched");
var accountKey = derivationSettings.AccountKeySettings.Where(a => a.IsFullySetup()).FirstOrDefault();
accountKey = accountKey ?? derivationSettings.AccountKeySettings.FirstOrDefault();
// Some deployment does not have the AccountKeyPath set, let's fix this...
if (derivationSettings.AccountKeyPath == null)
if (accountKey.AccountKeyPath == null)
{
// If the saved wallet key path is not present or incorrect, let's scan the wallet to see if it can sign strategy
var foundKeyPath = await hw.FindKeyPathFromDerivation(network,
derivationSettings.AccountDerivation,
normalOperationTimeout.Token);
derivationSettings.AccountKeyPath = foundKeyPath ?? throw new HardwareWalletException($"This store is not configured to use this ledger");
accountKey.AccountKeyPath = foundKeyPath ?? throw new HardwareWalletException($"This store is not configured to use this ledger");
storeData.SetSupportedPaymentMethod(derivationSettings);
await Repository.UpdateStore(storeData);
}
@ -468,10 +471,10 @@ namespace BTCPayServer.Controllers
{
// Checking if ledger is right with the RootFingerprint is faster as it does not need to make a query to the parent xpub,
// but some deployment does not have it, so let's use AccountKeyPath instead
if (derivationSettings.RootFingerprint == null)
if (accountKey.RootFingerprint == null)
{
var actualPubKey = await hw.GetExtPubKey(network, derivationSettings.AccountKeyPath, normalOperationTimeout.Token);
var actualPubKey = await hw.GetExtPubKey(network, accountKey.AccountKeyPath, normalOperationTimeout.Token);
if (!derivationSettings.AccountDerivation.GetExtPubKeys().Any(p => p.GetPublicKey() == actualPubKey.GetPublicKey()))
throw new HardwareWalletException($"This store is not configured to use this ledger");
}
@ -479,15 +482,15 @@ namespace BTCPayServer.Controllers
else
{
var actualPubKey = await hw.GetPubKey(network, new KeyPath(), normalOperationTimeout.Token);
if (actualPubKey.GetHDFingerPrint() != derivationSettings.RootFingerprint.Value)
if (actualPubKey.GetHDFingerPrint() != accountKey.RootFingerprint.Value)
throw new HardwareWalletException($"This store is not configured to use this ledger");
}
}
// Some deployment does not have the RootFingerprint set, let's fix this...
if (derivationSettings.RootFingerprint == null)
if (accountKey.RootFingerprint == null)
{
derivationSettings.RootFingerprint = (await hw.GetPubKey(network, new KeyPath(), normalOperationTimeout.Token)).GetHDFingerPrint();
accountKey.RootFingerprint = (await hw.GetPubKey(network, new KeyPath(), normalOperationTimeout.Token)).GetHDFingerPrint();
storeData.SetSupportedPaymentMethod(derivationSettings);
await Repository.UpdateStore(storeData);
}
@ -502,7 +505,7 @@ namespace BTCPayServer.Controllers
derivationSettings.RebaseKeyPaths(psbtResponse.PSBT);
signTimeout.CancelAfter(TimeSpan.FromMinutes(5));
psbtResponse.PSBT = await hw.SignTransactionAsync(psbtResponse.PSBT, psbtResponse.ChangeAddress?.ScriptPubKey, signTimeout.Token);
psbtResponse.PSBT = await hw.SignTransactionAsync(psbtResponse.PSBT, accountKey.RootFingerprint, accountKey.AccountKey, psbtResponse.ChangeAddress?.ScriptPubKey, signTimeout.Token);
result = new SendToAddressResult() { PSBT = psbtResponse.PSBT.ToBase64() };
}
}
@ -528,6 +531,57 @@ namespace BTCPayServer.Controllers
}
return new EmptyResult();
}
[Route("{walletId}/settings")]
public async Task<IActionResult> WalletSettings(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId)
{
var derivationSchemeSettings = await GetDerivationSchemeSettings(walletId);
if (derivationSchemeSettings == null)
return NotFound();
var vm = new WalletSettingsViewModel()
{
Label = derivationSchemeSettings.Label,
DerivationScheme = derivationSchemeSettings.AccountDerivation.ToString(),
DerivationSchemeInput = derivationSchemeSettings.AccountOriginal
};
vm.AccountKeys = derivationSchemeSettings.AccountKeySettings
.Select(e => new WalletSettingsAccountKeyViewModel()
{
AccountKey = e.AccountKey.ToString(),
MasterFingerprint = e.RootFingerprint is HDFingerprint fp ? fp.ToString() : null,
AccountKeyPath = e.AccountKeyPath == null ? "" : $"m/{e.AccountKeyPath}"
}).ToList();
return View(vm);
}
[Route("{walletId}/settings")]
[HttpPost]
public async Task<IActionResult> WalletSettings(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSettingsViewModel vm)
{
if (!ModelState.IsValid)
return View(vm);
var derivationScheme = await GetDerivationSchemeSettings(walletId);
if (derivationScheme == null)
return NotFound();
derivationScheme.Label = vm.Label;
for (int i = 0; i < derivationScheme.AccountKeySettings.Length; i++)
{
derivationScheme.AccountKeySettings[i].AccountKeyPath = string.IsNullOrWhiteSpace(vm.AccountKeys[i].AccountKeyPath) ? null
: new KeyPath(vm.AccountKeys[i].AccountKeyPath);
derivationScheme.AccountKeySettings[i].RootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint) ? (HDFingerprint?)null
: new HDFingerprint(Encoders.Hex.DecodeData(vm.AccountKeys[i].MasterFingerprint));
}
var store = (await Repository.FindStore(walletId.StoreId, GetUserId()));
store.SetSupportedPaymentMethod(derivationScheme);
await Repository.UpdateStore(store);
StatusMessage = "Wallet settings updated";
return RedirectToAction(nameof(WalletSettings));
}
}

View File

@ -65,6 +65,9 @@ namespace BTCPayServer
{
result.AccountOriginal = jobj["xpub"].Value<string>().Trim();
result.AccountDerivation = derivationSchemeParser.ParseElectrum(result.AccountOriginal);
result.AccountKeySettings = new AccountKeySettings[1];
result.AccountKeySettings[0] = new AccountKeySettings();
result.AccountKeySettings[0].AccountKey = result.AccountDerivation.GetExtPubKeys().Single().GetWif(network.NBitcoinNetwork);
if (result.AccountDerivation is DirectDerivationStrategy direct && !direct.Segwit)
result.AccountOriginal = null; // Saving this would be confusing for user, as xpub of electrum is legacy derivation, but for btcpay, it is segwit derivation
}
@ -91,7 +94,7 @@ namespace BTCPayServer
{
try
{
result.RootFingerprint = new HDFingerprint(jobj["ckcc_xfp"].Value<uint>());
result.AccountKeySettings[0].RootFingerprint = new HDFingerprint(jobj["ckcc_xfp"].Value<uint>());
}
catch { return false; }
}
@ -100,7 +103,7 @@ namespace BTCPayServer
{
try
{
result.AccountKeyPath = new KeyPath(jobj["derivation"].Value<string>());
result.AccountKeySettings[0].AccountKeyPath = new KeyPath(jobj["derivation"].Value<string>());
}
catch { return false; }
}
@ -121,20 +124,32 @@ namespace BTCPayServer
throw new ArgumentNullException(nameof(derivationStrategy));
AccountDerivation = derivationStrategy;
Network = network;
AccountKeySettings = derivationStrategy.GetExtPubKeys().Select(c => new AccountKeySettings()
{
AccountKey = c.GetWif(network.NBitcoinNetwork)
}).ToArray();
}
[JsonIgnore]
public BTCPayNetwork Network { get; set; }
public string Source { get; set; }
[Obsolete("Use GetAccountKeySettings().AccountKeyPath instead")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public KeyPath AccountKeyPath { get; set; }
public DerivationStrategyBase AccountDerivation { get; set; }
public string AccountOriginal { get; set; }
[Obsolete("Use GetAccountKeySettings().RootFingerprint instead")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public HDFingerprint? RootFingerprint { get; set; }
[Obsolete("Use GetAccountKeySettings().AccountKey instead")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public BitcoinExtPubKey ExplicitAccountKey { get; set; }
[JsonIgnore]
[Obsolete("Use GetAccountKeySettings().AccountKey instead")]
public BitcoinExtPubKey AccountKey
{
get
@ -143,16 +158,49 @@ namespace BTCPayServer
}
}
AccountKeySettings[] _AccountKeySettings;
public AccountKeySettings[] AccountKeySettings
{
get
{
// Legacy
if (_AccountKeySettings == null)
{
if (this.Network == null)
return null;
_AccountKeySettings = AccountDerivation.GetExtPubKeys().Select(e => new AccountKeySettings()
{
AccountKey = e.GetWif(this.Network.NBitcoinNetwork),
}).ToArray();
#pragma warning disable CS0618 // Type or member is obsolete
_AccountKeySettings[0].AccountKeyPath = AccountKeyPath;
_AccountKeySettings[0].RootFingerprint = RootFingerprint;
ExplicitAccountKey = null;
AccountKeyPath = null;
RootFingerprint = null;
#pragma warning restore CS0618 // Type or member is obsolete
}
return _AccountKeySettings;
}
set
{
_AccountKeySettings = value;
}
}
public IEnumerable<NBXplorer.Models.PSBTRebaseKeyRules> GetPSBTRebaseKeyRules()
{
if (AccountKey != null && AccountKeyPath != null && RootFingerprint is HDFingerprint fp)
foreach(var accountKey in AccountKeySettings)
{
yield return new NBXplorer.Models.PSBTRebaseKeyRules()
if (accountKey.AccountKeyPath != null && accountKey.RootFingerprint is HDFingerprint fp)
{
AccountKey = AccountKey,
AccountKeyPath = AccountKeyPath,
MasterFingerprint = fp
};
yield return new NBXplorer.Models.PSBTRebaseKeyRules()
{
AccountKey = accountKey.AccountKey,
AccountKeyPath = accountKey.AccountKeyPath,
MasterFingerprint = fp
};
}
}
}
@ -185,4 +233,14 @@ namespace BTCPayServer
}
}
}
public class AccountKeySettings
{
public HDFingerprint? RootFingerprint { get; set; }
public KeyPath AccountKeyPath { get; set; }
public BitcoinExtPubKey AccountKey { get; set; }
public bool IsFullySetup()
{
return AccountKeyPath != null && RootFingerprint is HDFingerprint;
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSettingsViewModel
{
public string Label { get; set; }
public string DerivationScheme { get; set; }
public string DerivationSchemeInput { get; set; }
public List<WalletSettingsAccountKeyViewModel> AccountKeys { get; set; } = new List<WalletSettingsAccountKeyViewModel>();
}
public class WalletSettingsAccountKeyViewModel
{
public string AccountKey { get; set; }
[Validation.HDFingerPrintValidator]
public string MasterFingerprint { get; set; }
[Validation.KeyPathValidator]
public string AccountKeyPath { get; set; }
}
}

View File

@ -62,8 +62,7 @@ namespace BTCPayServer.Services
return foundKeyPath;
}
public abstract Task<PSBT> SignTransactionAsync(PSBT psbt, Script changeHint,
CancellationToken cancellationToken);
public abstract Task<PSBT> SignTransactionAsync(PSBT psbt, HDFingerprint? rootFingerprint, BitcoinExtPubKey accountKey, Script changeHint, CancellationToken cancellationToken);
public virtual void Dispose()
{

View File

@ -114,25 +114,34 @@ namespace BTCPayServer.Services
account.Indexes.Length == 0 ? 0 : account.Indexes.Last()).GetWif(network.NBitcoinNetwork);
return extpubkey;
}
public override async Task<PSBT> SignTransactionAsync(PSBT psbt, Script changeHint, CancellationToken cancellationToken)
class HDKey
{
public PubKey PubKey { get; set; }
public KeyPath KeyPath { get; set; }
}
public override async Task<PSBT> SignTransactionAsync(PSBT psbt, HDFingerprint? rootFingerprint, BitcoinExtPubKey accountKey, Script changeHint, CancellationToken cancellationToken)
{
HashSet<HDFingerprint> knownFingerprints = new HashSet<HDFingerprint>();
knownFingerprints.Add(accountKey.GetPublicKey().GetHDFingerPrint());
if (rootFingerprint is HDFingerprint fp)
knownFingerprints.Add(fp);
var unsigned = psbt.GetGlobalTransaction();
var changeKeyPath = psbt.Outputs
.Where(o => changeHint == null ? true : changeHint == o.ScriptPubKey)
.Where(o => o.HDKeyPaths.Any())
.Select(o => o.HDKeyPaths.First().Value.Item2)
.Select(o => (Output: o, HDKey: GetHDKey(knownFingerprints, accountKey, o)))
.Where(o => o.HDKey != null)
.Select(o => o.HDKey.KeyPath)
.FirstOrDefault();
var signatureRequests = psbt
.Inputs
.Where(o => o.HDKeyPaths.Any())
.Where(o => !o.PartialSigs.ContainsKey(o.HDKeyPaths.First().Key))
.Select(i => (Input: i, HDKey: GetHDKey(knownFingerprints, accountKey, i)))
.Where(i => i.HDKey != null)
.Select(i => new SignatureRequest()
{
InputCoin = i.GetSignableCoin(),
InputTransaction = i.NonWitnessUtxo,
KeyPath = i.HDKeyPaths.First().Value.Item2,
PubKey = i.HDKeyPaths.First().Key
InputCoin = i.Input.GetSignableCoin(),
InputTransaction = i.Input.NonWitnessUtxo,
KeyPath = i.HDKey.KeyPath,
PubKey = i.HDKey.PubKey
}).ToArray();
var signedTransaction = await Ledger.SignTransactionAsync(signatureRequests, unsigned, changeKeyPath, cancellationToken);
if (signedTransaction == null)
@ -151,6 +160,22 @@ namespace BTCPayServer.Services
return psbt;
}
private HDKey GetHDKey(HashSet<HDFingerprint> knownFingerprints, BitcoinExtPubKey accountKey, PSBTCoin coin)
{
// Check if the accountKey match this coin by checking if the non hardened last part of the path
// can derive the same pubkey
foreach (var key in coin.HDKeyPaths)
{
if (!knownFingerprints.Contains(key.Value.Item1))
continue;
var accountKeyPath = key.Value.Item2.GetAccountKeyPath();
// We might have a fingerprint collision, let's check
if (accountKey.ExtPubKey.Derive(accountKeyPath).GetPublicKey() == key.Key)
return new HDKey() { KeyPath = key.Value.Item2, PubKey = key.Key };
}
return null;
}
public override void Dispose()
{
if (_Transport != null)

View File

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Validation
{
public class HDFingerPrintValidator : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var str = value as string;
if (string.IsNullOrWhiteSpace(str))
{
return ValidationResult.Success;
}
try
{
new HDFingerprint(Encoders.Hex.DecodeData(str));
return ValidationResult.Success;
}
catch
{
return new ValidationResult("Invalid fingerprint");
}
}
}
}

View File

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
namespace BTCPayServer.Validation
{
public class KeyPathValidator : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var str = value as string;
if (string.IsNullOrWhiteSpace(str))
{
return ValidationResult.Success;
}
if (KeyPath.TryParse(str, out _))
{
return ValidationResult.Success;
}
else
{
return new ValidationResult("Invalid keypath");
}
}
}
}

View File

@ -0,0 +1,70 @@
@model WalletSettingsViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Wallet settings";
ViewData.SetActivePageAndTitle(WalletsNavPages.Settings);
}
<div class="row">
<div class="col-md-10 text-center">
<partial name="_StatusMessage" for="@TempData["TempDataProperty-StatusMessage"]" />
</div>
</div>
<h4>@ViewData["Title"]</h4>
<div class="row">
<div class="col-md-10">
<p>
Additional information about your wallet
</p>
</div>
</div>
<div class="row">
<div class="col-md-6">
<form method="post" asp-action="WalletSettings">
<div class="form-group">
<label asp-for="Label"></label>
<input asp-for="Label" class="form-control" />
<span asp-validation-for="Label" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="DerivationScheme"></label>
<input asp-for="DerivationScheme" class="form-control" readonly />
<span asp-validation-for="DerivationScheme" class="text-danger"></span>
</div>
@if (!string.IsNullOrEmpty(Model.DerivationSchemeInput) && Model.DerivationSchemeInput != Model.DerivationScheme)
{
<div class="form-group">
<label asp-for="DerivationSchemeInput"></label>
<input asp-for="DerivationSchemeInput" class="form-control" readonly />
<span asp-validation-for="DerivationSchemeInput" class="text-danger"></span>
</div>
}
@for (int i = 0; i < Model.AccountKeys.Count; i++)
{
<hr />
<h5>Account key @i</h5>
<div class="form-group">
<label asp-for="@Model.AccountKeys[i].AccountKey"></label>
<input asp-for="@Model.AccountKeys[i].AccountKey" class="form-control" readonly />
<span asp-validation-for="@Model.AccountKeys[i].AccountKey" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="@Model.AccountKeys[i].MasterFingerprint"></label>
<input asp-for="@Model.AccountKeys[i].MasterFingerprint" class="form-control" />
<span asp-validation-for="@Model.AccountKeys[i].MasterFingerprint" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="@Model.AccountKeys[i].AccountKeyPath"></label>
<input asp-for="@Model.AccountKeys[i].AccountKeyPath" class="form-control" />
<span asp-validation-for="@Model.AccountKeys[i].AccountKeyPath" class="text-danger"></span>
</div>
}
<div class="form-group">
<button name="command" type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>

View File

@ -10,6 +10,7 @@ namespace BTCPayServer.Views.Wallets
Send,
Transactions,
Rescan,
PSBT
PSBT,
Settings
}
}

View File

@ -5,5 +5,6 @@
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Send)" asp-action="WalletSend">Send</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Rescan)" asp-action="WalletRescan">Rescan</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PSBT)" asp-action="WalletPSBT">PSBT</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Settings)" asp-action="WalletSettings">Settings</a>
</div>