diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 86ceb5e0e..679bebed2 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -125,6 +125,18 @@ $(IncludeRazorContentInPack) + + $(IncludeRazorContentInPack) + + + $(IncludeRazorContentInPack) + + + $(IncludeRazorContentInPack) + + + $(IncludeRazorContentInPack) + diff --git a/BTCPayServer/Controllers/StoresController.BTCLike.cs b/BTCPayServer/Controllers/StoresController.BTCLike.cs index 861f51ac9..62d16df2e 100644 --- a/BTCPayServer/Controllers/StoresController.BTCLike.cs +++ b/BTCPayServer/Controllers/StoresController.BTCLike.cs @@ -34,7 +34,7 @@ namespace BTCPayServer.Controllers } DerivationSchemeViewModel vm = new DerivationSchemeViewModel(); - vm.ServerUrl = GetStoreUrl(storeId); + vm.ServerUrl = WalletsController.GetLedgerWebsocketUrl(this.HttpContext, cryptoCode, null); vm.CryptoCode = cryptoCode; vm.RootKeyPath = network.GetRootKeyPath(); SetExistingValues(store, vm); @@ -59,7 +59,7 @@ namespace BTCPayServer.Controllers [Route("{storeId}/derivations/{cryptoCode}")] public async Task AddDerivationScheme(string storeId, DerivationSchemeViewModel vm, string cryptoCode) { - vm.ServerUrl = GetStoreUrl(storeId); + vm.ServerUrl = WalletsController.GetLedgerWebsocketUrl(this.HttpContext, cryptoCode, null); vm.CryptoCode = cryptoCode; var store = HttpContext.GetStoreData(); if (store == null) @@ -161,279 +161,5 @@ namespace BTCPayServer.Controllers vm.Confirmation = true; return View(vm); } - - - public class GetInfoResult - { - public int RecommendedSatoshiPerByte { get; set; } - public double Balance { get; set; } - } - - public class SendToAddressResult - { - public string TransactionId { get; set; } - } - - [HttpGet] - [Route("{storeId}/ws/ledger")] - public async Task LedgerConnection( - string storeId, - string command, - // getinfo - string cryptoCode = null, - // getxpub - int account = 0, - // sendtoaddress - string destination = null, string amount = null, string feeRate = null, string substractFees = null - ) - { - if (!HttpContext.WebSockets.IsWebSocketRequest) - return NotFound(); - var store = HttpContext.GetStoreData(); - if (store == null) - return NotFound(); - - var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); - - using (var normalOperationTimeout = new CancellationTokenSource()) - using (var signTimeout = new CancellationTokenSource()) - { - normalOperationTimeout.CancelAfter(TimeSpan.FromMinutes(30)); - var hw = new HardwareWalletService(webSocket); - object result = null; - try - { - BTCPayNetwork network = null; - if (cryptoCode != null) - { - network = _NetworkProvider.GetNetwork(cryptoCode); - if (network == null) - throw new FormatException("Invalid value for crypto code"); - } - - BitcoinAddress destinationAddress = null; - if (destination != null) - { - try - { - destinationAddress = BitcoinAddress.Create(destination, network.NBitcoinNetwork); - } - catch { } - if (destinationAddress == null) - throw new FormatException("Invalid value for destination"); - } - - FeeRate feeRateValue = null; - if (feeRate != null) - { - try - { - feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate, CultureInfo.InvariantCulture)), 1); - } - catch { } - if (feeRateValue == null || feeRateValue.FeePerK <= Money.Zero) - throw new FormatException("Invalid value for fee rate"); - } - - Money amountBTC = null; - if (amount != null) - { - try - { - amountBTC = Money.Parse(amount); - } - catch { } - if (amountBTC == null || amountBTC <= Money.Zero) - throw new FormatException("Invalid value for amount"); - } - - bool subsctractFeesValue = false; - if (substractFees != null) - { - try - { - subsctractFeesValue = bool.Parse(substractFees); - } - catch { throw new FormatException("Invalid value for subtract fees"); } - } - if (command == "test") - { - result = await hw.Test(normalOperationTimeout.Token); - } - if (command == "getxpub") - { - var getxpubResult = await hw.GetExtPubKey(network, account, normalOperationTimeout.Token); - result = getxpubResult; - } - if (command == "getinfo") - { - var strategy = GetDirectDerivationStrategy(store, network); - var strategyBase = GetDerivationStrategy(store, network); - if (strategy == null || await hw.GetKeyPath(network, strategy, normalOperationTimeout.Token) == null) - { - throw new Exception($"This store is not configured to use this ledger"); - } - - var feeProvider = _FeeRateProvider.CreateFeeProvider(network); - var recommendedFees = feeProvider.GetFeeRateAsync(); - var balance = _WalletProvider.GetWallet(network).GetBalance(strategyBase); - result = new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi }; - } - - if (command == "sendtoaddress") - { - if (!_Dashboard.IsFullySynched(network.CryptoCode, out var summary)) - throw new Exception($"{network.CryptoCode}: not started or fully synched"); - var strategy = GetDirectDerivationStrategy(store, network); - var strategyBase = GetDerivationStrategy(store, network); - var wallet = _WalletProvider.GetWallet(network); - var change = wallet.GetChangeAddressAsync(strategyBase); - - var unspentCoins = await wallet.GetUnspentCoins(strategyBase); - var changeAddress = await change; - var send = new[] { ( - destination: destinationAddress as IDestination, - amount: amountBTC, - substractFees: subsctractFeesValue) }; - - foreach (var element in send) - { - if (element.destination == null) - throw new ArgumentNullException(nameof(element.destination)); - if (element.amount == null) - throw new ArgumentNullException(nameof(element.amount)); - if (element.amount <= Money.Zero) - throw new ArgumentOutOfRangeException(nameof(element.amount), "The amount should be above zero"); - } - - var foundKeyPath = await hw.GetKeyPath(network, strategy, normalOperationTimeout.Token); - if (foundKeyPath == null) - { - throw new HardwareWalletException($"This store is not configured to use this ledger"); - } - - TransactionBuilder builder = new TransactionBuilder(); - builder.StandardTransactionPolicy.MinRelayTxFee = summary.Status.BitcoinStatus.MinRelayTxFee; - builder.SetConsensusFactory(network.NBitcoinNetwork); - builder.AddCoins(unspentCoins.Select(c => c.Coin).ToArray()); - - foreach (var element in send) - { - builder.Send(element.destination, element.amount); - if (element.substractFees) - builder.SubtractFees(); - } - builder.SetChange(changeAddress.Item1); - - if (network.MinFee == null) - { - builder.SendEstimatedFees(feeRateValue); - } - else - { - var estimatedFee = builder.EstimateFees(feeRateValue); - if (network.MinFee > estimatedFee) - builder.SendFees(network.MinFee); - else - builder.SendEstimatedFees(feeRateValue); - } - builder.Shuffle(); - var unsigned = builder.BuildTransaction(false); - - var keypaths = new Dictionary(); - foreach (var c in unspentCoins) - { - keypaths.TryAdd(c.Coin.ScriptPubKey, c.KeyPath); - } - - var hasChange = unsigned.Outputs.Count == 2; - var usedCoins = builder.FindSpentCoins(unsigned); - - Dictionary parentTransactions = new Dictionary(); - - if (!strategy.Segwit) - { - var parentHashes = usedCoins.Select(c => c.Outpoint.Hash).ToHashSet(); - var explorer = _ExplorerProvider.GetExplorerClient(network); - var getTransactionAsyncs = parentHashes.Select(h => (Op: explorer.GetTransactionAsync(h), Hash: h)).ToList(); - foreach (var getTransactionAsync in getTransactionAsyncs) - { - var tx = (await getTransactionAsync.Op); - if (tx == null) - throw new Exception($"Parent transaction {getTransactionAsync.Hash} not found"); - parentTransactions.Add(tx.Transaction.GetHash(), tx.Transaction); - } - } - - - signTimeout.CancelAfter(TimeSpan.FromMinutes(5)); - var transaction = await hw.SignTransactionAsync(usedCoins.Select(c => new SignatureRequest - { - InputTransaction = parentTransactions.TryGet(c.Outpoint.Hash), - InputCoin = c, - KeyPath = foundKeyPath.Derive(keypaths[c.TxOut.ScriptPubKey]), - PubKey = strategy.Root.Derive(keypaths[c.TxOut.ScriptPubKey]).PubKey - }).ToArray(), unsigned, hasChange ? foundKeyPath.Derive(changeAddress.Item2) : null, signTimeout.Token); - try - { - var broadcastResult = await wallet.BroadcastTransactionsAsync(new List() { transaction }); - if (!broadcastResult[0].Success) - { - throw new Exception($"RPC Error while broadcasting: {broadcastResult[0].RPCCode} {broadcastResult[0].RPCCodeMessage} {broadcastResult[0].RPCMessage}"); - } - } - catch (Exception ex) - { - throw new Exception("Error while broadcasting: " + ex.Message); - } - wallet.InvalidateCache(strategyBase); - result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() }; - } - } - catch (OperationCanceledException) - { result = new LedgerTestResult() { Success = false, Error = "Timeout" }; } - catch (Exception ex) - { result = new LedgerTestResult() { Success = false, Error = ex.Message }; } - finally { hw.Dispose(); } - try - { - if (result != null) - { - UTF8Encoding UTF8NOBOM = new UTF8Encoding(false); - var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _MvcJsonOptions.SerializerSettings)); - await webSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token); - } - } - catch { } - finally - { - await webSocket.CloseSocket(); - } - } - return new EmptyResult(); - } - - private DirectDerivationStrategy GetDirectDerivationStrategy(StoreData store, BTCPayNetwork network) - { - var strategy = GetDerivationStrategy(store, network); - var directStrategy = strategy as DirectDerivationStrategy; - if (directStrategy == null) - directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy; - return directStrategy; - } - - private DerivationStrategyBase GetDerivationStrategy(StoreData store, BTCPayNetwork network) - { - var strategy = store - .GetSupportedPaymentMethods(_NetworkProvider) - .OfType() - .FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork); - if (strategy == null) - { - throw new Exception($"Derivation strategy for {network.CryptoCode} is not set"); - } - - return strategy.DerivationStrategyBase; - } } } diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index fc9ca6285..daef61110 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -38,11 +38,9 @@ namespace BTCPayServer.Controllers BTCPayRateProviderFactory _RateFactory; public string CreatedStoreId { get; set; } public StoresController( - NBXplorerDashboard dashboard, IServiceProvider serviceProvider, BTCPayServerOptions btcpayServerOptions, BTCPayServerEnvironment btcpayEnv, - IOptions mvcJsonOptions, StoreRepository repo, TokenRepository tokenRepo, UserManager userManager, @@ -56,7 +54,6 @@ namespace BTCPayServer.Controllers IHostingEnvironment env) { _RateFactory = rateFactory; - _Dashboard = dashboard; _Repo = repo; _TokenRepository = tokenRepo; _UserManager = userManager; @@ -66,19 +63,16 @@ namespace BTCPayServer.Controllers _Env = env; _NetworkProvider = networkProvider; _ExplorerProvider = explorerProvider; - _MvcJsonOptions = mvcJsonOptions.Value; _FeeRateProvider = feeRateProvider; _ServiceProvider = serviceProvider; _BtcpayServerOptions = btcpayServerOptions; _BTCPayEnv = btcpayEnv; } - NBXplorerDashboard _Dashboard; BTCPayServerOptions _BtcpayServerOptions; BTCPayServerEnvironment _BTCPayEnv; IServiceProvider _ServiceProvider; BTCPayNetworkProvider _NetworkProvider; private ExplorerClientProvider _ExplorerProvider; - private MvcJsonOptions _MvcJsonOptions; private IFeeProviderFactory _FeeRateProvider; BTCPayWalletProvider _WalletProvider; AccessTokenController _TokenController; @@ -94,21 +88,6 @@ namespace BTCPayServer.Controllers get; set; } - [HttpGet] - [Route("{storeId}/wallet/{cryptoCode}")] - public IActionResult Wallet(string cryptoCode) - { - WalletModel model = new WalletModel(); - model.ServerUrl = GetStoreUrl(StoreData.Id); - model.CryptoCurrency = cryptoCode; - return View(model); - } - - private string GetStoreUrl(string storeId) - { - return HttpContext.Request.GetAbsoluteRoot() + "/stores/" + storeId + "/"; - } - [HttpGet] [Route("{storeId}/users")] public async Task StoreUsers() @@ -447,7 +426,8 @@ namespace BTCPayServer.Controllers vm.DerivationSchemes.Add(new StoreViewModel.DerivationScheme() { Crypto = network.CryptoCode, - Value = strategy?.DerivationStrategyBase?.ToString() ?? string.Empty + Value = strategy?.DerivationStrategyBase?.ToString() ?? string.Empty, + WalletId = new WalletId(store.Id, network.CryptoCode), }); } diff --git a/BTCPayServer/Controllers/UserStoresController.cs b/BTCPayServer/Controllers/UserStoresController.cs index 49a6bfc91..7e5a91789 100644 --- a/BTCPayServer/Controllers/UserStoresController.cs +++ b/BTCPayServer/Controllers/UserStoresController.cs @@ -23,18 +23,15 @@ namespace BTCPayServer.Controllers private StoreRepository _Repo; private BTCPayNetworkProvider _NetworkProvider; private UserManager _UserManager; - private BTCPayWalletProvider _WalletProvider; public UserStoresController( UserManager userManager, BTCPayNetworkProvider networkProvider, - BTCPayWalletProvider walletProvider, StoreRepository storeRepository) { _Repo = storeRepository; _NetworkProvider = networkProvider; _UserManager = userManager; - _WalletProvider = walletProvider; } [HttpGet] @@ -85,17 +82,6 @@ namespace BTCPayServer.Controllers { StoresViewModel result = new StoresViewModel(); var stores = await _Repo.GetStoresByUserId(GetUserId()); - - var balances = stores - .Select(s => s.GetSupportedPaymentMethods(_NetworkProvider) - .OfType() - .Select(d => ((Wallet: _WalletProvider.GetWallet(d.Network), - DerivationStrategy: d.DerivationStrategyBase))) - .Where(_ => _.Wallet != null) - .Select(async _ => (await GetBalanceString(_)) + " " + _.Wallet.Network.CryptoCode)) - .ToArray(); - - await Task.WhenAll(balances.SelectMany(_ => _)); for (int i = 0; i < stores.Length; i++) { var store = stores[i]; @@ -104,8 +90,7 @@ namespace BTCPayServer.Controllers Id = store.Id, Name = store.StoreName, WebSite = store.StoreWebsite, - IsOwner = store.HasClaim(Policies.CanModifyStoreSettings.Key), - Balances = store.HasClaim(Policies.CanModifyStoreSettings.Key) ? balances[i].Select(t => t.Result).ToArray() : Array.Empty() + IsOwner = store.HasClaim(Policies.CanModifyStoreSettings.Key) }); } return View(result); @@ -128,22 +113,6 @@ namespace BTCPayServer.Controllers }); } - private static async Task GetBalanceString((BTCPayWallet Wallet, DerivationStrategyBase DerivationStrategy) _) - { - using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10))) - { - try - { - return (await _.Wallet.GetBalance(_.DerivationStrategy, cts.Token)).ToString(); - } - catch - { - return "--"; - } - } - } - - private string GetUserId() { return _UserManager.GetUserId(User); diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs new file mode 100644 index 000000000..4c144f9ca --- /dev/null +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -0,0 +1,408 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.ModelBinders; +using BTCPayServer.Models; +using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Security; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using LedgerWallet; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NBitcoin; +using NBXplorer.DerivationStrategy; +using Newtonsoft.Json; +using static BTCPayServer.Controllers.StoresController; + +namespace BTCPayServer.Controllers +{ + [Route("wallets")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] + [AutoValidateAntiforgeryToken] + public class WalletsController : Controller + { + private StoreRepository _Repo; + private BTCPayNetworkProvider _NetworkProvider; + private readonly UserManager _userManager; + private readonly IOptions _mvcJsonOptions; + private readonly NBXplorerDashboard _dashboard; + private readonly ExplorerClientProvider _explorerProvider; + private readonly IFeeProviderFactory _feeRateProvider; + private readonly BTCPayWalletProvider _walletProvider; + + public WalletsController(StoreRepository repo, + BTCPayNetworkProvider networkProvider, + UserManager userManager, + IOptions mvcJsonOptions, + NBXplorerDashboard dashboard, + ExplorerClientProvider explorerProvider, + IFeeProviderFactory feeRateProvider, + BTCPayWalletProvider walletProvider) + { + _Repo = repo; + _NetworkProvider = networkProvider; + _userManager = userManager; + _mvcJsonOptions = mvcJsonOptions; + _dashboard = dashboard; + _explorerProvider = explorerProvider; + _feeRateProvider = feeRateProvider; + _walletProvider = walletProvider; + } + + public async Task ListWallets() + { + var wallets = new ListWalletsViewModel(); + var stores = await _Repo.GetStoresByUserId(GetUserId()); + + var onChainWallets = stores + .SelectMany(s => s.GetSupportedPaymentMethods(_NetworkProvider) + .OfType() + .Select(d => ((Wallet: _walletProvider.GetWallet(d.Network), + DerivationStrategy: d.DerivationStrategyBase, + Network: d.Network))) + .Where(_ => _.Wallet != null) + .Select(_ => (Wallet: _.Wallet, + Store: s, + Balance: GetBalanceString(_.Wallet, _.DerivationStrategy), + DerivationStrategy: _.DerivationStrategy, + Network: _.Network))) + .ToList(); + + foreach (var wallet in onChainWallets) + { + ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel(); + wallets.Wallets.Add(walletVm); + walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode; + if (!wallet.Store.HasClaim(Policies.CanModifyStoreSettings.Key)) + { + walletVm.Balance = ""; + } + walletVm.CryptoCode = wallet.Network.CryptoCode; + walletVm.StoreId = wallet.Store.Id; + walletVm.Id = new WalletId(wallet.Store.Id, wallet.Network.CryptoCode); + walletVm.StoreName = wallet.Store.StoreName; + walletVm.IsOwner = wallet.Store.HasClaim(Policies.CanModifyStoreSettings.Key); + } + + return View(wallets); + } + + [HttpGet] + [Route("{walletId}")] + public async Task WalletSend( + [ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId) + { + if (walletId?.StoreId == null) + return NotFound(); + var store = await _Repo.FindStore(walletId.StoreId, GetUserId()); + if (store == null || !store.HasClaim(Policies.CanModifyStoreSettings.Key)) + return NotFound(); + + var paymentMethod = store + .GetSupportedPaymentMethods(_NetworkProvider) + .OfType() + .FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == walletId.CryptoCode); + + if (paymentMethod == null) + return NotFound(); + WalletModel model = new WalletModel(); + model.ServerUrl = GetLedgerWebsocketUrl(this.HttpContext, walletId.CryptoCode, paymentMethod.DerivationStrategyBase); + model.CryptoCurrency = walletId.CryptoCode; + return View(model); + } + + private static async Task GetBalanceString(BTCPayWallet wallet, DerivationStrategyBase derivationStrategy) + { + using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10))) + { + try + { + return (await wallet.GetBalance(derivationStrategy, cts.Token)).ToString(); + } + catch + { + return "--"; + } + } + } + + private string GetUserId() + { + return _userManager.GetUserId(User); + } + + public static string GetLedgerWebsocketUrl(HttpContext httpContext, string cryptoCode, DerivationStrategyBase derivationStrategy) + { + return $"{httpContext.Request.GetAbsoluteRoot().WithTrailingSlash()}ws/ledger/{cryptoCode}/{derivationStrategy?.ToString() ?? string.Empty}"; + } + + [HttpGet] + [Route("/ws/ledger/{cryptoCode}/{derivationScheme?}")] + public async Task LedgerConnection( + string command, + // getinfo + string cryptoCode = null, + // getxpub + [ModelBinder(typeof(ModelBinders.DerivationSchemeModelBinder))] + DerivationStrategyBase derivationScheme = null, + int account = 0, + // sendtoaddress + string destination = null, string amount = null, string feeRate = null, string substractFees = null + ) + { + if (!HttpContext.WebSockets.IsWebSocketRequest) + return NotFound(); + var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + + using (var normalOperationTimeout = new CancellationTokenSource()) + using (var signTimeout = new CancellationTokenSource()) + { + normalOperationTimeout.CancelAfter(TimeSpan.FromMinutes(30)); + var hw = new HardwareWalletService(webSocket); + object result = null; + try + { + BTCPayNetwork network = null; + if (cryptoCode != null) + { + network = _NetworkProvider.GetNetwork(cryptoCode); + if (network == null) + throw new FormatException("Invalid value for crypto code"); + } + + BitcoinAddress destinationAddress = null; + if (destination != null) + { + try + { + destinationAddress = BitcoinAddress.Create(destination, network.NBitcoinNetwork); + } + catch { } + if (destinationAddress == null) + throw new FormatException("Invalid value for destination"); + } + + FeeRate feeRateValue = null; + if (feeRate != null) + { + try + { + feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate, CultureInfo.InvariantCulture)), 1); + } + catch { } + if (feeRateValue == null || feeRateValue.FeePerK <= Money.Zero) + throw new FormatException("Invalid value for fee rate"); + } + + Money amountBTC = null; + if (amount != null) + { + try + { + amountBTC = Money.Parse(amount); + } + catch { } + if (amountBTC == null || amountBTC <= Money.Zero) + throw new FormatException("Invalid value for amount"); + } + + bool subsctractFeesValue = false; + if (substractFees != null) + { + try + { + subsctractFeesValue = bool.Parse(substractFees); + } + catch { throw new FormatException("Invalid value for subtract fees"); } + } + if (command == "test") + { + result = await hw.Test(normalOperationTimeout.Token); + } + if (command == "getxpub") + { + var getxpubResult = await hw.GetExtPubKey(network, account, normalOperationTimeout.Token); + result = getxpubResult; + } + if (command == "getinfo") + { + var strategy = GetDirectDerivationStrategy(derivationScheme); + if (strategy == null || await hw.GetKeyPath(network, strategy, normalOperationTimeout.Token) == null) + { + throw new Exception($"This store is not configured to use this ledger"); + } + + var feeProvider = _feeRateProvider.CreateFeeProvider(network); + var recommendedFees = feeProvider.GetFeeRateAsync(); + var balance = _walletProvider.GetWallet(network).GetBalance(derivationScheme); + result = new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi }; + } + + if (command == "sendtoaddress") + { + if (!_dashboard.IsFullySynched(network.CryptoCode, out var summary)) + throw new Exception($"{network.CryptoCode}: not started or fully synched"); + var strategy = GetDirectDerivationStrategy(derivationScheme); + var wallet = _walletProvider.GetWallet(network); + var change = wallet.GetChangeAddressAsync(derivationScheme); + + var unspentCoins = await wallet.GetUnspentCoins(derivationScheme); + var changeAddress = await change; + var send = new[] { ( + destination: destinationAddress as IDestination, + amount: amountBTC, + substractFees: subsctractFeesValue) }; + + foreach (var element in send) + { + if (element.destination == null) + throw new ArgumentNullException(nameof(element.destination)); + if (element.amount == null) + throw new ArgumentNullException(nameof(element.amount)); + if (element.amount <= Money.Zero) + throw new ArgumentOutOfRangeException(nameof(element.amount), "The amount should be above zero"); + } + + var foundKeyPath = await hw.GetKeyPath(network, strategy, normalOperationTimeout.Token); + if (foundKeyPath == null) + { + throw new HardwareWalletException($"This store is not configured to use this ledger"); + } + + TransactionBuilder builder = new TransactionBuilder(); + builder.StandardTransactionPolicy.MinRelayTxFee = summary.Status.BitcoinStatus.MinRelayTxFee; + builder.SetConsensusFactory(network.NBitcoinNetwork); + builder.AddCoins(unspentCoins.Select(c => c.Coin).ToArray()); + + foreach (var element in send) + { + builder.Send(element.destination, element.amount); + if (element.substractFees) + builder.SubtractFees(); + } + builder.SetChange(changeAddress.Item1); + + if (network.MinFee == null) + { + builder.SendEstimatedFees(feeRateValue); + } + else + { + var estimatedFee = builder.EstimateFees(feeRateValue); + if (network.MinFee > estimatedFee) + builder.SendFees(network.MinFee); + else + builder.SendEstimatedFees(feeRateValue); + } + builder.Shuffle(); + var unsigned = builder.BuildTransaction(false); + + var keypaths = new Dictionary(); + foreach (var c in unspentCoins) + { + keypaths.TryAdd(c.Coin.ScriptPubKey, c.KeyPath); + } + + var hasChange = unsigned.Outputs.Count == 2; + var usedCoins = builder.FindSpentCoins(unsigned); + + Dictionary parentTransactions = new Dictionary(); + + if (!strategy.Segwit) + { + var parentHashes = usedCoins.Select(c => c.Outpoint.Hash).ToHashSet(); + var explorer = _explorerProvider.GetExplorerClient(network); + var getTransactionAsyncs = parentHashes.Select(h => (Op: explorer.GetTransactionAsync(h), Hash: h)).ToList(); + foreach (var getTransactionAsync in getTransactionAsyncs) + { + var tx = (await getTransactionAsync.Op); + if (tx == null) + throw new Exception($"Parent transaction {getTransactionAsync.Hash} not found"); + parentTransactions.Add(tx.Transaction.GetHash(), tx.Transaction); + } + } + + + signTimeout.CancelAfter(TimeSpan.FromMinutes(5)); + var transaction = await hw.SignTransactionAsync(usedCoins.Select(c => new SignatureRequest + { + InputTransaction = parentTransactions.TryGet(c.Outpoint.Hash), + InputCoin = c, + KeyPath = foundKeyPath.Derive(keypaths[c.TxOut.ScriptPubKey]), + PubKey = strategy.Root.Derive(keypaths[c.TxOut.ScriptPubKey]).PubKey + }).ToArray(), unsigned, hasChange ? foundKeyPath.Derive(changeAddress.Item2) : null, signTimeout.Token); + try + { + var broadcastResult = await wallet.BroadcastTransactionsAsync(new List() { transaction }); + if (!broadcastResult[0].Success) + { + throw new Exception($"RPC Error while broadcasting: {broadcastResult[0].RPCCode} {broadcastResult[0].RPCCodeMessage} {broadcastResult[0].RPCMessage}"); + } + } + catch (Exception ex) + { + throw new Exception("Error while broadcasting: " + ex.Message); + } + wallet.InvalidateCache(derivationScheme); + result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() }; + } + } + catch (OperationCanceledException) + { result = new LedgerTestResult() { Success = false, Error = "Timeout" }; } + catch (Exception ex) + { result = new LedgerTestResult() { Success = false, Error = ex.Message }; } + finally { hw.Dispose(); } + try + { + if (result != null) + { + UTF8Encoding UTF8NOBOM = new UTF8Encoding(false); + var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _mvcJsonOptions.Value.SerializerSettings)); + await webSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token); + } + } + catch { } + finally + { + await webSocket.CloseSocket(); + } + } + return new EmptyResult(); + } + + private DirectDerivationStrategy GetDirectDerivationStrategy(DerivationStrategyBase strategy) + { + if (strategy == null) + throw new Exception("The derivation scheme is not provided"); + var directStrategy = strategy as DirectDerivationStrategy; + if (directStrategy == null) + directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy; + return directStrategy; + } + } + + + public class GetInfoResult + { + public int RecommendedSatoshiPerByte { get; set; } + public double Balance { get; set; } + } + + public class SendToAddressResult + { + public string TransactionId { get; set; } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index b645d60b0..4e18b55c1 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -39,6 +39,8 @@ using BTCPayServer.HostedServices; using Meziantou.AspNetCore.BundleTagHelpers; using System.Security.Claims; using BTCPayServer.Security; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using NBXplorer.DerivationStrategy; namespace BTCPayServer.Hosting { @@ -105,7 +107,11 @@ namespace BTCPayServer.Hosting }); services.AddSingleton(); - services.Configure((o) => { o.Filters.Add(new ContentSecurityPolicyCssThemeManager()); }); + services.Configure((o) => { + o.Filters.Add(new ContentSecurityPolicyCssThemeManager()); + o.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(WalletId))); + o.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(DerivationStrategyBase))); + }); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index 320fcb6b0..953858a19 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -117,13 +117,13 @@ namespace BTCPayServer.Hosting }); // Needed to debug U2F for ledger support - //services.Configure(kestrel => - //{ - // kestrel.Listen(IPAddress.Loopback, 5012, l => - // { - // l.UseHttps("devtest.pfx", "toto"); - // }); - //}); + services.Configure(kestrel => + { + kestrel.Listen(IPAddress.Loopback, 5012, l => + { + l.UseHttps("devtest.pfx", "toto"); + }); + }); } public void Configure( diff --git a/BTCPayServer/ModelBinders/DerivationSchemeModelBinder.cs b/BTCPayServer/ModelBinders/DerivationSchemeModelBinder.cs new file mode 100644 index 000000000..2b47c1024 --- /dev/null +++ b/BTCPayServer/ModelBinders/DerivationSchemeModelBinder.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using NBitcoin; +using System.Reflection; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Internal; +using NBXplorer.DerivationStrategy; + +namespace BTCPayServer.ModelBinders +{ + public class DerivationSchemeModelBinder : IModelBinder + { + public DerivationSchemeModelBinder() + { + + } + + #region IModelBinder Members + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (!typeof(DerivationStrategyBase).GetTypeInfo().IsAssignableFrom(bindingContext.ModelType)) + { + return Task.CompletedTask; + } + + ValueProviderResult val = bindingContext.ValueProvider.GetValue( + bindingContext.ModelName); + if (val == null) + { + return Task.CompletedTask; + } + + string key = val.FirstValue as string; + if (key == null) + { + return Task.CompletedTask; + } + + var networkProvider = (BTCPayNetworkProvider)bindingContext.HttpContext.RequestServices.GetService(typeof(BTCPayNetworkProvider)); + var cryptoCode = bindingContext.ValueProvider.GetValue("cryptoCode").FirstValue; + var network = networkProvider.GetNetwork(cryptoCode ?? "BTC"); + try + { + var data = new DerivationStrategyFactory(network.NBitcoinNetwork).Parse(key); + if (!bindingContext.ModelType.IsInstanceOfType(data)) + { + throw new FormatException("Invalid destination type"); + } + bindingContext.Result = ModelBindingResult.Success(data); + } + catch { throw new FormatException("Invalid derivation scheme"); } + return Task.CompletedTask; + } + + #endregion + } +} diff --git a/BTCPayServer/ModelBinders/WalletIdModelBinder.cs b/BTCPayServer/ModelBinders/WalletIdModelBinder.cs new file mode 100644 index 000000000..1bd3ddd32 --- /dev/null +++ b/BTCPayServer/ModelBinders/WalletIdModelBinder.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace BTCPayServer.ModelBinders +{ + public class WalletIdModelBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (!typeof(WalletId).GetTypeInfo().IsAssignableFrom(bindingContext.ModelType)) + { + return Task.CompletedTask; + } + + ValueProviderResult val = bindingContext.ValueProvider.GetValue( + bindingContext.ModelName); + if (val == null) + { + return Task.CompletedTask; + } + + string key = val.FirstValue as string; + if (key == null) + { + return Task.CompletedTask; + } + + if(WalletId.TryParse(key, out var walletId)) + { + bindingContext.Result = ModelBindingResult.Success(walletId); + } + return Task.CompletedTask; + } + } +} diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index 6f4f168df..7e676b6f8 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -18,6 +18,7 @@ namespace BTCPayServer.Models.StoreViewModels { public string Crypto { get; set; } public string Value { get; set; } + public WalletId WalletId { get; set; } } public StoreViewModel() diff --git a/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs index 5f05d61f9..709a701a9 100644 --- a/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs @@ -34,10 +34,6 @@ namespace BTCPayServer.Models.StoreViewModels get; set; } - public string[] Balances - { - get; set; - } } } } diff --git a/BTCPayServer/Models/WalletViewModels/ListWalletsViewModel.cs b/BTCPayServer/Models/WalletViewModels/ListWalletsViewModel.cs new file mode 100644 index 000000000..9219a4f4b --- /dev/null +++ b/BTCPayServer/Models/WalletViewModels/ListWalletsViewModel.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Models.WalletViewModels +{ + public class ListWalletsViewModel + { + public class WalletViewModel + { + public string StoreName { get; set; } + public string StoreId { get; set; } + public string CryptoCode { get; set; } + public string Balance { get; set; } + public bool IsOwner { get; set; } + public WalletId Id { get; set; } + } + + public List Wallets { get; set; } = new List(); + } +} diff --git a/BTCPayServer/Models/StoreViewModels/WalletModel.cs b/BTCPayServer/Models/WalletViewModels/WalletModel.cs similarity index 88% rename from BTCPayServer/Models/StoreViewModels/WalletModel.cs rename to BTCPayServer/Models/WalletViewModels/WalletModel.cs index 8038c8429..191e42336 100644 --- a/BTCPayServer/Models/StoreViewModels/WalletModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletModel.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Rendering; -namespace BTCPayServer.Models.StoreViewModels +namespace BTCPayServer.Models.WalletViewModels { public class WalletModel { diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml index 98d4d44bf..bc49c385d 100644 --- a/BTCPayServer/Views/Shared/_Layout.cshtml +++ b/BTCPayServer/Views/Shared/_Layout.cshtml @@ -61,6 +61,7 @@ } +