Merge pull request #1 from cryptoadvance/0.0.0-alpha

0.0.0 alpha
This commit is contained in:
Stepan Snigirev 2019-09-24 18:15:26 +02:00 committed by GitHub
commit 828810873f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 3672 additions and 29 deletions

View file

@ -1,43 +1,46 @@
# Specter Desktop
## DISCLAIMER
A flask web GUI for Bitcoin Core to use with airgapped hardware wallets
This software is **WORK IN PROGRESS and NOT READY TO USE YET**. The master branch is empty **until the first release** of the software with core functionality implemented. At the moment all the code is in the [0.0.0-alpha branch](https://github.com/cryptoadvance/specter-desktop/tree/0.0.0-alpha).
This is the working alpha branch. Random comments, weird pieces of code and comments, not documented, breaking changes etc.
## Why?
Wait until we merge it to master, then you can use it.
Bitcoin Core has a very powerful command line interface and a wonderful daemon. Using PSBT and HWI it can also work with hardware wallets, but at the moment it is too linux-way. The same applies to multisignature setups.
## TODO
The goal of this project is to make a convenient and user-friendly GUI around Bitcoin Core with a focus on multisignature setup with airgapped hardware wallets.
- Send transaction:
- save psbt that is not finished
- upload file and combine psbt
- determine who else should sign (just look up pubkeys)
- show as qr
- work with coldcard to fix their multisig
- Display cosigners xpubs
- Migration between app versions?
At the moment we are working on integration of our [Specter-DIY hardware wallet](https://github.com/cryptoadvance/specter-diy) that uses QR codes as a main communication channel, and ColdCard that uses SD cards. Later on we plan to integrate "hot" hardware wallets using [HWI tool](https://github.com/bitcoin-core/HWI) and [Junction](https://github.com/justinmoon/junction).
### Later:
## Current status
- Import wallet from coldcard
- Show addresses QR codes in the PSBT
- Delete wallets (requires access to the wallet folder - can only unload remotely)
Most of the code is there, app should be ready for alpha testing by September 8th.
### A bit later:
## A few screenshots
- proper error handling
- run flask server from distribution
- run bitcoind and bitcoin-cli from the distribution
- bitcoin configuration gui
### Adding a new device
### Nice to have
![](screenshots/devices.jpg)
- control fee
- Tx batching (sendtomany)
- coin control
- rbf
- privacy mode - hide balances and/or menu bar (can be in settings)
- mobile version
- use vue js?
- specter-pi - airgapped bitcoin core for signing on pi
![](screenshots/device_keys.jpg)
### Far in the future
### Creating a new wallet
![](screenshots/wallets.jpg)
![](screenshots/new_multisig.jpg)
### Wallet interface
![](screenshots/transactions.jpg)
![](screenshots/receive.jpg)
![](screenshots/send.jpg)
### Configuration
![](screenshots/bitcoin-rpc.jpg)
- remote node over tor - camera requires https, how to solve? (let's encrypt doesn't work)

140
descriptor.py Normal file
View file

@ -0,0 +1,140 @@
import re
# From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp
def PolyMod(c, val):
c0 = c >> 35
c = ((c & 0x7ffffffff) << 5) ^ val
if (c0 & 1):
c ^= 0xf5dee51989
if (c0 & 2):
c ^= 0xa9fdca3312
if (c0 & 4):
c ^= 0x1bab10e32d
if (c0 & 8):
c ^= 0x3706b1677a
if (c0 & 16):
c ^= 0x644d626ffd
return c
def DescriptorChecksum(desc):
INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ ";
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
c = 1
cls = 0
clscount = 0
for ch in desc:
pos = INPUT_CHARSET.find(ch)
if pos == -1:
return ""
c = PolyMod(c, pos & 31)
cls = cls * 3 + (pos >> 5)
clscount += 1
if clscount == 3:
c = PolyMod(c, cls)
cls = 0
clscount = 0
if clscount > 0:
c = PolyMod(c, cls)
for j in range (0, 8):
c = PolyMod(c, 0)
c ^= 1
ret = [None] * 8
for j in range(0, 8):
ret[j] = CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
return ''.join(ret)
def AddChecksum(desc):
return desc + "#" + DescriptorChecksum(desc)
class Descriptor:
def __init__(self, origin_fingerprint, origin_path, base_key, path_suffix, testnet, sh_wpkh, wpkh):
self.origin_fingerprint = origin_fingerprint
self.origin_path = origin_path
self.path_suffix = path_suffix
self.base_key = base_key
self.testnet = testnet
self.sh_wpkh = sh_wpkh
self.wpkh = wpkh
self.m_path = None
if origin_path:
self.m_path_base = "m" + origin_path
self.m_path = "m" + origin_path + (path_suffix or "")
@classmethod
def parse(cls, desc, testnet = False):
sh_wpkh = None
wpkh = None
origin_fingerprint = None
origin_path = None
base_key_and_path_match = None
base_key = None
path_suffix = None
# Check the checksum
check_split = desc.split('#')
if len(check_split) > 2:
return None
if len(check_split) == 2:
if len(check_split[1]) != 8:
return None
checksum = DescriptorChecksum(check_split[0])
if not checksum.strip():
return None
if checksum != check_split[1]:
return None
desc = check_split[0]
if desc.startswith("sh(wpkh("):
sh_wpkh = True
elif desc.startswith("wpkh("):
wpkh = True
origin_match = re.search(r"\[(.*)\]", desc)
if origin_match:
origin = origin_match.group(1)
match = re.search(r"^([0-9a-fA-F]{8})(\/.*)", origin)
if match:
origin_fingerprint = match.group(1)
origin_path = match.group(2)
# Replace h with '
origin_path = origin_path.replace('h', '\'')
base_key_and_path_match = re.search(r"\[.*\](\w+)([\/\)][\d'\/\*]*)", desc)
else:
base_key_and_path_match = re.search(r"\((\w+)([\/\)][\d'\/\*]*)", desc)
if base_key_and_path_match:
base_key = base_key_and_path_match.group(1)
path_suffix = base_key_and_path_match.group(2)
if path_suffix == ")":
path_suffix = None
else:
if origin_match == None:
return None
return cls(origin_fingerprint, origin_path, base_key, path_suffix, testnet, sh_wpkh, wpkh)
def serialize(self):
descriptor_open = 'pkh('
descriptor_close = ')'
origin = ''
path_suffix = ''
if self.wpkh == True:
descriptor_open = 'wpkh('
elif self.sh_wpkh == True:
descriptor_open = 'sh(wpkh('
descriptor_close = '))'
if self.origin_fingerprint and self.origin_path:
origin = '[' + self.origin_fingerprint + self.origin_path + ']'
if self.path_suffix:
path_suffix = self.path_suffix
return AddChecksum(descriptor_open + origin + self.base_key + path_suffix + descriptor_close)

197
helpers.py Normal file
View file

@ -0,0 +1,197 @@
import hashlib
import subprocess
import collections
import six
from collections import OrderedDict
import os, json
try:
collectionsAbc = collections.abc
except:
collectionsAbc = collections
def deep_update(d, u):
for k, v in six.iteritems(u):
dv = d.get(k, {})
if not isinstance(dv, collectionsAbc.Mapping):
d[k] = v
elif isinstance(v, collectionsAbc.Mapping):
d[k] = deep_update(dv, v)
else:
d[k] = v
return d
def load_jsons(folder, key=None):
files = [f for f in os.listdir(folder) if f.endswith(".json")]
files.sort(key=lambda x: os.path.getmtime(os.path.join(folder, x)))
dd = OrderedDict()
for fname in files:
with open(os.path.join(folder, fname)) as f:
d = json.loads(f.read())
if key is None:
dd[fname[:-5]] = d
else:
d["fullpath"] = os.path.join(folder, fname)
d["alias"] = fname[:-5]
dd[d[key]] = d
return dd
BASE58_ALPHABET = b'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
VALID_PREFIXES = {
b"\x04\x35\x87\xcf": { # testnet
b"\x04\x35\x87\xcf": None, # unknown, maybe pkh
b"\x04\x4a\x52\x62": "sh-wpkh",
b"\x04\x5f\x1c\xf6": "wpkh",
b"\x02\x42\x89\xef": "sh-wsh",
b"\x02\x57\x54\x83": "wsh",
},
b"\x04\x88\xb2\x1e": { # mainnet
b"\x04\x88\xb2\x1e": None, #unknown, maybe pkh
b"\x04\x9d\x7c\xb2": "sh-wpkh",
b"\x04\xb2\x47\x46": "wpkh",
b"\x02\x95\xb4\x3f": "sh-wsh",
b"\x02\xaa\x7e\xd3": "wsh",
}
}
def double_sha256(s):
return hashlib.sha256(hashlib.sha256(s).digest()).digest()
def encode_base58(s):
# determine how many 0 bytes (b'\x00') s starts with
count = 0
for c in s:
if c == 0:
count += 1
else:
break
prefix = b'1' * count
# convert from binary to hex, then hex to integer
num = int.from_bytes(s, 'big')
result = bytearray()
while num > 0:
num, mod = divmod(num, 58)
result.insert(0, BASE58_ALPHABET[mod])
return prefix + bytes(result)
def encode_base58_checksum(s):
return encode_base58(s + double_sha256(s)[:4]).decode('ascii')
def decode_base58(s, num_bytes=82, strip_leading_zeros=False):
num = 0
for c in s.encode('ascii'):
num *= 58
num += BASE58_ALPHABET.index(c)
combined = num.to_bytes(num_bytes, byteorder='big')
if strip_leading_zeros:
while combined[0] == 0:
combined = combined[1:]
checksum = combined[-4:]
if double_sha256(combined[:-4])[:4] != checksum:
raise ValueError('bad address: {} {}'.format(
checksum, double_sha256(combined)[:4]))
return combined[:-4]
def parse_xpub(xpub):
r = {"derivation": None}
derivation = None
arr = xpub.strip().split("]")
r["original"] = arr[-1]
if len(arr) > 1:
derivation = arr[0].replace("'","h").lower()
xpub = arr[1]
if derivation is not None:
if derivation[0]!="[":
raise "Missing leading ["
arr = derivation[1:].split("/")
try:
fng = bytes.fromhex(arr[0])
except:
raise Exception("Fingerprint is not hex")
if len(fng) != 4:
raise Exception("Incorrect fingerprint length")
r["fingerprint"] = arr[0]
for der in arr[1:]:
if der[-1] == "h":
der = der[:-1]
try:
i = int(der)
except:
print("index")
raise Exception("Incorrect index")
arr[0] = "m"
r["derivation"] = "/".join(arr)
# checking xpub prefix and defining key type
b = decode_base58(xpub, num_bytes=82)
prefix = b[:4]
is_valid = False
key_type = None
for k in VALID_PREFIXES:
if prefix in VALID_PREFIXES[k].keys():
key_type = VALID_PREFIXES[k][prefix]
prefix = k
is_valid = True
break
if not is_valid:
raise Exception("Invalid xpub prefix: %s", prefix.hex())
# defining key type from derivation
if r["derivation"] is not None and key_type is None:
arr = r["derivation"].split("/")
purpose = arr[1]
if purpose == "44h":
key_type = "pkh"
elif purpose == "49h":
key_type = "sh-wpkh"
elif purpose == "84h":
key_type = "wpkh"
elif purpose == "45h":
key_type = "sh"
elif purpose == "48h":
if len(arr)>=5:
if arr[4] == "1h":
key_type = "sh-wsh"
elif arr[4] == "2h":
key_type = "wsh"
r["type"] = key_type
b = prefix + b[4:]
r["xpub"] = encode_base58_checksum(b)
return r
def normalize_xpubs(xpubs):
xpubs = xpubs
lines = [l.strip() for l in xpubs.split("\n") if len(l) > 0]
parsed = []
failed = []
normalized = []
for line in lines:
try:
x = parse_xpub(line)
normalized.append(x)
parsed.append(line)
except:
failed.append(line)
return (normalized, parsed, failed)
# should work in all python versions
def run_shell(cmd):
"""
Runs a shell command.
Example: run(["ls", "-a"])
Returns: dict({"code": returncode, "out": stdout, "err": stderr})
"""
try:
proc = subprocess.Popen(cmd,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
)
stdout, stderr = proc.communicate()
return { "code": proc.returncode, "out": stdout, "err": stderr }
except:
return { "code": 0xf00dbabe, "out": b"", "err": b"Can't run subprocess" }

BIN
nwjs_app/app.icns Normal file

Binary file not shown.

BIN
nwjs_app/ca.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
nwjs_app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

76
nwjs_app/index.html Normal file
View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Specter Desktop</title>
<style type="text/css">
html, body{
margin: 0;
padding: 0;
background: #192432;
/*color: #7f8fa4;*/
color: #fff;
font-family: "Source Sans Pro", sans-serif;
font-weight: lighter;
display: flex;
width: 100%;
height: 100%;
}
body{
flex-direction: column;
max-width: 100%;
align-items: center;
justify-content: center;
}
*{
box-sizing: border-box;
}
img{
width: 300px;
}
div{
margin: 20px;
}
.top{
-webkit-app-region: drag;
height: 70px;
width: 100%;
/*border: 1px solid red;*/
position: fixed;
top: 0;
left: 0;
margin: 0;
}
</style>
</head>
<body>
<div class="top">&nbsp;</div>
<div>
<img src="ca.png">
</div>
<div id="lbl">
Specter Desktop is loading...
</div>
</body>
<script type="text/javascript">
var err;
let url = "http://localhost:25441/";
setInterval( () => {
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
if(xmlHttp.status == 200 || xmlHttp.status == 302){
window.location.href = url;
}else{
document.getElementById("lbl").innerHTML = "Connection failed... Trying again...";
}
}
xmlHttp.onerror = function(e){
document.getElementById("lbl").innerHTML = "Connection failed... Trying again...";
console.log(xmlHttp);
err = xmlHttp;
}
xmlHttp.open("GET", url, true); // true for asynchronous
xmlHttp.send(null);
},5000);
</script>
</html>

10
nwjs_app/package.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "Specter Desktop",
"main": "index.html",
"window": {
"width": 1200,
"height": 800,
"icon": "icon.png",
"frame": true
}
}

82
rpc.py Normal file
View file

@ -0,0 +1,82 @@
import requests, json, os
# TODO: redefine __dir__ and help
RPC_PORTS = { "test": 18332, "regtest": 18443, "main": 8332, 'signet': 38332 }
class BitcoinCLI:
def __init__(self, user, passwd, host="127.0.0.1", port=18332, protocol="http", path="", timeout=30):
path = path.replace("//","/") # just in case
self.user = user
self.passwd = passwd
self.port = port
self.protocol = protocol
self.host = host
self.path = path
self.timeout = timeout
self.r = None
def wallet(name=""):
return BitcoinCLI(user=self.user,
passwd=self.passwd,
port=self.port,
protocol=self.protocol,
host=self.host,
path="{}/wallet/{}".format(self.path, name),
timeout=self.timeout
)
self.wallet = wallet
@property
def url(self):
return "{s.protocol}://{s.user}:{s.passwd}@{s.host}:{s.port}{s.path}".format(s=self)
def __getattr__(self, method):
# if hasattr(self, method):
# self.
headers = {'content-type': 'application/json'}
def fn(*args, **kwargs):
payload = {
"method": method,
"params": args,
"jsonrpc": "2.0",
"id": 0,
}
timeout = self.timeout
if "timeout" in kwargs:
timeout = kwargs["timeout"]
url = self.url
if "wallet" in kwargs:
url = url+"/wallet/{}".format(kwargs["wallet"])
r = requests.post(
url, data=json.dumps(payload), headers=headers, timeout=timeout)
self.r = r
if r.status_code != 200:
raise Exception("Server responded with error code %d: %s" % (r.status_code, r.text))
r = r.json()
if r["error"] is not None:
raise Exception(r["error"])
return r["result"]
return fn
if __name__ == '__main__':
cli = BitcoinCLI("bitcoinrpc", "foi3uf092ury97iufhjf30982hf928uew9jd209j", port=18443)
print(cli.url)
print(cli.getmininginfo())
print(cli.listwallets())
##### WORKING WITH WALLETS #########
print(cli.getbalance(wallet=""))
# or
w = cli.wallet("") # will load default wallet.dat
print(w.url)
print(w.getbalance()) # now you can run -rpcwallet commands

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

499
server.py Normal file
View file

@ -0,0 +1,499 @@
import sys, json, os, time, base64
import requests
import random, copy
from collections import OrderedDict
from threading import Thread
from flask import Flask, render_template, request, redirect
from flask_qrcode import QRcode
from flask_socketio import SocketIO
from helpers import normalize_xpubs, run_shell
from descriptor import AddChecksum
from rpc import BitcoinCLI, RPC_PORTS
from specter import Specter, purposes, addrtypes
from datetime import datetime
import urllib
app = Flask(__name__)
QRcode(app) # enable qr codes generation
DATA_FOLDER = "~/.specter"
rand = random.randint(0, 1e32) # to force style refresh
MSIG_TYPES = {
"legacy": "P2SH",
"p2sh-segwit": "P2SH_P2WSH",
"bech32": "P2WSH"
}
SINGLE_TYPES = {
"legacy": "P2PKH",
"p2sh-segwit": "P2SH_P2WPKH",
"bech32": "P2WPKH"
}
################ routes ####################
@app.route('/combine/', methods=['GET', 'POST'])
def combine():
if request.method == 'POST': # FIXME: ugly...
d = request.json
psbt0 = d['psbt0'] # request.args.get('psbt0')
psbt1 = d['psbt1'] # request.args.get('psbt1')
psbt = specter.combine([psbt0, psbt1])
raw = specter.finalize(psbt)
specter.broadcast(raw["hex"])
return 'psbt0: %s<br><br>psbt1: %s<br><br>final: %s<br><br>raw: %s' % (psbt0, psbt1, psbt, raw["hex"])
return 'meh'
@app.route('/')
def index():
specter.check()
if len(specter.wallets) > 0:
return redirect("/wallets/%s" % specter.wallets[specter.wallets.names()[0]]["alias"])
# TODO: add onboarding process
return render_template("base.html", specter=specter, rand=rand)
@app.route('/settings/', methods=['GET', 'POST'])
def settings():
specter.check()
rpc = specter.config["rpc"]
user = rpc["user"]
passwd = rpc["password"]
port = rpc["port"]
host = rpc["host"]
test = None
if request.method == 'POST':
user = request.form['username']
passwd = request.form['password']
port = request.form['port']
host = request.form["host"]
action = request.form['action']
if action == "test":
test = specter.test_rpc(user=user, password=passwd, port=port, host=host)
if action == "save":
specter.update_rpc(user=user, password=passwd, port=port, host=host)
specter.check()
return redirect("/")
else:
pass
return render_template("settings.html", username=user, password=passwd, port=port, host=host, specter=specter, rand=rand)
################# wallet management #####################
@app.route('/new_wallet/')
def new_wallet():
specter.check()
err = None
if specter.chain is None:
err = "Configure Bitcoin Core to create wallets"
return render_template("base.html", error=err, specter=specter, rand=rand)
return render_template("new_wallet.html", specter=specter, rand=rand)
@app.route('/new_wallet/simple/', methods=['GET', 'POST'])
def new_wallet_simple():
specter.check()
name = "Simple"
wallet_name = name
i = 2
err = None
while wallet_name in specter.wallets.names():
wallet_name = "%s %d" % (name, i)
i+=1
device = None
if request.method == 'POST':
action = request.form['action']
wallet_name = request.form['wallet_name']
if wallet_name in specter.wallets.names():
err = "Wallet already exists"
if "device" not in request.form:
err = "Select the device"
else:
device_name = request.form['device']
wallet_type = request.form['type']
if action == 'device' and err is None:
dev = copy.deepcopy(specter.devices[device_name])
prefix = "tpub"
if specter.chain == "main":
prefix = "xpub"
allowed_types = [None, wallet_type]
dev["keys"] = [k for k in dev["keys"] if k["xpub"].startswith(prefix) and k["type"] in allowed_types]
pur = {
None: "General",
"wpkh": "Segwit (bech32)",
"sh-wpkh": "Nested Segwit",
"pkh": "Legacy",
}
return render_template("new_simple_keys.html", purposes=pur, wallet_type=wallet_type, wallet_name=wallet_name, device=dev, error=err, specter=specter, rand=rand)
if action == 'key' and err is None:
original_xpub = request.form['key']
device = specter.devices[device_name]
key = None
for k in device["keys"]:
if k["original"] == original_xpub:
key = k
break
if key is None:
return render_template("base.html", error="Key not found", specter=specter, rand=rand)
# create a wallet here
wallet = specter.wallets.create_simple(wallet_name, wallet_type, key, device)
return redirect("/wallets/%s/" % wallet["alias"])
return render_template("new_simple.html", wallet_name=wallet_name, device=device, error=err, specter=specter, rand=rand)
@app.route('/new_wallet/multisig/', methods=['GET', 'POST'])
def new_wallet_multi():
specter.check()
name = "Multisig"
wallet_type = "wsh"
wallet_name = name
i = 2
err = None
while wallet_name in specter.wallets.names():
wallet_name = "%s %d" % (name, i)
i+=1
device = None
sigs_total = len(specter.devices)
if sigs_total < 2:
err = "You need more devices to do multisig"
return render_template("base.html", specter=specter, rand=rand)
sigs_required = sigs_total*2//3
if sigs_required < 2:
sigs_required = 2
cosigner_index = 0
cosigners = []
keys = []
if request.method == 'POST':
print(request.form)
action = request.form['action']
wallet_name = request.form['wallet_name']
cosigner_index = int(request.form['cosigner_index'])
sigs_required = int(request.form['sigs_required'])
sigs_total = int(request.form['sigs_total'])
if wallet_name in specter.wallets.names():
err = "Wallet already exists"
wallet_type = request.form['type']
for i in range(0, cosigner_index):
cosigners.append(request.form['cosigner%d' % i])
pur = {
None: "General",
"wsh": "Segwit (bech32)",
"sh-wsh": "Nested Segwit",
"sh": "Legacy",
}
if action == 'device' and err is None:
if "device" not in request.form:
err = "Select the device"
else:
device_name = request.form['device']
if err is None:
cosigner_index += 1
cosigners.append(request.form["device"])
print(cosigners, len(cosigners), sigs_required)
if len(cosigners) == sigs_total:
devs = []
prefix = "tpub"
if specter.chain == "main":
prefix = "xpub"
for k in cosigners:
dev = copy.deepcopy(specter.devices[k])
dev["keys"] = [k for k in dev["keys"] if k["xpub"].startswith(prefix) and (k["type"] is None or k["type"] == wallet_type)]
if len(dev["keys"]) == 0:
err = "Device %s doesn't have keys matching this wallet type" % dev["name"]
devs.append(dev)
return render_template("new_simple_keys.html", purposes=pur,
wallet_type=wallet_type, wallet_name=wallet_name,
cosigners=devs, keys=keys, sigs_required=sigs_required,
sigs_total=sigs_total, cosigner_index=cosigner_index,
error=err, specter=specter, rand=rand)
if action == 'key' and err is None:
cosigners = [specter.devices[k] for k in cosigners]
for i in range(0, cosigner_index):
try:
key = request.form['key%d' % i]
for k in cosigners[i]["keys"]:
if k["original"] == key:
keys.append(k)
break
except:
pass
devs = []
if len(keys) != sigs_total or len(cosigners) != sigs_total:
prefix = "tpub"
if specter.chain == "main":
prefix = "xpub"
for k in cosigners:
dev = copy.deepcopy(k)
dev["keys"] = [k for k in dev["keys"] if k["xpub"].startswith(prefix) and (k["type"] is None or k["type"] == wallet_type)]
devs.append(dev)
err="Did you select all the keys?"
return render_template("new_simple_keys.html", purposes=pur,
wallet_type=wallet_type, wallet_name=wallet_name,
cosigners=devs, keys=keys, sigs_required=sigs_required,
sigs_total=sigs_total, cosigner_index=cosigner_index,
error=err, specter=specter, rand=rand)
# create a wallet here
wallet = specter.wallets.create_multi(wallet_name, sigs_required, wallet_type, keys, cosigners)
return redirect("/wallets/%s/" % wallet["alias"])
return render_template("new_simple.html", cosigners=cosigners, wallet_type=wallet_type, wallet_name=wallet_name, device=device, error=err, sigs_required=sigs_required, sigs_total=sigs_total, cosigner_index=cosigner_index, specter=specter, rand=rand)
@app.route('/wallets/<wallet_alias>/')
def wallet(wallet_alias):
specter.check()
try:
wallet = specter.wallets.get_by_alias(wallet_alias)
except:
return render_template("base.html", error="Wallet not found", specter=specter, rand=rand)
print(wallet.balance["untrusted_pending"] + wallet.balance["trusted"])
if wallet.balance["untrusted_pending"] + wallet.balance["trusted"] == 0:
return redirect("/wallets/%s/receive/" % wallet_alias)
else:
return redirect("/wallets/%s/tx/" % wallet_alias)
@app.route('/wallets/<wallet_alias>/tx/')
def wallet_tx(wallet_alias):
specter.check()
try:
wallet = specter.wallets.get_by_alias(wallet_alias)
except:
return render_template("base.html", error="Wallet not found", specter=specter, rand=rand)
return render_template("wallet_tx.html", wallet_alias=wallet_alias, wallet=wallet, specter=specter, rand=rand)
@app.route('/wallets/<wallet_alias>/receive/', methods=['GET', 'POST'])
def wallet_receive(wallet_alias):
specter.check()
try:
wallet = specter.wallets.get_by_alias(wallet_alias)
except:
return render_template("base.html", error="Wallet not found", specter=specter, rand=rand)
if request.method == "POST":
action = request.form['action']
if action == "newaddress":
wallet.getnewaddress()
return render_template("wallet_receive.html", wallet_alias=wallet_alias, wallet=wallet, specter=specter, rand=rand)
@app.route('/wallets/<wallet_alias>/send/', methods=['GET', 'POST'])
def wallet_send(wallet_alias):
specter.check()
try:
wallet = specter.wallets.get_by_alias(wallet_alias)
except:
return render_template("base.html", error="Wallet not found", specter=specter, rand=rand)
psbt = None
address = ""
amount = 0
err = None
if request.method == "POST":
action = request.form['action']
if action == "createpsbt":
address = request.form['address']
amount = float(request.form['amount'])
# try:
psbt = wallet.createpsbt(address, amount)
if psbt is None:
err = "Probably you don't have enough funds, or something else..."
# except Exception as e:
# print(e)
# err = wallet.geterror()
# if err is None:
# err = "Unknown error"
# print(err, e)
return render_template("wallet_send.html", psbt=psbt, address=address, amount=amount, wallet_alias=wallet_alias, wallet=wallet, specter=specter, rand=rand)
@app.route('/wallets/<wallet_alias>/settings/')
def wallet_settings(wallet_alias):
specter.check()
try:
wallet = specter.wallets.get_by_alias(wallet_alias)
except:
return render_template("base.html", error="Wallet not found", specter=specter, rand=rand)
cc_file = None
if wallet.is_multisig():
CC_TYPES = {
'legacy': 'BIP45',
'p2sh-segwit': 'P2WSH-P2SH',
'bech32': 'P2WSH'
}
cc_file = """# Coldcard Multisig setup file (created on Specter Desktop)
#
Name: {}
Policy: {} of {}
Derivation: {}
Format: {}
""".format(wallet['name'], wallet['sigs_required'],
len(wallet['keys']), wallet['keys'][0]["derivation"].replace("h","'"),
CC_TYPES[wallet['address_type']]
)
for k in wallet['keys']:
cc_file += "{}: {}\n".format(k['fingerprint'].upper(), k['xpub'])
qr_text = "name={}\ntype={}\nm={}\nn={}".format(wallet["name"], MSIG_TYPES[wallet['address_type']], wallet['sigs_required'], len(wallet['keys']))
for k in wallet['keys']:
qr_text += "\n[{}{}]{}".format(k['fingerprint'], k['derivation'][1:], k['xpub'])
return render_template("wallet_settings.html",
cc_file=urllib.parse.quote(cc_file),
wallet_alias=wallet_alias, wallet=wallet,
specter=specter, rand=rand,
qr_text=qr_text)
else:
qr_text = "name={}\ntype={}".format(wallet["name"], SINGLE_TYPES[wallet['address_type']])
k = wallet["key"]
qr_text += "\n[{}{}]{}".format(k['fingerprint'], k['derivation'][1:], k['xpub'])
return render_template("wallet_settings.html",
wallet_alias=wallet_alias, wallet=wallet,
specter=specter, rand=rand,
qr_text=qr_text)
################# devices management #####################
@app.route('/new_device/')
def new_device():
specter.check()
return render_template("new_device.html", specter=specter, rand=rand)
@app.route('/new_device/<device_type>/', methods=['GET', 'POST'])
def new_device_xpubs(device_type):
err = None
specter.check()
# get default new name
name = device_type.capitalize()
device_name = name
i = 2
while device_name in specter.devices.names():
device_name = "%s %d" % (name, i)
i+=1
xpubs = ""
if request.method == 'POST':
device_name = request.form['device_name']
if device_name in specter.devices.names():
err = "Device with this name already exists"
xpubs = request.form['xpubs']
normalized, parsed, failed = normalize_xpubs(xpubs)
if len(failed) > 0:
err = "Failed to parse these xpubs:\n" + "\n".join(failed)
if err is None:
dev = specter.devices.add(name=device_name, device_type=device_type, keys=normalized)
return redirect("/devices/%s/" % dev["alias"])
return render_template("new_device_xpubs.html", device_type=device_type, device_name=device_name, xpubs=xpubs, error=err, specter=specter, rand=rand)
def get_key_meta(key):
k = copy.deepcopy(key)
k["chain"] = "Mainnet" if k["xpub"].startswith("xpub") else "Testnet"
k["purpose"] = purposes[k["type"]]
if k["derivation"] is not None:
k["combined"] = "[%s%s]%s" % (k["fingerprint"], k["derivation"][1:], k["xpub"])
else:
k["combined"] = k["xpub"]
return k
@app.route('/devices/<device_alias>/', methods=['GET', 'POST'])
def device(device_alias):
specter.check()
try:
device = specter.devices.get_by_alias(device_alias)
except:
return render_template("base.html", error="Device not found", specter=specter, rand=rand)
if request.method == 'POST':
action = request.form['action']
if action == "forget":
specter.devices.remove(device)
return redirect("/")
if action == "delete_key":
key = request.form['key']
device.remove_key(key)
if action == "add_keys":
return render_template("new_device_xpubs.html", device_alias=device_alias, device=device, device_type=device["type"], specter=specter, rand=rand)
if action == "morekeys":
# refactor to fn
xpubs = request.form['xpubs']
normalized, parsed, failed = normalize_xpubs(xpubs)
err = None
if len(failed) > 0:
err = "Failed to parse these xpubs:\n" + "\n".join(failed)
return render_template("new_device_xpubs.html", device_alias=device_alias, device=device, xpubs=xpubs, device_type=device["type"], error=err, specter=specter, rand=rand)
if err is None:
device.add_keys(normalized)
device = copy.deepcopy(device)
device["keys"] = [get_key_meta(key) for key in device["keys"]]
device["keys"].sort(key=lambda x: x["chain"]+x["purpose"], reverse=True)
return render_template("device.html", device_alias=device_alias, device=device, purposes=purposes, specter=specter, rand=rand)
############### filters ##################
@app.template_filter('datetime')
def timedatetime(s):
return format(datetime.fromtimestamp(s), "%d.%m.%Y %H:%M")
@app.template_filter('derivation')
def derivation(wallet):
s = "address=m/0/{}\n".format(wallet['address_index'])
if wallet.is_multisig():
s += "type={}".format(MSIG_TYPES[wallet['address_type']])
for k in wallet['keys']:
s += "\n{}{}".format(k['fingerprint'], k['derivation'][1:])
else:
s += "type={}".format(SINGLE_TYPES[wallet['address_type']])
k = wallet['key']
s += "\n{}{}".format(k['fingerprint'], k['derivation'][1:])
return s
# d = wallet["recv_descriptor"].split("#")[0].replace("*",str(wallet["address_index"]))
# if wallet.is_multisig():
# for k in wallet["keys"]:
# d = d.replace(k["xpub"],"m")
# else:
# d = d.replace(wallet["key"]["xpub"],"m")
# pass
# return d
@app.template_filter('txonaddr')
def txonaddr(wallet):
addr = wallet["address"]
txlist = [tx for tx in wallet.transactions if tx["address"] == addr]
return len(txlist)
@app.template_filter('prettyjson')
def txonaddr(obj):
return json.dumps(obj, indent=4)
############### startup ##################
def run_chrome(timeout):
t0 = time.time()
t = t0
r = None
url = "http://localhost:25441/"
while t < t0+10 or r is None:
try:
r = requests.get(url)
except:
time.sleep(0.2)
run_shell(["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome","--app=%s" % url])
os._exit(0) # stop server when chrome app is closed
if __name__ == '__main__':
debug = True
specter = Specter(DATA_FOLDER)
specter.check()
# also run chrome at start
if len(sys.argv) > 1 and sys.argv[-1]=="--app":
debug = False # to prevent new windows on reload
th = Thread(target=run_chrome, args=(10,))
th.start()
# watch templates folder to reload when something changes
extra_dirs = ['templates']
extra_files = extra_dirs[:]
for extra_dir in extra_dirs:
for dirname, dirs, files in os.walk(extra_dir):
for filename in files:
filename = os.path.join(dirname, filename)
if os.path.isfile(filename):
extra_files.append(filename)
app.run(port=25441, debug=debug, extra_files=extra_files)

649
specter.py Normal file
View file

@ -0,0 +1,649 @@
from rpc import BitcoinCLI, RPC_PORTS
import os, json, copy
from helpers import deep_update, load_jsons
from collections import OrderedDict
from descriptor import AddChecksum
import base64
WALLET_CHUNK = 5
purposes = OrderedDict({
None: "General",
"wpkh": "Single (Segwit)",
"sh-wpkh": "Single (Nested)",
"pkh": "Single (Legacy)",
"wsh": "Multisig (Segwit)",
"sh-wsh": "Multisig (Nested)",
"sh": "Multisig (Legacy)",
})
addrtypes = {
"pkh": "legacy",
"sh-wpkh": "p2sh-segwit",
"wpkh": "bech32",
"sh": "legacy",
"sh-wsh": "p2sh-segwit",
"wsh": "bech32"
}
def load_bitcoin_conf(path=None):
# trying to find bitcoin.conf in default location
if path is None:
# add other platforms later
paths = ["~/Library/Application Support/Bitcoin/", "~/.bitcoin/"]
for p in paths:
p = os.path.expanduser(p)
if os.path.isfile(os.path.join(p, "bitcoin.conf")):
path = os.path.join(p, "bitcoin.conf")
break
if path is None:
raise Exception("Didn't find bitcoin.conf in standard folders")
with open(path, "r") as f:
content = f.read()
lines = content.split("\n")
conf = {}
for line in lines:
arr = line.split("#")[0].split("=") # get rid of comments and get values
if len(arr) == 2:
k = arr[0].strip()
v = arr[1].strip()
if k.startswith("rpc"): #rpcuser, rpcpassword, rpcport
conf[k[3:]] = v
if k == "testnet" and v=="1" and "port" not in conf:
conf["port"] = RPC_PORTS["test"]
if k == "regtest" and v=="1" and "port" not in conf:
conf["port"] = RPC_PORTS["regtest"]
return conf
def alias(name):
name = name.replace(" ", "_")
return "".join(x for x in name if x.isalnum() or x=="_").lower()
class Specter:
def __init__(self, data_folder="./data", config={}):
if data_folder.startswith("~"):
data_folder = os.path.expanduser(data_folder)
self.data_folder = data_folder
self.cli = None
self.devices = None
self.wallets = None
self.file_config = None # what comes from config file
self.arg_config = config # what comes from arguments
# default config
self.config = {
"rpc": {
"user": None,
"password": None,
"port": RPC_PORTS["main"],
"host": "localhost", # localhost
"protocol": "http" # https for the future
},
# add hwi later?
}
# creating folders if they don't exist
if not os.path.isdir(data_folder):
os.mkdir(data_folder)
self._info = { "chain": None, "last_chain": None }
# health check: loads config and tests rpc
self.check()
def check(self):
# bitcoin.conf
try:
self.config["rpc"].update(load_bitcoin_conf()) # should rize an error if fails
except:
pass # not a big deal
# config.json file
if os.path.isfile(os.path.join(self.data_folder, "config.json")):
with open(os.path.join(self.data_folder, "config.json"), "r") as f:
self.file_config = json.loads(f.read())
deep_update(self.config, self.file_config)
# init arguments
deep_update(self.config, self.arg_config) # override loaded config
# check if we have user, password and can connect
self._is_configured = bool(self.config["rpc"]["user"] and self.config["rpc"]["password"])
self._is_running = False
if self._is_configured:
self.cli = BitcoinCLI(self.config["rpc"]["user"], self.config["rpc"]["password"],
host=self.config["rpc"]["host"], port=self.config["rpc"]["port"], protocol=self.config["rpc"]["protocol"])
try:
self._info = self.cli.getmininginfo()
self._is_running = True
except:
# last_chain is used to manage wallets when can't reach bitcoin-cli
last_chain = self._info["chain"]
if "last_chain" in self._info and last_chain is None:
last_chain = self.info["last_chain"]
self._info = { "chain": None, "last_chain": last_chain }
chain = self._info["chain"]
if chain is None:
chain = self._info["last_chain"]
if self.wallets is None:
self.wallets = WalletManager(os.path.join(self.data_folder, "wallets"), self.cli, chain=chain)
else:
self.wallets.update(os.path.join(self.data_folder, "wallets"), self.cli, chain=chain)
if self.devices is None:
self.devices = DeviceManager(os.path.join(self.data_folder, "devices"))
else:
self.devices.update(os.path.join(self.data_folder, "devices"))
try:
self.wallets.load_all()
except Exception as e:
print("can't load wallets...", e)
def test_rpc(self, **kwargs):
conf = copy.deepcopy(self.config["rpc"])
conf.update(kwargs)
cli = BitcoinCLI(conf["user"], conf["password"],
host=conf["host"], port=conf["port"], protocol=conf["protocol"])
r = {}
try:
r["out"] = json.dumps(cli.getmininginfo(),indent=4)
r["err"] = ""
r["code"] = 0
except:
r["out"] = ""
if cli.r is not None and "error" in cli.r:
r["err"] = cli.r["error"]
r["code"] = cli.r.status_code
else:
r["err"] = "Failed to connect"
r["code"] = -1
return r
def update_rpc(self, **kwargs):
need_update = False
for k in kwargs:
if self.config["rpc"][k] != kwargs[k]:
self.config["rpc"][k] = kwargs[k]
need_update = True
if need_update:
with open(os.path.join(self.data_folder, "config.json"), "w") as f:
f.write(json.dumps(self.config, indent=4))
self.check()
@property
def info(self):
return self._info
def combine(self, psbt_arr):
final_psbt = self.cli.combinepsbt(psbt_arr)
return final_psbt
def finalize(self, psbt):
final_psbt = self.cli.finalizepsbt(psbt)
return final_psbt
def broadcast(self, raw):
res = self.cli.sendrawtransaction(raw)
return res
@property
def chain(self):
return self._info["chain"]
class DeviceManager:
def __init__(self, data_folder):
self.update(data_folder)
def update(self, data_folder=None):
if data_folder is not None:
self.data_folder = data_folder
if data_folder.startswith("~"):
data_folder = os.path.expanduser(data_folder)
# creating folders if they don't exist
if not os.path.isdir(data_folder):
os.mkdir(data_folder)
self._devices = load_jsons(self.data_folder, key="name")
def names(self):
return list(self._devices.keys())
def add(self, name, device_type, keys):
dev = {
"name": name,
"type": device_type,
"keys": []
}
fname = alias(name)
i = 2
while os.path.isfile(os.path.join(self.data_folder, "%s.json" % fname)):
fname = alias("%s %d" % (name, i))
i+=1
# removing duplicates
key_arr = [k["original"] for k in dev["keys"]]
for k in keys:
if k["original"] not in key_arr:
dev["keys"].append(k)
key_arr.append(k["original"])
with open(os.path.join(self.data_folder, "%s.json" % fname), "w") as f:
f.write(json.dumps(dev,indent=4))
self.update() # reload files
return self[name]
def get_by_alias(self, fname):
for dev in self:
if dev["alias"] == fname:
return dev
def remove(self, device):
os.remove(device["fullpath"])
self.update()
def __getitem__(self, name):
return Device(self._devices[name], manager=self)
def __iter__(self):
self._n = 0
return self
def __next__(self):
arr = list(self._devices.keys())
if self._n < len(arr):
v = self._devices[arr[self._n]]
self._n += 1
return Device(v, manager=self)
else:
raise StopIteration
def __len__(self):
return len(self._devices.keys())
class Device(dict):
def __init__(self, d, manager=None):
self.manager = manager
self.update(d)
self._dict = d
def update_keys(self, keys):
self["keys"] = keys
with open(self["fullpath"], "r") as f:
content = json.loads(f.read())
content["keys"] = self["keys"]
with open(self["fullpath"], "w") as f:
f.write(json.dumps(content,indent=4))
self.manager.update()
def remove_key(self, key):
keys = [k for k in self["keys"] if k["original"]!=key]
self.update_keys(keys)
def add_keys(self, normalized):
key_arr = [k["original"] for k in self["keys"]]
keys = self["keys"]
for k in normalized:
if k["original"] not in key_arr:
keys.append(k)
key_arr.append(k["original"])
self.update_keys(keys)
class WalletManager:
# chain is required to manage wallets when bitcoin-cli is not running
def __init__(self, data_folder, cli, chain, path="specter/"):
self.data_folder = data_folder
self.chain = chain
self.cli = cli
self.cli_path = path
self.update(data_folder, cli, chain)
def update(self, data_folder=None, cli=None, chain=None):
if chain is not None:
self.chain = chain
if data_folder is not None:
self.data_folder = data_folder
if data_folder.startswith("~"):
data_folder = os.path.expanduser(data_folder)
# creating folders if they don't exist
if not os.path.isdir(data_folder):
os.mkdir(data_folder)
self.working_folder = None
if self.chain is not None and self.data_folder is not None:
self.working_folder = os.path.join(self.data_folder, self.chain)
if self.working_folder is not None and not os.path.isdir(self.working_folder):
os.mkdir(self.working_folder)
if cli is not None:
self.cli = cli
if self.working_folder is not None:
self._wallets = load_jsons(self.working_folder, key="name")
else:
self._wallets = {}
def load_all(self):
loaded_wallets = self.cli.listwallets()
loadable_wallets = [w["name"] for w in self.cli.listwalletdir()["wallets"]]
not_loaded_wallets = [w for w in loadable_wallets if w not in loaded_wallets]
print(not_loaded_wallets)
for k in self._wallets:
if self.cli_path+self._wallets[k]["alias"] in not_loaded_wallets:
print("loading", self._wallets[k]["alias"])
self.cli.loadwallet(self.cli_path+self._wallets[k]["alias"])
def get_by_alias(self, fname):
for dev in self:
if dev["alias"] == fname:
return dev
def names(self):
return list(self._wallets.keys())
def create_simple(self, name, key_type, key, device):
al = alias(name)
i = 2
while os.path.isfile(os.path.join(self.working_folder, "%s.json" % al)):
al = alias("%s %d" % (name, i))
i+=1
arr = key_type.split("-")
desc = key["xpub"]
if key["derivation"] is not None:
desc = "[%s%s]%s" % (key["fingerprint"], key["derivation"][1:], key["xpub"])
recv_desc = "%s/0/*" % desc
change_desc = "%s/1/*" % desc
for el in arr[::-1]:
recv_desc = "%s(%s)" % (el, recv_desc)
change_desc = "%s(%s)" % (el, change_desc)
recv_desc = AddChecksum(recv_desc)
change_desc = AddChecksum(change_desc)
o = {
"type": "simple",
"name": name,
"description": purposes[key_type],
"key": key,
"recv_descriptor": recv_desc,
"change_descriptor": change_desc,
"device": device["name"],
"address_type": addrtypes[key_type],
"address_index": 0,
"keypool": WALLET_CHUNK,
"address": None,
"change_index": 0,
"change_address": None,
"change_keypool": WALLET_CHUNK,
}
self._wallets[al] = o
args = [
{
"desc": recv_desc,
"internal": False,
"range": [0, WALLET_CHUNK],
"timestamp": "now",
"keypool": True,
"watchonly": True
},
{
"desc": change_desc,
"internal": True,
"range": [0, WALLET_CHUNK],
"timestamp": "now",
"keypool": True,
"watchonly": True
}
]
r = self.cli.createwallet(self.cli_path+al, True)
r = self.cli.importmulti(args, {"rescan": False}, wallet=self.cli_path+al, timeout=120)
addr = self.cli.deriveaddresses(recv_desc, [0, 1])[0]
change_addr = self.cli.deriveaddresses(change_desc, [0, 1])[0]
o["address"] = addr
o["change_address"] = change_addr
if self.working_folder is not None:
fullpath = os.path.join(self.working_folder, "%s.json" % al)
with open(fullpath, "w+") as f:
f.write(json.dumps(o, indent=4))
o["alias"] = al
o["fullpath"] = fullpath
self.update()
return Wallet(o, self)
def create_multi(self, name, sigs_required, key_type, keys, devices):
al = alias(name)
i = 2
while os.path.isfile(os.path.join(self.working_folder, "%s.json" % al)):
al = alias("%s %d" % (name, i))
i+=1
# TODO: refactor, ugly
arr = key_type.split("-")
descs = [key["xpub"] for key in keys]
for i, desc in enumerate(descs):
key = keys[i]
if key["derivation"] is not None:
descs[i] = "[%s%s]%s" % (key["fingerprint"], key["derivation"][1:], key["xpub"])
recv_descs = ["%s/0/*" % desc for desc in descs]
change_descs = ["%s/1/*" % desc for desc in descs]
recv_desc = "multi({},{})".format(sigs_required, ",".join(recv_descs))
change_desc = "multi({},{})".format(sigs_required, ",".join(change_descs))
for el in arr[::-1]:
recv_desc = "%s(%s)" % (el, recv_desc)
change_desc = "%s(%s)" % (el, change_desc)
recv_desc = AddChecksum(recv_desc)
change_desc = AddChecksum(change_desc)
o = {
"type": "multisig",
"name": name,
"description": "{} of {} {}".format(sigs_required, len(keys), purposes[key_type]),
"sigs_required": sigs_required,
"keys": keys,
"recv_descriptor": recv_desc,
"change_descriptor": change_desc,
"devices": [device["name"] for device in devices],
"address_type": addrtypes[key_type],
"address_index": 0,
"keypool": WALLET_CHUNK,
"address": None,
"change_address": None,
"change_keypool": WALLET_CHUNK,
}
self._wallets[al] = o
args = [
{
"desc": recv_desc,
"internal": False,
"range": [0, o["keypool"]],
"timestamp": "now",
"keypool": True,
"watchonly": True
},
{
"desc": change_desc,
"internal": True,
"range": [0, o["keypool"]],
"timestamp": "now",
"keypool": True,
"watchonly": True
}
]
r = self.cli.createwallet(self.cli_path+al, True)
r = self.cli.importmulti(args, {"rescan": False}, wallet=self.cli_path+al, timeout=120)
print(args)
addr = self.cli.deriveaddresses(recv_desc, [0, 1])[0]
o["address"] = addr
if self.working_folder is not None:
with open(os.path.join(self.working_folder, "%s.json" % al), "w+") as f:
f.write(json.dumps(o, indent=4))
self.update()
return self[name]
def __getitem__(self, name):
return Wallet(self._wallets[name], manager=self)
def __iter__(self):
self._n = 0
return self
def __next__(self):
arr = list(self._wallets.keys())
if self._n < len(arr):
v = self._wallets[arr[self._n]]
self._n += 1
return Wallet(v, manager=self)
else:
raise StopIteration
def __len__(self):
return len(self._wallets.keys())
class Wallet(dict):
def __init__(self, d, manager=None):
self.update(d)
self.manager = manager
self.cli_path = manager.cli_path
self.cli = manager.cli.wallet(self.cli_path+self["alias"])
self._dict = d
self.getdata()
def _commit(self):
with open(self["fullpath"], "w") as f:
f.write(json.dumps(self._dict, indent=4))
self.update(self._dict)
self.manager.update()
def is_multisig(self):
return "sigs_required" in self
def _check_change(self):
addr = self["change_address"]
if addr is not None:
v = self.cli.getreceivedbyaddress(addr, 0)
if v > 0:
self._dict["change_index"] += 1
index = self._dict["change_index"]
self._dict["change_address"] = self.cli.deriveaddresses(self._dict["change_descriptor"], [index, index+1])[0]
if index == self._dict["change_keypool"]-1:
self._dict["change_keypool"] = self.keypoolrefill(self._dict["change_keypool"])
def getdata(self):
try:
self.balance = self.getbalances()
except:
self.balance = None
try:
self.transactions = self.cli.listtransactions("*", 20, 0, True)[::-1]
except:
self.transactions = None
self._check_change()
return {
"balance": self.balance,
"transactions": self.transactions
}
def getnewaddress(self):
self._dict["address_index"] += 1
index = self._dict["address_index"]
addr = self.cli.deriveaddresses(self._dict["recv_descriptor"], [index, index+1])[0]
if index == self._dict["keypool"]-1:
self._dict["keypool"] = self.keypoolrefill(self._dict["keypool"])
self._dict["address"] = addr
self._commit()
return addr
def geterror(self):
if self.cli.r is not None:
try:
err = self.cli.r.json()
except:
return self.cli.r.text
if "error" in err:
if "message" in err["error"]:
return err["error"]["message"]
return err
return self.cli.r
return None
def getbalance(self, *args, **kwargs):
default_args = ["*", 0, True]
args = list(args) + default_args[len(args):]
try:
return self.cli.getbalance(*args, **kwargs)
except:
return None
def getbalances(self, *args, **kwargs):
""" 18.1 doesn't support it, so we need to build it ourselves... """
r = {
"trusted": 0,
"untrusted_pending": 0,
}
try:
r["trusted"] = self.getbalance()
unspent = self.cli.listunspent(0, 0)
for t in unspent:
r["untrusted_pending"] += t["amount"]
except:
r = { "trusted": None, "untrusted_pending": None }
self.balance = r
return r
def getfullbalance(self):
r = self.getbalances()
if r["trusted"] is None:
return None
return r["trusted"]+r["untrusted_pending"]
def keypoolrefill(self, start, end=None, change=False):
if end is None:
end = start + WALLET_CHUNK
desc = self["recv_descriptor"] if not change else self["change_descriptor"]
args = [
{
"desc": self["recv_descriptor"],
"internal": change,
"range": [start, end],
"timestamp": "now",
"keypool": True,
"watchonly": True
}
]
r = self.cli.importmulti(args, timeout=120)
return end
@property
def fullbalance(self):
if self.balance is None:
return None
if self.balance["trusted"] is None or self.balance["untrusted_pending"] is None:
return None
return self.balance["trusted"]+self.balance["untrusted_pending"]
def createpsbt(self, address:str, amount:float):
if self.fullbalance < amount:
return None
extra_inputs = []
if self.balance["trusted"] < amount:
txlist = self.cli.listunspent(0,0)
b = amount-self.balance["trusted"]
for tx in txlist:
extra_inputs.append({"txid": tx["txid"], "vout": tx["vout"]})
b -= tx["amount"]
if b < 0:
break;
# Dont reuse change addresses - use getrawchangeaddress instead
r = self.cli.walletcreatefundedpsbt(extra_inputs, [{address: amount}], 0,
{
"includeWatching": True,
"changeAddress": self["change_address"]
}, True)
b64psbt = r["psbt"]
psbt = self.cli.decodepsbt(b64psbt)
psbt['base64'] = b64psbt
return psbt
if __name__ == '__main__':
# specter = Specter("~/_specter", config={"rpc":{"port":18332}})
# specter = Specter(config={"rpc":{"port":18332}})
specter = Specter("~/.specter")
w = specter.wallets['Stupid']
print(w.getbalances())
# print(w.getbalance("*", 0, True))
# for v in specter.devices:
# print(v)
# print(v.is_multisig())

BIN
static/img/ca.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

36
static/img/coldcard.svg Normal file
View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="209px" height="359px" viewBox="0 0 209 359" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Group</title>
<desc>Created with Sketch.</desc>
<g id="devices" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group">
<rect id="Rectangle" stroke="#FFFFFF" x="0.5" y="0.5" width="208" height="358" rx="19"></rect>
<rect id="Rectangle" stroke="#FFFFFF" x="28.5" y="49.5" width="96" height="49" rx="6"></rect>
<circle id="Oval" stroke="#FFFFFF" cx="54.5" cy="166.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="104.5" cy="166.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="154.5" cy="166.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="54.5" cy="216.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="104.5" cy="216.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="154.5" cy="216.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="54.5" cy="266.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="104.5" cy="266.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="154.5" cy="266.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="54.5" cy="316.5" r="21.5"></circle>
<rect id="Rectangle" stroke-opacity="0.385869565" stroke="#FFFFFF" x="0.5" y="0.5" width="208" height="124" rx="19"></rect>
<circle id="Oval" stroke="#FFFFFF" cx="104.5" cy="316.5" r="21.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="154.5" cy="316.5" r="21.5"></circle>
<text id="COLDCARD" font-family="Helvetica" font-size="12" font-weight="normal" fill="#F8FEFF" fill-opacity="0.39">
<tspan x="20" y="26">COLDCARD</tspan>
</text>
<circle id="Oval" stroke="#FFFFFF" cx="139.5" cy="60.5" r="5.5"></circle>
<circle id="Oval" stroke="#FFFFFF" cx="139.5" cy="90.5" r="5.5"></circle>
<path d="M12.3457341,122.625737 L12.3457341,338.305246 C12.3457341,342.723524 15.9274561,346.305246 20.3457341,346.305246 L189.96993,346.305246 C194.388208,346.305246 197.96993,342.723524 197.96993,338.305246 L197.96993,122.625737" id="Path" stroke-opacity="0.39" stroke="#FFFFFF"></path>
<path d="M132,106.698453 L195.96993,106.698453" id="Path-2" stroke-opacity="0.39" stroke="#FFFFFF"></path>
<path d="M132,101.698453 L195.96993,101.698453" id="Path-2" stroke-opacity="0.39" stroke="#FFFFFF"></path>
<path d="M132,46.6984533 L195.96993,46.6984533" id="Path-2" stroke-opacity="0.39" stroke="#FFFFFF"></path>
<path d="M132,41.6984533 L195.96993,41.6984533" id="Path-2" stroke-opacity="0.39" stroke="#FFFFFF"></path>
<path d="M14.6369556,113.805945 L14.6369556,15.1560431 C14.6369556,12.9469041 16.4278166,11.1560431 18.6369556,11.1560431 L192.182085,11.1560431 C194.391224,11.1560431 196.182085,12.9469041 196.182085,15.1560431 L196.182085,113.805945" id="Path-3" stroke-opacity="0.39" stroke="#FFFFFF"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14px" height="24px" viewBox="0 0 14 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Group 5</title>
<desc>Created with Sketch.</desc>
<g id="devices" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-5">
<g id="Group-4">
<rect id="Rectangle" stroke="#FFFFFF" stroke-width="0.7" x="0.35" y="0.35" width="13.3" height="23.3" rx="1"></rect>
<path d="M2,0 L12,0 C13.1045695,-2.02906125e-16 14,0.8954305 14,2 L14,9 L0,9 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z M2,4 L2,7 L9,7 L9,4 L2,4 Z" id="Combined-Shape" fill="#FFFFFF"></path>
</g>
<circle id="Oval" fill="#FFFFFF" cx="3" cy="11" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="7" cy="11" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="11" cy="11" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="3" cy="14" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="7" cy="14" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="11" cy="14" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="3" cy="17" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="7" cy="17" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="11" cy="17" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="3" cy="20" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="7" cy="20" r="1"></circle>
<circle id="Oval" fill="#FFFFFF" cx="11" cy="20" r="1"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
static/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
static/img/loader.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="136px" height="183px" viewBox="0 0 136 183" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Group 2</title>
<desc>Created with Sketch.</desc>
<g id="Page-2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-2" transform="translate(56.301537, 101.210101) rotate(70.000000) translate(-56.301537, -101.210101) translate(-37.698463, 21.710101)">
<path d="M119.379895,40.6029336 C114.608986,25.846267 101.692623,15.2696003 86.5071681,15.2696003 C67.2489863,15.2696003 51.5980772,32.306267 51.5980772,53.2696003 C51.5980772,74.2329336 67.2489863,91.2696003 86.5071681,91.2696003 C101.692623,91.2696003 114.608986,80.6929336 119.379895,65.936267 L144.688986,65.936267 L144.688986,91.2696003 L167.961714,91.2696003 L167.961714,65.936267 L179.598077,65.936267 L179.598077,40.6029336 L119.379895,40.6029336 Z M86.5071681,65.936267 C80.1071681,65.936267 74.8708045,60.236267 74.8708045,53.2696003 C74.8708045,46.3029336 80.1071681,40.6029336 86.5071681,40.6029336 C92.9071681,40.6029336 98.1435318,46.3029336 98.1435318,53.2696003 C98.1435318,60.236267 92.9071681,65.936267 86.5071681,65.936267 Z" id="Shape" fill-opacity="0.53" fill="#FFFFFF" fill-rule="nonzero" transform="translate(115.598077, 53.269600) rotate(-15.000000) translate(-115.598077, -53.269600) "></path>
<path d="M109.318138,82.5452222 C104.547229,67.7885555 91.6308655,57.2118888 76.445411,57.2118888 C57.1872292,57.2118888 41.5363201,74.2485555 41.5363201,95.2118888 C41.5363201,116.175222 57.1872292,133.211889 76.445411,133.211889 C91.6308655,133.211889 104.547229,122.635222 109.318138,107.878555 L134.627229,107.878555 L134.627229,133.211889 L157.899956,133.211889 L157.899956,107.878555 L169.53632,107.878555 L169.53632,82.5452222 L109.318138,82.5452222 Z M76.445411,107.878555 C70.045411,107.878555 64.8090473,102.178555 64.8090473,95.2118888 C64.8090473,88.2452222 70.045411,82.5452222 76.445411,82.5452222 C82.845411,82.5452222 88.0817746,88.2452222 88.0817746,95.2118888 C88.0817746,102.178555 82.845411,107.878555 76.445411,107.878555 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero" transform="translate(105.536320, 95.211889) rotate(28.000000) translate(-105.536320, -95.211889) "></path>
<circle id="Oval" stroke="#FFFFFF" stroke-width="10" cx="42" cy="64" r="42"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

15
static/img/other.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="171px" height="303px" viewBox="0 0 171 303" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Group 2</title>
<desc>Created with Sketch.</desc>
<g id="devices" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-2">
<rect id="Rectangle" stroke="#FFFFFF" x="0.5" y="0.5" width="170" height="302" rx="11"></rect>
<g id="unknown_device" transform="translate(33.000000, 99.000000)">
<polygon id="Path" points="0 0 106 0 106 106 0 106"></polygon>
<path d="M66.3333333,39.6666667 L39.6666667,39.6666667 L39.6666667,66.3333333 L66.3333333,66.3333333 L66.3333333,39.6666667 Z M57.4444444,57.4444444 L48.5555556,57.4444444 L48.5555556,48.5555556 L57.4444444,48.5555556 L57.4444444,57.4444444 Z M93,48.5555556 L93,39.6666667 L84.1111111,39.6666667 L84.1111111,30.7777778 C84.1111111,25.8888889 80.1111111,21.8888889 75.2222222,21.8888889 L66.3333333,21.8888889 L66.3333333,13 L57.4444444,13 L57.4444444,21.8888889 L48.5555556,21.8888889 L48.5555556,13 L39.6666667,13 L39.6666667,21.8888889 L30.7777778,21.8888889 C25.8888889,21.8888889 21.8888889,25.8888889 21.8888889,30.7777778 L21.8888889,39.6666667 L13,39.6666667 L13,48.5555556 L21.8888889,48.5555556 L21.8888889,57.4444444 L13,57.4444444 L13,66.3333333 L21.8888889,66.3333333 L21.8888889,75.2222222 C21.8888889,80.1111111 25.8888889,84.1111111 30.7777778,84.1111111 L39.6666667,84.1111111 L39.6666667,93 L48.5555556,93 L48.5555556,84.1111111 L57.4444444,84.1111111 L57.4444444,93 L66.3333333,93 L66.3333333,84.1111111 L75.2222222,84.1111111 C80.1111111,84.1111111 84.1111111,80.1111111 84.1111111,75.2222222 L84.1111111,66.3333333 L93,66.3333333 L93,57.4444444 L84.1111111,57.4444444 L84.1111111,48.5555556 L93,48.5555556 Z M75.2222222,75.2222222 L30.7777778,75.2222222 L30.7777778,30.7777778 L75.2222222,30.7777778 L75.2222222,75.2222222 Z" id="Shape" fill-opacity="0.389999986" fill="#FFFFFF" fill-rule="nonzero"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

15
static/img/other_icon.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14px" height="24px" viewBox="0 0 14 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Group 6</title>
<desc>Created with Sketch.</desc>
<g id="devices" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-6">
<rect id="Rectangle" stroke="#FFFFFF" x="0.5" y="0.5" width="13" height="23" rx="2"></rect>
<g id="unknown_device" transform="translate(1.000000, 6.000000)">
<polygon id="Path" points="0 0 12 0 12 12 0 12"></polygon>
<path d="M7.5,4.5 L4.5,4.5 L4.5,7.5 L7.5,7.5 L7.5,4.5 Z M6.5,6.5 L5.5,6.5 L5.5,5.5 L6.5,5.5 L6.5,6.5 Z M10.5,5.5 L10.5,4.5 L9.5,4.5 L9.5,3.5 C9.5,2.95 9.05,2.5 8.5,2.5 L7.5,2.5 L7.5,1.5 L6.5,1.5 L6.5,2.5 L5.5,2.5 L5.5,1.5 L4.5,1.5 L4.5,2.5 L3.5,2.5 C2.95,2.5 2.5,2.95 2.5,3.5 L2.5,4.5 L1.5,4.5 L1.5,5.5 L2.5,5.5 L2.5,6.5 L1.5,6.5 L1.5,7.5 L2.5,7.5 L2.5,8.5 C2.5,9.05 2.95,9.5 3.5,9.5 L4.5,9.5 L4.5,10.5 L5.5,10.5 L5.5,9.5 L6.5,9.5 L6.5,10.5 L7.5,10.5 L7.5,9.5 L8.5,9.5 C9.05,9.5 9.5,9.05 9.5,8.5 L9.5,7.5 L10.5,7.5 L10.5,6.5 L9.5,6.5 L9.5,5.5 L10.5,5.5 Z M8.5,8.5 L3.5,8.5 L3.5,3.5 L8.5,3.5 L8.5,8.5 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

1
static/img/qr_icon.svg Normal file
View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="qrcode" class="svg-inline--fa fa-qrcode fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="#fff" d="M0 224h192V32H0v192zM64 96h64v64H64V96zm192-64v192h192V32H256zm128 128h-64V96h64v64zM0 480h192V288H0v192zm64-128h64v64H64v-64zm352-64h32v128h-96v-32h-32v96h-64V288h96v32h64v-32zm0 160h32v32h-32v-32zm-64 0h32v32h-32v-32z"></path></svg>

After

Width:  |  Height:  |  Size: 443 B

11
static/img/qr_tiny.svg Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="9px" height="9px" viewBox="0 0 9 9" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>qr_icon</title>
<desc>Created with Sketch.</desc>
<g id="devices" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="qr_icon" fill="#FFFFFF" fill-rule="nonzero">
<path d="M0,3.85714286 L3.85714286,3.85714286 L3.85714286,0 L0,0 L0,3.85714286 Z M1.28571429,1.28571429 L2.57142857,1.28571429 L2.57142857,2.57142857 L1.28571429,2.57142857 L1.28571429,1.28571429 Z M5.14285714,0 L5.14285714,3.85714286 L9,3.85714286 L9,0 L5.14285714,0 Z M7.71428571,2.57142857 L6.42857143,2.57142857 L6.42857143,1.28571429 L7.71428571,1.28571429 L7.71428571,2.57142857 Z M0,9 L3.85714286,9 L3.85714286,5.14285714 L0,5.14285714 L0,9 Z M1.28571429,6.42857143 L2.57142857,6.42857143 L2.57142857,7.71428571 L1.28571429,7.71428571 L1.28571429,6.42857143 Z M8.35714286,5.14285714 L9,5.14285714 L9,7.71428571 L7.07142857,7.71428571 L7.07142857,7.07142857 L6.42857143,7.07142857 L6.42857143,9 L5.14285714,9 L5.14285714,5.14285714 L7.07142857,5.14285714 L7.07142857,5.78571429 L8.35714286,5.78571429 L8.35714286,5.14285714 Z M8.35714286,8.35714286 L9,8.35714286 L9,9 L8.35714286,9 L8.35714286,8.35714286 Z M7.07142857,8.35714286 L7.71428571,8.35714286 L7.71428571,9 L7.07142857,9 L7.07142857,8.35714286 Z" id="Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14px" height="16px" viewBox="0 0 14 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>baseline-publish-24px</title>
<desc>Created with Sketch.</desc>
<g id="screenshot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="baseline-publish-24px" transform="translate(7.000000, 8.000000) scale(1, -1) translate(-7.000000, -8.000000) translate(-5.000000, -4.000000)">
<polygon id="Path" points="0 0 24 0 24 24 0 24"></polygon>
<path d="M5,4 L5,6 L19,6 L19,4 L5,4 Z M5,14 L9,14 L9,20 L15,20 L15,14 L19,14 L12,7 L5,14 Z" id="Shape" fill="#4A90E2" fill-rule="nonzero"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 848 B

12
static/img/send_icon.svg Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14px" height="16px" viewBox="0 0 14 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>baseline-publish-24px</title>
<desc>Created with Sketch.</desc>
<g id="screenshot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="baseline-publish-24px" transform="translate(7.000000, 8.000000) scale(1, -1) translate(-7.000000, -8.000000) translate(-5.000000, -4.000000)">
<polygon id="Path" points="0 0 24 0 24 24 0 24"></polygon>
<path d="M5,4 L5,6 L19,6 L19,4 L5,4 Z M5,13 L9,13 L9,7 L15,7 L15,13 L19,13 L12,20 L5,13 Z" id="Shape" fill="#F5A623" fill-rule="nonzero"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 847 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="124px" height="110px" viewBox="0 0 124 110" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Shape</title>
<desc>Created with Sketch.</desc>
<g id="Page-2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M65.1303835,38.9315219 C60.3594744,24.1748552 47.4431108,13.5981885 32.2576562,13.5981885 C12.9994744,13.5981885 -2.65143467,30.6348552 -2.65143467,51.5981885 C-2.65143467,72.5615219 12.9994744,89.5981885 32.2576562,89.5981885 C47.4431108,89.5981885 60.3594744,79.0215219 65.1303835,64.2648552 L90.4394744,64.2648552 L90.4394744,89.5981885 L113.712202,89.5981885 L113.712202,64.2648552 L125.348565,64.2648552 L125.348565,38.9315219 L65.1303835,38.9315219 Z M32.2576562,64.2648552 C25.8576562,64.2648552 20.6212926,58.5648552 20.6212926,51.5981885 C20.6212926,44.6315219 25.8576562,38.9315219 32.2576562,38.9315219 C38.6576562,38.9315219 43.8940199,44.6315219 43.8940199,51.5981885 C43.8940199,58.5648552 38.6576562,64.2648552 32.2576562,64.2648552 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero" transform="translate(61.348565, 51.598189) rotate(28.000000) translate(-61.348565, -51.598189) "></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

247
static/img/specter.svg Normal file
View file

@ -0,0 +1,247 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="171px" height="303px" viewBox="0 0 171 303" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Group 2</title>
<desc>Created with Sketch.</desc>
<g id="devices" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-2">
<rect id="Rectangle" stroke="#FFFFFF" x="0.5" y="0.5" width="170" height="302" rx="11"></rect>
<rect id="Rectangle" stroke="#FFFFFF" x="12.5" y="29.5" width="147" height="247" rx="2"></rect>
<g id="Group" opacity="0.39" transform="translate(44.000000, 87.000000)" fill="#FFFFFF">
<rect id="p" x="0" y="0" width="4" height="4"></rect>
<rect id="p" x="0" y="4" width="4" height="4"></rect>
<rect id="p" x="0" y="8" width="4" height="4"></rect>
<rect id="p" x="0" y="12" width="4" height="4"></rect>
<rect id="p" x="0" y="16" width="4" height="4"></rect>
<rect id="p" x="0" y="20" width="4" height="4"></rect>
<rect id="p" x="0" y="24" width="4" height="4"></rect>
<rect id="p" x="0" y="32" width="4" height="4"></rect>
<rect id="p" x="0" y="40" width="4" height="4"></rect>
<rect id="p" x="0" y="56" width="4" height="4"></rect>
<rect id="p" x="0" y="60" width="4" height="4"></rect>
<rect id="p" x="0" y="64" width="4" height="4"></rect>
<rect id="p" x="0" y="68" width="4" height="4"></rect>
<rect id="p" x="0" y="72" width="4" height="4"></rect>
<rect id="p" x="0" y="76" width="4" height="4"></rect>
<rect id="p" x="0" y="80" width="4" height="4"></rect>
<rect id="p" x="4" y="0" width="4" height="4"></rect>
<rect id="p" x="4" y="24" width="4" height="4"></rect>
<rect id="p" x="4" y="32" width="4" height="4"></rect>
<rect id="p" x="4" y="36" width="4" height="4"></rect>
<rect id="p" x="4" y="40" width="4" height="4"></rect>
<rect id="p" x="4" y="48" width="4" height="4"></rect>
<rect id="p" x="4" y="56" width="4" height="4"></rect>
<rect id="p" x="4" y="80" width="4" height="4"></rect>
<rect id="p" x="8" y="0" width="4" height="4"></rect>
<rect id="p" x="8" y="8" width="4" height="4"></rect>
<rect id="p" x="8" y="12" width="4" height="4"></rect>
<rect id="p" x="8" y="16" width="4" height="4"></rect>
<rect id="p" x="8" y="24" width="4" height="4"></rect>
<rect id="p" x="8" y="32" width="4" height="4"></rect>
<rect id="p" x="8" y="36" width="4" height="4"></rect>
<rect id="p" x="8" y="40" width="4" height="4"></rect>
<rect id="p" x="8" y="44" width="4" height="4"></rect>
<rect id="p" x="8" y="56" width="4" height="4"></rect>
<rect id="p" x="8" y="64" width="4" height="4"></rect>
<rect id="p" x="8" y="68" width="4" height="4"></rect>
<rect id="p" x="8" y="72" width="4" height="4"></rect>
<rect id="p" x="8" y="80" width="4" height="4"></rect>
<rect id="p" x="12" y="0" width="4" height="4"></rect>
<rect id="p" x="12" y="8" width="4" height="4"></rect>
<rect id="p" x="12" y="12" width="4" height="4"></rect>
<rect id="p" x="12" y="16" width="4" height="4"></rect>
<rect id="p" x="12" y="24" width="4" height="4"></rect>
<rect id="p" x="12" y="32" width="4" height="4"></rect>
<rect id="p" x="12" y="40" width="4" height="4"></rect>
<rect id="p" x="12" y="48" width="4" height="4"></rect>
<rect id="p" x="12" y="56" width="4" height="4"></rect>
<rect id="p" x="12" y="64" width="4" height="4"></rect>
<rect id="p" x="12" y="68" width="4" height="4"></rect>
<rect id="p" x="12" y="72" width="4" height="4"></rect>
<rect id="p" x="12" y="80" width="4" height="4"></rect>
<rect id="p" x="16" y="0" width="4" height="4"></rect>
<rect id="p" x="16" y="8" width="4" height="4"></rect>
<rect id="p" x="16" y="12" width="4" height="4"></rect>
<rect id="p" x="16" y="16" width="4" height="4"></rect>
<rect id="p" x="16" y="24" width="4" height="4"></rect>
<rect id="p" x="16" y="36" width="4" height="4"></rect>
<rect id="p" x="16" y="44" width="4" height="4"></rect>
<rect id="p" x="16" y="48" width="4" height="4"></rect>
<rect id="p" x="16" y="56" width="4" height="4"></rect>
<rect id="p" x="16" y="64" width="4" height="4"></rect>
<rect id="p" x="16" y="68" width="4" height="4"></rect>
<rect id="p" x="16" y="72" width="4" height="4"></rect>
<rect id="p" x="16" y="80" width="4" height="4"></rect>
<rect id="p" x="20" y="0" width="4" height="4"></rect>
<rect id="p" x="20" y="24" width="4" height="4"></rect>
<rect id="p" x="20" y="40" width="4" height="4"></rect>
<rect id="p" x="20" y="44" width="4" height="4"></rect>
<rect id="p" x="20" y="56" width="4" height="4"></rect>
<rect id="p" x="20" y="80" width="4" height="4"></rect>
<rect id="p" x="24" y="0" width="4" height="4"></rect>
<rect id="p" x="24" y="4" width="4" height="4"></rect>
<rect id="p" x="24" y="8" width="4" height="4"></rect>
<rect id="p" x="24" y="12" width="4" height="4"></rect>
<rect id="p" x="24" y="16" width="4" height="4"></rect>
<rect id="p" x="24" y="20" width="4" height="4"></rect>
<rect id="p" x="24" y="24" width="4" height="4"></rect>
<rect id="p" x="24" y="32" width="4" height="4"></rect>
<rect id="p" x="24" y="40" width="4" height="4"></rect>
<rect id="p" x="24" y="48" width="4" height="4"></rect>
<rect id="p" x="24" y="56" width="4" height="4"></rect>
<rect id="p" x="24" y="60" width="4" height="4"></rect>
<rect id="p" x="24" y="64" width="4" height="4"></rect>
<rect id="p" x="24" y="68" width="4" height="4"></rect>
<rect id="p" x="24" y="72" width="4" height="4"></rect>
<rect id="p" x="24" y="76" width="4" height="4"></rect>
<rect id="p" x="24" y="80" width="4" height="4"></rect>
<rect id="p" x="28" y="36" width="4" height="4"></rect>
<rect id="p" x="28" y="44" width="4" height="4"></rect>
<rect id="p" x="28" y="48" width="4" height="4"></rect>
<rect id="p" x="32" y="0" width="4" height="4"></rect>
<rect id="p" x="32" y="8" width="4" height="4"></rect>
<rect id="p" x="32" y="12" width="4" height="4"></rect>
<rect id="p" x="32" y="16" width="4" height="4"></rect>
<rect id="p" x="32" y="24" width="4" height="4"></rect>
<rect id="p" x="32" y="32" width="4" height="4"></rect>
<rect id="p" x="32" y="36" width="4" height="4"></rect>
<rect id="p" x="32" y="52" width="4" height="4"></rect>
<rect id="p" x="32" y="68" width="4" height="4"></rect>
<rect id="p" x="32" y="72" width="4" height="4"></rect>
<rect id="p" x="32" y="76" width="4" height="4"></rect>
<rect id="p" x="32" y="80" width="4" height="4"></rect>
<rect id="p" x="36" y="8" width="4" height="4"></rect>
<rect id="p" x="36" y="12" width="4" height="4"></rect>
<rect id="p" x="36" y="20" width="4" height="4"></rect>
<rect id="p" x="36" y="32" width="4" height="4"></rect>
<rect id="p" x="36" y="36" width="4" height="4"></rect>
<rect id="p" x="36" y="40" width="4" height="4"></rect>
<rect id="p" x="36" y="44" width="4" height="4"></rect>
<rect id="p" x="36" y="52" width="4" height="4"></rect>
<rect id="p" x="36" y="76" width="4" height="4"></rect>
<rect id="p" x="36" y="80" width="4" height="4"></rect>
<rect id="p" x="40" y="0" width="4" height="4"></rect>
<rect id="p" x="40" y="4" width="4" height="4"></rect>
<rect id="p" x="40" y="20" width="4" height="4"></rect>
<rect id="p" x="40" y="24" width="4" height="4"></rect>
<rect id="p" x="40" y="32" width="4" height="4"></rect>
<rect id="p" x="40" y="40" width="4" height="4"></rect>
<rect id="p" x="40" y="52" width="4" height="4"></rect>
<rect id="p" x="40" y="56" width="4" height="4"></rect>
<rect id="p" x="40" y="60" width="4" height="4"></rect>
<rect id="p" x="40" y="68" width="4" height="4"></rect>
<rect id="p" x="40" y="72" width="4" height="4"></rect>
<rect id="p" x="40" y="76" width="4" height="4"></rect>
<rect id="p" x="40" y="80" width="4" height="4"></rect>
<rect id="p" x="44" y="0" width="4" height="4"></rect>
<rect id="p" x="44" y="4" width="4" height="4"></rect>
<rect id="p" x="44" y="8" width="4" height="4"></rect>
<rect id="p" x="44" y="16" width="4" height="4"></rect>
<rect id="p" x="44" y="20" width="4" height="4"></rect>
<rect id="p" x="44" y="28" width="4" height="4"></rect>
<rect id="p" x="44" y="32" width="4" height="4"></rect>
<rect id="p" x="44" y="36" width="4" height="4"></rect>
<rect id="p" x="44" y="40" width="4" height="4"></rect>
<rect id="p" x="44" y="44" width="4" height="4"></rect>
<rect id="p" x="44" y="52" width="4" height="4"></rect>
<rect id="p" x="44" y="56" width="4" height="4"></rect>
<rect id="p" x="48" y="0" width="4" height="4"></rect>
<rect id="p" x="48" y="12" width="4" height="4"></rect>
<rect id="p" x="48" y="20" width="4" height="4"></rect>
<rect id="p" x="48" y="24" width="4" height="4"></rect>
<rect id="p" x="48" y="28" width="4" height="4"></rect>
<rect id="p" x="48" y="32" width="4" height="4"></rect>
<rect id="p" x="48" y="36" width="4" height="4"></rect>
<rect id="p" x="48" y="48" width="4" height="4"></rect>
<rect id="p" x="48" y="56" width="4" height="4"></rect>
<rect id="p" x="48" y="64" width="4" height="4"></rect>
<rect id="p" x="48" y="72" width="4" height="4"></rect>
<rect id="p" x="48" y="80" width="4" height="4"></rect>
<rect id="p" x="52" y="32" width="4" height="4"></rect>
<rect id="p" x="52" y="52" width="4" height="4"></rect>
<rect id="p" x="52" y="56" width="4" height="4"></rect>
<rect id="p" x="52" y="60" width="4" height="4"></rect>
<rect id="p" x="52" y="68" width="4" height="4"></rect>
<rect id="p" x="52" y="72" width="4" height="4"></rect>
<rect id="p" x="52" y="76" width="4" height="4"></rect>
<rect id="p" x="56" y="0" width="4" height="4"></rect>
<rect id="p" x="56" y="4" width="4" height="4"></rect>
<rect id="p" x="56" y="8" width="4" height="4"></rect>
<rect id="p" x="56" y="12" width="4" height="4"></rect>
<rect id="p" x="56" y="16" width="4" height="4"></rect>
<rect id="p" x="56" y="20" width="4" height="4"></rect>
<rect id="p" x="56" y="24" width="4" height="4"></rect>
<rect id="p" x="56" y="40" width="4" height="4"></rect>
<rect id="p" x="56" y="52" width="4" height="4"></rect>
<rect id="p" x="56" y="64" width="4" height="4"></rect>
<rect id="p" x="56" y="68" width="4" height="4"></rect>
<rect id="p" x="56" y="76" width="4" height="4"></rect>
<rect id="p" x="60" y="0" width="4" height="4"></rect>
<rect id="p" x="60" y="24" width="4" height="4"></rect>
<rect id="p" x="60" y="36" width="4" height="4"></rect>
<rect id="p" x="60" y="48" width="4" height="4"></rect>
<rect id="p" x="60" y="52" width="4" height="4"></rect>
<rect id="p" x="60" y="56" width="4" height="4"></rect>
<rect id="p" x="60" y="64" width="4" height="4"></rect>
<rect id="p" x="60" y="80" width="4" height="4"></rect>
<rect id="p" x="64" y="0" width="4" height="4"></rect>
<rect id="p" x="64" y="8" width="4" height="4"></rect>
<rect id="p" x="64" y="12" width="4" height="4"></rect>
<rect id="p" x="64" y="16" width="4" height="4"></rect>
<rect id="p" x="64" y="24" width="4" height="4"></rect>
<rect id="p" x="64" y="32" width="4" height="4"></rect>
<rect id="p" x="64" y="36" width="4" height="4"></rect>
<rect id="p" x="64" y="52" width="4" height="4"></rect>
<rect id="p" x="64" y="56" width="4" height="4"></rect>
<rect id="p" x="64" y="80" width="4" height="4"></rect>
<rect id="p" x="68" y="0" width="4" height="4"></rect>
<rect id="p" x="68" y="8" width="4" height="4"></rect>
<rect id="p" x="68" y="12" width="4" height="4"></rect>
<rect id="p" x="68" y="16" width="4" height="4"></rect>
<rect id="p" x="68" y="24" width="4" height="4"></rect>
<rect id="p" x="68" y="32" width="4" height="4"></rect>
<rect id="p" x="68" y="40" width="4" height="4"></rect>
<rect id="p" x="68" y="44" width="4" height="4"></rect>
<rect id="p" x="68" y="56" width="4" height="4"></rect>
<rect id="p" x="68" y="60" width="4" height="4"></rect>
<rect id="p" x="68" y="64" width="4" height="4"></rect>
<rect id="p" x="68" y="72" width="4" height="4"></rect>
<rect id="p" x="68" y="80" width="4" height="4"></rect>
<rect id="p" x="72" y="0" width="4" height="4"></rect>
<rect id="p" x="72" y="8" width="4" height="4"></rect>
<rect id="p" x="72" y="12" width="4" height="4"></rect>
<rect id="p" x="72" y="16" width="4" height="4"></rect>
<rect id="p" x="72" y="24" width="4" height="4"></rect>
<rect id="p" x="72" y="32" width="4" height="4"></rect>
<rect id="p" x="72" y="36" width="4" height="4"></rect>
<rect id="p" x="72" y="60" width="4" height="4"></rect>
<rect id="p" x="72" y="64" width="4" height="4"></rect>
<rect id="p" x="72" y="80" width="4" height="4"></rect>
<rect id="p" x="76" y="0" width="4" height="4"></rect>
<rect id="p" x="76" y="24" width="4" height="4"></rect>
<rect id="p" x="76" y="36" width="4" height="4"></rect>
<rect id="p" x="76" y="40" width="4" height="4"></rect>
<rect id="p" x="76" y="52" width="4" height="4"></rect>
<rect id="p" x="76" y="60" width="4" height="4"></rect>
<rect id="p" x="76" y="68" width="4" height="4"></rect>
<rect id="p" x="80" y="0" width="4" height="4"></rect>
<rect id="p" x="80" y="4" width="4" height="4"></rect>
<rect id="p" x="80" y="8" width="4" height="4"></rect>
<rect id="p" x="80" y="12" width="4" height="4"></rect>
<rect id="p" x="80" y="16" width="4" height="4"></rect>
<rect id="p" x="80" y="20" width="4" height="4"></rect>
<rect id="p" x="80" y="24" width="4" height="4"></rect>
<rect id="p" x="80" y="32" width="4" height="4"></rect>
<rect id="p" x="80" y="36" width="4" height="4"></rect>
<rect id="p" x="80" y="40" width="4" height="4"></rect>
<rect id="p" x="80" y="44" width="4" height="4"></rect>
<rect id="p" x="80" y="52" width="4" height="4"></rect>
<rect id="p" x="80" y="60" width="4" height="4"></rect>
<rect id="p" x="80" y="76" width="4" height="4"></rect>
</g>
<text id="Specter-DIY" font-family="Helvetica" font-size="12" font-weight="normal" fill="#F8FEFF" fill-opacity="0.39">
<tspan x="53" y="198">Specter DIY</tspan>
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="15px" height="24px" viewBox="0 0 15 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Group 3</title>
<desc>Created with Sketch.</desc>
<g id="devices" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-3" fill="#FFFFFF" fill-rule="nonzero">
<g id="specter_icon">
<path d="M12.8783652,0.0200166806 L2.14013649,0 C0.957429483,0 -3.55271368e-15,1.02085071 -3.55271368e-15,2.28190158 L-3.55271368e-15,21.7180984 C-3.55271368e-15,22.9791493 0.957429483,24 2.14013649,24 L12.8595921,24 C14.0422991,24 14.9997286,22.9791493 14.9997286,21.7180984 L14.9997286,2.28190158 C15.0185017,1.02085071 14.0610722,0.0200166806 12.8783652,0.0200166806 Z M13.9390469,21.7180984 L1.07945481,21.7180984 L1.07945481,4.00333611 L13.9390469,4.00333611 L13.9390469,21.7180984 Z" id="Shape"></path>
</g>
<g id="qr_icon" transform="translate(3.000000, 8.000000)">
<path d="M0,3.85714286 L3.85714286,3.85714286 L3.85714286,0 L0,0 L0,3.85714286 Z M1.28571429,1.28571429 L2.57142857,1.28571429 L2.57142857,2.57142857 L1.28571429,2.57142857 L1.28571429,1.28571429 Z M5.14285714,0 L5.14285714,3.85714286 L9,3.85714286 L9,0 L5.14285714,0 Z M7.71428571,2.57142857 L6.42857143,2.57142857 L6.42857143,1.28571429 L7.71428571,1.28571429 L7.71428571,2.57142857 Z M0,9 L3.85714286,9 L3.85714286,5.14285714 L0,5.14285714 L0,9 Z M1.28571429,6.42857143 L2.57142857,6.42857143 L2.57142857,7.71428571 L1.28571429,7.71428571 L1.28571429,6.42857143 Z M8.35714286,5.14285714 L9,5.14285714 L9,7.71428571 L7.07142857,7.71428571 L7.07142857,7.07142857 L6.42857143,7.07142857 L6.42857143,9 L5.14285714,9 L5.14285714,5.14285714 L7.07142857,5.14285714 L7.07142857,5.78571429 L8.35714286,5.78571429 L8.35714286,5.14285714 Z M8.35714286,8.35714286 L9,8.35714286 L9,9 L8.35714286,9 L8.35714286,8.35714286 Z M7.07142857,8.35714286 L7.71428571,8.35714286 L7.71428571,9 L7.07142857,9 L7.07142857,8.35714286 Z" id="Shape"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Combined Shape</title>
<desc>Created with Sketch.</desc>
<g id="screenshot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M7.992,0 C12.416,0 16,3.584 16,8 C16,12.416 12.416,16 7.992,16 C3.576,16 0,12.416 0,8 C0,3.584 3.576,0 7.992,0 Z M8,14.4 C11.536,14.4 14.4,11.536 14.4,8 C14.4,4.464 11.536,1.6 8,1.6 C4.464,1.6 1.6,4.464 1.6,8 C1.6,11.536 4.464,14.4 8,14.4 Z M8.5,4 L8.5,8.01639344 L13,10.0590164 L12.25,11 L7,8.59016393 L7,4 L8.5,4 Z" id="Combined-Shape" fill="#4B8CD8" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 839 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Combined Shape</title>
<desc>Created with Sketch.</desc>
<g id="screenshot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M7.992,0 C12.416,0 16,3.584 16,8 C16,12.416 12.416,16 7.992,16 C3.576,16 0,12.416 0,8 C0,3.584 3.576,0 7.992,0 Z M8,14.4 C11.536,14.4 14.4,11.536 14.4,8 C14.4,4.464 11.536,1.6 8,1.6 C4.464,1.6 1.6,4.464 1.6,8 C1.6,11.536 4.464,14.4 8,14.4 Z M8.5,4 L8.5,8.01639344 L13,10.0590164 L12.25,11 L7,8.59016393 L7,4 L8.5,4 Z" id="Combined-Shape" fill="#F5A623" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 839 B

86
static/qr/qr-scanner-worker.min.js vendored Normal file
View file

@ -0,0 +1,86 @@
'use strict';(function(){function S(a,b){let c=[],d="";b=a.readBits([8,16,16][b]);for(let d=0;d<b;d++){let b=a.readBits(8);c.push(b)}try{d+=decodeURIComponent(c.map((a)=>`%${("0"+a.toString(16)).substr(-2)}`).join(""))}catch(e){}return{bytes:c,text:d}}function T(a,b){a=new U(a);b=9>=b?0:26>=b?1:2;let c={text:"",bytes:[],chunks:[]};for(;4<=a.available();){var d=a.readBits(4);if(d===y.Terminator)return c;if(d===y.ECI)0===a.readBits(1)?c.chunks.push({type:u.ECI,assignmentNumber:a.readBits(7)}):0===a.readBits(1)?
c.chunks.push({type:u.ECI,assignmentNumber:a.readBits(14)}):0===a.readBits(1)?c.chunks.push({type:u.ECI,assignmentNumber:a.readBits(21)}):c.chunks.push({type:u.ECI,assignmentNumber:-1});else if(d===y.Numeric){var e=a;d=[];for(var g="",f=e.readBits([10,12,14][b]);3<=f;){var h=e.readBits(10);if(1E3<=h)throw Error("Invalid numeric value above 999");var k=Math.floor(h/100),m=Math.floor(h/10)%10;h%=10;d.push(48+k,48+m,48+h);g+=k.toString()+m.toString()+h.toString();f-=3}if(2===f){f=e.readBits(7);if(100<=
f)throw Error("Invalid numeric value above 99");e=Math.floor(f/10);f%=10;d.push(48+e,48+f);g+=e.toString()+f.toString()}else if(1===f){e=e.readBits(4);if(10<=e)throw Error("Invalid numeric value above 9");d.push(48+e);g+=e.toString()}d={bytes:d,text:g};c.text+=d.text;c.bytes.push(...d.bytes);c.chunks.push({type:u.Numeric,text:d.text})}else if(d===y.Alphanumeric){e=a;d=[];g="";for(f=e.readBits([9,11,13][b]);2<=f;)m=e.readBits(11),k=Math.floor(m/45),m%=45,d.push(B[k].charCodeAt(0),B[m].charCodeAt(0)),
g+=B[k]+B[m],f-=2;1===f&&(e=e.readBits(6),d.push(B[e].charCodeAt(0)),g+=B[e]);d={bytes:d,text:g};c.text+=d.text;c.bytes.push(...d.bytes);c.chunks.push({type:u.Alphanumeric,text:d.text})}else if(d===y.Byte)d=S(a,b),c.text+=d.text,c.bytes.push(...d.bytes),c.chunks.push({type:u.Byte,bytes:d.bytes,text:d.text});else if(d===y.Kanji){g=a;d=[];e=g.readBits([8,10,12][b]);for(f=0;f<e;f++)k=g.readBits(13),k=Math.floor(k/192)<<8|k%192,k=7936>k?k+33088:k+49472,d.push(k>>8,k&255);g=(new TextDecoder("shift-jis")).decode(Uint8Array.from(d));
d={bytes:d,text:g};c.text+=d.text;c.bytes.push(...d.bytes);c.chunks.push({type:u.Kanji,bytes:d.bytes,text:d.text})}}if(0===a.available()||0===a.readBits(a.available()))return c}function J(a,b){return a^b}function V(a,b,c,d){b.degree()<c.degree()&&([b,c]=[c,b]);let e=a.zero;for(var g=a.one;c.degree()>=d/2;){var f=b;let d=e;b=c;e=g;if(b.isZero())return null;c=f;g=a.zero;f=b.getCoefficient(b.degree());for(f=a.inverse(f);c.degree()>=b.degree()&&!c.isZero();){let d=c.degree()-b.degree(),e=a.multiply(c.getCoefficient(c.degree()),
f);g=g.addOrSubtract(a.buildMonomial(d,e));c=c.addOrSubtract(b.multiplyByMonomial(d,e))}g=g.multiplyPoly(e).addOrSubtract(d);if(c.degree()>=b.degree())return null}d=g.getCoefficient(0);if(0===d)return null;a=a.inverse(d);return[g.multiply(a),c.multiply(a)]}function W(a,b){let c=new Uint8ClampedArray(a.length);c.set(a);a=new X(285,256,0);var d=new w(a,c),e=new Uint8ClampedArray(b),g=!1;for(var f=0;f<b;f++){var h=d.evaluateAt(a.exp(f+a.generatorBase));e[e.length-1-f]=h;0!==h&&(g=!0)}if(!g)return c;
d=new w(a,e);d=V(a,a.buildMonomial(b,1),d,b);if(null===d)return null;b=d[0];f=b.degree();if(1===f)b=[b.getCoefficient(1)];else{e=Array(f);g=0;for(h=1;h<a.size&&g<f;h++)0===b.evaluateAt(h)&&(e[g]=a.inverse(h),g++);b=g!==f?null:e}if(null==b)return null;d=d[1];e=b.length;g=Array(e);for(f=0;f<e;f++){h=a.inverse(b[f]);let c=1;for(let d=0;d<e;d++)f!==d&&(c=a.multiply(c,J(1,a.multiply(b[d],h))));g[f]=a.multiply(d.evaluateAt(h),a.inverse(c));0!==a.generatorBase&&(g[f]=a.multiply(g[f],h))}d=g;for(e=0;e<b.length;e++){g=
c.length-1-a.log(b[e]);if(0>g)return null;c[g]^=d[e]}return c}function E(a,b){a^=b;for(b=0;a;)b++,a&=a-1;return b}function C(a,b){return b<<1|a}function Y(a,b,c){c=Z[c.dataMask];let d=a.height;var e=17+4*b.versionNumber,g=A.createEmpty(e,e);g.setRegion(0,0,9,9,!0);g.setRegion(e-8,0,8,9,!0);g.setRegion(0,e-8,9,8,!0);for(var f of b.alignmentPatternCenters)for(var h of b.alignmentPatternCenters)6===f&&6===h||6===f&&h===e-7||f===e-7&&6===h||g.setRegion(f-2,h-2,5,5,!0);g.setRegion(6,9,1,e-17,!0);g.setRegion(9,
6,e-17,1,!0);6<b.versionNumber&&(g.setRegion(e-11,0,3,6,!0),g.setRegion(0,e-11,6,3,!0));b=g;f=[];e=h=0;g=!0;for(let k=d-1;0<k;k-=2){6===k&&k--;for(let m=0;m<d;m++){let p=g?d-1-m:m;for(let d=0;2>d;d++){let g=k-d;if(!b.get(g,p)){e++;let b=a.get(g,p);c({y:p,x:g})&&(b=!b);h=h<<1|b;8===e&&(f.push(h),h=e=0)}}}g=!g}return f}function aa(a){var b=a.height,c=Math.floor((b-17)/4);if(6>=c)return K[c-1];c=0;for(var d=5;0<=d;d--)for(var e=b-9;e>=b-11;e--)c=C(a.get(e,d),c);d=0;for(e=5;0<=e;e--)for(let c=b-9;c>=
b-11;c--)d=C(a.get(e,c),d);a=Infinity;let g;for(let e of K){if(e.infoBits===c||e.infoBits===d)return e;b=E(c,e.infoBits);b<a&&(g=e,a=b);b=E(d,e.infoBits);b<a&&(g=e,a=b)}if(3>=a)return g}function ba(a){let b=0;for(var c=0;8>=c;c++)6!==c&&(b=C(a.get(c,8),b));for(c=7;0<=c;c--)6!==c&&(b=C(a.get(8,c),b));var d=a.height;c=0;for(var e=d-1;e>=d-7;e--)c=C(a.get(8,e),c);for(e=d-8;e<d;e++)c=C(a.get(e,8),c);a=Infinity;d=null;for(let {bits:g,formatInfo:f}of ca){if(g===b||g===c)return f;e=E(b,g);e<a&&(d=f,a=e);
b!==c&&(e=E(c,g),e<a&&(d=f,a=e))}return 3>=a?d:null}function da(a,b,c){let d=b.errorCorrectionLevels[c],e=[],g=0;d.ecBlocks.forEach((a)=>{for(let b=0;b<a.numBlocks;b++)e.push({numDataCodewords:a.dataCodewordsPerBlock,codewords:[]}),g+=a.dataCodewordsPerBlock+d.ecCodewordsPerBlock});if(a.length<g)return null;a=a.slice(0,g);b=d.ecBlocks[0].dataCodewordsPerBlock;for(c=0;c<b;c++)for(var f of e)f.codewords.push(a.shift());if(1<d.ecBlocks.length)for(f=d.ecBlocks[0].numBlocks,b=d.ecBlocks[1].numBlocks,c=
0;c<b;c++)e[f+c].codewords.push(a.shift());for(;0<a.length;)for(let b of e)b.codewords.push(a.shift());return e}function L(a){let b=aa(a);if(!b)return null;var c=ba(a);if(!c)return null;a=Y(a,b,c);var d=da(a,b,c.errorCorrectionLevel);if(!d)return null;c=d.reduce((a,b)=>a+b.numDataCodewords,0);c=new Uint8ClampedArray(c);a=0;for(let b of d){d=W(b.codewords,b.codewords.length-b.numDataCodewords);if(!d)return null;for(let e=0;e<b.numDataCodewords;e++)c[a++]=d[e]}try{return T(c,b.versionNumber)}catch(e){return null}}
function M(a,b,c,d){var e=a.x-b.x+c.x-d.x;let g=a.y-b.y+c.y-d.y;if(0===e&&0===g)return{a11:b.x-a.x,a12:b.y-a.y,a13:0,a21:c.x-b.x,a22:c.y-b.y,a23:0,a31:a.x,a32:a.y,a33:1};{let h=b.x-c.x;var f=d.x-c.x;let k=b.y-c.y,m=d.y-c.y;c=h*m-f*k;f=(e*m-f*g)/c;e=(h*g-e*k)/c;return{a11:b.x-a.x+f*b.x,a12:b.y-a.y+f*b.y,a13:f,a21:d.x-a.x+e*d.x,a22:d.y-a.y+e*d.y,a23:e,a31:a.x,a32:a.y,a33:1}}}function ea(a,b,c,d){a=M(a,b,c,d);return{a11:a.a22*a.a33-a.a23*a.a32,a12:a.a13*a.a32-a.a12*a.a33,a13:a.a12*a.a23-a.a13*a.a22,
a21:a.a23*a.a31-a.a21*a.a33,a22:a.a11*a.a33-a.a13*a.a31,a23:a.a13*a.a21-a.a11*a.a23,a31:a.a21*a.a32-a.a22*a.a31,a32:a.a12*a.a31-a.a11*a.a32,a33:a.a11*a.a22-a.a12*a.a21}}function fa(a,b){var c=ea({x:3.5,y:3.5},{x:b.dimension-3.5,y:3.5},{x:b.dimension-6.5,y:b.dimension-6.5},{x:3.5,y:b.dimension-3.5}),d=M(b.topLeft,b.topRight,b.alignmentPattern,b.bottomLeft),e=d.a11*c.a11+d.a21*c.a12+d.a31*c.a13,g=d.a12*c.a11+d.a22*c.a12+d.a32*c.a13,f=d.a13*c.a11+d.a23*c.a12+d.a33*c.a13,h=d.a11*c.a21+d.a21*c.a22+d.a31*
c.a23,k=d.a12*c.a21+d.a22*c.a22+d.a32*c.a23,m=d.a13*c.a21+d.a23*c.a22+d.a33*c.a23,p=d.a11*c.a31+d.a21*c.a32+d.a31*c.a33,n=d.a12*c.a31+d.a22*c.a32+d.a32*c.a33,l=d.a13*c.a31+d.a23*c.a32+d.a33*c.a33;c=A.createEmpty(b.dimension,b.dimension);d=(a,b)=>{const c=f*a+m*b+l;return{x:(e*a+h*b+p)/c,y:(g*a+k*b+n)/c}};for(let e=0;e<b.dimension;e++)for(let f=0;f<b.dimension;f++){let b=d(f+.5,e+.5);c.set(f,e,a.get(Math.floor(b.x),Math.floor(b.y)))}return{matrix:c,mappingFunction:d}}function t(a){return a.reduce((a,
c)=>a+c)}function ha(a,b,c){let d=x(a,b),e=x(b,c),g=x(a,c),f,h,k;e>=d&&e>=g?[f,h,k]=[b,a,c]:g>=e&&g>=d?[f,h,k]=[a,b,c]:[f,h,k]=[a,c,b];0>(k.x-h.x)*(f.y-h.y)-(k.y-h.y)*(f.x-h.x)&&([f,k]=[k,f]);return{bottomLeft:f,topLeft:h,topRight:k}}function ia(a,b,c,d){d=(t(z(a,c,d,5))/7+t(z(a,b,d,5))/7+t(z(c,a,d,5))/7+t(z(b,a,d,5))/7)/4;if(1>d)throw Error("Invalid module size");b=Math.round(x(a,b)/d);a=Math.round(x(a,c)/d);a=Math.floor((b+a)/2)+7;switch(a%4){case 0:a++;break;case 2:a--}return{dimension:a,moduleSize:d}}
function N(a,b,c,d){let e=[{x:Math.floor(a.x),y:Math.floor(a.y)}];var g=Math.abs(b.y-a.y)>Math.abs(b.x-a.x);if(g){var f=Math.floor(a.y);var h=Math.floor(a.x);a=Math.floor(b.y);b=Math.floor(b.x)}else f=Math.floor(a.x),h=Math.floor(a.y),a=Math.floor(b.x),b=Math.floor(b.y);let k=Math.abs(a-f),m=Math.abs(b-h),p=Math.floor(-k/2),n=f<a?1:-1,l=h<b?1:-1,q=!0;for(let r=f,F=h;r!==a+n;r+=n){f=g?F:r;h=g?r:F;if(c.get(f,h)!==q&&(q=!q,e.push({x:f,y:h}),e.length===d+1))break;p+=m;if(0<p){if(F===b)break;F+=l;p-=k}}c=
[];for(g=0;g<d;g++)e[g]&&e[g+1]?c.push(x(e[g],e[g+1])):c.push(0);return c}function z(a,b,c,d){let e=b.y-a.y,g=b.x-a.x;b=N(a,b,c,Math.ceil(d/2));a=N(a,{x:a.x-g,y:a.y-e},c,Math.ceil(d/2));c=b.shift()+a.shift()-1;return a.concat(c).concat(...b)}function G(a,b){let c=t(a)/t(b),d=0;b.forEach((b,g)=>{d+=Math.pow(a[g]-b*c,2)});return{averageSize:c,error:d}}function O(a,b,c){try{let d=z(a,{x:-1,y:a.y},c,b.length),e=z(a,{x:a.x,y:-1},c,b.length),g=z(a,{x:Math.max(0,a.x-a.y)-1,y:Math.max(0,a.y-a.x)-1},c,b.length),
f=z(a,{x:Math.min(c.width,a.x+a.y)+1,y:Math.min(c.height,a.y+a.x)+1},c,b.length),h=G(d,b),k=G(e,b),m=G(g,b),p=G(f,b),n=(h.averageSize+k.averageSize+m.averageSize+p.averageSize)/4;return Math.sqrt(h.error*h.error+k.error*k.error+m.error*m.error+p.error*p.error)+(Math.pow(h.averageSize-n,2)+Math.pow(k.averageSize-n,2)+Math.pow(m.averageSize-n,2)+Math.pow(p.averageSize-n,2))/n}catch(d){return Infinity}}function ja(a){var b=[],c=[],d=[],e=[];for(let r=0;r<=a.height;r++){let k=0,m=!1,l=[0,0,0,0,0];for(let b=
-1;b<=a.width;b++){var g=a.get(b,r);if(g===m)k++;else{l=[l[1],l[2],l[3],l[4],k];k=1;m=g;var f=t(l)/7;f=Math.abs(l[0]-f)<f&&Math.abs(l[1]-f)<f&&Math.abs(l[2]-3*f)<3*f&&Math.abs(l[3]-f)<f&&Math.abs(l[4]-f)<f&&!g;var h=t(l.slice(-3))/3;g=Math.abs(l[2]-h)<h&&Math.abs(l[3]-h)<h&&Math.abs(l[4]-h)<h&&g;if(f){let a=b-l[3]-l[4],d=a-l[2];f={startX:d,endX:a,y:r};h=c.filter((b)=>d>=b.bottom.startX&&d<=b.bottom.endX||a>=b.bottom.startX&&d<=b.bottom.endX||d<=b.bottom.startX&&a>=b.bottom.endX&&1.5>l[2]/(b.bottom.endX-
b.bottom.startX)&&.5<l[2]/(b.bottom.endX-b.bottom.startX));0<h.length?h[0].bottom=f:c.push({top:f,bottom:f})}if(g){let a=b-l[4],c=a-l[3];g={startX:c,y:r,endX:a};f=e.filter((b)=>c>=b.bottom.startX&&c<=b.bottom.endX||a>=b.bottom.startX&&c<=b.bottom.endX||c<=b.bottom.startX&&a>=b.bottom.endX&&1.5>l[2]/(b.bottom.endX-b.bottom.startX)&&.5<l[2]/(b.bottom.endX-b.bottom.startX));0<f.length?f[0].bottom=g:e.push({top:g,bottom:g})}}}b.push(...c.filter((a)=>a.bottom.y!==r&&2<=a.bottom.y-a.top.y));c=c.filter((a)=>
a.bottom.y===r);d.push(...e.filter((a)=>a.bottom.y!==r));e=e.filter((a)=>a.bottom.y===r)}b.push(...c.filter((a)=>2<=a.bottom.y-a.top.y));d.push(...e);b=b.filter((a)=>2<=a.bottom.y-a.top.y).map((b)=>{const c=(b.top.startX+b.top.endX+b.bottom.startX+b.bottom.endX)/4,d=(b.top.y+b.bottom.y+1)/2;if(a.get(Math.round(c),Math.round(d)))return b=[b.top.endX-b.top.startX,b.bottom.endX-b.bottom.startX,b.bottom.y-b.top.y+1],b=t(b)/b.length,{score:O({x:Math.round(c),y:Math.round(d)},[1,1,3,1,1],a),x:c,y:d,size:b}}).filter((a)=>
!!a).sort((a,b)=>a.score-b.score).map((a,b,c)=>{if(4<b)return null;c=c.filter((a,c)=>b!==c).map((b)=>({x:b.x,y:b.y,score:b.score+Math.pow(b.size-a.size,2)/a.size,size:b.size})).sort((a,b)=>a.score-b.score);if(2>c.length)return null;const d=a.score+c[0].score+c[1].score;return{points:[a].concat(c.slice(0,2)),score:d}}).filter((a)=>!!a).sort((a,b)=>a.score-b.score);if(0===b.length)return null;var {topRight:k,topLeft:m,bottomLeft:p}=ha(b[0].points[0],b[0].points[1],b[0].points[2]);let n;try{({dimension:n,
moduleSize:l}=ia(m,k,p,a))}catch(r){return null}b=k.x-m.x+p.x;c=k.y-m.y+p.y;var l=(x(m,p)+x(m,k))/2/l;e=1-3/l;let q={x:m.x+e*(b-m.x),y:m.y+e*(c-m.y)};d=d.map((b)=>{const c=(b.top.startX+b.top.endX+b.bottom.startX+b.bottom.endX)/4,d=(b.top.y+b.bottom.y+1)/2;if(a.get(Math.floor(c),Math.floor(d)))return t([b.top.endX-b.top.startX,b.bottom.endX-b.bottom.startX,b.bottom.y-b.top.y+1]),b=O({x:Math.floor(c),y:Math.floor(d)},[1,1,1],a)+x({x:c,y:d},q),{x:c,y:d,score:b}}).filter((a)=>!!a).sort((a,b)=>a.score-
b.score);d=15<=l&&d.length?d[0]:q;return{alignmentPattern:{x:d.x,y:d.y},bottomLeft:{x:p.x,y:p.y},dimension:n,topLeft:{x:m.x,y:m.y},topRight:{x:k.x,y:k.y}}}function P(a){let b=ja(a);if(!b)return null;a=fa(a,b);var c=a.matrix;if(null==c)c=null;else{var d=L(c);if(d)c=d;else{for(d=0;d<c.width;d++)for(let a=d+1;a<c.height;a++)c.get(d,a)!==c.get(a,d)&&(c.set(d,a,!c.get(d,a)),c.set(a,d,!c.get(a,d)));c=L(c)}}return c?{binaryData:c.bytes,data:c.text,chunks:c.chunks,location:{topRightCorner:a.mappingFunction(b.dimension,
0),topLeftCorner:a.mappingFunction(0,0),bottomRightCorner:a.mappingFunction(b.dimension,b.dimension),bottomLeftCorner:a.mappingFunction(0,b.dimension),topRightFinderPattern:b.topRight,topLeftFinderPattern:b.topLeft,bottomLeftFinderPattern:b.bottomLeft,bottomRightAlignmentPattern:b.alignmentPattern}}:null}function Q(a,b){Object.keys(b).forEach((c)=>{a[c]=b[c]})}function I(a,b,c,d={}){let e=Object.create(null);Q(e,ka);Q(e,d);d="onlyInvert"===e.inversionAttempts||"invertFirst"===e.inversionAttempts;
var g="attemptBoth"===e.inversionAttempts||"invertFirst"===e.inversionAttempts;var f=e.greyScaleWeights,h=e.canOverwriteImage,k=b*c;if(a.length!==4*k)throw Error("Malformed data passed to binarizer.");var m=0;if(h){var p=new Uint8ClampedArray(a.buffer,m,k);m+=k}p=new R(b,c,p);if(f.useIntegerApproximation)for(var n=0;n<c;n++)for(var l=0;l<b;l++){var q=4*(n*b+l);p.set(l,n,f.red*a[q]+f.green*a[q+1]+f.blue*a[q+2]+128>>8)}else for(n=0;n<c;n++)for(l=0;l<b;l++)q=4*(n*b+l),p.set(l,n,f.red*a[q]+f.green*a[q+
1]+f.blue*a[q+2]);f=Math.ceil(b/8);n=Math.ceil(c/8);l=f*n;if(h){var r=new Uint8ClampedArray(a.buffer,m,l);m+=l}r=new R(f,n,r);for(l=0;l<n;l++)for(q=0;q<f;q++){var u=0,v=Infinity,t=0;for(let a=0;8>a;a++)for(let b=0;8>b;b++){let c=p.get(8*q+b,8*l+a);u+=c;v=Math.min(v,c);t=Math.max(t,c)}u/=Math.pow(8,2);24>=t-v&&(u=v/2,0<l&&0<q&&(t=(r.get(q,l-1)+2*r.get(q-1,l)+r.get(q-1,l-1))/4,v<t&&(u=t)));r.set(q,l,u)}h?(l=new Uint8ClampedArray(a.buffer,m,k),m+=k,l=new A(l,b)):l=A.createEmpty(b,c);q=null;g&&(h?(a=
new Uint8ClampedArray(a.buffer,m,k),q=new A(a,b)):q=A.createEmpty(b,c));for(b=0;b<n;b++)for(a=0;a<f;a++){c=f-3;c=2>a?2:a>c?c:a;h=n-3;h=2>b?2:b>h?h:b;k=0;for(m=-2;2>=m;m++)for(v=-2;2>=v;v++)k+=r.get(c+m,h+v);c=k/25;for(h=0;8>h;h++)for(k=0;8>k;k++)m=8*a+h,v=8*b+k,t=p.get(m,v),l.set(m,v,t<=c),g&&q.set(m,v,!(t<=c))}g=g?{binarized:l,inverted:q}:{binarized:l};let {binarized:w,inverted:x}=g;(g=P(d?x:w))||"attemptBoth"!==e.inversionAttempts&&"invertFirst"!==e.inversionAttempts||(g=P(d?w:x));return g}class A{static createEmpty(a,
b){return new A(new Uint8ClampedArray(a*b),a)}constructor(a,b){this.width=b;this.height=a.length/b;this.data=a}get(a,b){return 0>a||a>=this.width||0>b||b>=this.height?!1:!!this.data[b*this.width+a]}set(a,b,c){this.data[b*this.width+a]=c?1:0}setRegion(a,b,c,d,e){for(let g=b;g<b+d;g++)for(let b=a;b<a+c;b++)this.set(b,g,!!e)}}class R{constructor(a,b,c){this.width=a;a*=b;if(c&&c.length!==a)throw Error("Wrong buffer size");this.data=c||new Uint8ClampedArray(a)}get(a,b){return this.data[b*this.width+a]}set(a,
b,c){this.data[b*this.width+a]=c}}class U{constructor(a){this.bitOffset=this.byteOffset=0;this.bytes=a}readBits(a){if(1>a||32<a||a>this.available())throw Error("Cannot read "+a.toString()+" bits");var b=0;if(0<this.bitOffset){b=8-this.bitOffset;var c=a<b?a:b;b-=c;b=(this.bytes[this.byteOffset]&255>>8-c<<b)>>b;a-=c;this.bitOffset+=c;8===this.bitOffset&&(this.bitOffset=0,this.byteOffset++)}if(0<a){for(;8<=a;)b=b<<8|this.bytes[this.byteOffset]&255,this.byteOffset++,a-=8;0<a&&(c=8-a,b=b<<a|(this.bytes[this.byteOffset]&
255>>c<<c)>>c,this.bitOffset+=a)}return b}available(){return 8*(this.bytes.length-this.byteOffset)-this.bitOffset}}var u;(function(a){a.Numeric="numeric";a.Alphanumeric="alphanumeric";a.Byte="byte";a.Kanji="kanji";a.ECI="eci"})(u||(u={}));var y;(function(a){a[a.Terminator=0]="Terminator";a[a.Numeric=1]="Numeric";a[a.Alphanumeric=2]="Alphanumeric";a[a.Byte=4]="Byte";a[a.Kanji=8]="Kanji";a[a.ECI=7]="ECI"})(y||(y={}));let B="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:".split("");class w{constructor(a,
b){if(0===b.length)throw Error("No coefficients.");this.field=a;let c=b.length;if(1<c&&0===b[0]){let d=1;for(;d<c&&0===b[d];)d++;if(d===c)this.coefficients=a.zero.coefficients;else for(this.coefficients=new Uint8ClampedArray(c-d),a=0;a<this.coefficients.length;a++)this.coefficients[a]=b[d+a]}else this.coefficients=b}degree(){return this.coefficients.length-1}isZero(){return 0===this.coefficients[0]}getCoefficient(a){return this.coefficients[this.coefficients.length-1-a]}addOrSubtract(a){if(this.isZero())return a;
if(a.isZero())return this;let b=this.coefficients;a=a.coefficients;b.length>a.length&&([b,a]=[a,b]);let c=new Uint8ClampedArray(a.length),d=a.length-b.length;for(var e=0;e<d;e++)c[e]=a[e];for(e=d;e<a.length;e++)c[e]=b[e-d]^a[e];return new w(this.field,c)}multiply(a){if(0===a)return this.field.zero;if(1===a)return this;let b=this.coefficients.length,c=new Uint8ClampedArray(b);for(let d=0;d<b;d++)c[d]=this.field.multiply(this.coefficients[d],a);return new w(this.field,c)}multiplyPoly(a){if(this.isZero()||
a.isZero())return this.field.zero;let b=this.coefficients,c=b.length;a=a.coefficients;let d=a.length,e=new Uint8ClampedArray(c+d-1);for(let h=0;h<c;h++){let c=b[h];for(let b=0;b<d;b++){var g=h+b,f=this.field.multiply(c,a[b]);e[g]=e[h+b]^f}}return new w(this.field,e)}multiplyByMonomial(a,b){if(0>a)throw Error("Invalid degree less than 0");if(0===b)return this.field.zero;let c=this.coefficients.length;a=new Uint8ClampedArray(c+a);for(let d=0;d<c;d++)a[d]=this.field.multiply(this.coefficients[d],b);
return new w(this.field,a)}evaluateAt(a){let b=0;if(0===a)return this.getCoefficient(0);let c=this.coefficients.length;if(1===a)return this.coefficients.forEach((a)=>{b^=a}),b;b=this.coefficients[0];for(let d=1;d<c;d++)b=J(this.field.multiply(a,b),this.coefficients[d]);return b}}class X{constructor(a,b,c){this.primitive=a;this.size=b;this.generatorBase=c;this.expTable=Array(this.size);this.logTable=Array(this.size);a=1;for(b=0;b<this.size;b++)this.expTable[b]=a,a*=2,a>=this.size&&(a=(a^this.primitive)&
this.size-1);for(a=0;a<this.size-1;a++)this.logTable[this.expTable[a]]=a;this.zero=new w(this,Uint8ClampedArray.from([0]));this.one=new w(this,Uint8ClampedArray.from([1]))}multiply(a,b){return 0===a||0===b?0:this.expTable[(this.logTable[a]+this.logTable[b])%(this.size-1)]}inverse(a){if(0===a)throw Error("Can't invert 0");return this.expTable[this.size-this.logTable[a]-1]}buildMonomial(a,b){if(0>a)throw Error("Invalid monomial degree less than 0");if(0===b)return this.zero;a=new Uint8ClampedArray(a+
1);a[0]=b;return new w(this,a)}log(a){if(0===a)throw Error("Can't take log(0)");return this.logTable[a]}exp(a){return this.expTable[a]}}let K=[{infoBits:null,versionNumber:1,alignmentPatternCenters:[],errorCorrectionLevels:[{ecCodewordsPerBlock:7,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:19}]},{ecCodewordsPerBlock:10,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:16}]},{ecCodewordsPerBlock:13,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:13}]},{ecCodewordsPerBlock:17,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:9}]}]},
{infoBits:null,versionNumber:2,alignmentPatternCenters:[6,18],errorCorrectionLevels:[{ecCodewordsPerBlock:10,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:34}]},{ecCodewordsPerBlock:16,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:28}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:22}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:16}]}]},{infoBits:null,versionNumber:3,alignmentPatternCenters:[6,22],errorCorrectionLevels:[{ecCodewordsPerBlock:15,ecBlocks:[{numBlocks:1,
dataCodewordsPerBlock:55}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:44}]},{ecCodewordsPerBlock:18,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:17}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:13}]}]},{infoBits:null,versionNumber:4,alignmentPatternCenters:[6,26],errorCorrectionLevels:[{ecCodewordsPerBlock:20,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:80}]},{ecCodewordsPerBlock:18,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:32}]},{ecCodewordsPerBlock:26,
ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:24}]},{ecCodewordsPerBlock:16,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:9}]}]},{infoBits:null,versionNumber:5,alignmentPatternCenters:[6,30],errorCorrectionLevels:[{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:108}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:43}]},{ecCodewordsPerBlock:18,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:15},{numBlocks:2,dataCodewordsPerBlock:16}]},{ecCodewordsPerBlock:22,
ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:11},{numBlocks:2,dataCodewordsPerBlock:12}]}]},{infoBits:null,versionNumber:6,alignmentPatternCenters:[6,34],errorCorrectionLevels:[{ecCodewordsPerBlock:18,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:68}]},{ecCodewordsPerBlock:16,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:27}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:19}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:15}]}]},{infoBits:31892,versionNumber:7,
alignmentPatternCenters:[6,22,38],errorCorrectionLevels:[{ecCodewordsPerBlock:20,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:78}]},{ecCodewordsPerBlock:18,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:31}]},{ecCodewordsPerBlock:18,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:14},{numBlocks:4,dataCodewordsPerBlock:15}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:13},{numBlocks:1,dataCodewordsPerBlock:14}]}]},{infoBits:34236,versionNumber:8,alignmentPatternCenters:[6,24,42],
errorCorrectionLevels:[{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:97}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:38},{numBlocks:2,dataCodewordsPerBlock:39}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:18},{numBlocks:2,dataCodewordsPerBlock:19}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:14},{numBlocks:2,dataCodewordsPerBlock:15}]}]},{infoBits:39577,versionNumber:9,alignmentPatternCenters:[6,
26,46],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:116}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:36},{numBlocks:2,dataCodewordsPerBlock:37}]},{ecCodewordsPerBlock:20,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:16},{numBlocks:4,dataCodewordsPerBlock:17}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:12},{numBlocks:4,dataCodewordsPerBlock:13}]}]},{infoBits:42195,versionNumber:10,alignmentPatternCenters:[6,
28,50],errorCorrectionLevels:[{ecCodewordsPerBlock:18,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:68},{numBlocks:2,dataCodewordsPerBlock:69}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:43},{numBlocks:1,dataCodewordsPerBlock:44}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:6,dataCodewordsPerBlock:19},{numBlocks:2,dataCodewordsPerBlock:20}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:6,dataCodewordsPerBlock:15},{numBlocks:2,dataCodewordsPerBlock:16}]}]},{infoBits:48118,
versionNumber:11,alignmentPatternCenters:[6,30,54],errorCorrectionLevels:[{ecCodewordsPerBlock:20,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:81}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:50},{numBlocks:4,dataCodewordsPerBlock:51}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:22},{numBlocks:4,dataCodewordsPerBlock:23}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:12},{numBlocks:8,dataCodewordsPerBlock:13}]}]},{infoBits:51042,
versionNumber:12,alignmentPatternCenters:[6,32,58],errorCorrectionLevels:[{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:92},{numBlocks:2,dataCodewordsPerBlock:93}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:6,dataCodewordsPerBlock:36},{numBlocks:2,dataCodewordsPerBlock:37}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:20},{numBlocks:6,dataCodewordsPerBlock:21}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:7,dataCodewordsPerBlock:14},{numBlocks:4,
dataCodewordsPerBlock:15}]}]},{infoBits:55367,versionNumber:13,alignmentPatternCenters:[6,34,62],errorCorrectionLevels:[{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:107}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:8,dataCodewordsPerBlock:37},{numBlocks:1,dataCodewordsPerBlock:38}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:8,dataCodewordsPerBlock:20},{numBlocks:4,dataCodewordsPerBlock:21}]},{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:12,dataCodewordsPerBlock:11},{numBlocks:4,
dataCodewordsPerBlock:12}]}]},{infoBits:58893,versionNumber:14,alignmentPatternCenters:[6,26,46,66],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:115},{numBlocks:1,dataCodewordsPerBlock:116}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:40},{numBlocks:5,dataCodewordsPerBlock:41}]},{ecCodewordsPerBlock:20,ecBlocks:[{numBlocks:11,dataCodewordsPerBlock:16},{numBlocks:5,dataCodewordsPerBlock:17}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:11,
dataCodewordsPerBlock:12},{numBlocks:5,dataCodewordsPerBlock:13}]}]},{infoBits:63784,versionNumber:15,alignmentPatternCenters:[6,26,48,70],errorCorrectionLevels:[{ecCodewordsPerBlock:22,ecBlocks:[{numBlocks:5,dataCodewordsPerBlock:87},{numBlocks:1,dataCodewordsPerBlock:88}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:5,dataCodewordsPerBlock:41},{numBlocks:5,dataCodewordsPerBlock:42}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:5,dataCodewordsPerBlock:24},{numBlocks:7,dataCodewordsPerBlock:25}]},
{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:11,dataCodewordsPerBlock:12},{numBlocks:7,dataCodewordsPerBlock:13}]}]},{infoBits:68472,versionNumber:16,alignmentPatternCenters:[6,26,50,74],errorCorrectionLevels:[{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:5,dataCodewordsPerBlock:98},{numBlocks:1,dataCodewordsPerBlock:99}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:7,dataCodewordsPerBlock:45},{numBlocks:3,dataCodewordsPerBlock:46}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:15,dataCodewordsPerBlock:19},
{numBlocks:2,dataCodewordsPerBlock:20}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:15},{numBlocks:13,dataCodewordsPerBlock:16}]}]},{infoBits:70749,versionNumber:17,alignmentPatternCenters:[6,30,54,78],errorCorrectionLevels:[{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:107},{numBlocks:5,dataCodewordsPerBlock:108}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:10,dataCodewordsPerBlock:46},{numBlocks:1,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:28,
ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:22},{numBlocks:15,dataCodewordsPerBlock:23}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:14},{numBlocks:17,dataCodewordsPerBlock:15}]}]},{infoBits:76311,versionNumber:18,alignmentPatternCenters:[6,30,56,82],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:5,dataCodewordsPerBlock:120},{numBlocks:1,dataCodewordsPerBlock:121}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:9,dataCodewordsPerBlock:43},{numBlocks:4,
dataCodewordsPerBlock:44}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:22},{numBlocks:1,dataCodewordsPerBlock:23}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:14},{numBlocks:19,dataCodewordsPerBlock:15}]}]},{infoBits:79154,versionNumber:19,alignmentPatternCenters:[6,30,58,86],errorCorrectionLevels:[{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:113},{numBlocks:4,dataCodewordsPerBlock:114}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:3,
dataCodewordsPerBlock:44},{numBlocks:11,dataCodewordsPerBlock:45}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:21},{numBlocks:4,dataCodewordsPerBlock:22}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:9,dataCodewordsPerBlock:13},{numBlocks:16,dataCodewordsPerBlock:14}]}]},{infoBits:84390,versionNumber:20,alignmentPatternCenters:[6,34,62,90],errorCorrectionLevels:[{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:107},{numBlocks:5,dataCodewordsPerBlock:108}]},
{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:41},{numBlocks:13,dataCodewordsPerBlock:42}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:15,dataCodewordsPerBlock:24},{numBlocks:5,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:15,dataCodewordsPerBlock:15},{numBlocks:10,dataCodewordsPerBlock:16}]}]},{infoBits:87683,versionNumber:21,alignmentPatternCenters:[6,28,50,72,94],errorCorrectionLevels:[{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:116},
{numBlocks:4,dataCodewordsPerBlock:117}]},{ecCodewordsPerBlock:26,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:42}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:22},{numBlocks:6,dataCodewordsPerBlock:23}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:19,dataCodewordsPerBlock:16},{numBlocks:6,dataCodewordsPerBlock:17}]}]},{infoBits:92361,versionNumber:22,alignmentPatternCenters:[6,26,50,74,98],errorCorrectionLevels:[{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:111},
{numBlocks:7,dataCodewordsPerBlock:112}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:46}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:7,dataCodewordsPerBlock:24},{numBlocks:16,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:24,ecBlocks:[{numBlocks:34,dataCodewordsPerBlock:13}]}]},{infoBits:96236,versionNumber:23,alignmentPatternCenters:[6,30,54,74,102],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:121},{numBlocks:5,dataCodewordsPerBlock:122}]},
{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:47},{numBlocks:14,dataCodewordsPerBlock:48}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:11,dataCodewordsPerBlock:24},{numBlocks:14,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:16,dataCodewordsPerBlock:15},{numBlocks:14,dataCodewordsPerBlock:16}]}]},{infoBits:102084,versionNumber:24,alignmentPatternCenters:[6,28,54,80,106],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:6,dataCodewordsPerBlock:117},
{numBlocks:4,dataCodewordsPerBlock:118}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:6,dataCodewordsPerBlock:45},{numBlocks:14,dataCodewordsPerBlock:46}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:11,dataCodewordsPerBlock:24},{numBlocks:16,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:30,dataCodewordsPerBlock:16},{numBlocks:2,dataCodewordsPerBlock:17}]}]},{infoBits:102881,versionNumber:25,alignmentPatternCenters:[6,32,58,84,110],errorCorrectionLevels:[{ecCodewordsPerBlock:26,
ecBlocks:[{numBlocks:8,dataCodewordsPerBlock:106},{numBlocks:4,dataCodewordsPerBlock:107}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:8,dataCodewordsPerBlock:47},{numBlocks:13,dataCodewordsPerBlock:48}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:7,dataCodewordsPerBlock:24},{numBlocks:22,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:22,dataCodewordsPerBlock:15},{numBlocks:13,dataCodewordsPerBlock:16}]}]},{infoBits:110507,versionNumber:26,alignmentPatternCenters:[6,
30,58,86,114],errorCorrectionLevels:[{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:10,dataCodewordsPerBlock:114},{numBlocks:2,dataCodewordsPerBlock:115}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:19,dataCodewordsPerBlock:46},{numBlocks:4,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:28,dataCodewordsPerBlock:22},{numBlocks:6,dataCodewordsPerBlock:23}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:33,dataCodewordsPerBlock:16},{numBlocks:4,dataCodewordsPerBlock:17}]}]},
{infoBits:110734,versionNumber:27,alignmentPatternCenters:[6,34,62,90,118],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:8,dataCodewordsPerBlock:122},{numBlocks:4,dataCodewordsPerBlock:123}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:22,dataCodewordsPerBlock:45},{numBlocks:3,dataCodewordsPerBlock:46}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:8,dataCodewordsPerBlock:23},{numBlocks:26,dataCodewordsPerBlock:24}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:12,dataCodewordsPerBlock:15},
{numBlocks:28,dataCodewordsPerBlock:16}]}]},{infoBits:117786,versionNumber:28,alignmentPatternCenters:[6,26,50,74,98,122],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:117},{numBlocks:10,dataCodewordsPerBlock:118}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:3,dataCodewordsPerBlock:45},{numBlocks:23,dataCodewordsPerBlock:46}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:24},{numBlocks:31,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,
ecBlocks:[{numBlocks:11,dataCodewordsPerBlock:15},{numBlocks:31,dataCodewordsPerBlock:16}]}]},{infoBits:119615,versionNumber:29,alignmentPatternCenters:[6,30,54,78,102,126],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:7,dataCodewordsPerBlock:116},{numBlocks:7,dataCodewordsPerBlock:117}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:21,dataCodewordsPerBlock:45},{numBlocks:7,dataCodewordsPerBlock:46}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:1,dataCodewordsPerBlock:23},{numBlocks:37,
dataCodewordsPerBlock:24}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:19,dataCodewordsPerBlock:15},{numBlocks:26,dataCodewordsPerBlock:16}]}]},{infoBits:126325,versionNumber:30,alignmentPatternCenters:[6,26,52,78,104,130],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:5,dataCodewordsPerBlock:115},{numBlocks:10,dataCodewordsPerBlock:116}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:19,dataCodewordsPerBlock:47},{numBlocks:10,dataCodewordsPerBlock:48}]},{ecCodewordsPerBlock:30,
ecBlocks:[{numBlocks:15,dataCodewordsPerBlock:24},{numBlocks:25,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:23,dataCodewordsPerBlock:15},{numBlocks:25,dataCodewordsPerBlock:16}]}]},{infoBits:127568,versionNumber:31,alignmentPatternCenters:[6,30,56,82,108,134],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:13,dataCodewordsPerBlock:115},{numBlocks:3,dataCodewordsPerBlock:116}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:46},
{numBlocks:29,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:42,dataCodewordsPerBlock:24},{numBlocks:1,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:23,dataCodewordsPerBlock:15},{numBlocks:28,dataCodewordsPerBlock:16}]}]},{infoBits:133589,versionNumber:32,alignmentPatternCenters:[6,34,60,86,112,138],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:115}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:10,
dataCodewordsPerBlock:46},{numBlocks:23,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:10,dataCodewordsPerBlock:24},{numBlocks:35,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:19,dataCodewordsPerBlock:15},{numBlocks:35,dataCodewordsPerBlock:16}]}]},{infoBits:136944,versionNumber:33,alignmentPatternCenters:[6,30,58,86,114,142],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:115},{numBlocks:1,dataCodewordsPerBlock:116}]},
{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:14,dataCodewordsPerBlock:46},{numBlocks:21,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:29,dataCodewordsPerBlock:24},{numBlocks:19,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:11,dataCodewordsPerBlock:15},{numBlocks:46,dataCodewordsPerBlock:16}]}]},{infoBits:141498,versionNumber:34,alignmentPatternCenters:[6,34,62,90,118,146],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:13,dataCodewordsPerBlock:115},
{numBlocks:6,dataCodewordsPerBlock:116}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:14,dataCodewordsPerBlock:46},{numBlocks:23,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:44,dataCodewordsPerBlock:24},{numBlocks:7,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:59,dataCodewordsPerBlock:16},{numBlocks:1,dataCodewordsPerBlock:17}]}]},{infoBits:145311,versionNumber:35,alignmentPatternCenters:[6,30,54,78,102,126,150],errorCorrectionLevels:[{ecCodewordsPerBlock:30,
ecBlocks:[{numBlocks:12,dataCodewordsPerBlock:121},{numBlocks:7,dataCodewordsPerBlock:122}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:12,dataCodewordsPerBlock:47},{numBlocks:26,dataCodewordsPerBlock:48}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:39,dataCodewordsPerBlock:24},{numBlocks:14,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:22,dataCodewordsPerBlock:15},{numBlocks:41,dataCodewordsPerBlock:16}]}]},{infoBits:150283,versionNumber:36,alignmentPatternCenters:[6,
24,50,76,102,128,154],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:6,dataCodewordsPerBlock:121},{numBlocks:14,dataCodewordsPerBlock:122}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:6,dataCodewordsPerBlock:47},{numBlocks:34,dataCodewordsPerBlock:48}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:46,dataCodewordsPerBlock:24},{numBlocks:10,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:2,dataCodewordsPerBlock:15},{numBlocks:64,dataCodewordsPerBlock:16}]}]},
{infoBits:152622,versionNumber:37,alignmentPatternCenters:[6,28,54,80,106,132,158],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:17,dataCodewordsPerBlock:122},{numBlocks:4,dataCodewordsPerBlock:123}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:29,dataCodewordsPerBlock:46},{numBlocks:14,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:49,dataCodewordsPerBlock:24},{numBlocks:10,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:24,
dataCodewordsPerBlock:15},{numBlocks:46,dataCodewordsPerBlock:16}]}]},{infoBits:158308,versionNumber:38,alignmentPatternCenters:[6,32,58,84,110,136,162],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:4,dataCodewordsPerBlock:122},{numBlocks:18,dataCodewordsPerBlock:123}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:13,dataCodewordsPerBlock:46},{numBlocks:32,dataCodewordsPerBlock:47}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:48,dataCodewordsPerBlock:24},{numBlocks:14,dataCodewordsPerBlock:25}]},
{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:42,dataCodewordsPerBlock:15},{numBlocks:32,dataCodewordsPerBlock:16}]}]},{infoBits:161089,versionNumber:39,alignmentPatternCenters:[6,26,54,82,110,138,166],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:20,dataCodewordsPerBlock:117},{numBlocks:4,dataCodewordsPerBlock:118}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:40,dataCodewordsPerBlock:47},{numBlocks:7,dataCodewordsPerBlock:48}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:43,
dataCodewordsPerBlock:24},{numBlocks:22,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:10,dataCodewordsPerBlock:15},{numBlocks:67,dataCodewordsPerBlock:16}]}]},{infoBits:167017,versionNumber:40,alignmentPatternCenters:[6,30,58,86,114,142,170],errorCorrectionLevels:[{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:19,dataCodewordsPerBlock:118},{numBlocks:6,dataCodewordsPerBlock:119}]},{ecCodewordsPerBlock:28,ecBlocks:[{numBlocks:18,dataCodewordsPerBlock:47},{numBlocks:31,dataCodewordsPerBlock:48}]},
{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:34,dataCodewordsPerBlock:24},{numBlocks:34,dataCodewordsPerBlock:25}]},{ecCodewordsPerBlock:30,ecBlocks:[{numBlocks:20,dataCodewordsPerBlock:15},{numBlocks:61,dataCodewordsPerBlock:16}]}]}],ca=[{bits:21522,formatInfo:{errorCorrectionLevel:1,dataMask:0}},{bits:20773,formatInfo:{errorCorrectionLevel:1,dataMask:1}},{bits:24188,formatInfo:{errorCorrectionLevel:1,dataMask:2}},{bits:23371,formatInfo:{errorCorrectionLevel:1,dataMask:3}},{bits:17913,formatInfo:{errorCorrectionLevel:1,
dataMask:4}},{bits:16590,formatInfo:{errorCorrectionLevel:1,dataMask:5}},{bits:20375,formatInfo:{errorCorrectionLevel:1,dataMask:6}},{bits:19104,formatInfo:{errorCorrectionLevel:1,dataMask:7}},{bits:30660,formatInfo:{errorCorrectionLevel:0,dataMask:0}},{bits:29427,formatInfo:{errorCorrectionLevel:0,dataMask:1}},{bits:32170,formatInfo:{errorCorrectionLevel:0,dataMask:2}},{bits:30877,formatInfo:{errorCorrectionLevel:0,dataMask:3}},{bits:26159,formatInfo:{errorCorrectionLevel:0,dataMask:4}},{bits:25368,
formatInfo:{errorCorrectionLevel:0,dataMask:5}},{bits:27713,formatInfo:{errorCorrectionLevel:0,dataMask:6}},{bits:26998,formatInfo:{errorCorrectionLevel:0,dataMask:7}},{bits:5769,formatInfo:{errorCorrectionLevel:3,dataMask:0}},{bits:5054,formatInfo:{errorCorrectionLevel:3,dataMask:1}},{bits:7399,formatInfo:{errorCorrectionLevel:3,dataMask:2}},{bits:6608,formatInfo:{errorCorrectionLevel:3,dataMask:3}},{bits:1890,formatInfo:{errorCorrectionLevel:3,dataMask:4}},{bits:597,formatInfo:{errorCorrectionLevel:3,
dataMask:5}},{bits:3340,formatInfo:{errorCorrectionLevel:3,dataMask:6}},{bits:2107,formatInfo:{errorCorrectionLevel:3,dataMask:7}},{bits:13663,formatInfo:{errorCorrectionLevel:2,dataMask:0}},{bits:12392,formatInfo:{errorCorrectionLevel:2,dataMask:1}},{bits:16177,formatInfo:{errorCorrectionLevel:2,dataMask:2}},{bits:14854,formatInfo:{errorCorrectionLevel:2,dataMask:3}},{bits:9396,formatInfo:{errorCorrectionLevel:2,dataMask:4}},{bits:8579,formatInfo:{errorCorrectionLevel:2,dataMask:5}},{bits:11994,
formatInfo:{errorCorrectionLevel:2,dataMask:6}},{bits:11245,formatInfo:{errorCorrectionLevel:2,dataMask:7}}],Z=[(a)=>0===(a.y+a.x)%2,(a)=>0===a.y%2,(a)=>0===a.x%3,(a)=>0===(a.y+a.x)%3,(a)=>0===(Math.floor(a.y/2)+Math.floor(a.x/3))%2,(a)=>0===a.x*a.y%2+a.x*a.y%3,(a)=>0===(a.y*a.x%2+a.y*a.x%3)%2,(a)=>0===((a.y+a.x)%2+a.y*a.x%3)%2],x=(a,b)=>Math.sqrt(Math.pow(b.x-a.x,2)+Math.pow(b.y-a.y,2)),ka={inversionAttempts:"attemptBoth",greyScaleWeights:{red:.2126,green:.7152,blue:.0722,useIntegerApproximation:!1},
canOverwriteImage:!0};I.default=I;let H="dontInvert",D={red:77,green:150,blue:29,useIntegerApproximation:!0};self.onmessage=(a)=>{let b=a.data.data;switch(a.data.type){case "decode":a=I(b.data,b.width,b.height,{inversionAttempts:H,greyScaleWeights:D});self.postMessage({type:"qrResult",data:a?a.data:null});break;case "grayscaleWeights":D.red=b.red;D.green=b.green;D.blue=b.blue;D.useIntegerApproximation=b.useIntegerApproximation;break;case "inversionMode":switch(b){case "original":H="dontInvert";break;
case "invert":H="attemptBoth";break;case "both":H="attemptBoth";break;default:throw Error("Invalid inversion mode");}break;case "close":self.close()}}})();
//# sourceMappingURL=qr-scanner-worker.min.js.map

12
static/qr/qr-scanner.min.js vendored Normal file
View file

@ -0,0 +1,12 @@
class e{static hasCamera(){return navigator.mediaDevices.enumerateDevices().then((a)=>a.some((a)=>"videoinput"===a.kind)).catch(()=>!1)}constructor(a,c,b=e.DEFAULT_CANVAS_SIZE){this.$video=a;this.$canvas=document.createElement("canvas");this._onDecode=c;this._paused=this._active=!1;this.$canvas.width=b;this.$canvas.height=b;this._sourceRect={x:0,y:0,width:b,height:b};this._onCanPlay=this._onCanPlay.bind(this);this._onPlay=this._onPlay.bind(this);this._onVisibilityChange=this._onVisibilityChange.bind(this);
this.$video.addEventListener("canplay",this._onCanPlay);this.$video.addEventListener("play",this._onPlay);document.addEventListener("visibilitychange",this._onVisibilityChange);this._qrWorker=new Worker(e.WORKER_PATH)}destroy(){this.$video.removeEventListener("canplay",this._onCanPlay);this.$video.removeEventListener("play",this._onPlay);document.removeEventListener("visibilitychange",this._onVisibilityChange);this.stop();this._qrWorker.postMessage({type:"close"})}start(){if(this._active&&!this._paused)return Promise.resolve();
"https:"!==window.location.protocol&&console.warn("The camera stream is only accessible if the page is transferred via https.");this._active=!0;this._paused=!1;if(document.hidden)return Promise.resolve();clearTimeout(this._offTimeout);this._offTimeout=null;if(this.$video.srcObject)return this.$video.play(),Promise.resolve();let a="environment";return this._getCameraStream("environment",!0).catch(()=>{a="user";return this._getCameraStream()}).then((c)=>{this.$video.srcObject=c;this._setVideoMirror(a)}).catch((a)=>
{this._active=!1;throw a;})}stop(){this.pause();this._active=!1}pause(){this._paused=!0;this._active&&(this.$video.pause(),this._offTimeout||(this._offTimeout=setTimeout(()=>{let a=this.$video.srcObject&&this.$video.srcObject.getTracks()[0];a&&(a.stop(),this._offTimeout=this.$video.srcObject=null)},300)))}static scanImage(a,c=null,b=null,d=null,f=!1,g=!1){let h=!1,l=new Promise((l,g)=>{b||(b=new Worker(e.WORKER_PATH),h=!0,b.postMessage({type:"inversionMode",data:"both"}));let n,m,k;m=(a)=>{"qrResult"===
a.data.type&&(b.removeEventListener("message",m),b.removeEventListener("error",k),clearTimeout(n),null!==a.data.data?l(a.data.data):g("QR code not found."))};k=(a)=>{b.removeEventListener("message",m);b.removeEventListener("error",k);clearTimeout(n);g("Scanner error: "+(a?a.message||a:"Unknown Error"))};b.addEventListener("message",m);b.addEventListener("error",k);n=setTimeout(()=>k("timeout"),3E3);e._loadImage(a).then((a)=>{a=e._getImageData(a,c,d,f);b.postMessage({type:"decode",data:a},[a.data.buffer])}).catch(k)});
c&&g&&(l=l.catch(()=>e.scanImage(a,null,b,d,f)));return l=l.finally(()=>{h&&b.postMessage({type:"close"})})}setGrayscaleWeights(a,c,b,d=!0){this._qrWorker.postMessage({type:"grayscaleWeights",data:{red:a,green:c,blue:b,useIntegerApproximation:d}})}setInversionMode(a){this._qrWorker.postMessage({type:"inversionMode",data:a})}_onCanPlay(){this._updateSourceRect();this.$video.play()}_onPlay(){this._updateSourceRect();this._scanFrame()}_onVisibilityChange(){document.hidden?this.pause():this._active&&
this.start()}_updateSourceRect(){let a=Math.round(2/3*Math.min(this.$video.videoWidth,this.$video.videoHeight));this._sourceRect.width=this._sourceRect.height=a;this._sourceRect.x=(this.$video.videoWidth-a)/2;this._sourceRect.y=(this.$video.videoHeight-a)/2}_scanFrame(){if(!this._active||this.$video.paused||this.$video.ended)return!1;requestAnimationFrame(()=>{e.scanImage(this.$video,this._sourceRect,this._qrWorker,this.$canvas,!0).then(this._onDecode,(a)=>{this._active&&"QR code not found."!==a&&
console.error(a)}).then(()=>this._scanFrame())})}_getCameraStream(a,c=!1){let b=[{width:{min:1024}},{width:{min:768}},{}];a&&(c&&(a={exact:a}),b.forEach((b)=>b.facingMode=a));return this._getMatchingCameraStream(b)}_getMatchingCameraStream(a){return 0===a.length?Promise.reject("Camera not found."):navigator.mediaDevices.getUserMedia({video:a.shift()}).catch(()=>this._getMatchingCameraStream(a))}_setVideoMirror(a){this.$video.style.transform="scaleX("+("user"===a?-1:1)+")"}static _getImageData(a,c=
null,b=null,d=!1){b=b||document.createElement("canvas");let f=c&&c.x?c.x:0,g=c&&c.y?c.y:0,h=c&&c.width?c.width:a.width||a.videoWidth;c=c&&c.height?c.height:a.height||a.videoHeight;d||b.width===h&&b.height===c||(b.width=h,b.height=c);d=b.getContext("2d",{alpha:!1});d.imageSmoothingEnabled=!1;d.drawImage(a,f,g,h,c,0,0,b.width,b.height);return d.getImageData(0,0,b.width,b.height)}static _loadImage(a){if(a instanceof HTMLCanvasElement||a instanceof HTMLVideoElement||window.ImageBitmap&&a instanceof window.ImageBitmap||
window.OffscreenCanvas&&a instanceof window.OffscreenCanvas)return Promise.resolve(a);if(a instanceof Image)return e._awaitImageLoad(a).then(()=>a);if(a instanceof File||a instanceof URL||"string"===typeof a){let c=new Image;c.src=a instanceof File?URL.createObjectURL(a):a;return e._awaitImageLoad(c).then(()=>{a instanceof File&&URL.revokeObjectURL(c.src);return c})}return Promise.reject("Unsupported image type.")}static _awaitImageLoad(a){return new Promise((c,b)=>{if(a.complete&&0!==a.naturalWidth)c();
else{let d,f;d=()=>{a.removeEventListener("load",d);a.removeEventListener("error",f);c()};f=()=>{a.removeEventListener("load",d);a.removeEventListener("error",f);b("Image load error")};a.addEventListener("load",d);a.addEventListener("error",f)}})}}e.DEFAULT_CANVAS_SIZE=400;e.WORKER_PATH="qr-scanner-worker.min.js";export default e;
//# sourceMappingURL=qr-scanner.min.js.map

File diff suppressed because one or more lines are too long

517
static/styles.css Normal file
View file

@ -0,0 +1,517 @@
html, body{
margin: 0;
padding: 0;
background: #192432;
color: #7f8fa4;
font-family: "Source Sans Pro", sans-serif;
font-weight: lighter;
display: flex;
width: 100%;
height: 100%;
}
body{
flex-direction: column;
max-width: 100%;
height: 100%;
}
*{
box-sizing: border-box;
}
.loader{
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.9);
flex-direction: column;
}
.loader img{
width: 390px;
height: 428px;
margin: 20px;
}
.overflow{
flex-wrap: wrap;
/*max-width: 100%;*/
justify-content: center;
}
tr.unconfirmed{
background: #192432;
/*font-style: italic;*/
}
.unconfirmed td img{
opacity: 0.7;
}
.unconfirmed:hover td img{
opacity: 1;
}
.row{
display: flex;
flex-direction: row;
/*flex-grow: 1;*/
}
.row.center{
justify-content: center;
}
.holder{
height: 100%;
}
.qr{
background: #fff;
padding: 30px;
}
.qr.broad{
padding: 40px;
}
.side .balance{
margin-left: auto;
margin-right: 20px;
font-size: 0.7em;
align-self: center;
}
.grow{
flex-grow: 1;
}
nav.top{
height: 70px;
border-bottom: 1px solid #313E50;
-webkit-app-region: drag;
}
nav.side{
background: #263044;
width: 250px;
max-width: 250px;
min-width: 250px;
border-right: 1px solid #313E50;
flex-grow: 0;
/*padding-top: 30px;*/
padding-top: 10px;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-y: scroll;
}
main{
flex-grow: 1;
background: #192432;
display: flex;
flex-direction: column;
align-items: center;
/*justify-content: center;*/
padding: 50px 0 20px 0;
/*padding: 30px 0 20px 0;*/
overflow-y: scroll;
}
nav.side > .item{
padding: 10px 0;
display: flex;
flex-direction: row;
align-items: center;
border-left: 3px solid transparent;
/*font-size: 0.85em;*/
}
nav.side > a.item.active{
border-left: 3px solid #4A90E2;
background: rgba(0,0,0,0.1);
/*color: #fff;*/
}
a.item{
text-decoration: none;
color: inherit;
}
a.btn, a.small-card{
text-decoration: none;
}
a.item:hover{
color: #F8FEFF;
}
.item.core > svg, .item.core > img{
margin: 5px 12px 7px 23px;
}
.item:hover > svg, .item:hover > img{
opacity: 1;
}
.item > svg, .item > img{
opacity: 0.7;
margin: 0px 10px 0px 30px;
}
.item:hover > svg{
opacity: 1;
}
svg.core .main{
fill: #79869B;
}
svg.core.test .main{
fill: #00F100;
}
svg.core.main .main{
fill: #FF9A00;
}
svg.core.regtest .main{
fill: #00CAF1;
}
svg.core.signet .main{
fill: #00CAF1;
}
small{
font-size: 0.7em;
}
nav .separator{
text-transform: uppercase;
/*text-align: center;*/
font-size: 0.85em;
margin: 20px 0px 5px 26px;
/*font-weight: bold;*/
/*opacity: 0.8;*/
}
.item .btn{
margin: 5px 20px 5px 20px;
min-width: 170px;
}
.btn{
background: #506072;
border: 1px solid transparent;
border-radius: 4px;
padding: 8px 15px 8px 10px;
width: 170px;
color: #fff;
font-size: 0.85em;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-grow: 1;
}
.btn:hover{
background: #405062;
}
.btn svg{
margin-right: 5px;
}
.btn svg path{
stroke: #fff;
fill: #fff;
}
.footer{
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
font-size: 0.7em;
flex-grow: 1;
}
.footer img{
width: 85px;
margin: 10px 0 20px 0;
}
.log{
/*display: block;*/
max-width: 500px;
/*max-width: 70%;*/
}
pre{
overflow-x: scroll;
background: rgba(0,0,0,0.1);
padding: 10px;
margin: 10px -10px;
}
h1, h2{
font-weight: lighter;
/*font-size: 2em;*/
}
h1{
font-size: 1.5em;
text-align: center;
margin: 0 0 20px 0;
}
h2{
font-size: 1.25em;
margin: 0 0 0px 0;
}
input, textarea, .input{
border: 1px solid #506072;
background: transparent;
border-radius: 4px;
padding: 8px 15px 8px 10px;
width: 100%;
font-size: inherit;
font-weight: inherit;
min-width: 170px;
color: #fff;
}
.input{
text-align: left;
background: rgba(255,255,255,0.05);
}
.input.inline{
display: inline-block;
}
input.inline, .input.inline{
width: auto;
min-width: 300px;
}
input[type="number"].inline, .input.inline{
min-width: 30px;
width: 80px;
margin: 0 30px;
}
textarea{
font-size: 0.7em;
}
input:hover{
border: 1px solid #607082;
}
.card{
width: 580px;
border: 1px solid #506072;
border-radius: 4px;
padding: 40px;
}
.spacer{
height: 50px;
min-height: 50px;
}
.center{
text-align: center;
}
.small-card{
min-width: 220px;
/*height: 250px;*/
padding: 40px;
background: transparent;
border: 1px solid #506072;
border-radius: 4px;
/*padding: 8px 15px 8px 10px;*/
color: #fff;
font-size: 0.85em;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
margin: 20px;
}
.small-card:hover{
background: #203042;
}
.small-card.highlighted{
border: 1px solid #4B8CD8 !important;
cursor: default;
}
.small-card.highlighted:hover{
background: transparent !important;
}
.small-card img{
height: 130px;
width: 70px;
margin-bottom: 20px;
}
.note{
font-size: 0.7em;
width: 400px;
margin: 0 auto;
line-height: 1.5;
}
textarea{
margin-bottom: 5px;
height: 100px;
}
.inputfile {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: fixed;
z-index: -1;
}
.inputfile + label {
cursor: pointer;
}
.card .note{
width: 100%;
margin-bottom: 10px;
}
/*.btn.btn-inline{
display: inline;
padding: 5px 15px;
align-items: center;
margin: 0 5px;
font-size: 15px;
}*/
.btn > img{
width: 12px;
margin-right: 10px;
}
.btn.centered{
margin-left: auto;
margin-right: auto;
width: 300px;
}
.video{
width: 90%;
}
.popup{
background: rgba(0,0,0,0.7);
width: 100%;
height: 100%;
display: none;
position: fixed;
left: 0;
top: 0;
align-items: center;
justify-content: center;
z-index: 1000;
}
.notification{
min-width: 200px;
max-width: 800px;
margin: auto;
position: absolute;
top: 0;
font-size: 0.7em;
color: #fff;
overflow-x: scroll;
background: #313E50;
border-radius: 0 0 4px 4px;
padding: 7px 30px;
box-shadow: 0 3px 3px rgba(0,0,0,0.7);
line-height: 1.5;
}
.notification.error, .btn.danger{
background: #951E2D;
}
.btn.danger:hover{
background: #A12737;
}
.btn.action{
background: #3575C0;
}
.btn.action:hover{
background: #4B8CD8;
}
.btn.radio{
border-radius: 0;
cursor: pointer;
background: transparent;
border: 1px solid #405062;
}
.btn.radio:hover{
background: #304052;
}
input[type="radio"].hidden{
position: absolute;
top: -100px;
left: -100px;
}
input[type="radio"]:checked + .btn.radio, .btn.radio.checked{
background: #405062;
}
input[type="radio"]:checked + .btn{
background: #3575C0;
}
input[type="radio"]:checked + .btn.hovering{
visibility: visible;
}
.btn.radio.left{
border-radius: 10px 0 0 10px;
}
.btn.radio.right{
border-radius: 0 10px 10px 0;
}
input[type="radio"]:checked + .small-card{
background: #304052;
}
table{
border-collapse: collapse;
font-size: 0.85em;
width: 100%;
border: 1px solid #313E50;
}
.table-holder{
padding: 0 30px;
width: 100%;
}
.full-width{
all: inherit;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
td, th{
padding: 20px 10px;
border-bottom: 1px solid #313E50;
}
tr, thead{
background: #263044;
}
th{
text-align: left;
font-weight: inherit;
border-bottom: 3px solid #313E50;
}
tbody tr:hover{
background: rgba(255,255,255,0.03);
color: #fff;
}
td.xpub{
max-width: 300px;
overflow-x: scroll;
}
td.txid{
max-width: 200px;
overflow-x: scroll;
}
table a{
display: inline;
color: inherit;
text-decoration: none;
}
table a:hover{
color: #fff;
text-decoration: underline;
}
table svg.core.test{
margin: 0 3px -3px 0;
opacity: 0.7;
}
.qr-popup{
/*display: none;*/
visibility: hidden;
position: absolute;
right: 20px;
top: 20px;
/*margin-left: -100px;*/
z-index: 100;
}
.qr-trigger:hover .qr-popup{
/*display: block;*/
visibility: visible;
}
.qr-trigger:hover ~ .qr.alt-qr{
opacity: 0.02;
}
.qr-popup img{
width: 200px;
height: 200px;
background: #fff;
padding: 15px;
}
table .btn{
margin: -10px 0;
padding: 5px 10px;
width: auto;
min-width: 0;
}
table .btn.hovering{
visibility: hidden;
}
table tr:hover .btn.hovering{
visibility: visible;
}

135
templates/base.html Normal file
View file

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<link rel="shortcut icon" type="image/png" href="/static/img/icon.png"/>
<title>Specter Desktop</title>
<link rel="stylesheet" type="text/css" href="/static/styles.css?{{rand}}">
</head>
<body>
<div class="row holder">
<nav class="side">
<a href="/settings/" class="item core">
<svg class="core {{specter.info.chain}}" height="32" width="32" viewBox="0 0 64 64">
<g>
<path class="main" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>
<div>
Bitcoin Core<br>
<small>
{{specter.chain}}:
{% if specter.info["blocks"] %}
{{ specter.info.blocks }} blocks
{% else %}
{{ specter.info.chain }}
{% endif %}
</small>
</div>
</a>
<div class="separator">
Wallets
</div>
{% for wallet in specter.wallets %}
{% if wallet_alias == wallet.alias %}
<a href="/wallets/{{wallet.alias}}/" class="item active">
{% else %}
<a href="/wallets/{{wallet.alias}}/" class="item">
{% endif %}
{% if wallet.is_multisig() %}
<svg width="18px" height="25px" viewBox="0 0 140 170" version="1.1">
{% else %}
<svg width="18px" height="25px" viewBox="0 50 100 150" version="1.1">
{% endif %}
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-2" transform="translate(57.000000, 99.500000) rotate(75.000000) translate(-57.000000, -99.500000) translate(-37.000000, 25.000000)">
<path d="M109.318138,82.5452222 C104.547229,67.7885555 91.6308655,57.2118888 76.445411,57.2118888 C57.1872292,57.2118888 41.5363201,74.2485555 41.5363201,95.2118888 C41.5363201,116.175222 57.1872292,133.211889 76.445411,133.211889 C91.6308655,133.211889 104.547229,122.635222 109.318138,107.878555 L134.627229,107.878555 L134.627229,133.211889 L157.899956,133.211889 L157.899956,107.878555 L169.53632,107.878555 L169.53632,82.5452222 L109.318138,82.5452222 Z M76.445411,107.878555 C70.045411,107.878555 64.8090473,102.178555 64.8090473,95.2118888 C64.8090473,88.2452222 70.045411,82.5452222 76.445411,82.5452222 C82.845411,82.5452222 88.0817746,88.2452222 88.0817746,95.2118888 C88.0817746,102.178555 82.845411,107.878555 76.445411,107.878555 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero" transform="translate(105.536320, 95.211889) rotate(15.000000) translate(-105.536320, -95.211889) "></path>
{% if wallet.is_multisig() %}
<path d="M119.379895,40.6029336 C114.608986,25.846267 101.692623,15.2696003 86.5071681,15.2696003 C67.2489863,15.2696003 51.5980772,32.306267 51.5980772,53.2696003 C51.5980772,74.2329336 67.2489863,91.2696003 86.5071681,91.2696003 C101.692623,91.2696003 114.608986,80.6929336 119.379895,65.936267 L144.688986,65.936267 L144.688986,91.2696003 L167.961714,91.2696003 L167.961714,65.936267 L179.598077,65.936267 L179.598077,40.6029336 L119.379895,40.6029336 Z M86.5071681,65.936267 C80.1071681,65.936267 74.8708045,60.236267 74.8708045,53.2696003 C74.8708045,46.3029336 80.1071681,40.6029336 86.5071681,40.6029336 C92.9071681,40.6029336 98.1435318,46.3029336 98.1435318,53.2696003 C98.1435318,60.236267 92.9071681,65.936267 86.5071681,65.936267 Z" id="Shape" fill-opacity="0.53" fill="#FFFFFF" fill-rule="nonzero" transform="translate(115.598077, 53.269600) rotate(-15.000000) translate(-115.598077, -53.269600) "></path>
<circle id="Oval" stroke="#FFFFFF" stroke-width="10" cx="42" cy="64" r="42"></circle>
{% endif %}
</g>
</g>
</svg>
<div class="grow">
<div class="row">
{{wallet.name}}
{% if specter.chain %}
{% if wallet.fullbalance is not none %}
<span class="balance">{{ "%0.8f" % (wallet.fullbalance ) | float}}
<!-- {% if specter.chain != "main" %} t{%endif%}BTC -->
</span>
{%endif%}
{%endif%}</div>
<small>{{ wallet.description }}</small>
</div>
</a>
{% endfor %}
<div class="item">
<a href="/new_wallet/" class="btn">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Create new wallet
</a>
</div>
<div class="separator">
Devices
</div>
{% for device in specter.devices %}
{% if device_alias == device.alias %}
<a href="/devices/{{device.alias}}/" class="item active">
{% else %}
<a href="/devices/{{device.alias}}/" class="item">
{% endif %}
<img src="/static/img/{{device.type}}_icon.svg" width="18px">
<div>{{device.name}}<br><small>{{ device["keys"] | length}} keys</small></div>
</a>
{% endfor %}
<div class="item">
<a href="/new_device/" class="btn">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Add new device
</a>
</div>
<div class="footer">
Brought to you by<br>
<a href="https://cryptoadvance.io" target="_blank">
<img src="/static/img/ca.png">
</a>
</div>
</nav>
<main>
{% if error %}
<div class="notification error">
ERROR: {{error}}
</div>
{% endif %}
{% block main %}
{% if error %}
<br><br>Something went wrong :(<br><br>{{error}}
{% else %}
<br><br>Nothing here
{% endif %}
{% endblock %}
</main>
</div>
<div class="loader" id="loader">
<img src="/static/img/loader.gif"/>
<h1>It may take a while...</h1>
</div>
<script type="text/javascript">
function showLoader(){
console.log("Loading");
window.setTimeout(()=>{
document.getElementById("loader").style.display = "flex";
}, 1000);
}
function hideLoader(){
document.getElementById("loader").style.display = "none";
}
// document.querySelectorAll("button").forEach((e)=>{
// e.addEventListener("click", showLoader);
// });
</script>
{% block scripts %}
{% endblock %}
</body>
</html>

62
templates/device.html Normal file
View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block main %}
<h1>Public keys of {{device.name}}</h1>
<div class="table-holder">
<table>
<thead>
<tr>
<th>Network</th><th>Purpose</th><th>Derivation</th><th></th><th>Key</th><th>Actions</th>
</tr></thead>
<tbody>
{% for key in device["keys"] %}
<tr>
<td>
{% if key["xpub"].startswith("xpub") %}
<svg class="core main" height="15" width="15" viewBox="0 0 64 64">
{%else%}
<svg class="core test" height="15" width="15" viewBox="0 0 64 64">
{%endif%}
<g>
<path class="main" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>
{% if key["xpub"].startswith("xpub") %}
Main
{%else%}
Test
{%endif%}
</td>
<td>{{ purposes[key["type"]] }}</td>
<td>{{key["derivation"]}}</td>
<td width="10" class="qr-trigger">
<img src="/static/img/qr_tiny.svg"/>
<div class="qr-popup">
<img src="{{qrcode('name='+device['name']+'\n'+key['combined'])}}">
</div>
</td>
<td class="xpub">{{key["original"]}}</td>
<td width="80px">
<form action="./" method="POST">
<form action="./" method="POST">
<input type="hidden" name="key" value="{{key['original']}}">
<button type="submit" name="action" value="delete_key" class="btn danger hovering">Delete</button>
</form>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="spacer"></div>
<div class="row">
<form action="./" method="POST">
<button type="submit" name="action" value="add_keys" class="btn centered">Add more keys</button>
</form>
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp;
<form action="./" method="POST">
<button type="submit" name="action" value="forget" class="btn danger centered">Forget the device</button>
</form>
</div>
{% endblock %}

24
templates/new_device.html Normal file
View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block main %}
<div class="spacer"></div>
<h1>Select the hardware</h1>
<div class="row">
<a href="./coldcard/" class="small-card">
<img src="/static/img/coldcard_icon.svg?{{rand}}">
ColdCard
</a>
<a href="./specter/" class="small-card">
<img src="/static/img/specter_icon.svg?{{rand}}">
DIY Specter
</a>
<a href="./other/" class="small-card">
<img src="/static/img/other_icon.svg?{{rand}}">
Other device
</a>
</div>
<div><br><br></div>
<div class="note">
<p>In order to use Bitcoin Core in watch only mode with hardware devices we need to export extended public keys from it.</p>
<p>Currently we support only airgapped devices. Do not connect the hardware device to your online computer. Airgap is the best way to improve your security.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block main %}
<form action="./" method="POST">
<!-- <div class="spacer"></div> -->
{% if device %}
<h1>Adding keys to {{device["name"]}}</h1>
{% else %}
<h1>Setting up a new device</h1>
{% endif %}
<div class="card">
<form action="./" method="POST">
{% if not device %}
<div>Name it &nbsp; &nbsp;<input type="text" name="device_name" class="inline" value="{{ device_name }}" placeholder="Name your device"></div><br>
{% endif %}
<h2>Scan, paste or load xpubs:</h2>
<div class="note">
{% if not device %}
You will be able add more keys later, from the device menu.
{% endif %}
</div>
<textarea id="txt" name="xpubs" placeholder="Enter your xpubs here">{{xpubs}}</textarea>
<div class="row">
<a href="#" class="btn" id="scanme"><img src="/static/img/qr_icon.svg" height="12px"> Scan</a> &nbsp;
<input type="file" id="file" class="inputfile" multiple/>
<label for="file" class="btn">Choose files</label>
</div>
<br><div class="note">
One line per xpub. Ideally with derivation path in the form "<b>[fingerprint/derivation/path]xpub</b>".<br>
Examples:<br>
<pre style="font-size: 1.2em">[f79ec910/84'/1'/0']tpubDA5h1hw24fzoBi9...pR1DiM4EBu
[F79EC910/48h/1h/0h/2h]Vpub5DRk6RvRAjZf9rmZV1Q8...TZSDQL67e5i
upub5En4f7k8gaG2KDHvBeEYox...rFpJRHpiZ4DE</pre>
Loading from file supports ColdCard and Electrum wallet format.<br>
</div>
<button type="submit" class="btn centered" name="action" value="morekeys">Continue</button>
</form>
</div>
<div class="note">
{%if device_type == "coldcard" %}
<p>Export extended public keys for single-key wallets to the SD card from<br>
<b>Advanced → MicroSD Card → Electrum Wallet</b></p>
<p>To export multisignature pubkeys go to <br>
<b>Settings → Multisig Wallets Export XPUB</b></p>
<p>We recommend using Native Segwit.</p>
{% endif %}
{%if device_type == "specter" %}
<p>Scan QR codes with extended public keys one by one.<br>
<p>We recommend importing <b>Native Segwit</b> and <b>Segwit Multisig</b> keys.</p>
{% endif %}
</div>
</form>
{% endblock %}
{% block scripts %}
<div class="popup" id="popup">
<video muted playsinline id="qr-video" class="video"></video>
</div>
<script type="text/javascript">
var el = document.getElementById("file");
var txt = document.getElementById("txt");
el.addEventListener("change", (e) => {
files = el.files;
console.log(files);
for(let i=0; i<files.length; i++){
console.log(files[i].name);
let reader = new FileReader();
reader.onload = function(e) {
let str = reader.result
if(str.indexOf("{") >= 0){
let json = JSON.parse(str);
console.log(str);
if("keystore" in json){ // ColdCard electrum file
let prefix = "";
if( ("ckcc_xfp" in json.keystore) && ("derivation" in json.keystore)){
prefix = "[";
let num = json.keystore.ckcc_xfp;
for(let i=0;i<4;i++){
prefix += (num % 256).toString(16);
num = num >> 8;
}
prefix += json.keystore.derivation.substring(1);
prefix += "]";
}
let s = prefix + json.keystore.xpub + "\n";
txt.value += s;
}else if("xfp" in json){ // probably ColdCard multisig file
let s = "";
for(let k in json){
if(k+"_deriv" in json){
s += "["+json.xfp+json[k+"_deriv"].substring(1)+"]"+json[k]+"\n";
}
}
txt.value += s;
}
}else{
txt.value += str+"\n";
}
}
reader.readAsText(files[i]);
}
});
</script>
<script type="module">
import QrScanner from "/static/qr/qr-scanner.min.js";
QrScanner.WORKER_PATH = '/static/qr/qr-scanner-worker.min.js';
const video = document.getElementById('qr-video');
const scanner = new QrScanner(video, result => {
scanner.stop();
document.getElementById("popup").style.display = 'none';
document.getElementById("txt").value += result + "\n";
});
document.getElementById("scanme").addEventListener("click", function(){
document.getElementById("popup").style.display = 'flex';
scanner.start();
});
document.getElementById("popup").addEventListener("click", function(){
document.getElementById("popup").style.display = 'none';
scanner.stop();
});
</script>
{% endblock %}

109
templates/new_simple.html Normal file
View file

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block main %}
<form action="./" method="POST">
<div class="center">Name it &nbsp; &nbsp;<input type="text" name="wallet_name" class="inline" value="{{ wallet_name }}" placeholder="Name your wallet"></div><br>
<h1>Type of the wallet</h1>
{% if sigs_total %}
<div class="row center">
<label>
{% if wallet_type=="sh" %}
<input type="radio" name="type" value="sh" class="hidden" checked>
{% else %}
<input type="radio" name="type" value="sh" class="hidden">
{%endif%}
<div class="btn radio left">Legacy</div>
</label>
<label>
{% if wallet_type=="sh-wsh" %}
<input type="radio" name="type" value="sh-wsh" class="hidden" checked>
{% else %}
<input type="radio" name="type" value="sh-wsh" class="hidden">
{%endif%}
<div class="btn radio">Nested Segwit</div>
</label>
<label>
{% if wallet_type=="wsh" %}
<input type="radio" name="type" value="wsh" class="hidden" checked>
{% else %}
<input type="radio" name="type" value="wsh" class="hidden">
{%endif%}
<div class="btn radio right">Segwit</div>
</label>
{% else %}
<div class="row center">
<label>
<input type="radio" name="type" value="pkh" class="hidden">
<div class="btn radio left">Legacy</div>
</label>
<label>
<input type="radio" name="type" value="sh-wpkh" class="hidden">
<div class="btn radio">Nested Segwit</div>
</label>
<label>
<input type="radio" name="type" value="wpkh" checked class="hidden">
<div class="btn radio right">Segwit</div>
</label>
{% endif %}
</div>
<div class="note"><center>
<br><b>Segwit</b> uses bech32-encoded addresses (bc1), <b>Nested Segwit</b> makes it compatible with legacy software. Don't use legacy.
</center></div>
{% if sigs_total %}
<br>
<div class="center">
Using {%if not cosigner_index %}<input class="inline" type="number" name="sigs_required" min=1 max={{sigs_total}} step=1 value="{{sigs_required}}" />{% else %}<div class="input inline">{{sigs_required}}</div><input type="hidden" name="sigs_required" value="{{sigs_required}}"/> {%endif%}
of {%if not cosigner_index %}<input class="inline" type="number" name="sigs_total" min={{2}} max={{sigs_total}} step=1 value="{{sigs_total}}"/>{% else %}<div class="input inline">{{sigs_total}}</div><input type="hidden" name="sigs_total" value="{{sigs_total}}"/> {%endif%} multisig
<input type="hidden" name="cosigner_index" value="{{cosigner_index}}"/>
{% for cosigner in cosigners %}
<input type="hidden" name="cosigner{{loop.index0}}" value="{{cosigner}}"/>
{% endfor %}
</div>
<br>
<h1>Pick the device you want to use as signer #{{cosigner_index+1}}</h1>
{% else %}
<div class="spacer"></div>
<h1>Pick the device you want to use</h1>
{% endif %}
<div class="row overflow">
{% for k in specter.devices.names() %}
<label>
{% if cosigners and k in cosigners %}{% else %}
{% if device["name"] == k %}
<input type="radio" name="device" value="{{k}}" class="hidden" checked>
{% else %}
<input type="radio" name="device" value="{{k}}" class="hidden">
{% endif %}
{% endif %}
{% if cosigners and k in cosigners %}
<div class="small-card radio highlighted">
{% else %}
<div class="small-card radio">
{% endif %}
<img src="/static/img/{{specter.devices[k]['type']}}_icon.svg" width="18px">
{{ k }}
{% if cosigners %}
{% for cosigner in cosigners %}
{% if cosigner==k %}
- cosigner {{loop.index}}
{% endif %}
{% endfor %}
{% endif %}
</div>
</label>
{% endfor %}
</div>
<button type="submit" name="action" value="device" class="btn centered">Continue</button>
<div class="note"><br>
{% if sigs_total %}
<center>Remember the order of signers. <b>Order is important.</b> It is another piece of information you need to back up.<br>
On the next page we will ask you to choose the next device.</center>
{% else %}
<center>On the next page we will ask you to choose the keys.</center>
{% endif %}
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block main %}
<div class="spacer"></div>
{% if device %}
<h1>Select the {{device["name"] }}'s key to use in {{wallet_name}}.</h1>
{% else %}
<h1>Select the key of devices to use in {{wallet_name}}.</h1>
{% endif %}
{% if cosigners %}
<form action="./" method="POST" class="table-holder">
<input type="hidden" name="wallet_name" value="{{wallet_name}}">
{% for cosigner in cosigners %}
<input type="hidden" name="cosigner{{loop.index0}}" value="{{cosigner['name']}}"/>
{% endfor %}
<input type="hidden" name="type" value="{{wallet_type}}">
<input type="hidden" name="sigs_required" value="{{sigs_required}}">
<input type="hidden" name="sigs_total" value="{{sigs_total}}">
<input type="hidden" name="cosigner_index" value="{{cosigner_index}}">
{% for device in cosigners %}
<h1>Signer {{ loop.index }} - {{device["name"]}}</h1>
{% set outer_loop = loop %}
{% if device["keys"] | length > 0 %}
<table>
<thead>
<tr>
<th>Network</th><th>Purpose</th><th>Derivation</th><th>Key</th><th>Actions</th>
</tr></thead>
<tbody>
{% for key in device["keys"] %}
<tr>
<td>
{% if key["xpub"].startswith("xpub") %}
<svg class="core main" height="15" width="15" viewBox="0 0 64 64">
{%else%}
<svg class="core test" height="15" width="15" viewBox="0 0 64 64">
{%endif%}
<g>
<path class="main" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>
{% if key["xpub"].startswith("xpub") %}
Mainnet
{%else%}
Testnet
{%endif%}
</td>
<td>{{ purposes[key["type"]] }}</td>
<td>{{key["derivation"]}}</td>
<td class="xpub">{{key["original"]}}</td>
<td width="120px">
<label>
<input type="radio" name="key{{outer_loop.index0}}" value="{{key['original']}}" class="hidden" {% if loop.index == 1 %}checked{% endif %}/>
<div class="btn inline hovering">Use this key</div>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="center">Looks like this device doesn't have keys matching this type of wallet.</div>
{% endif %}
<div class="spacer"></div>
{% endfor %}
{% if not error %}
<button type="submit" onclick="showLoader()" name="action" value="key" class="btn centered action">Create wallet</button>
{% endif %}
</form>
{% else %}
<!-- UGLY COPY-PASTE -->
<div class="spacer"></div>
<div class="table-holder">
<table>
<thead>
<tr>
<th>Network</th><th>Purpose</th><th>Derivation</th><th>Key</th><th>Actions</th>
</tr></thead>
<tbody>
{% for key in device["keys"] %}
<tr>
<td>
{% if key["xpub"].startswith("xpub") %}
<svg class="core main" height="15" width="15" viewBox="0 0 64 64">
{%else%}
<svg class="core test" height="15" width="15" viewBox="0 0 64 64">
{%endif%}
<g>
<path class="main" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>
{% if key["xpub"].startswith("xpub") %}
Main
{%else%}
Test
{%endif%}
</td>
<td>{{ purposes[key["type"]] }}</td>
<td>{{key["derivation"]}}</td>
<td class="xpub">{{key["original"]}}</td>
<td width="120px">
<form action="./" method="POST">
<input type="hidden" name="key" value="{{key['original']}}">
<input type="hidden" name="wallet_name" value="{{wallet_name}}">
<input type="hidden" name="device" value="{{device['name']}}">
<input type="hidden" name="type" value="{{wallet_type}}">
<button type="submit" onclick="showLoader()" name="action" value="key" class="btn hovering action">Use this key</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="spacer"></div>
<div class="note">
<center>Here we only show <b>{{purposes[wallet_type]}}</b> and <b>General</b> keys. Follow the standards.</center>
</div>
{% endblock %}

18
templates/new_wallet.html Normal file
View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block main %}
<div class="spacer"></div>
<h1>Select the type of the wallet</h1>
<div class="row">
<a href="./simple/" class="small-card">
<img src="/static/img/simple_icon.svg"/>
Single key wallet
</a>
<a href="./multisig/" class="small-card">
<img src="/static/img/multisig_icon.svg"/>
Multisignature wallet
</a>
</div>
<div class="note">
<center>Multisig is better for larg-ish amounts.</center>
</div>
{% endblock %}

45
templates/settings.html Normal file
View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block main %}
<form action="./" method="POST">
<h1>App settings</h1>
<div class="card">
<h2>Bitcoin JSON-RPC configuration</h2>
<br>
Username:<br><input type="text" name="username" value="{{username}}">
<br><br>
Password:<br><input type="text" name="password" value="{{password}}">
<br><br>
Host:<br>
<input type="text" name="host" type="text" value="{{host}}">
<br><br>
Port:<br>
<input type="text" name="port" type="text" value="{{port}}">
<div class="note">Default ports: <b>8332</b> for mainnet, <b>18332</b> for testnet, <b>18443</b> for Regtest, <b>38332</b> for signet</div>
<div class="row">
<button type="submit" class="btn" name="action" value="test">
Test
</button>&nbsp;
<button type="submit" class="btn" name="action" value="save">
Save
</button>
</div>
{%if test %}
<br><div class="log">Test results:<br>
<code><pre>
Process finished with code <b>{{ test.code }}</b>
{% if test.code == 0 %}
Output: {{ test.out }}
{% else %}
Error message: {{ test.err }}
{% endif %}</pre></code>
</div>
{% endif %}
</div>
<div class="spacer"></div>
<div class="card">
<h2>Command to run HWI tool</h2>
<br>
<div>Not implemented yet</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block main %}
<h1>{{wallet.name}}</h1>
<div class="row">
<a href="/wallets/{{wallet['alias']}}/tx/" class="btn radio left">Transactions</a>
<a href="/wallets/{{wallet['alias']}}/receive/" class="btn radio checked">Receive</a>
<a href="/wallets/{{wallet['alias']}}/send/" class="btn radio">Send</a>
<a href="/wallets/{{wallet['alias']}}/settings/" class="btn radio right">Settings</a>
</div>
{% set url="https://blockstream.info/" %}
{% if specter.chain == "test" %}
{% set url="https://blockstream.info/testnet/" %}
{% endif %}
{% if specter.chain == "regtest" %}
{% set url="#" %}
{% endif %}
<br>
<div class="center">
<span class="qr-trigger">
Address #{{wallet['address_index']+1}}<br>
<small>( hover
<img src="/static/img/qr_tiny.svg"/>
to verify
<div class="qr-popup">
<img src="{{qrcode(wallet | derivation)}}">
</div>)
</small>
</span><br><br>
<img src="{{ qrcode( 'bitcoin:'+wallet['address']+'?index='+(wallet['address_index']|string) ) }}" class="qr alt-qr" width="300px" /><br>
<br>{{wallet['address']}}
</div><br><br>
<form action="./" method="POST">
<button type="submit" onclick="showLoader()" name="action" value="newaddress" class="btn centered">Get new address</button>&nbsp; &nbsp; &nbsp;
<!-- <button type="submit" name="action" value="send" class="btn centered action">Send</button> -->
</form>
<div class="note"><center>Specter can verify this address if you scan derivation path<br>( just hover over <img src="/static/img/qr_tiny.svg"/> symbol above the qr code of the address )</center></div>
{% if (wallet | txonaddr) > 0 %}
<br>
<h1>Transactions on this address ( {{wallet | txonaddr}} )</h1>
<div class="table-holder">
<table>
<thead>
<tr>
<th></th><th>TxID</th><th>Address</th><th>Amount</th><th>Confirmations</th><th>Time</th>
</tr>
</thead>
<tbody>
{% for tx in wallet.transactions %}
{% if tx['address'] == wallet['address'] %}
{%if tx["confirmations"] == 0 %}
<tr class="unconfirmed">
{%else%}
<tr>
{%endif%}
<td>
{%if tx["confirmations"] == 0 %}
<img src="/static/img/unconfirmed_{{tx['category']}}_icon.svg"/>
{% else %}
<img src="/static/img/{{tx['category']}}_icon.svg"/>
{%endif%}
</td>
<td class="txid"><a target="blank" href="{{url}}tx/{{tx['txid']}}">{{tx["txid"]}}</a></td>
<td class="txid"><a target="blank" href="{{url}}address/{{tx['address']}}">{{tx["address"]}}</a></td>
<td>{{tx["amount"]}}</td><td>
{%if tx["confirmations"] == 0 %}
Pending
{% else %}
{{tx["confirmations"]}}
{% endif %}
</td>
<td>{{tx["time"] | datetime}}</td></tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

118
templates/wallet_send.html Normal file
View file

@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block main %}
<h1>{{wallet.name}}</h1>
<div class="row">
<a href="/wallets/{{wallet['alias']}}/tx/" class="btn radio left">Transactions</a>
<a href="/wallets/{{wallet['alias']}}/receive/" class="btn radio">Receive</a>
<a href="/wallets/{{wallet['alias']}}/send/" class="btn radio checked">Send</a>
<a href="/wallets/{{wallet['alias']}}/settings/" class="btn radio right">Settings</a>
</div>
<br>
<form action="./" method="POST" class="full-width">
{% if not psbt %}
<br><h1>Sending to:</h1>
<div class="card">
Receipient address:<br>
<div class="row">
<input type="text" id="address" name="address" value="{{address}}"> &nbsp;
<a class="btn" id="scanme"><img src="/static/img/qr_tiny.svg"/> Scan</a>
</div>
<br><br>
Amount:<br>
<input type="number" name="amount" value="{{amount}}" id="amount" max="{{wallet.fullbalance}}" min=0 step="1e-8">
<div class="note">Available: {{"%.8f" % wallet.fullbalance}}</div>
<br><br>
<button type="submit" onclick="showLoader()" name="action" value="createpsbt" class="btn centered">Create unsigned transaction</button>&nbsp; &nbsp; &nbsp;
</div>
<br>
{% else %}
<div>
<div class="row">
<!-- {% set chunk = 700 %}
{%for i in range( (psbt["base64"] | length)//chunk+1)%}
<img src="{{qrcode((loop.index0 | string)+''+psbt['base64'][loop.index0*chunk:(loop.index0+1)*chunk])}}" class="qr" width="300px"/>
{%endfor%}
-->
<img src="{{qrcode(psbt['base64'])}}" class="qr broad" width="500px"/>
</div>
<div class="log"><code><pre>{{psbt['base64']}}</pre></code></div>
<br><a class="btn centered" download='to{{address}}{{amount}}.psbt' href="data:application/octet-stream;base64,{{psbt['base64']}}">Download transaction</a>
<br>
<br><a class="btn centered" id="scanme">Scan signed transaction</a>
</div>
{% endif %}
</form>
{% endblock %}
{% block scripts %}
<div class="popup" id="popup">
<video muted playsinline id="qr-video" class="video"></video>
</div>
<script type="module">
import QrScanner from "/static/qr/qr-scanner.min.js";
QrScanner.WORKER_PATH = '/static/qr/qr-scanner-worker.min.js';
const video = document.getElementById('qr-video');
const scanner = new QrScanner(video, result => {
scanner.stop();
document.getElementById("popup").style.display = 'none';
{% if not psbt %}
let addr = result;
if(addr.indexOf("bitcoin:") >= 0){
addr = addr.substr(addr.indexOf("bitcoin:")+8);
}
let arr = addr.split("?");
addr = arr[0]
document.getElementById("address").value = addr;
if(arr.length > 1){
arr = arr[1].split("&");
arr.forEach((e)=>{
if(e.startsWith("amount=")){
document.getElementById("amount").value = parseFloat(e.substr(7));
}
});
}
{% else %}
let psbt0 = "{{psbt['base64']}}";
let psbt1 = result;
let url="/combine/";
// console.log(url);
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
if(xmlHttp.status == 200){
console.log(psbt0);
console.log(psbt1);
console.log(this.responseText);
// console.log(this.responseText);
window.location.replace("../tx/");
}else{
// document.getElementById("lbl").innerHTML = "Connection failed... Trying again...";
}
}
xmlHttp.onerror = function(e){
console.log(xmlHttp);
err = xmlHttp;
}
xmlHttp.open("POST", url, true); // true for asynchronous
// xmlHttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xmlHttp.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
xmlHttp.send(JSON.stringify({"psbt0": psbt0, "psbt1": psbt1}));
// xmlHttp.send(null);
{% endif %}
});
document.getElementById("scanme").addEventListener("click", function(){
document.getElementById("popup").style.display = 'flex';
scanner.start();
});
document.getElementById("popup").addEventListener("click", function(){
document.getElementById("popup").style.display = 'none';
scanner.stop();
});
</script>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block main %}
<h1>{{wallet.name}}</h1>
<div class="row">
<a href="/wallets/{{wallet['alias']}}/tx/" class="btn radio left">Transactions</a>
<a href="/wallets/{{wallet['alias']}}/receive/" class="btn radio">Receive</a>
<a href="/wallets/{{wallet['alias']}}/send/" class="btn radio">Send</a>
<a href="/wallets/{{wallet['alias']}}/settings/" class="btn radio right checked">Settings</a>
</div>
<br>
<div class="note center">
Import this wallet to the device by scanning QR code below.
</div>
<br>
<div>
<img src="{{qrcode(qr_text)}}" class="qr" width="400px" />
</div>
{% if wallet.is_multisig() %}
<br>
<div>
<a download="{{wallet['name']}}" href="data:text/plain;charset=US-ASCII,{{cc_file}}" class="btn centered">Download ColdCard file</a>
</div>
{% endif %}
<div>
<code><pre>{{qr_text}}</pre></code>
</div>
<br>
{% endblock %}

68
templates/wallet_tx.html Normal file
View file

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block main %}
<h1>{{wallet.name}}</h1>
<div class="row">
<a href="/wallets/{{wallet['alias']}}/tx/" class="btn radio left checked">Transactions</a>
<a href="/wallets/{{wallet['alias']}}/receive/" class="btn radio">Receive</a>
<a href="/wallets/{{wallet['alias']}}/send/" class="btn radio">Send</a>
<a href="/wallets/{{wallet['alias']}}/settings/" class="btn radio right">Settings</a>
</div>
{% set url="https://blockstream.info/" %}
{% if specter.chain == "test" %}
{% set url="https://blockstream.info/testnet/" %}
{% endif %}
{% if specter.chain == "regtest" %}
{% set url="#" %}
{% endif %}
{% if specter.chain == "signet" %}
{% set url="https://explorer.bc-2.jp/" %}
{% endif %}
<br>
{% if ( wallet.fullbalance ) is not none %}
<h1><small style="line-height:30px">Total balance:<br></small><span style="color: #fff">
{{ "%0.8f" % ( wallet.fullbalance ) | float }}
{% if specter.chain !='main' %}t{%endif%}BTC
{% if wallet.balance["untrusted_pending"] > 0 %}</span><br><small>
( {{ "%0.8f" % wallet.balance["trusted"] | float }} confirmed, {{"%0.8f" % wallet.balance["untrusted_pending"] | float}} pending )
{% endif %}
</small></h1>
<br>
<h1>Latest transactions</h1>
<div class="table-holder">
<table>
<thead>
<tr>
<th></th><th>TxID</th><th>Address</th><th>Amount</th><th>Confirmations</th><th>Time</th>
</tr>
</thead>
<tbody>
{% for tx in wallet.transactions %}
{%if tx["confirmations"] == 0 %}
<tr class="unconfirmed">
{%else%}
<tr>
{%endif%}
<td>
{%if tx["confirmations"] == 0 %}
<img src="/static/img/unconfirmed_{{tx['category']}}_icon.svg"/>
{% else %}
<img src="/static/img/{{tx['category']}}_icon.svg"/>
{%endif%}
</td>
<td class="txid"><a target="blank" href="{{url}}tx/{{tx['txid']}}">{{tx["txid"]}}</a></td>
<td class="txid"><a target="blank" href="{{url}}address/{{tx['address']}}">{{tx["address"]}}</a></td>
<td>{{tx["amount"]}}</td><td>
{%if tx["confirmations"] == 0 %}
Pending
{% else %}
{{tx["confirmations"]}}
{% endif %}
</td>
<td>{{tx["time"] | datetime}}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}