Add QR code scan/show for PSBT + Import wallet via QR (#1931)

* Add PSBT QR code scan/show

This PR introduces support to show and read PSBTs in BC-UR format via animated QR codes.  This allows you to use BTCPay with HW devices such as Cobo Vault and Blue wallet to sign transactions without ever exposing the keys outside of that device.
Spec: https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md
I've also bumped the QR code library we sue as it had a bug with large datasets.

* Reuse same code for all and allow wallet import via QR code scan

* remove unecessary js vendor files

* Allow export wallet from settings via QR

* formatting

* bundle

* fix wallet receive bundle
This commit is contained in:
Andrew Camilleri 2020-10-21 14:03:11 +02:00 committed by GitHub
parent 5979fe5eef
commit 4176f3659b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 480 additions and 10581 deletions

View File

@ -102,7 +102,8 @@ namespace BTCPayServer.Controllers
if (vm.WalletFile != null)
{
if (!DerivationSchemeSettings.TryParseFromWalletFile(await ReadAllText(vm.WalletFile), network, out strategy))
if (!DerivationSchemeSettings.TryParseFromWalletFile(await ReadAllText(vm.WalletFile), network,
out strategy))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
@ -113,6 +114,19 @@ namespace BTCPayServer.Controllers
return View(nameof(AddDerivationScheme), vm);
}
}
else if (!string.IsNullOrEmpty(vm.WalletFileContent))
{
if (!DerivationSchemeSettings.TryParseFromWalletFile(vm.WalletFileContent, network, out strategy))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "QR import was not in the correct format"
});
vm.Confirmation = false;
return View(nameof(AddDerivationScheme), vm);
}
}
else
{
try
@ -122,16 +136,24 @@ namespace BTCPayServer.Controllers
var newStrategy = ParseDerivationStrategy(vm.DerivationScheme, null, network);
if (newStrategy.AccountDerivation != strategy?.AccountDerivation)
{
var accountKey = string.IsNullOrEmpty(vm.AccountKey) ? null : new BitcoinExtPubKey(vm.AccountKey, network.NBitcoinNetwork);
var accountKey = string.IsNullOrEmpty(vm.AccountKey)
? null
: new BitcoinExtPubKey(vm.AccountKey, network.NBitcoinNetwork);
if (accountKey != null)
{
var accountSettings = newStrategy.AccountKeySettings.FirstOrDefault(a => a.AccountKey == accountKey);
var accountSettings =
newStrategy.AccountKeySettings.FirstOrDefault(a => a.AccountKey == accountKey);
if (accountSettings != null)
{
accountSettings.AccountKeyPath = vm.KeyPath == null ? null : KeyPath.Parse(vm.KeyPath);
accountSettings.RootFingerprint = string.IsNullOrEmpty(vm.RootFingerprint) ? (HDFingerprint?)null : new HDFingerprint(NBitcoin.DataEncoders.Encoders.Hex.DecodeData(vm.RootFingerprint));
accountSettings.AccountKeyPath =
vm.KeyPath == null ? null : KeyPath.Parse(vm.KeyPath);
accountSettings.RootFingerprint = string.IsNullOrEmpty(vm.RootFingerprint)
? (HDFingerprint?)null
: new HDFingerprint(
NBitcoin.DataEncoders.Encoders.Hex.DecodeData(vm.RootFingerprint));
}
}
strategy = newStrategy;
strategy.Source = vm.Source;
vm.DerivationScheme = strategy.AccountDerivation.ToString();
@ -189,22 +211,22 @@ namespace BTCPayServer.Controllers
}
await _Repo.UpdateStore(store);
_EventAggregator.Publish(new WalletChangedEvent()
{
WalletId = new WalletId(storeId, cryptoCode)
});
_EventAggregator.Publish(new WalletChangedEvent() {WalletId = new WalletId(storeId, cryptoCode)});
if (willBeExcluded != wasExcluded)
{
var label = willBeExcluded ? "disabled" : "enabled";
TempData[WellKnownTempData.SuccessMessage] = $"On-Chain payments for {network.CryptoCode} has been {label}.";
TempData[WellKnownTempData.SuccessMessage] =
$"On-Chain payments for {network.CryptoCode} has been {label}.";
}
else
{
TempData[WellKnownTempData.SuccessMessage] = $"Derivation settings for {network.CryptoCode} has been modified.";
TempData[WellKnownTempData.SuccessMessage] =
$"Derivation settings for {network.CryptoCode} has been modified.";
}
// This is success case when derivation scheme is added to the store
return RedirectToAction(nameof(UpdateStore), new { storeId = storeId });
return RedirectToAction(nameof(UpdateStore), new {storeId = storeId});
}
else if (!string.IsNullOrEmpty(vm.HintAddress))
{
@ -269,7 +291,7 @@ namespace BTCPayServer.Controllers
Severity = StatusMessageModel.StatusSeverity.Error,
Html = $"There was an error generating your wallet: {e.Message}"
});
return RedirectToAction(nameof(AddDerivationScheme), new { storeId, cryptoCode });
return RedirectToAction(nameof(AddDerivationScheme), new {storeId, cryptoCode});
}
if (response == null)
@ -279,7 +301,7 @@ namespace BTCPayServer.Controllers
Severity = StatusMessageModel.StatusSeverity.Error,
Html = "There was an error generating your wallet. Is your node available?"
});
return RedirectToAction(nameof(AddDerivationScheme), new { storeId, cryptoCode });
return RedirectToAction(nameof(AddDerivationScheme), new {storeId, cryptoCode});
}
var store = HttpContext.GetStoreData();
@ -315,7 +337,7 @@ namespace BTCPayServer.Controllers
Mnemonic = response.Mnemonic,
Passphrase = response.Passphrase,
IsStored = request.SavePrivateKeys,
ReturnUrl = Url.Action(nameof(UpdateStore), new { storeId })
ReturnUrl = Url.Action(nameof(UpdateStore), new {storeId})
};
return this.RedirectToRecoverySeedBackup(vm);
}
@ -332,7 +354,8 @@ namespace BTCPayServer.Controllers
private async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet()
{
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded;
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings))
.Succeeded;
if (isAdmin)
return (true, true);
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();

View File

@ -80,6 +80,7 @@ namespace BTCPayServer.Controllers
{
vm.Decoded = psbt.ToString();
vm.PSBT = psbt.ToBase64();
vm.PSBTHex = psbt.ToHex();
}
return View(nameof(WalletPSBT), vm ?? new WalletPSBTViewModel() { CryptoCode = walletId.CryptoCode });
@ -104,6 +105,8 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
vm.PSBTHex = psbt.ToHex();
var res = await TryHandleSigningCommands(walletId, psbt, command, new SigningContextModel(psbt));
if (res != null)
{
@ -117,6 +120,7 @@ namespace BTCPayServer.Controllers
ModelState.Remove(nameof(vm.FileName));
ModelState.Remove(nameof(vm.UploadedPSBTFile));
vm.PSBT = psbt.ToBase64();
vm.PSBTHex = psbt.ToHex();
vm.FileName = vm.UploadedPSBTFile?.FileName;
return View(vm);

View File

@ -35,6 +35,8 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Wallet File")]
public IFormFile WalletFile { get; set; }
[Display(Name = "Wallet File Content")]
public string WalletFileContent { get; set; }
public string Config { get; set; }
public string Source { get; set; }
public string DerivationSchemeFormat { get; set; }

View File

@ -13,6 +13,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string CryptoCode { get; set; }
public string Decoded { get; set; }
string _FileName;
public string PSBTHex { get; set; }
public bool NBXSeedAvailable { get; set; }
public string FileName

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace BTCPayServer.Models.WalletViewModels
{
@ -22,6 +23,7 @@ namespace BTCPayServer.Models.WalletViewModels
public class WalletSettingsAccountKeyViewModel
{
[JsonProperty("ExtPubKey")]
[DisplayName("Account key")]
public string AccountKey { get; set; }
[DisplayName("Master fingerprint")]

View File

@ -0,0 +1,220 @@
<div id="camera-qr-scanner-modal-app" v-cloak class="only-for-js">
<div class="modal fade" data-backdrop="static" :id="modalId">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{title}}
<span v-if="workload.length > 0">Animated QR detected: {{workload.length}} / {{workload[0].total}} scanned</span>
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" v-on:click="close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body p-0" v-if="loaded" v-bind:class="{'alert-danger': errorMessage}">
<qrcode-drop-zone v-on:decode="onDecode" v-on:init="logErrors">
<qrcode-stream v-on:decode="onDecode" v-on:init="onInit" v-bind:camera="camera" v-bind:track="paint">
<div v-if="data || errorMessage" class="pending-action">
<div class="text-danger p-2" v-if="errorMessage">{{errorMessage}}</div>
<span class="text-muted text-truncate">{{data}}</span>
<div class="w-100 btn-group">
<button v-if="data" type="button" class="btn btn-primary" data-dismiss="modal" v-on:click="submitData">Submit</button>
<button type="button" class="btn btn-secondary" v-on:click="retry">Retry</button>
<button type="button" class="btn btn-danger" data-dismiss="modal" v-on:click="close">Cancel</button>
</div>
</div>
</qrcode-stream>
</qrcode-drop-zone>
<qrcode-capture v-if="noStreamApiSupport" v-on:decode="onDecode" v-bind:camera="camera"/>
</div>
</div>
</div>
</div>
</div>
<style>
.pending-action {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, .8);
text-align: center;
font-size: 1.4rem;
padding: 10px;
word-wrap: break-word;
display: flex;
flex-flow: column nowrap;
justify-content: center;
}
</style>
<script>
function initCameraScanningApp(title, onDataSubmit, modalId)
{
new Vue(
{
el: '#camera-qr-scanner-modal-app',
data:
{
noStreamApiSupport: false,
loaded: false,
workload: [],
data: "",
title: title,
errorMessage: "",
modalId: modalId
},
mounted: function ()
{
var self = this;
$("#" + this.modalId)
.on("shown.bs.modal", function ()
{
self.loaded = true;
})
.on("hide.bs.modal", function ()
{
self.close();
});
},
computed:
{
camera: function ()
{
return this.data ? "off" : "auto";
}
},
methods:
{
retry: function ()
{
if (!this.data)
{
this.close();
this.$nextTick(function ()
{
this.loaded = true;
});
return;
}
this.data = "";
this.workload = [];
this.errorMessage = "";
},
close: function ()
{
this.loaded = false;
this.data = "";
this.workload = [];
this.errorMessage = "";
},
onDecode: function (content)
{
if (this.data)
{
return;
}
if (!content.toLowerCase().startsWith("ur:"))
{
this.data = content;
this.workload = [];
}
else
{
const [index, total] = window.bcur.extractSingleWorkload(content);
if (this.workload.length > 0)
{
const currentTotal = this.workload[0].total;
if (total !== currentTotal)
{
this.workload = [];
}
}
if (!this.workload.find(i => i.index === index))
{
this.workload.push(
{
index,
total,
data: content,
});
if (this.workload.length === total)
{
this.data = window.bcur.decodeUR(this.workload.map(i => i.data));
}
}
}
},
submitData: function ()
{
if (onDataSubmit)
{
onDataSubmit(this.data);
}
this.close();
},
logErrors: function (promise)
{
promise.catch(console.error)
},
paint: function (location, ctx)
{
ctx.fillStyle = '#137547';
[
location.topLeftFinderPattern,
location.topRightFinderPattern,
location.bottomLeftFinderPattern
].forEach((
{
x,
y
}) =>
{
ctx.fillRect(x - 5, y - 5, 10, 10);
})
},
onInit: function (promise)
{
var self = this;
promise.then(() =>
{
self.errorMessage = "";
})
.catch(error =>
{
if (error.name === 'StreamApiNotSupportedError')
{
self.noStreamApiSupport = true;
}
else if (error.name === 'NotAllowedError')
{
self.errorMessage = 'A permission to the camera is needed to scan the QR code.'
}
else if (error.name === 'NotFoundError')
{
self.errorMessage = 'A camera was not detected on your device.'
}
else if (error.name === 'NotSupportedError')
{
self.errorMessage = 'This page is served in non-secure context (HTTPS, localhost or file://)'
}
else if (error.name === 'NotReadableError')
{
self.errorMessage = 'Couldn\'t access your camera. Is it already in use?'
}
else if (error.name === 'OverconstrainedError')
{
self.errorMessage = 'Constraints don\'t match any installed camera.'
}
else
{
self.errorMessage = 'UNKNOWN ERROR: ' + error.message
}
})
}
}
});
}
</script>

View File

@ -0,0 +1,105 @@
<div id="scan-qr-modal-app">
<div class="modal" tabindex="-1" role="dialog" :id="modalId">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{title}} <template v-if="fragments.length > 1">({{index+1}}/{{fragments.length}})</template></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body ">
<div class="qr-container text-center" style="min-height: 256px;">
<qrcode v-bind:value="currentFragment" :options="{ width: 256,height:256, margin: 1, color: {dark:'#000', light:'#f5f5f7'} }">
</qrcode>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
<script>
function initQRShow(title, data, modalId)
{
return new Vue(
{
el: '#scan-qr-modal-app',
components:
{
qrcode: VueQrcode
},
data:
{
index: -1,
title: title,
speed: 500,
data: data,
fragments: [],
active: false,
modalId: modalId
},
computed:
{
currentFragment: function ()
{
return this.fragments[this.index];
}
},
mounted: function ()
{
var self = this;
$("#" + this.modalId)
.on("shown.bs.modal", function ()
{
self.start();
})
.on("hide.bs.modal", function ()
{
self.active = false;
});
self.setFragments();
},
watch:
{
data: function ()
{
this.setFragments();
}
},
methods:
{
setFragments: function ()
{
if (!this.data)
{
this.fragments = [];
return;
}
this.fragments = window.bcur.encodeUR(this.data.toString(), 200);
},
start: function ()
{
this.active = true;
this.index = -1;
this.playNext();
},
playNext: function ()
{
if (!this.active)
{
return;
}
this.index++;
if (this.index > (this.fragments.length - 1))
{
this.index = 0;
}
setTimeout(this.playNext, this.speed)
}
}
});
}
</script>

View File

@ -1,4 +1,5 @@
@model DerivationSchemeViewModel
@addTagHelper *, BundlerMinifier.TagHelpers
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(StoreNavPages.Index, $"{Model.CryptoCode} Derivation scheme");
@ -94,6 +95,7 @@
<button class="dropdown-item check-for-vault" type="button">... a hardware wallet</button>
}
<button class="dropdown-item" type="button" data-toggle="modal" data-target="#electrumimport">... a wallet file (Electrum, Wasabi, Cobo Vault, ColdCard)</button>
<button class="dropdown-item" type="button" data-toggle="modal" data-target="#scanqrModal">... a QR code</button>
@if (Model.CanUseHotWallet)
{
<button class="dropdown-item" data-toggle="modal" data-target="#nbxplorergeneratewallet" type="button" id="nbxplorergeneratewalletbtn">... a new/existing seed.</button>
@ -216,7 +218,16 @@
<script src="~/js/StoreAddDerivationScheme.js" type="text/javascript" defer="defer" asp-append-version="true"></script>
<script src="~/js/vaultbridge.js" type="text/javascript" defer="defer" asp-append-version="true"></script>
<script src="~/js/vaultbridge.ui.js" type="text/javascript" defer="defer" asp-append-version="true"></script>
<bundle name="wwwroot/bundles/camera-bundle.min.js"></bundle>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<script>
window.coinName = "@Model.Network.DisplayName.ToLowerInvariant()";
$(function () {
initCameraScanningApp("Scan wallet QR", function(data){
$("#WalletFileContent").val(data);
$("#qr-import-form").submit();
},"scanqrModal");
});
</script>
}

View File

@ -6,7 +6,10 @@
<partial name="AddDerivationSchemes_NBXWalletGenerate" model="@(new GenerateWalletRequest())"/>
}
<partial name="CameraScanner"/>
<form id="qr-import-form" method="post">
<input type="hidden" asp-for="WalletFileContent"/>
</form>
<div class="modal fade" id="electrumimport" tabindex="-1" role="dialog" aria-labelledby="electrumimport" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" method="post" enctype="multipart/form-data">
@ -62,7 +65,7 @@
<template id="btcpayservervault_template">
<div class="modal-dialog" role="document">
<form class="modal-content" form method="post" enctype="multipart/form-data">
<form class="modal-content" 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">

View File

@ -1,51 +0,0 @@
@model WalletSendModel
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true" />
<div id="wallet-camera-app" v-cloak class="only-for-js">
<div class="modal fade" data-backdrop="static" id="scanModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body p-0" v-if="loaded" v-bind:class="{'alert-danger': errorMessage}">
<div class="p-2" style="position: absolute; right: 0; top: 0; width: 100%; z-index:5">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" v-on:click="close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<qrcode-drop-zone v-on:decode="onDecode" v-on:init="logErrors">
<qrcode-stream v-on:decode="onDecode" v-on:init="onInit" v-bind:camera="camera" v-bind:track="paint">
<div v-if="data || errorMessage" class="pending-action">
<div class="text-danger p-2" v-if="errorMessage">{{errorMessage}}</div>
<span class="text-muted">{{data}}</span>
<div class="w-100 btn-group">
<button v-if="data" type="button" class="btn btn-primary" data-dismiss="modal" v-on:click="submitData">Submit</button>
<button type="button" class="btn btn-secondary" v-on:click="retry">Retry</button>
<button type="button" class="btn btn-danger" data-dismiss="modal" v-on:click="close">Cancel</button>
</div>
</div>
</qrcode-stream>
</qrcode-drop-zone>
<qrcode-capture v-if="noStreamApiSupport" v-on:decode="onDecode" v-bind:camera="camera"/>
</div>
</div>
</div>
</div>
</div>
<style>
.pending-action {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, .8);
text-align: center;
font-size: 1.4rem;
padding: 10px;
word-wrap: break-word;
display: flex;
flex-flow: column nowrap;
justify-content: center;
}
</style>

View File

@ -1,4 +1,5 @@
@model WalletPSBTViewModel
@addTagHelper *, BundlerMinifier.TagHelpers
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "PSBT";
@ -8,7 +9,7 @@
{
<div class="row">
<div class="col-md-10 text-center">
<partial name="_StatusMessage" />
<partial name="_StatusMessage"/>
</div>
</div>
}
@ -17,10 +18,13 @@
@if (Model.Errors != null && Model.Errors.Count != 0)
{
<div class="alert alert-danger alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
@foreach (var error in Model.Errors)
{
<span>@error</span><br />
<span>@error</span>
<br/>
}
</div>
}
@ -29,10 +33,10 @@
<h3>Decoded PSBT</h3>
<div class="form-group">
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@this.Context.GetRouteValue("walletId")">
<input type="hidden" asp-for="CryptoCode" />
<input type="hidden" asp-for="NBXSeedAvailable" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="FileName" />
<input type="hidden" asp-for="CryptoCode"/>
<input type="hidden" asp-for="NBXSeedAvailable"/>
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="FileName"/>
<div class="d-flex">
<partial name="WalletSigningMenu" model="@((Model.CryptoCode, Model.NBXSeedAvailable))"/>
<div class="ml-2 dropdown">
@ -44,6 +48,7 @@
<button name="command" type="submit" class="dropdown-item" value="update">Update</button>
<button name="command" type="submit" class="dropdown-item" value="combine">Combine</button>
<button name="command" type="submit" class="dropdown-item" value="save-psbt">Download</button>
<button name="command" type="button" class="dropdown-item only-for-js" data-toggle="modal" data-target="#scan-qr-modal">Show QR</button>
</div>
</div>
</div>
@ -58,18 +63,40 @@
<label asp-for="PSBT"></label>
<textarea class="form-control" rows="5" asp-for="PSBT"></textarea>
<span asp-validation-for="PSBT" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="UploadedPSBTFile"></label>
<input type="file" class="form-control-file" asp-for="UploadedPSBTFile">
</div>
<button type="submit" name="command" value="decode" class="btn btn-primary">Decode</button>
<button type="button" id="scanqrcode" class="ml-2 btn btn-secondary only-for-js" data-toggle="modal" data-target="#scanModal" title="Scan with camera">
<i class="fa fa-camera"></i>
</button>
<button type="submit" name="command" value="decode" class="btn btn-primary" id="Decode">Decode</button>
</form>
</div>
</div>
<partial name="ShowQR"/>
<partial name="CameraScanner"/>
@section Scripts {
<link rel="stylesheet" href="~/vendor/highlightjs/default.min.css" asp-append-version="true">
<script src="~/vendor/highlightjs/highlight.min.js" asp-append-version="true"></script>
<bundle name="wwwroot/bundles/camera-bundle.min.js"></bundle>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<script>hljs.initHighlightingOnLoad();</script>
<script>
$(function (){
initQRShow("Scan PSBT", @Json.Serialize(Model.PSBTHex), "scan-qr-modal");
initCameraScanningApp("Scan PSBT", function (data){
$("textarea[name=PSBT]").val(data);
$("#Decode").click();
}, "scanModal")
});
</script>
}

View File

@ -1,4 +1,5 @@
@using Microsoft.AspNetCore.Mvc.ModelBinding

@addTagHelper *, BundlerMinifier.TagHelpers
@model BTCPayServer.Controllers.WalletReceiveViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
@ -74,7 +75,7 @@
@section HeadScripts
{
<script src="~/bundles/lightning-node-info-bundle.min.js" type="text/javascript" asp-append-version="true"></script>
<bundle name="wwwroot/bundles/lightning-node-info-bundle.min.js" asp-append-version="true" />
<script type="text/javascript">
var srvModel = @Safe.Json(Model);
window.onload = function() {

View File

@ -15,7 +15,7 @@
</div>
</div>
}
<partial name="WalletCameraScanner"/>
<partial name="CameraScanner"/>
<div class="row">
<div class="@(!Model.InputSelection && Model.Outputs.Count==1? "col-lg-7 transaction-output-form": "col-lg-8")">
@ -229,7 +229,8 @@
@section HeadScripts
{
<bundle name="wwwroot/bundles/wallet-send-bundle.min.js" asp-append-version="true"></bundle>
<bundle name="wwwroot/bundles/wallet-send-bundle.min.js"></bundle>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<style>
.remove-destination-btn { font-size: 1.5rem; }
.remove-destination-btn:hover { border-color: var(--btcpay-color-danger); }

View File

@ -1,4 +1,7 @@
@model WalletSettingsViewModel
@using Newtonsoft.Json
@using System.Text
@using NBitcoin.DataEncoders
@model WalletSettingsViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Wallet settings";
@ -37,8 +40,9 @@
@for (int i = 0; i < Model.AccountKeys.Count; i++)
{
<hr class="my-4"/>
<span class="d-flex">
<h4 class="mb-3">Account key @i</h4>
<button type="button" class="btn btn-link only-for-js mb-2 fa fa-qrcode text-decoration-none" data-wallet="@i" title="Show QR"></button></span>
<div class="form-group">
<label asp-for="@Model.AccountKeys[i].AccountKey"></label>
<input asp-for="@Model.AccountKeys[i].AccountKey" class="form-control" readonly/>
@ -86,9 +90,25 @@
</form>
</div>
</div>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/vendor/bc-ur/web-bundle.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode-reader/vue-qrcode-reader.browser.js" asp-append-version="true"></script>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<partial name="ShowQR"/>
<script>
var wallets = @Safe.Json(Model.AccountKeys.Select(model => Encoders.Hex.EncodeData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(model)))));
$(document).ready(function(){
var qrApp = initQRShow("Wallet QR", "", "scan-qr-modal");
$("button[data-wallet]").on("click", function (){
var data = $(this).data("wallet");
var wallet = wallets[parseInt(data)];
qrApp.data = wallet;
$("#scan-qr-modal").modal("show");
});
if(navigator.registerProtocolHandler){
$(".register-wallet")
.show()

View File

@ -1,2 +1,3 @@
@using BTCPayServer.Views.Wallets
@using BTCPayServer.Models.WalletViewModels
@addTagHelper *, BundlerMinifier.TagHelpers

View File

@ -49,7 +49,7 @@
"wwwroot/vendor/clipboard.js/clipboard.js",
"wwwroot/vendor/jquery/jquery.js",
"wwwroot/vendor/vuejs/vue.min.js",
"wwwroot/vendor/vue-qrcode/vue-qrcode.js",
"wwwroot/vendor/vue-qrcode/vue-qrcode.min.js",
"wwwroot/vendor/i18next/i18next.js",
"wwwroot/vendor/i18next/i18nextXHRBackend.js",
"wwwroot/vendor/i18next/vue-i18next.js",
@ -64,7 +64,7 @@
"wwwroot/vendor/clipboard.js/clipboard.js",
"wwwroot/vendor/jquery/jquery.js",
"wwwroot/vendor/vuejs/vue.min.js",
"wwwroot/vendor/vue-qrcode/vue-qrcode.js",
"wwwroot/vendor/vue-qrcode/vue-qrcode.min.js",
"wwwroot/vendor/vue-toasted/vue-toasted.min.js"
]
},
@ -196,6 +196,7 @@
"inputFiles": [
"wwwroot/vendor/vuejs/vue.min.js",
"wwwroot/vendor/babel-polyfill/polyfill.min.js",
"wwwroot/vendor/bc-ur/web-bundle.js",
"wwwroot/vendor/vue-qrcode-reader/vue-qrcode-reader.browser.js",
"wwwroot/js/wallet/**/*.js"
],
@ -209,5 +210,17 @@
"wwwroot/modal/btcpay.js",
"wwwroot/shopify/btcpay-shopify.js"
]
},
{
"outputFileName": "wwwroot/bundles/camera-bundle.min.js",
"inputFiles": [
"wwwroot/vendor/vuejs/vue.min.js",
"wwwroot/vendor/vue-qrcode/vue-qrcode.min.js",
"wwwroot/vendor/bc-ur/web-bundle.js",
"wwwroot/vendor/vue-qrcode-reader/vue-qrcode-reader.browser.js"
],
"minify": {
"enabled": false
}
}
]

View File

@ -1,83 +1,6 @@
$(function () {
new Vue({
el: '#wallet-camera-app',
data: {
noStreamApiSupport: false,
loaded: false,
data: "",
errorMessage: ""
},
mounted: function () {
var self = this;
$("#scanqrcode").click(function () {
self.loaded = true;
});
},
computed: {
camera: function () {
return this.data ? "off" : "auto";
}
},
methods: {
retry: function () {
if (!this.data) {
this.close();
this.$nextTick(function () {
this.loaded = true;
});
return;
}
this.data = "";
},
close: function () {
this.loaded = false;
this.data = "";
this.errorMessage = "";
},
onDecode(content) {
this.data = decodeURIComponent(content);
},
submitData: function () {
$("#BIP21").val(this.data);
initCameraScanningApp("Scan address/ payment link", function(data){
$("#BIP21").val(data);
$("form").submit();
this.close();
},
logErrors: function (promise) {
promise.catch(console.error)
},
paint: function (location, ctx) {
ctx.fillStyle = '#137547';
[
location.topLeftFinderPattern,
location.topRightFinderPattern,
location.bottomLeftFinderPattern
].forEach(({x, y}) => {
ctx.fillRect(x - 5, y - 5, 10, 10);
})
},
onInit: function (promise) {
var self = this;
promise.then(() => {
self.errorMessage = "";
})
.catch(error => {
if (error.name === 'StreamApiNotSupportedError') {
self.noStreamApiSupport = true;
} else if (error.name === 'NotAllowedError') {
self.errorMessage = 'A permission to the camera is needed to scan the QR code.'
} else if (error.name === 'NotFoundError') {
self.errorMessage = 'A camera was not detected on your device.'
} else if (error.name === 'NotSupportedError') {
self.errorMessage = 'This page is served in non-secure context (HTTPS, localhost or file://)'
} else if (error.name === 'NotReadableError') {
self.errorMessage = 'Couldn\'t access your camera. Is it already in use?'
} else if (error.name === 'OverconstrainedError') {
self.errorMessage = 'Constraints don\'t match any installed camera.'
} else {
self.errorMessage = 'UNKNOWN ERROR: ' + error.message
}
})
}
}
});
},"scanModal");
});

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long