mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-23 06:35:13 +01:00
Merge pull request #1152 from NicolasDorier/feature/vault
Add hardware support via BTCPayServer Vault
This commit is contained in:
commit
f809dd51a6
19 changed files with 1079 additions and 24 deletions
|
@ -27,12 +27,13 @@
|
||||||
<EmbeddedResource Include="Currencies.txt" />
|
<EmbeddedResource Include="Currencies.txt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BTCPayServer.Hwi" Version="1.1.2" />
|
||||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.1.5" />
|
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.1.5" />
|
||||||
<PackageReference Include="BuildBundlerMinifier" Version="3.1.430" />
|
<PackageReference Include="BuildBundlerMinifier" Version="3.1.430" />
|
||||||
<PackageReference Include="BundlerMinifier.Core" Version="3.1.430" />
|
<PackageReference Include="BundlerMinifier.Core" Version="3.1.430" />
|
||||||
<PackageReference Include="BundlerMinifier.TagHelpers" Version="3.1.430" />
|
<PackageReference Include="BundlerMinifier.TagHelpers" Version="3.1.430" />
|
||||||
<PackageReference Include="HtmlSanitizer" Version="4.0.217" />
|
<PackageReference Include="HtmlSanitizer" Version="4.0.217" />
|
||||||
<PackageReference Include="LedgerWallet" Version="2.0.0.3" />
|
<PackageReference Include="LedgerWallet" Version="2.0.0.5" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />
|
||||||
<PackageReference Include="Microsoft.NetCore.Analyzers" Version="2.6.2">
|
<PackageReference Include="Microsoft.NetCore.Analyzers" Version="2.6.2">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
@ -201,6 +202,9 @@
|
||||||
<Content Update="Views\Wallets\WalletRescan.cshtml">
|
<Content Update="Views\Wallets\WalletRescan.cshtml">
|
||||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Update="Views\Wallets\WalletSendVault.cshtml">
|
||||||
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
|
</Content>
|
||||||
<Content Update="Views\Wallets\WalletSendLedger.cshtml">
|
<Content Update="Views\Wallets\WalletSendLedger.cshtml">
|
||||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
</Content>
|
</Content>
|
||||||
|
|
|
@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBXplorer.DerivationStrategy;
|
using NBXplorer.DerivationStrategy;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Controllers
|
namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
|
@ -119,8 +120,8 @@ namespace BTCPayServer.Controllers
|
||||||
return new EmptyResult();
|
return new EmptyResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private void SetExistingValues(StoreData store, DerivationSchemeViewModel vm)
|
private void SetExistingValues(StoreData store, DerivationSchemeViewModel vm)
|
||||||
{
|
{
|
||||||
var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
|
var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
|
||||||
|
@ -140,8 +141,6 @@ namespace BTCPayServer.Controllers
|
||||||
.FirstOrDefault(d => d.PaymentId == id);
|
.FirstOrDefault(d => d.PaymentId == id);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("{storeId}/derivations/{cryptoCode}")]
|
[Route("{storeId}/derivations/{cryptoCode}")]
|
||||||
|
@ -162,7 +161,7 @@ namespace BTCPayServer.Controllers
|
||||||
vm.Network = network;
|
vm.Network = network;
|
||||||
vm.RootKeyPath = network.GetRootKeyPath();
|
vm.RootKeyPath = network.GetRootKeyPath();
|
||||||
DerivationSchemeSettings strategy = null;
|
DerivationSchemeSettings strategy = null;
|
||||||
|
|
||||||
var wallet = _WalletProvider.GetWallet(network);
|
var wallet = _WalletProvider.GetWallet(network);
|
||||||
if (wallet == null)
|
if (wallet == null)
|
||||||
{
|
{
|
||||||
|
@ -246,7 +245,7 @@ namespace BTCPayServer.Controllers
|
||||||
var willBeExcluded = !vm.Enabled;
|
var willBeExcluded = !vm.Enabled;
|
||||||
|
|
||||||
var showAddress = // Show addresses if:
|
var showAddress = // Show addresses if:
|
||||||
// - If the user is testing the hint address in confirmation screen
|
// - If the user is testing the hint address in confirmation screen
|
||||||
(vm.Confirmation && !string.IsNullOrWhiteSpace(vm.HintAddress)) ||
|
(vm.Confirmation && !string.IsNullOrWhiteSpace(vm.HintAddress)) ||
|
||||||
// - The user is clicking on continue after changing the config
|
// - The user is clicking on continue after changing the config
|
||||||
(!vm.Confirmation && oldConfig != vm.Config) ||
|
(!vm.Confirmation && oldConfig != vm.Config) ||
|
||||||
|
@ -280,7 +279,7 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
TempData[WellKnownTempData.SuccessMessage] = $"Derivation settings for {network.CryptoCode} has been modified.";
|
TempData[WellKnownTempData.SuccessMessage] = $"Derivation settings for {network.CryptoCode} has been modified.";
|
||||||
}
|
}
|
||||||
return RedirectToAction(nameof(UpdateStore), new {storeId = storeId});
|
return RedirectToAction(nameof(UpdateStore), new { storeId = storeId });
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(vm.HintAddress))
|
else if (!string.IsNullOrEmpty(vm.HintAddress))
|
||||||
{
|
{
|
||||||
|
|
261
BTCPayServer/Controllers/VaultController.cs
Normal file
261
BTCPayServer/Controllers/VaultController.cs
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.ModelBinders;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
|
using BTCPayServer.Payments;
|
||||||
|
using BTCPayServer.Security;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using LedgerWallet;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NBitcoin;
|
||||||
|
using NBXplorer.DerivationStrategy;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Controllers
|
||||||
|
{
|
||||||
|
[Route("vault")]
|
||||||
|
public class VaultController : Controller
|
||||||
|
{
|
||||||
|
private readonly IAuthorizationService _authorizationService;
|
||||||
|
|
||||||
|
public VaultController(BTCPayNetworkProvider networks, IAuthorizationService authorizationService)
|
||||||
|
{
|
||||||
|
Networks = networks;
|
||||||
|
_authorizationService = authorizationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BTCPayNetworkProvider Networks { get; }
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Route("{cryptoCode}/xpub")]
|
||||||
|
[Route("wallets/{walletId}/xpub")]
|
||||||
|
public async Task<IActionResult> VaultBridgeConnection(string cryptoCode = null,
|
||||||
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
||||||
|
WalletId walletId = null)
|
||||||
|
{
|
||||||
|
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||||
|
return NotFound();
|
||||||
|
cryptoCode = cryptoCode ?? walletId.CryptoCode;
|
||||||
|
using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)))
|
||||||
|
{
|
||||||
|
var cancellationToken = cts.Token;
|
||||||
|
var network = Networks.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||||
|
if (network == null)
|
||||||
|
return NotFound();
|
||||||
|
var websocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||||
|
var hwi = new Hwi.HwiClient(network.NBitcoinNetwork)
|
||||||
|
{
|
||||||
|
Transport = new HwiWebSocketTransport(websocket)
|
||||||
|
};
|
||||||
|
Hwi.HwiDeviceClient device = null;
|
||||||
|
HDFingerprint? fingerprint = null;
|
||||||
|
var websocketHelper = new WebSocketHelper(websocket);
|
||||||
|
JObject o = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var command = await websocketHelper.NextMessageAsync(cancellationToken);
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case "ask-sign":
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"need-device\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (walletId == null)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"invalid-walletId\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fingerprint is null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fingerprint = (await device.GetXPubAsync(new KeyPath("44'"), cancellationToken)).ExtPubKey.ParentFingerprint;
|
||||||
|
}
|
||||||
|
catch (Hwi.HwiException ex) when (ex.ErrorCode == Hwi.HwiErrorCode.DeviceNotReady)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"need-pin\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await websocketHelper.Send("{ \"info\": \"ready\"}", cancellationToken);
|
||||||
|
o = JObject.Parse(await websocketHelper.NextMessageAsync(cancellationToken));
|
||||||
|
var authorization = await _authorizationService.AuthorizeAsync(User, Policies.CanModifyStoreSettings.Key);
|
||||||
|
if (!authorization.Succeeded)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"not-authorized\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var psbt = PSBT.Parse(o["psbt"].Value<string>(), network.NBitcoinNetwork);
|
||||||
|
var derivationSettings = GetDerivationSchemeSettings(walletId);
|
||||||
|
derivationSettings.RebaseKeyPaths(psbt);
|
||||||
|
var signing = derivationSettings.GetSigningAccountKeySettings();
|
||||||
|
if (signing.GetRootedKeyPath()?.MasterFingerprint != fingerprint)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"wrong-wallet\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
psbt = await device.SignPSBTAsync(psbt, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Hwi.HwiException ex) when (ex.ErrorCode == Hwi.HwiErrorCode.DeviceNotReady)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"need-pin\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (Hwi.HwiException)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"user-reject\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
o = new JObject();
|
||||||
|
o.Add("psbt", psbt.ToBase64());
|
||||||
|
await websocketHelper.Send(o.ToString(), cancellationToken);
|
||||||
|
break;
|
||||||
|
case "ask-pin":
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"need-device\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await device.PromptPinAsync(cancellationToken);
|
||||||
|
await websocketHelper.Send("{ \"info\": \"prompted, please input the pin\"}", cancellationToken);
|
||||||
|
o = JObject.Parse(await websocketHelper.NextMessageAsync(cancellationToken));
|
||||||
|
var pin = (int)o["pinCode"].Value<long>();
|
||||||
|
var passphrase = o["passphrase"].Value<string>();
|
||||||
|
device.Password = passphrase;
|
||||||
|
if (await device.SendPinAsync(pin, cancellationToken))
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"info\": \"the pin is correct\"}", cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"incorrect-pin\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ask-xpubs":
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"need-device\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JObject result = new JObject();
|
||||||
|
var factory = network.NBXplorerNetwork.DerivationStrategyFactory;
|
||||||
|
var keyPath = new KeyPath("84'").Derive(network.CoinType).Derive(0, true);
|
||||||
|
BitcoinExtPubKey xpub = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
xpub = await device.GetXPubAsync(keyPath);
|
||||||
|
}
|
||||||
|
catch (Hwi.HwiException ex) when (ex.ErrorCode == Hwi.HwiErrorCode.DeviceNotReady)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"need-pin\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fingerprint is null)
|
||||||
|
{
|
||||||
|
fingerprint = (await device.GetXPubAsync(new KeyPath("44'"), cancellationToken)).ExtPubKey.ParentFingerprint;
|
||||||
|
}
|
||||||
|
result["fingerprint"] = fingerprint.Value.ToString();
|
||||||
|
var strategy = factory.CreateDirectDerivationStrategy(xpub, new DerivationStrategyOptions()
|
||||||
|
{
|
||||||
|
ScriptPubKeyType = ScriptPubKeyType.Segwit
|
||||||
|
});
|
||||||
|
AddDerivationSchemeToJson("segwit", result, keyPath, xpub, strategy);
|
||||||
|
keyPath = new KeyPath("49'").Derive(network.CoinType).Derive(0, true);
|
||||||
|
xpub = await device.GetXPubAsync(keyPath);
|
||||||
|
strategy = factory.CreateDirectDerivationStrategy(xpub, new DerivationStrategyOptions()
|
||||||
|
{
|
||||||
|
ScriptPubKeyType = ScriptPubKeyType.SegwitP2SH
|
||||||
|
});
|
||||||
|
AddDerivationSchemeToJson("segwitWrapped", result, keyPath, xpub, strategy);
|
||||||
|
keyPath = new KeyPath("44'").Derive(network.CoinType).Derive(0, true);
|
||||||
|
xpub = await device.GetXPubAsync(keyPath);
|
||||||
|
strategy = factory.CreateDirectDerivationStrategy(xpub, new DerivationStrategyOptions()
|
||||||
|
{
|
||||||
|
ScriptPubKeyType = ScriptPubKeyType.Legacy
|
||||||
|
});
|
||||||
|
AddDerivationSchemeToJson("legacy", result, keyPath, xpub, strategy);
|
||||||
|
await websocketHelper.Send(result.ToString(), cancellationToken);
|
||||||
|
break;
|
||||||
|
case "ask-device":
|
||||||
|
var devices = (await hwi.EnumerateDevicesAsync(cancellationToken)).ToList();
|
||||||
|
device = devices.FirstOrDefault();
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
await websocketHelper.Send("{ \"error\": \"no-device\"}", cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fingerprint = device.Fingerprint;
|
||||||
|
JObject json = new JObject();
|
||||||
|
json.Add("model", device.Model.ToString());
|
||||||
|
json.Add("fingerprint", device.Fingerprint?.ToString());
|
||||||
|
await websocketHelper.Send(json.ToString(), cancellationToken);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
JObject obj = new JObject();
|
||||||
|
obj.Add("error", "unknown-error");
|
||||||
|
obj.Add("details", ex.ToString());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await websocketHelper.Send(obj.ToString(), cancellationToken);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await websocketHelper.DisposeAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new EmptyResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoreData CurrentStore
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return HttpContext.GetStoreData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DerivationSchemeSettings GetDerivationSchemeSettings(WalletId walletId)
|
||||||
|
{
|
||||||
|
var paymentMethod = CurrentStore
|
||||||
|
.GetSupportedPaymentMethods(Networks)
|
||||||
|
.OfType<DerivationSchemeSettings>()
|
||||||
|
.FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == walletId.CryptoCode);
|
||||||
|
return paymentMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddDerivationSchemeToJson(string propertyName, JObject result, KeyPath keyPath, BitcoinExtPubKey xpub, DerivationStrategyBase strategy)
|
||||||
|
{
|
||||||
|
result.Add(new JProperty(propertyName, new JObject()
|
||||||
|
{
|
||||||
|
new JProperty("strategy", strategy.ToString()),
|
||||||
|
new JProperty("accountKey", xpub.ToString()),
|
||||||
|
new JProperty("keyPath", keyPath.ToString()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,7 +60,7 @@ namespace BTCPayServer.Controllers
|
||||||
vm.Decoded = psbt.ToString();
|
vm.Decoded = psbt.ToString();
|
||||||
vm.PSBT = psbt.ToBase64();
|
vm.PSBT = psbt.ToBase64();
|
||||||
}
|
}
|
||||||
return View(vm ?? new WalletPSBTViewModel());
|
return View(vm ?? new WalletPSBTViewModel() { CryptoCode = walletId.CryptoCode });
|
||||||
}
|
}
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("{walletId}/psbt")]
|
[Route("{walletId}/psbt")]
|
||||||
|
@ -88,6 +88,8 @@ namespace BTCPayServer.Controllers
|
||||||
vm.PSBT = psbt.ToBase64();
|
vm.PSBT = psbt.ToBase64();
|
||||||
vm.FileName = vm.UploadedPSBTFile?.FileName;
|
vm.FileName = vm.UploadedPSBTFile?.FileName;
|
||||||
return View(vm);
|
return View(vm);
|
||||||
|
case "vault":
|
||||||
|
return ViewVault(walletId, psbt);
|
||||||
case "ledger":
|
case "ledger":
|
||||||
return ViewWalletSendLedger(psbt);
|
return ViewWalletSendLedger(psbt);
|
||||||
case "update":
|
case "update":
|
||||||
|
@ -156,7 +158,8 @@ namespace BTCPayServer.Controllers
|
||||||
private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
|
private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
|
||||||
{
|
{
|
||||||
var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
|
var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
|
||||||
psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject;
|
if (!psbtObject.IsAllFinalized())
|
||||||
|
psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject;
|
||||||
IHDKey signingKey = null;
|
IHDKey signingKey = null;
|
||||||
RootedKeyPath signingKeyPath = null;
|
RootedKeyPath signingKeyPath = null;
|
||||||
try
|
try
|
||||||
|
|
|
@ -449,6 +449,8 @@ namespace BTCPayServer.Controllers
|
||||||
|
|
||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
|
case "vault":
|
||||||
|
return ViewVault(walletId, psbt.PSBT);
|
||||||
case "ledger":
|
case "ledger":
|
||||||
return ViewWalletSendLedger(psbt.PSBT, psbt.ChangeAddress);
|
return ViewWalletSendLedger(psbt.PSBT, psbt.ChangeAddress);
|
||||||
case "seed":
|
case "seed":
|
||||||
|
@ -463,6 +465,16 @@ namespace BTCPayServer.Controllers
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IActionResult ViewVault(WalletId walletId, PSBT psbt)
|
||||||
|
{
|
||||||
|
return View("WalletSendVault", new WalletSendVaultModel()
|
||||||
|
{
|
||||||
|
WalletId = walletId.ToString(),
|
||||||
|
PSBT = psbt.ToBase64(),
|
||||||
|
WebsocketPath = this.Url.Action(nameof(VaultController.VaultBridgeConnection), "Vault", new { walletId = walletId.ToString() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private IActionResult RedirectToWalletPSBT(WalletId walletId, PSBT psbt, string fileName = null)
|
private IActionResult RedirectToWalletPSBT(WalletId walletId, PSBT psbt, string fileName = null)
|
||||||
{
|
{
|
||||||
var vm = new PostRedirectViewModel()
|
var vm = new PostRedirectViewModel()
|
||||||
|
|
28
BTCPayServer/HwiWebSocketTransport.cs
Normal file
28
BTCPayServer/HwiWebSocketTransport.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace BTCPayServer
|
||||||
|
{
|
||||||
|
public class HwiWebSocketTransport : Hwi.Transports.ITransport
|
||||||
|
{
|
||||||
|
private readonly WebSocketHelper _webSocket;
|
||||||
|
|
||||||
|
public HwiWebSocketTransport(WebSocket webSocket)
|
||||||
|
{
|
||||||
|
_webSocket = new WebSocketHelper(webSocket);
|
||||||
|
}
|
||||||
|
public async Task<string> SendCommandAsync(string[] arguments, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
JObject request = new JObject();
|
||||||
|
request.Add("params", new JArray(arguments));
|
||||||
|
await _webSocket.Send(request.ToString(), cancel);
|
||||||
|
return await _webSocket.NextMessageAsync(cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
{
|
{
|
||||||
public class WalletPSBTViewModel
|
public class WalletPSBTViewModel
|
||||||
{
|
{
|
||||||
|
public string CryptoCode { get; set; }
|
||||||
public string Decoded { get; set; }
|
public string Decoded { get; set; }
|
||||||
string _FileName;
|
string _FileName;
|
||||||
public string FileName
|
public string FileName
|
||||||
|
|
14
BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs
Normal file
14
BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Models.WalletViewModels
|
||||||
|
{
|
||||||
|
public class WalletSendVaultModel
|
||||||
|
{
|
||||||
|
public string WalletId { get; set; }
|
||||||
|
public string PSBT { get; set; }
|
||||||
|
public string WebsocketPath { get; set; }
|
||||||
|
}
|
||||||
|
}
|
56
BTCPayServer/Views/Shared/VaultElements.cshtml
Normal file
56
BTCPayServer/Views/Shared/VaultElements.cshtml
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<script id="VaultConnection" type="text/template">
|
||||||
|
<div class="vault-feedback vault-feedback1">
|
||||||
|
<span class="vault-feedback-icon"></span> <span class="vault-feedback-content"></span>
|
||||||
|
</div>
|
||||||
|
<div class="vault-feedback vault-feedback2">
|
||||||
|
<span class="vault-feedback-icon"></span> <span class="vault-feedback-content"></span>
|
||||||
|
</div>
|
||||||
|
<div class="vault-feedback vault-feedback3">
|
||||||
|
<span class="vault-feedback-icon"></span> <span class="vault-feedback-content"></span>
|
||||||
|
</div>
|
||||||
|
<div id="pin-input" class="mt-4" style="display: none;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input id="pin-display" type="text" class="form-control" readonly>
|
||||||
|
<div class="input-group-append">
|
||||||
|
<div id="pin-display-delete" class="input-group-text" style="cursor: pointer"><span class="fa fa-remove"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><div class="pin-button" id="pin-7"></div></div>
|
||||||
|
<div class="col"><div class="pin-button" id="pin-8"></div></div>
|
||||||
|
<div class="col"><div class="pin-button" id="pin-9"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><div class="pin-button" id="pin-4"></div></div>
|
||||||
|
<div class="col"><div class="pin-button" id="pin-5"></div></div>
|
||||||
|
<div class="col"><div class="pin-button" id="pin-6"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><div class="pin-button" id="pin-1"></div></div>
|
||||||
|
<div class="col"><div class="pin-button" id="pin-2"></div></div>
|
||||||
|
<div class="col"><div class="pin-button" id="pin-3"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="passphrase-input" class="mt-4" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<label for="Password" class="input-group-text"><span class="input-group-addon fa fa-lock"></span></label>
|
||||||
|
</div>
|
||||||
|
<input id="Password" class="form-control" placeholder="Password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<label for="PasswordConfirmation" class="input-group-text"><span class="input-group-addon fa fa-lock"></span></label>
|
||||||
|
</div>
|
||||||
|
<input id="PasswordConfirmation" class="form-control" placeholder="Passphrase confirmation" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</script>
|
|
@ -9,6 +9,14 @@
|
||||||
.hw-fields {
|
.hw-fields {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pin-button {
|
||||||
|
height: 135px;
|
||||||
|
margin-top: 20px;
|
||||||
|
background: white;
|
||||||
|
border: solid lightgray 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,11 +53,14 @@
|
||||||
<div class="dropdown mt-2 text-right">
|
<div class="dropdown mt-2 text-right">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-link dropdown-toggle" type="button" id="hardwarewlletimportdropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button class="btn btn-link dropdown-toggle" type="button" id="hardwarewlletimportdropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
Import from hardware device
|
Import from...
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu dropdown-menu-right w-100" aria-labelledby="hardwarewlletimportdropdown">
|
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="hardwarewlletimportdropdown">
|
||||||
<button class="dropdown-item" type="button" data-toggle="modal" data-target="#coldcardimport">Coldcard</button>
|
<button class="dropdown-item" type="button" data-toggle="modal" data-target="#coldcardimport">... Coldcard (air gap)</button>
|
||||||
<button class="dropdown-item check-for-ledger" data-toggle="modal" data-target="#ledgerimport" type="button">Ledger Wallet</button>
|
<button class="dropdown-item check-for-ledger" data-toggle="modal" data-target="#ledgerimport" type="button">... Ledger Wallet</button>
|
||||||
|
@if (Model.CryptoCode == "BTC") {
|
||||||
|
<button class="dropdown-item check-for-vault" data-toggle="modal" data-target="#btcpayservervault" type="button">... the vault (preview)</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -162,6 +173,8 @@
|
||||||
@await Html.PartialAsync("_ValidationScriptsPartial")
|
@await Html.PartialAsync("_ValidationScriptsPartial")
|
||||||
<script src="~/js/ledgerwebsocket.js" type="text/javascript" defer="defer"></script>
|
<script src="~/js/ledgerwebsocket.js" type="text/javascript" defer="defer"></script>
|
||||||
<script src="~/js/StoreAddDerivationScheme.js" type="text/javascript" defer="defer"></script>
|
<script src="~/js/StoreAddDerivationScheme.js" type="text/javascript" defer="defer"></script>
|
||||||
|
<script src="~/js/vaultbridge.js" type="text/javascript" defer="defer"></script>
|
||||||
|
<script src="~/js/vaultbridge.ui.js" type="text/javascript" defer="defer"></script>
|
||||||
<script>
|
<script>
|
||||||
window.coinName = "@Model.Network.DisplayName.ToLowerInvariant()";
|
window.coinName = "@Model.Network.DisplayName.ToLowerInvariant()";
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -28,10 +28,9 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://docs.btcpayserver.org/getting-started/connectwallet/ledgerwallet#manual-setup"
|
<a href="https://docs.btcpayserver.org/getting-started/connectwallet/ledgerwallet#manual-setup"
|
||||||
title="Open Ledger wallet manual setup docs"
|
title="Open Ledger wallet manual setup docs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer">
|
||||||
>
|
|
||||||
Can't find your account in the select?
|
Can't find your account in the select?
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
@ -80,3 +79,41 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="WebsocketPath" style="display:none;">@Url.Action("VaultBridgeConnection", "Vault", new { cryptoCode = Model.CryptoCode })</div>
|
||||||
|
<div class="modal fade" id="btcpayservervault" tabindex="-1" role="dialog" aria-labelledby="btcpayservervault" aria-hidden="true">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<form class="modal-content" form method="post" enctype="multipart/form-data">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="exampleModalLabel">Import from BTCPayServer Vault</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>You may import from BTCPayServer Vault.</p>
|
||||||
|
<div id="vaultPlaceholder"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||||
|
|
||||||
|
<div id="vault-dropdown" style="display:none;" class="dropdown">
|
||||||
|
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
Select the type of address you want
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu overflow-auto" style="max-height: 200px;">
|
||||||
|
<a class="dropdown-item" href="#" id="vault-segwit">Segwit (Recommended, cheapest transaction fee)</a>
|
||||||
|
<a class="dropdown-item" href="#" id="vault-segwitWrapped">Segwit wrapped (less cheap but compatible with old wallets)</a>
|
||||||
|
<a class="dropdown-item" href="#" id="vault-legacy">Legacy (Not recommended)</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="vault-confirm" class="btn btn-primary" style="display:none;" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
Confirm pin code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="VaultElements" />
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
<h3>Decoded PSBT</h3>
|
<h3>Decoded PSBT</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<form method="post" asp-action="WalletPSBT">
|
<form method="post" asp-action="WalletPSBT">
|
||||||
|
<input type="hidden" asp-for="CryptoCode" />
|
||||||
<input type="hidden" asp-for="PSBT" />
|
<input type="hidden" asp-for="PSBT" />
|
||||||
<input type="hidden" asp-for="FileName" />
|
<input type="hidden" asp-for="FileName" />
|
||||||
<div class="dropdown d-inline-block" style="margin-top:16px;">
|
<div class="dropdown d-inline-block" style="margin-top:16px;">
|
||||||
|
@ -39,6 +40,9 @@
|
||||||
<button name="command" type="submit" class="dropdown-item" value="ledger">... your Ledger Wallet device</button>
|
<button name="command" type="submit" class="dropdown-item" value="ledger">... your Ledger Wallet device</button>
|
||||||
<button name="command" type="submit" class="dropdown-item" value="seed">... an HD private key or mnemonic seed</button>
|
<button name="command" type="submit" class="dropdown-item" value="seed">... an HD private key or mnemonic seed</button>
|
||||||
<button name="command" type="submit" class="dropdown-item" value="save-psbt">... a wallet supporting PSBT (save as file)</button>
|
<button name="command" type="submit" class="dropdown-item" value="save-psbt">... a wallet supporting PSBT (save as file)</button>
|
||||||
|
@if (Model.CryptoCode == "BTC") {
|
||||||
|
<button name="command" type="submit" class="dropdown-item" value="vault">... the vault (preview)</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown d-inline-block">
|
<div class="dropdown d-inline-block">
|
||||||
|
|
|
@ -152,6 +152,9 @@
|
||||||
<button name="command" type="submit" class="dropdown-item" value="ledger">... your Ledger Wallet device</button>
|
<button name="command" type="submit" class="dropdown-item" value="ledger">... your Ledger Wallet device</button>
|
||||||
<button name="command" type="submit" class="dropdown-item" value="seed">... an HD private key or mnemonic seed</button>
|
<button name="command" type="submit" class="dropdown-item" value="seed">... an HD private key or mnemonic seed</button>
|
||||||
<button name="command" type="submit" class="dropdown-item" value="analyze-psbt">... a wallet supporting PSBT</button>
|
<button name="command" type="submit" class="dropdown-item" value="analyze-psbt">... a wallet supporting PSBT</button>
|
||||||
|
@if (Model.CryptoCode == "BTC") {
|
||||||
|
<button name="command" type="submit" class="dropdown-item" value="vault">... the vault (preview)</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="command" value="add-output" class="ml-1 btn btn-secondary">Add another destination </button>
|
<button type="submit" name="command" value="add-output" class="ml-1 btn btn-secondary">Add another destination </button>
|
||||||
|
|
56
BTCPayServer/Views/Wallets/WalletSendVault.cshtml
Normal file
56
BTCPayServer/Views/Wallets/WalletSendVault.cshtml
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
@model WalletSendVaultModel
|
||||||
|
@{
|
||||||
|
Layout = "../Shared/_NavLayout.cshtml";
|
||||||
|
ViewData["Title"] = "Manage wallet";
|
||||||
|
ViewData.SetActivePageAndTitle(WalletsNavPages.Send);
|
||||||
|
}
|
||||||
|
|
||||||
|
<h4>Sign the transaction with BTCPayServer Vault</h4>
|
||||||
|
<div id="walletAlert" class="alert alert-danger alert-dismissible" style="display:none;" role="alert">
|
||||||
|
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||||
|
<span id="alertMessage"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div id="body" class="col-md-10">
|
||||||
|
<form id="broadcastForm" asp-action="WalletPSBTReady" method="post" style="display:none;">
|
||||||
|
<input type="hidden" id="WalletId" asp-for="WalletId" />
|
||||||
|
<input type="hidden" id="PSBT" asp-for="PSBT" />
|
||||||
|
<input type="hidden" asp-for="WebsocketPath" />
|
||||||
|
</form>
|
||||||
|
<div id="vaultPlaceholder"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<partial name="VaultElements" />
|
||||||
|
@section Scripts
|
||||||
|
{
|
||||||
|
<script src="~/js/vaultbridge.js" type="text/javascript" defer="defer"></script>
|
||||||
|
<script src="~/js/vaultbridge.ui.js" type="text/javascript" defer="defer"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(function () {
|
||||||
|
var websocketPath = $("#WebsocketPath").val();
|
||||||
|
var loc = window.location, ws_uri;
|
||||||
|
if (loc.protocol === "https:") {
|
||||||
|
ws_uri = "wss:";
|
||||||
|
} else {
|
||||||
|
ws_uri = "ws:";
|
||||||
|
}
|
||||||
|
ws_uri += "//" + loc.host;
|
||||||
|
ws_uri += websocketPath;
|
||||||
|
var html = $("#VaultConnection").html();
|
||||||
|
$("#vaultPlaceholder").html(html);
|
||||||
|
|
||||||
|
var vaultUI = new vaultui.VaultBridgeUI(ws_uri);
|
||||||
|
vaultUI.askSignPSBT({
|
||||||
|
walletId: $("#WalletId").val(),
|
||||||
|
psbt: $("#PSBT").val()
|
||||||
|
}).then(function (ok) {
|
||||||
|
if (ok) {
|
||||||
|
$("#PSBT").val(vaultUI.psbt);
|
||||||
|
$("#broadcastForm").submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
120
BTCPayServer/WebSocketHelper.cs
Normal file
120
BTCPayServer/WebSocketHelper.cs
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
using System;
|
||||||
|
using NBXplorer;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace BTCPayServer
|
||||||
|
{
|
||||||
|
public class WebSocketHelper
|
||||||
|
{
|
||||||
|
private readonly WebSocket _Socket;
|
||||||
|
public WebSocket Socket
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return _Socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebSocketHelper(WebSocket socket)
|
||||||
|
{
|
||||||
|
_Socket = socket;
|
||||||
|
var buffer = new byte[ORIGINAL_BUFFER_SIZE];
|
||||||
|
_Buffer = new ArraySegment<byte>(buffer, 0, buffer.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int ORIGINAL_BUFFER_SIZE = 1024 * 5;
|
||||||
|
const int MAX_BUFFER_SIZE = 1024 * 1024 * 5;
|
||||||
|
|
||||||
|
ArraySegment<byte> _Buffer;
|
||||||
|
|
||||||
|
UTF8Encoding UTF8 = new UTF8Encoding(false, true);
|
||||||
|
public async Task<string> NextMessageAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
var buffer = _Buffer;
|
||||||
|
var array = _Buffer.Array;
|
||||||
|
var originalSize = _Buffer.Array.Length;
|
||||||
|
var newSize = _Buffer.Array.Length;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var message = await Socket.ReceiveAsync(buffer, cancellation);
|
||||||
|
if (message.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
await CloseSocketAndThrow(WebSocketCloseStatus.NormalClosure, "Close message received from the peer", cancellation);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (message.MessageType != WebSocketMessageType.Text)
|
||||||
|
{
|
||||||
|
await CloseSocketAndThrow(WebSocketCloseStatus.InvalidMessageType, "Only Text is supported", cancellation);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (message.EndOfMessage)
|
||||||
|
{
|
||||||
|
buffer = new ArraySegment<byte>(array, 0, buffer.Offset + message.Count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var o = UTF8.GetString(buffer.Array, 0, buffer.Count);
|
||||||
|
if (newSize != originalSize)
|
||||||
|
{
|
||||||
|
Array.Resize(ref array, originalSize);
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await CloseSocketAndThrow(WebSocketCloseStatus.InvalidPayloadData, $"Invalid payload: {ex.Message}", cancellation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (buffer.Count - message.Count <= 0)
|
||||||
|
{
|
||||||
|
newSize *= 2;
|
||||||
|
if (newSize > MAX_BUFFER_SIZE)
|
||||||
|
await CloseSocketAndThrow(WebSocketCloseStatus.MessageTooBig, "Message is too big", cancellation);
|
||||||
|
Array.Resize(ref array, newSize);
|
||||||
|
buffer = new ArraySegment<byte>(array, buffer.Offset, newSize - buffer.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = buffer.Slice(message.Count, buffer.Count - message.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("Should never happen");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseSocketAndThrow(WebSocketCloseStatus status, string description, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
var array = _Buffer.Array;
|
||||||
|
if (array.Length != ORIGINAL_BUFFER_SIZE)
|
||||||
|
Array.Resize(ref array, ORIGINAL_BUFFER_SIZE);
|
||||||
|
await Socket.CloseSocket(status, description, cancellation);
|
||||||
|
throw new WebSocketException($"The socket has been closed ({status}: {description})");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Send(string evt, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
var bytes = UTF8.GetBytes(evt);
|
||||||
|
using (var cts = new CancellationTokenSource(5000))
|
||||||
|
{
|
||||||
|
using (var cts2 = CancellationTokenSource.CreateLinkedTokenSource(cancellation))
|
||||||
|
{
|
||||||
|
await Socket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, cts2.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Socket.CloseSocket(WebSocketCloseStatus.NormalClosure, "Disposing NotificationServer", cancellation);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
finally { try { Socket.Dispose(); } catch { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
function initLedger(){
|
function initLedger() {
|
||||||
|
|
||||||
var ledgerDetected = false;
|
var ledgerDetected = false;
|
||||||
|
|
||||||
var loc = window.location, new_uri;
|
var loc = window.location, new_uri;
|
||||||
|
@ -90,12 +89,57 @@
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
var ledgerInit = false;
|
var ledgerInit = false;
|
||||||
$(".check-for-ledger").on("click", function(){
|
$(".check-for-ledger").on("click", function () {
|
||||||
if(!ledgerInit){
|
if (!ledgerInit) {
|
||||||
|
|
||||||
initLedger();
|
initLedger();
|
||||||
}
|
}
|
||||||
ledgerInit = true;
|
ledgerInit = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function show(id, category) {
|
||||||
|
$("." + category).css("display", "none");
|
||||||
|
$("#" + id).css("display", "block");
|
||||||
|
}
|
||||||
|
|
||||||
|
var websocketPath = $("#WebsocketPath").text();
|
||||||
|
var loc = window.location, ws_uri;
|
||||||
|
if (loc.protocol === "https:") {
|
||||||
|
ws_uri = "wss:";
|
||||||
|
} else {
|
||||||
|
ws_uri = "ws:";
|
||||||
|
}
|
||||||
|
ws_uri += "//" + loc.host;
|
||||||
|
ws_uri += websocketPath;
|
||||||
|
|
||||||
|
function displayXPubs(xpubs) {
|
||||||
|
$("#vault-dropdown").css("display", "block");
|
||||||
|
$("#vault-dropdown .dropdown-item").click(function () {
|
||||||
|
var id = $(this).attr('id').replace("vault-", "");
|
||||||
|
var xpub = xpubs[id];
|
||||||
|
$("#DerivationScheme").val(xpub.strategy);
|
||||||
|
$("#RootFingerprint").val(xpubs.fingerprint);
|
||||||
|
$("#AccountKey").val(xpub.accountKey);
|
||||||
|
$("#Source").val("Vault");
|
||||||
|
$("#DerivationSchemeFormat").val("BTCPay");
|
||||||
|
$("#KeyPath").val(xpub.keyPath);
|
||||||
|
$(".modal").modal('hide');
|
||||||
|
$(".hw-fields").show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var vaultInit = false;
|
||||||
|
$(".check-for-vault").on("click", async function () {
|
||||||
|
if (vaultInit)
|
||||||
|
return;
|
||||||
|
vaultInit = true;
|
||||||
|
|
||||||
|
var html = $("#VaultConnection").html();
|
||||||
|
$("#vaultPlaceholder").html(html);
|
||||||
|
|
||||||
|
var vaultUI = new vaultui.VaultBridgeUI(ws_uri);
|
||||||
|
if (await vaultUI.askForXPubs()) {
|
||||||
|
displayXPubs(vaultUI.xpubs);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
104
BTCPayServer/wwwroot/js/vaultbridge.js
Normal file
104
BTCPayServer/wwwroot/js/vaultbridge.js
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
var vault = (function () {
|
||||||
|
/** @param {WebSocket} websocket
|
||||||
|
*/
|
||||||
|
function VaultBridge(websocket) {
|
||||||
|
var self = this;
|
||||||
|
/**
|
||||||
|
* @type {WebSocket}
|
||||||
|
*/
|
||||||
|
this.socket = websocket;
|
||||||
|
this.onerror = function (error) { };
|
||||||
|
this.onbackendmessage = function (json) { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
this.waitBackendMessage = function () {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
self.nextResolveBackendMessage = resolve;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.socket.onmessage = function (event) {
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
var jsonObject = JSON.parse(event.data);
|
||||||
|
if (jsonObject.hasOwnProperty("params")) {
|
||||||
|
var request = new XMLHttpRequest();
|
||||||
|
request.onreadystatechange = function () {
|
||||||
|
if (request.readyState == 4 && request.status == 200) {
|
||||||
|
self.socket.send(request.responseText);
|
||||||
|
}
|
||||||
|
if (request.readyState == 4 && request.status == 0) {
|
||||||
|
self.onerror(vault.errors.notRunning);
|
||||||
|
}
|
||||||
|
if (request.readyState == 4 && request.status == 401) {
|
||||||
|
self.onerror(vault.errors.denied);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.open('POST', 'http://localhost:65092/hwi-bridge/v1');
|
||||||
|
request.send(JSON.stringify(jsonObject));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.onbackendmessage(jsonObject);
|
||||||
|
if (self.nextResolveBackendMessage)
|
||||||
|
self.nextResolveBackendMessage(jsonObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ws_uri
|
||||||
|
* @returns {Promise<VaultBridge>}
|
||||||
|
*/
|
||||||
|
function connectToBackendSocket(ws_uri) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var supportWebSocket = "WebSocket" in window && window.WebSocket.CLOSING === 2;
|
||||||
|
if (!supportWebSocket) {
|
||||||
|
reject(vault.errors.socketNotSupported);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var socket = new WebSocket(ws_uri);
|
||||||
|
socket.onerror = function (error) {
|
||||||
|
console.warn(error);
|
||||||
|
reject(vault.errors.socketError);
|
||||||
|
};
|
||||||
|
socket.onopen = function () {
|
||||||
|
resolve(new vault.VaultBridge(socket));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
function askVaultPermission() {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var request = new XMLHttpRequest();
|
||||||
|
request.onreadystatechange = function () {
|
||||||
|
if (request.readyState == 4 && request.status == 200) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
if (request.readyState == 4 && request.status == 0) {
|
||||||
|
reject(vault.errors.notRunning);
|
||||||
|
}
|
||||||
|
if (request.readyState == 4 && request.status == 401) {
|
||||||
|
reject(vault.errors.denied);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.open('GET', 'http://localhost:65092/hwi-bridge/v1/request-permission');
|
||||||
|
request.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
notRunning: "NotRunning",
|
||||||
|
denied: "Denied",
|
||||||
|
socketNotSupported: "SocketNotSupported",
|
||||||
|
socketError: "SocketError",
|
||||||
|
},
|
||||||
|
askVaultPermission: askVaultPermission,
|
||||||
|
connectToBackendSocket: connectToBackendSocket,
|
||||||
|
VaultBridge: VaultBridge
|
||||||
|
};
|
||||||
|
})();
|
286
BTCPayServer/wwwroot/js/vaultbridge.ui.js
Normal file
286
BTCPayServer/wwwroot/js/vaultbridge.ui.js
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
/// <reference path="vaultbridge.js" />
|
||||||
|
/// file: vaultbridge.js
|
||||||
|
|
||||||
|
var vaultui = (function () {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} type
|
||||||
|
* @param {string} txt
|
||||||
|
* @param {string} category
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
function VaultFeedback(type, txt, category, id) {
|
||||||
|
var self = this;
|
||||||
|
this.type = type;
|
||||||
|
this.txt = txt;
|
||||||
|
this.category = category;
|
||||||
|
this.id = id;
|
||||||
|
/**
|
||||||
|
* @param {string} str
|
||||||
|
* @param {string} by
|
||||||
|
*/
|
||||||
|
this.replace = function (str, by) {
|
||||||
|
return new VaultFeedback(self.type, self.txt.replace(str, by), self.category, self.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var VaultFeedbacks = {
|
||||||
|
vaultLoading: new VaultFeedback("?", "Checking BTCPayServer Vault is running...", "vault-feedback1", "vault-loading"),
|
||||||
|
vaultDenied: new VaultFeedback("failed", "The user declined access to the vault.", "vault-feedback1", "vault-denied"),
|
||||||
|
vaultGranted: new VaultFeedback("ok", "Access to vault granted by owner.", "vault-feedback1", "vault-granted"),
|
||||||
|
noVault: new VaultFeedback("failed", "BTCPayServer Vault does not seems running, you can download it on <a target=\"_blank\" href=\"https://github.com/btcpayserver/BTCPayServer.Vault/releases/latest\">Github</a>.", "vault-feedback1", "no-vault"),
|
||||||
|
noWebsockets: new VaultFeedback("failed", "Web sockets are not supported by the browser.", "vault-feedback1", "no-websocket"),
|
||||||
|
errorWebsockets: new VaultFeedback("failed", "Error of the websocket while connecting to the backend.", "vault-feedback1", "error-websocket"),
|
||||||
|
bridgeConnected: new VaultFeedback("ok", "BTCPayServer successfully connected to the vault.", "vault-feedback1", "bridge-connected"),
|
||||||
|
noDevice: new VaultFeedback("failed", "No device connected.", "vault-feedback2", "no-device"),
|
||||||
|
fetchingDevice: new VaultFeedback("?", "Fetching device...", "vault-feedback2", "fetching-device"),
|
||||||
|
deviceFound: new VaultFeedback("ok", "Device found: {{0}}", "vault-feedback2", "device-selected"),
|
||||||
|
fetchingXpubs: new VaultFeedback("?", "Fetching public keys...", "vault-feedback3", "fetching-xpubs"),
|
||||||
|
fetchedXpubs: new VaultFeedback("ok", "Public keys successfully fetched.", "vault-feedback3", "xpubs-fetched"),
|
||||||
|
unexpectedError: new VaultFeedback("failed", "An unexpected error happened.", "vault-feedback3", "unknown-error"),
|
||||||
|
needPin: new VaultFeedback("?", "Enter the pin.", "vault-feedback3", "need-pin"),
|
||||||
|
incorrectPin: new VaultFeedback("failed", "Incorrect pin code.", "vault-feedback3", "incorrect-pin"),
|
||||||
|
invalidPasswordConfirmation: new VaultFeedback("failed", "Invalid password confirmation.", "vault-feedback3", "invalid-password-confirm"),
|
||||||
|
wrongWallet: new VaultFeedback("failed", "This device can't sign the transaction.", "vault-feedback3", "wrong-wallet"),
|
||||||
|
needPassphrase: new VaultFeedback("?", "Enter the passphrase.", "vault-feedback3", "need-passphrase"),
|
||||||
|
signingTransaction: new VaultFeedback("?", "Signing the transaction...", "vault-feedback3", "ask-signing"),
|
||||||
|
signingRejected: new VaultFeedback("failed", "The user refused to sign the transaction", "vault-feedback3", "user-reject"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} backend_uri
|
||||||
|
*/
|
||||||
|
function VaultBridgeUI(backend_uri) {
|
||||||
|
/**
|
||||||
|
* @type {VaultBridgeUI}
|
||||||
|
*/
|
||||||
|
var self = this;
|
||||||
|
this.backend_uri = backend_uri;
|
||||||
|
/**
|
||||||
|
* @type {vault.VaultBridge}
|
||||||
|
*/
|
||||||
|
this.bridge = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.psbt = null;
|
||||||
|
|
||||||
|
this.xpubs = null;
|
||||||
|
/**
|
||||||
|
* @param {VaultFeedback} feedback
|
||||||
|
*/
|
||||||
|
function show(feedback) {
|
||||||
|
var icon = $(".vault-feedback." + feedback.category + " " + ".vault-feedback-icon");
|
||||||
|
icon.removeClass();
|
||||||
|
icon.addClass("vault-feedback-icon");
|
||||||
|
if (feedback.type == "?") {
|
||||||
|
icon.addClass("fa fa-question-circle feedback-icon-loading");
|
||||||
|
}
|
||||||
|
else if (feedback.type == "ok") {
|
||||||
|
icon.addClass("fa fa-check-circle feedback-icon-success");
|
||||||
|
}
|
||||||
|
else if (feedback.type == "failed") {
|
||||||
|
icon.addClass("fa fa-times-circle feedback-icon-failed");
|
||||||
|
}
|
||||||
|
var content = $(".vault-feedback." + feedback.category + " " + ".vault-feedback-content");
|
||||||
|
content.html(feedback.txt);
|
||||||
|
}
|
||||||
|
function showError(json) {
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
for (var key in VaultFeedbacks) {
|
||||||
|
if (VaultFeedbacks.hasOwnProperty(key) && VaultFeedbacks[key].id == json.error) {
|
||||||
|
show(VaultFeedbacks[key]);
|
||||||
|
if (json.hasOwnProperty("details"))
|
||||||
|
console.warn(json.details);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
show(VaultFeedbacks.unexpectedError);
|
||||||
|
if (json.hasOwnProperty("details"))
|
||||||
|
console.warn(json.details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function needRetry(json) {
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
var handled = false;
|
||||||
|
if (json.error === "need-device") {
|
||||||
|
handled = true;
|
||||||
|
if (await self.askForDevice())
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (json.error === "need-pin") {
|
||||||
|
handled = true;
|
||||||
|
if (await self.askForPin())
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!handled) {
|
||||||
|
showError(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureConnectedToBackend = async function () {
|
||||||
|
if (!self.bridge) {
|
||||||
|
$("#vault-dropdown").css("display", "none");
|
||||||
|
show(VaultFeedbacks.vaultLoading);
|
||||||
|
try {
|
||||||
|
await vault.askVaultPermission();
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex == vault.errors.notRunning)
|
||||||
|
show(VaultFeedbacks.noVault);
|
||||||
|
else if (ex == vault.errors.denied)
|
||||||
|
show(VaultFeedbacks.vaultDenied);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
show(VaultFeedbacks.vaultGranted);
|
||||||
|
try {
|
||||||
|
self.bridge = await vault.connectToBackendSocket(self.backend_uri);
|
||||||
|
show(VaultFeedbacks.bridgeConnected);
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex == vault.errors.socketNotSupported)
|
||||||
|
show(VaultFeedbacks.noWebsockets);
|
||||||
|
if (ex == vault.errors.socketError)
|
||||||
|
show(VaultFeedbacks.errorWebsockets);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
this.askForDevice = async function () {
|
||||||
|
if (!await self.ensureConnectedToBackend())
|
||||||
|
return false;
|
||||||
|
show(VaultFeedbacks.fetchingDevice);
|
||||||
|
self.bridge.socket.send("ask-device");
|
||||||
|
var json = await self.bridge.waitBackendMessage();
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
showError(json);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
show(VaultFeedbacks.deviceFound.replace("{{0}}", json.model));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
this.askForXPubs = async function () {
|
||||||
|
if (!await self.ensureConnectedToBackend())
|
||||||
|
return false;
|
||||||
|
show(VaultFeedbacks.fetchingXpubs);
|
||||||
|
self.bridge.socket.send("ask-xpubs");
|
||||||
|
var json = await self.bridge.waitBackendMessage();
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
if (await needRetry(json))
|
||||||
|
return await self.askForXPubs();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
show(VaultFeedbacks.fetchedXpubs);
|
||||||
|
self.xpubs = json;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
this.getUserEnterPin = function () {
|
||||||
|
show(VaultFeedbacks.needPin);
|
||||||
|
$("#pin-input").css("display", "block");
|
||||||
|
$("#vault-confirm").css("display", "block");
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var pinCode = "";
|
||||||
|
$("#vault-confirm").click(async function () {
|
||||||
|
$("#pin-input").css("display", "none");
|
||||||
|
$("#vault-confirm").css("display", "none");
|
||||||
|
$(this).unbind();
|
||||||
|
$(".pin-button").unbind();
|
||||||
|
$("#pin-display-delete").unbind();
|
||||||
|
resolve(pinCode);
|
||||||
|
});
|
||||||
|
$("#pin-display-delete").click(function () {
|
||||||
|
pinCode = "";
|
||||||
|
$("#pin-display").val("");
|
||||||
|
});
|
||||||
|
$(".pin-button").click(function () {
|
||||||
|
var id = $(this).attr('id').replace("pin-", "");
|
||||||
|
pinCode = pinCode + id;
|
||||||
|
$("#pin-display").val($("#pin-display").val() + "*");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
this.getUserPassphrase = function () {
|
||||||
|
show(VaultFeedbacks.needPassphrase);
|
||||||
|
$("#passphrase-input").css("display", "block");
|
||||||
|
$("#vault-confirm").css("display", "block");
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
$("#vault-confirm").click(async function () {
|
||||||
|
var passphrase = $("#Password").val();
|
||||||
|
if (passphrase !== $("#PasswordConfirmation").val()) {
|
||||||
|
show("invalid-password-confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("#passphrase-input").css("display", "none");
|
||||||
|
$("#vault-confirm").css("display", "none");
|
||||||
|
$(this).unbind();
|
||||||
|
resolve(passphrase);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
this.askForPin = async function () {
|
||||||
|
if (!await self.ensureConnectedToBackend())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
self.bridge.socket.send("ask-pin");
|
||||||
|
var json = await self.bridge.waitBackendMessage();
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
if (await needRetry(json))
|
||||||
|
return await self.askForPin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinCode = await self.getUserEnterPin();
|
||||||
|
var passphrase = await self.getUserPassphrase();
|
||||||
|
self.bridge.socket.send(JSON.stringify({ pinCode: pinCode, passphrase: passphrase }));
|
||||||
|
var json = await self.bridge.waitBackendMessage();
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
showError(json);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<Boolean>}
|
||||||
|
*/
|
||||||
|
this.askSignPSBT = async function (args) {
|
||||||
|
if (!await self.ensureConnectedToBackend())
|
||||||
|
return false;
|
||||||
|
show(VaultFeedbacks.signingTransaction);
|
||||||
|
self.bridge.socket.send("ask-sign");
|
||||||
|
var json = await self.bridge.waitBackendMessage();
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
if (await needRetry(json))
|
||||||
|
return await self.askSignPSBT(args);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.bridge.socket.send(JSON.stringify(args));
|
||||||
|
json = await self.bridge.waitBackendMessage();
|
||||||
|
if (json.hasOwnProperty("error")) {
|
||||||
|
if (await needRetry(json))
|
||||||
|
return await self.askSignPSBT(args);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.psbt = json.psbt;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
VaultFeedback: VaultFeedback,
|
||||||
|
VaultBridgeUI: VaultBridgeUI
|
||||||
|
};
|
||||||
|
})();
|
|
@ -124,5 +124,15 @@ a.nav-link:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
color: var(--btcpay-preformatted-text-color);
|
color: var(--btcpay-preformatted-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-icon-loading {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
.feedback-icon-success {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
.feedback-icon-failed {
|
||||||
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue