diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 8082a8d99..9eb400b48 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -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()); }); } } diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 54f46bad4..9136a2589 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -47,7 +47,7 @@ all runtime; build; native; contentfiles; analyzers - + diff --git a/BTCPayServer/Controllers/StoresController.BTCLike.cs b/BTCPayServer/Controllers/StoresController.BTCLike.cs index a16875891..80bfefc3f 100644 --- a/BTCPayServer/Controllers/StoresController.BTCLike.cs +++ b/BTCPayServer/Controllers/StoresController.BTCLike.cs @@ -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(); } diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index d18988344..8d14a45ad 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -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"); diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 1fb9d4b11..e33a69bc5 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -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 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 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)); + } } diff --git a/BTCPayServer/DerivationSchemeSettings.cs b/BTCPayServer/DerivationSchemeSettings.cs index 016cd3eee..3ddcac379 100644 --- a/BTCPayServer/DerivationSchemeSettings.cs +++ b/BTCPayServer/DerivationSchemeSettings.cs @@ -65,6 +65,9 @@ namespace BTCPayServer { result.AccountOriginal = jobj["xpub"].Value().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()); + result.AccountKeySettings[0].RootFingerprint = new HDFingerprint(jobj["ckcc_xfp"].Value()); } catch { return false; } } @@ -100,7 +103,7 @@ namespace BTCPayServer { try { - result.AccountKeyPath = new KeyPath(jobj["derivation"].Value()); + result.AccountKeySettings[0].AccountKeyPath = new KeyPath(jobj["derivation"].Value()); } 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 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; + } + } } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSettingsViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSettingsViewModel.cs new file mode 100644 index 000000000..d7119c34b --- /dev/null +++ b/BTCPayServer/Models/WalletViewModels/WalletSettingsViewModel.cs @@ -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 AccountKeys { get; set; } = new List(); + } + + public class WalletSettingsAccountKeyViewModel + { + public string AccountKey { get; set; } + [Validation.HDFingerPrintValidator] + public string MasterFingerprint { get; set; } + [Validation.KeyPathValidator] + public string AccountKeyPath { get; set; } + } +} diff --git a/BTCPayServer/Services/HardwareWalletService.cs b/BTCPayServer/Services/HardwareWalletService.cs index 54295a0aa..7d4228a03 100644 --- a/BTCPayServer/Services/HardwareWalletService.cs +++ b/BTCPayServer/Services/HardwareWalletService.cs @@ -62,8 +62,7 @@ namespace BTCPayServer.Services return foundKeyPath; } - public abstract Task SignTransactionAsync(PSBT psbt, Script changeHint, - CancellationToken cancellationToken); + public abstract Task SignTransactionAsync(PSBT psbt, HDFingerprint? rootFingerprint, BitcoinExtPubKey accountKey, Script changeHint, CancellationToken cancellationToken); public virtual void Dispose() { diff --git a/BTCPayServer/Services/LedgerHardwareWalletService.cs b/BTCPayServer/Services/LedgerHardwareWalletService.cs index ceb0f277c..ce6de932f 100644 --- a/BTCPayServer/Services/LedgerHardwareWalletService.cs +++ b/BTCPayServer/Services/LedgerHardwareWalletService.cs @@ -114,25 +114,34 @@ namespace BTCPayServer.Services account.Indexes.Length == 0 ? 0 : account.Indexes.Last()).GetWif(network.NBitcoinNetwork); return extpubkey; } - - public override async Task SignTransactionAsync(PSBT psbt, Script changeHint, CancellationToken cancellationToken) + class HDKey { + public PubKey PubKey { get; set; } + public KeyPath KeyPath { get; set; } + } + public override async Task SignTransactionAsync(PSBT psbt, HDFingerprint? rootFingerprint, BitcoinExtPubKey accountKey, Script changeHint, CancellationToken cancellationToken) + { + HashSet knownFingerprints = new HashSet(); + 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 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) diff --git a/BTCPayServer/Validation/HDFingerPrintValidator.cs b/BTCPayServer/Validation/HDFingerPrintValidator.cs new file mode 100644 index 000000000..e148ac213 --- /dev/null +++ b/BTCPayServer/Validation/HDFingerPrintValidator.cs @@ -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"); + } + } + } +} diff --git a/BTCPayServer/Validation/KeyPathValidator.cs b/BTCPayServer/Validation/KeyPathValidator.cs new file mode 100644 index 000000000..6bcf253a4 --- /dev/null +++ b/BTCPayServer/Validation/KeyPathValidator.cs @@ -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"); + } + } + } +} diff --git a/BTCPayServer/Views/Wallets/WalletSettings.cshtml b/BTCPayServer/Views/Wallets/WalletSettings.cshtml new file mode 100644 index 000000000..9247a276a --- /dev/null +++ b/BTCPayServer/Views/Wallets/WalletSettings.cshtml @@ -0,0 +1,70 @@ +@model WalletSettingsViewModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["Title"] = "Wallet settings"; + ViewData.SetActivePageAndTitle(WalletsNavPages.Settings); +} + +
+
+ +
+
+ +

@ViewData["Title"]

+
+
+

+ Additional information about your wallet +

+
+
+
+
+
+
+ + + +
+
+ + + +
+ @if (!string.IsNullOrEmpty(Model.DerivationSchemeInput) && Model.DerivationSchemeInput != Model.DerivationScheme) + { +
+ + + +
+ } + + @for (int i = 0; i < Model.AccountKeys.Count; i++) + { +
+
Account key @i
+
+ + + +
+
+ + + +
+
+ + + +
+ } + +
+ +
+
+
+
diff --git a/BTCPayServer/Views/Wallets/WalletsNavPages.cs b/BTCPayServer/Views/Wallets/WalletsNavPages.cs index 9b4509933..981c23df3 100644 --- a/BTCPayServer/Views/Wallets/WalletsNavPages.cs +++ b/BTCPayServer/Views/Wallets/WalletsNavPages.cs @@ -10,6 +10,7 @@ namespace BTCPayServer.Views.Wallets Send, Transactions, Rescan, - PSBT + PSBT, + Settings } } diff --git a/BTCPayServer/Views/Wallets/_Nav.cshtml b/BTCPayServer/Views/Wallets/_Nav.cshtml index 69d85e52c..71569fc9e 100644 --- a/BTCPayServer/Views/Wallets/_Nav.cshtml +++ b/BTCPayServer/Views/Wallets/_Nav.cshtml @@ -5,5 +5,6 @@ Send Rescan PSBT + Settings