Skip to content

Commit

Permalink
Merge pull request #210 from ben-kaufman/hot-wallet
Browse files Browse the repository at this point in the history
Bitcoin Core hot wallet
  • Loading branch information
stepansnigirev authored Jul 13, 2020
2 parents 06b82d8 + 4e0b7ff commit b2d6509
Show file tree
Hide file tree
Showing 13 changed files with 469 additions and 106 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
bip32==0.0.8
certifi==2019.9.11
chardet==3.0.4
Click==7.0
Expand Down
84 changes: 68 additions & 16 deletions src/cryptoadvance/specter/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import requests
import random, copy
from collections import OrderedDict
from mnemonic import Mnemonic
from threading import Thread
from .key import Key

Expand All @@ -13,9 +14,10 @@
from flask_login.config import EXEMPT_METHODS


from .devices.bitcoin_core import BitcoinCore
from .helpers import (alias, get_devices_with_keys_by_type, hash_password,
get_loglevel, get_version_info, run_shell, set_loglevel,
verify_password, bcur2base64, get_txid)
verify_password, bcur2base64, get_txid, generate_mnemonic)
from .specter import Specter
from .specter_error import SpecterError
from .wallet_manager import purposes
Expand Down Expand Up @@ -705,6 +707,33 @@ def wallet_send(wallet_alias):
wallet.delete_pending_psbt(ast.literal_eval(request.form["pending_psbt"])["tx"]["txid"])
except Exception as e:
flash("Could not delete Pending PSBT!", "error")
elif action == 'signhotwallet':
passphrase = request.form['passphrase']
psbt = ast.literal_eval(request.form["psbt"])
b64psbt = wallet.pending_psbts[psbt['tx']['txid']]['base64']
device = request.form['device']
if 'devices_signed' not in psbt or device not in psbt['devices_signed']:
try:
signed_psbt = app.specter.device_manager.get_by_alias(device).sign_psbt(b64psbt, wallet, passphrase)
if signed_psbt['complete']:
if 'devices_signed' not in psbt:
psbt['devices_signed'] = []
# TODO: This uses device name, but should use device alias...
psbt['devices_signed'].append(app.specter.device_manager.get_by_alias(device).name)
psbt['sigs_count'] = len(psbt['devices_signed'])
raw = wallet.cli.finalizepsbt(b64psbt)
if "hex" in raw:
psbt["raw"] = raw["hex"]
signed_psbt = signed_psbt['psbt']
except Exception as e:
signed_psbt = None
flash("Failed to sign PSBT: %s" % e, "error")
else:
signed_psbt = None
flash("Device already signed the PSBT", "error")
return render_template("wallet/send/sign/wallet_send_sign_psbt.jinja", signed_psbt=signed_psbt, psbt=psbt, label=label,
wallet_alias=wallet_alias, wallet=wallet,
specter=app.specter, rand=rand)
return render_template("wallet/send/new/wallet_send.jinja", psbt=psbt, label=label,
wallet_alias=wallet_alias, wallet=wallet,
specter=app.specter, rand=rand, error=err)
Expand Down Expand Up @@ -819,23 +848,46 @@ def new_device():
device_type = "other"
device_name = ""
xpubs = ""
strength = 128
mnemonic = generate_mnemonic(strength=strength)
if request.method == 'POST':
action = request.form['action']
device_type = request.form['device_type']
device_name = request.form['device_name']
if not device_name:
err = "Device name must not be empty"
elif device_name in app.specter.device_manager.devices_names:
err = "Device with this name already exists"
xpubs = request.form['xpubs']
if not xpubs:
err = "xpubs name must not be empty"
keys, failed = Key.parse_xpubs(xpubs)
if len(failed) > 0:
err = "Failed to parse these xpubs:\n" + "\n".join(failed)
if err is None:
device = app.specter.device_manager.add_device(name=device_name, device_type=device_type, keys=keys)
return redirect("/devices/%s/" % device.alias)
return render_template("device/new_device.jinja", device_type=device_type, device_name=device_name, xpubs=xpubs, error=err, specter=app.specter, rand=rand)
if action == "newcolddevice":
if not device_name:
err = "Device name must not be empty"
elif device_name in app.specter.device_manager.devices_names:
err = "Device with this name already exists"
xpubs = request.form['xpubs']
if not xpubs:
err = "xpubs name must not be empty"
keys, failed = Key.parse_xpubs(xpubs)
if len(failed) > 0:
err = "Failed to parse these xpubs:\n" + "\n".join(failed)
if err is None:
device = app.specter.device_manager.add_device(name=device_name, device_type=device_type, keys=keys)
return redirect("/devices/%s/" % device.alias)
elif action == "newhotdevice":
if not device_name:
err = "Device name must not be empty"
elif device_name in app.specter.device_manager.devices_names:
err = "Device with this name already exists"
if len(request.form['mnemonic'].split(' ')) not in [12, 15, 18, 21, 24]:
err = "Invalid mnemonic entered: Must contain either: 12, 15, 18, 21, or 24 words."
mnemo = Mnemonic('english')
if not mnemo.check(request.form['mnemonic']):
err = "Invalid mnemonic entered."
if err is None:
mnemonic = request.form['mnemonic']
passphrase = request.form['passphrase']
device = app.specter.device_manager.add_device(name=device_name, device_type=device_type, keys=[])
device.setup_device(mnemonic, passphrase, app.specter.wallet_manager, app.specter.chain != 'main')
return redirect("/devices/%s/" % device.alias)
elif action == 'generatemnemonic':
strength = int(request.form['strength'])
mnemonic = generate_mnemonic(strength=strength)
return render_template("device/new_device.jinja", device_type=device_type, device_name=device_name, xpubs=xpubs, mnemonic=mnemonic, strength=strength, error=err, specter=app.specter, rand=rand)

@app.route('/devices/<device_alias>/', methods=['GET', 'POST'])
@login_required
Expand All @@ -853,7 +905,7 @@ def device(device_alias):
if len(wallets) != 0:
err = "Device could not be removed since it is used in wallets: {}.\nYou must delete those wallets before you can remove this device.".format([wallet.name for wallet in wallets])
else:
app.specter.device_manager.remove_device(device)
app.specter.device_manager.remove_device(device, app.specter.wallet_manager)
return redirect("/")
elif action == "delete_key":
key = request.form['key']
Expand Down
6 changes: 5 additions & 1 deletion src/cryptoadvance/specter/device_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .devices.keepkey import Keepkey
from .devices.specter import Specter
from .devices.cobo import Cobo
from .devices.bitcoin_core import BitcoinCore
from .helpers import alias, load_jsons, fslock


Expand All @@ -18,6 +19,7 @@
'ledger': Ledger,
'specter': Specter,
'cobo': Cobo,
'bitcoincore': BitcoinCore,
}

def get_device_class(device_type):
Expand Down Expand Up @@ -85,6 +87,8 @@ def get_by_alias(self, device_alias):
return self.devices[device_name]
logger.error("Could not find Device %s" % device_alias)

def remove_device(self, device):
def remove_device(self, device, wallet_manager=None):
os.remove(device.fullpath)
if isinstance(device, BitcoinCore):
device.delete(wallet_manager)
self.update()
117 changes: 117 additions & 0 deletions src/cryptoadvance/specter/devices/bitcoin_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import os, shutil
from bip32 import BIP32
from mnemonic import Mnemonic
from ..descriptor import AddChecksum
from ..device import Device
from ..helpers import alias, convert_xpub_prefix, encode_base58_checksum, get_xpub_fingerprint, seed_to_hd_master_key
from ..key import Key

class BitcoinCore(Device):
def __init__(self, name, alias, device_type, keys, fullpath, manager):
Device.__init__(self, name, alias, device_type, keys, fullpath, manager)
self.hwi_support = False
self.exportable_to_wallet = False
self.hot_wallet = True

def setup_device(self, mnemonic, passphrase, wallet_manager, testnet):
seed = Mnemonic.to_seed(mnemonic)
xprv = seed_to_hd_master_key(seed, testnet=testnet)
wallet_name = os.path.join(wallet_manager.cli_path + '_hotstorage', self.alias)
wallet_manager.cli.createwallet(wallet_name, False, True)
cli = wallet_manager.cli.wallet(wallet_name)
# TODO: Maybe more than 1000? Maybe add mechanism to add more later.
## NOTE: This will work only on the network the device was added, so hot devices should be filtered out by network.
coin = int(testnet)
cli.importmulti([
{ 'desc': AddChecksum('sh(wpkh({}/49h/{}h/0h/0/*))'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
{ 'desc': AddChecksum('sh(wpkh({}/49h/{}h/0h/1/*))'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
{ 'desc': AddChecksum('wpkh({}/84h/{}h/0h/0/*)'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
{ 'desc': AddChecksum('wpkh({}/84h/{}h/0h/1/*)'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
{ 'desc': AddChecksum('sh(wpkh({}/48h/{}h/0h/1h/0/*))'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
{ 'desc': AddChecksum('sh(wpkh({}/48h/{}h/0h/1h/1/*))'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
{ 'desc': AddChecksum('wpkh({}/48h/{}h/0h/2h/0/*)'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
{ 'desc': AddChecksum('wpkh({}/48h/{}h/0h/2h/1/*)'.format(xprv, coin)), 'range': 1000, 'timestamp': 'now'},
])
if passphrase:
cli.encryptwallet(passphrase)

bip32 = BIP32.from_seed(seed)
xpubs = ""
master_fpr = get_xpub_fingerprint(bip32.get_xpub_from_path('m/0h')).hex()

if not testnet:
# Nested Segwit
xpub = bip32.get_xpub_from_path('m/49h/0h/0h')
ypub = convert_xpub_prefix(xpub, b'\x04\x9d\x7c\xb2')
xpubs += "[%s/49'/0'/0']%s\n" % (master_fpr, ypub)
# native Segwit
xpub = bip32.get_xpub_from_path('m/84h/0h/0h')
zpub = convert_xpub_prefix(xpub, b'\x04\xb2\x47\x46')
xpubs += "[%s/84'/0'/0']%s\n" % (master_fpr, zpub)
# Multisig nested Segwit
xpub = bip32.get_xpub_from_path('m/48h/0h/0h/1h')
Ypub = convert_xpub_prefix(xpub, b'\x02\x95\xb4\x3f')
xpubs += "[%s/48'/0'/0'/1']%s\n" % (master_fpr, Ypub)
# Multisig native Segwit
xpub = bip32.get_xpub_from_path('m/48h/0h/0h/2h')
Zpub = convert_xpub_prefix(xpub, b'\x02\xaa\x7e\xd3')
xpubs += "[%s/48'/0'/0'/2']%s\n" % (master_fpr, Zpub)
else:
# Testnet nested Segwit
xpub = bip32.get_xpub_from_path('m/49h/1h/0h')
upub = convert_xpub_prefix(xpub, b'\x04\x4a\x52\x62')
xpubs += "[%s/49'/1'/0']%s\n" % (master_fpr, upub)
# Testnet native Segwit
xpub = bip32.get_xpub_from_path('m/84h/1h/0h')
vpub = convert_xpub_prefix(xpub, b'\x04\x5f\x1c\xf6')
xpubs += "[%s/84'/1'/0']%s\n" % (master_fpr, vpub)
# Testnet multisig nested Segwit
xpub = bip32.get_xpub_from_path('m/48h/1h/0h/1h')
Upub = convert_xpub_prefix(xpub, b'\x02\x42\x89\xef')
xpubs += "[%s/48'/1'/0'/1']%s\n" % (master_fpr, Upub)
# Testnet multisig native Segwit
xpub = bip32.get_xpub_from_path('m/48h/1h/0h/2h')
Vpub = convert_xpub_prefix(xpub, b'\x02\x57\x54\x83')
xpubs += "[%s/48'/1'/0'/2']%s\n" % (master_fpr, Vpub)

keys, failed = Key.parse_xpubs(xpubs)
if len(failed) > 0:
# TODO: This should never occur, but just in case, we must make sure to catch it properly so it doesn't crash the app no matter what.
raise Exception("Failed to parse these xpubs:\n" + "\n".join(failed))
else:
self.add_keys(keys)

def _load_wallet(self, wallet_manager):
existing_wallets = [w["name"] for w in wallet_manager.cli.listwalletdir()["wallets"]]
loaded_wallets = wallet_manager.cli.listwallets()
not_loaded_wallets = [w for w in existing_wallets if w not in loaded_wallets]
if os.path.join(wallet_manager.cli_path + "_hotstorage", self.alias) in existing_wallets:
if os.path.join(wallet_manager.cli_path + "_hotstorage", self.alias) in not_loaded_wallets:
wallet_manager.cli.loadwallet(os.path.join(wallet_manager.cli_path + "_hotstorage", self.alias))

def create_psbts(self, base64_psbt, wallet):
return { 'core': base64_psbt }

def sign_psbt(self, base64_psbt, wallet, passphrase):
# Load the wallet if not loaded
self._load_wallet(wallet.manager)
cli = wallet.manager.cli.wallet(os.path.join(wallet.manager.cli_path + "_hotstorage", self.alias))
if passphrase:
cli.walletpassphrase(passphrase, 60)
signed_psbt = cli.walletprocesspsbt(base64_psbt)
if base64_psbt == signed_psbt['psbt']:
raise Exception('Make sure you have entered the passphrase correctly.')
if passphrase:
cli.walletlock()
return signed_psbt

def delete(self, wallet_manager):
try:
wallet_cli_path = os.path.join(wallet_manager.cli_path + "_hotstorage", self.alias)
cli = wallet_manager.cli.wallet(wallet_cli_path)
cli.unloadwallet(wallet_cli_path)
# Try deleting wallet file
if wallet_manager.get_default_datadir() and os.path.exists(wallet_cli_path):
shutil.rmtree(os.path.join(wallet_manager.get_default_datadir(), wallet_cli_path))
except:
pass # We tried...
51 changes: 50 additions & 1 deletion src/cryptoadvance/specter/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import binascii, collections, copy, hashlib, json, logging, os, six, subprocess, sys
import binascii, collections, copy, hashlib, hmac, json, logging, os, six, subprocess, sys
from collections import OrderedDict
from mnemonic import Mnemonic
from .descriptor import AddChecksum
from hwilib.serializations import PSBT, CTransaction
from .bcur import bcur_decode
Expand Down Expand Up @@ -360,3 +361,51 @@ def get_txid(tx):
inp.scriptSig = b""
t.rehash()
return t.hash

# Hot wallet helpers
def generate_mnemonic(strength=256):
# Generate words list
mnemo = Mnemonic("english")
words = mnemo.generate(strength=strength)
return words

# From https://github.com/trezor/python-mnemonic/blob/ad06157e21fc2c2145c726efbfdcf69df1350061/mnemonic/mnemonic.py#L246
# Refactored code segments from <https://github.com/keis/base58>
def b58encode(v: bytes) -> str:
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"

p, acc = 1, 0
for c in reversed(v):
acc += p * c
p = p << 8

string = ""
while acc:
acc, idx = divmod(acc, 58)
string = alphabet[idx : idx + 1] + string
return string
# We need to copy it like this because HWI uses it as a dependency, but requires v0.18 which doesn't have this function.
def seed_to_hd_master_key(seed, testnet=False) -> str:
if len(seed) != 64:
raise ValueError("Provided seed should have length of 64")

# Compute HMAC-SHA512 of seed
seed = hmac.new(b"Bitcoin seed", seed, digestmod=hashlib.sha512).digest()

# Serialization format can be found at: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#Serialization_format
xprv = b"\x04\x88\xad\xe4" # Version for private mainnet
if testnet:
xprv = b"\x04\x35\x83\x94" # Version for private testnet
xprv += b"\x00" * 9 # Depth, parent fingerprint, and child number
xprv += seed[32:] # Chain code
xprv += b"\x00" + seed[:32] # Master key

# Double hash using SHA256
hashed_xprv = hashlib.sha256(xprv).digest()
hashed_xprv = hashlib.sha256(hashed_xprv).digest()

# Append 4 bytes of checksum
xprv += hashed_xprv[:4]

# Return base58
return b58encode(xprv)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<fieldset style="border:none; display: inline;">
<select name="device_type" id="device_type">
<option value="other">Other</option>
<option value="bitcoincore">Bitcoin Core (hot wallet)</option>
<option value="coldcard">ColdCard</option>
<option value="keepkey">Keepkey</option>
<option value="ledger">Ledger</option>
Expand Down
4 changes: 3 additions & 1 deletion src/cryptoadvance/specter/templates/device/device.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@
</div>
<div class="spacer"></div>
<div class="row">
{% if device.device_type != 'bitcoincore' %}
<form action="./" method="POST">
<button type="submit" name="action" value="add_keys" class="btn centered">Add more keys</button>
<button id="add_keys" type="submit" name="action" value="add_keys" class="btn centered">Add more keys</button>
</form>
{% endif %}
&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>
Expand Down
Loading

0 comments on commit b2d6509

Please sign in to comment.