electrum-personal-server

Maximally lightweight electrum server for a single user
git clone https://git.parazyd.org/electrum-personal-server
Log | Files | Refs | README

commit 0d68b0827e7aac7c0202a1dc95d21a4353c6983e
parent 76386c21c7e1c6b766949144be00dd59036ba559
Author: chris-belcher <chris-belcher@users.noreply.github.com>
Date:   Tue, 20 Mar 2018 02:11:10 +0000

finished code for deterministic wallets, all electrum mpks work now

Diffstat:
MREADME.md | 21++++++++++++---------
Mbitcoin/deterministic.py | 40++++++++++++++++++++++++++++++++++++++++
Mbitcoin/py3specials.py | 2++
Mconfig.cfg_sample | 9+++------
Mdeterministicwallet.py | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mserver.py | 12++++++++----
6 files changed, 192 insertions(+), 33 deletions(-)

diff --git a/README.md b/README.md @@ -33,7 +33,8 @@ of any binaries before running them, or compile from source. Download the latest release or clone the git repository. Enter the directory and rename the file `config.cfg_sample` to `config.cfg`, edit this file to configure your bitcoin node json-rpc authentication details. Next add your -wallet addresses to the `[wallets]` section. +wallet master public keys or watch-only addresses to the `[master-public-keys]` +and `[watch-only-addresses]` sections. Finally run `./server.py` on Linux or double-click `run-server.bat` on Windows. The first time the server is run it will import all configured addresses as @@ -41,9 +42,10 @@ watch-only into the Bitcoin node, and then exit giving you a chance to rescan if your wallet contains historical transactions. Tell Electrum to connect to the server in `Tools` -> `Server`, usually -`localhost` if running on the same machine. +`localhost` if running on the same machine. Make sure the port number matches +whats written in `config.cfg`. -Note that you can also try this with on [testnet bitcoin](https://en.bitcoin.it/wiki/Testnet). +You can also try this with on [testnet bitcoin](https://en.bitcoin.it/wiki/Testnet). Electrum can be started in testnet mode with the command line flag `--testnet`. #### Exposure to the Internet @@ -61,17 +63,15 @@ tunnel. This project is in alpha stages as there are several essential missing features such as: -* Deterministic wallets and master public keys are not supported. Addresses - must be imported individually. - * The Electrum server protocol has a caveat about multiple transactions included in the same block. So there may be weird behaviour if that happens. * There's no way to turn off debug messages, so the console will be spammed by them when used. -When trying this, make sure you report any crashes, odd behaviour or times when -Electrum disconnects (which indicates the server behaved unexpectedly). +When trying this, make sure you report any crashes, odd behaviour, transactions +appearing as `Not Verified` or times when Electrum disconnects (which +indicates the server behaved unexpectedly). Someone should try running this on a Raspberry PI. @@ -81,7 +81,10 @@ I welcome contributions. Please keep lines under 80 characters in length and ideally don't add any external dependencies to keep this as easy to install as possible. -I can be contacted on freenode IRC on the `#bitcoin` and `#electrum` channels. +I can be contacted on freenode IRC on the `#bitcoin` and `#electrum` channels, +or by email. + +My PGP key fingerprint is: `0A8B 038F 5E10 CC27 89BF CFFF EF73 4EA6 77F3 1129`. ## Media Coverage diff --git a/bitcoin/deterministic.py b/bitcoin/deterministic.py @@ -141,3 +141,43 @@ def bip32_descend(*args): for p in path: key = bip32_ckd(key, p) return bip32_extract_key(key) + +# electrum +def electrum_stretch(seed): + return slowsha(seed) + +# Accepts seed or stretched seed, returns master public key + +def electrum_mpk(seed): + if len(seed) == 32: + seed = electrum_stretch(seed) + return privkey_to_pubkey(seed)[2:] + +# Accepts (seed or stretched seed), index and secondary index +# (conventionally 0 for ordinary addresses, 1 for change) , returns privkey + + +def electrum_privkey(seed, n, for_change=0): + if len(seed) == 32: + seed = electrum_stretch(seed) + mpk = electrum_mpk(seed) + offset = dbl_sha256(from_int_representation_to_bytes(n)+b':'+ + from_int_representation_to_bytes(for_change)+b':'+ + binascii.unhexlify(mpk)) + return add_privkeys(seed, offset) + +# Accepts (seed or stretched seed or master pubkey), index and secondary index +# (conventionally 0 for ordinary addresses, 1 for change) , returns pubkey + +def electrum_pubkey(masterkey, n, for_change=0): + if len(masterkey) == 32: + mpk = electrum_mpk(electrum_stretch(masterkey)) + elif len(masterkey) == 64: + mpk = electrum_mpk(masterkey) + else: + mpk = masterkey + bin_mpk = encode_pubkey(mpk, 'bin_electrum') + offset = bin_dbl_sha256(from_int_representation_to_bytes(n)+b':'+ + from_int_representation_to_bytes(for_change)+b':'+bin_mpk) + return add_pubkeys('04'+mpk, privtopub(offset)) + diff --git a/bitcoin/py3specials.py b/bitcoin/py3specials.py @@ -113,3 +113,5 @@ if sys.version_info.major == 3: string = string[1:] return result + def from_int_representation_to_bytes(a): + return bytes(str(a), 'utf-8') diff --git a/config.cfg_sample b/config.cfg_sample @@ -2,18 +2,15 @@ ## Electrum Personal Server configuration file ## Comments start with # -[electrum-master-public-keys] +[master-public-keys] ## Add electrum master public keys to this section +## Create a wallet in electrum then go Wallet -> Information #any_key_works = xpub661MyMwAqRbcFseXCwRdRVkhVuzEiskg4QUp5XpUdNf2uGXvQmnD4zcofZ1MN6Fo8PjqQ5cemJQ39f7RTwDVVputHMFjPUn8VRp2pJQMgEF # Multisig wallets use format `required-signatures [list of master pub keys]` #multisig_wallet = 2 xpub661MyMwAqRbcFseXCwRdRVkhVuzEiskg4QUp5XpUdNf2uGXvQmnD4zcofZ1MN6Fo8PjqQ5cemJQ39f7RTwDVVputHMFjPUn8VRp2pJQMgEF xpub661MyMwAqRbcFseXCwRdRVkhVuzEiskg4QUp5XpUdNf2uGXvQmnD4zcofZ1MN6Fo8PjqQ5cemJQ39f7RTwDVVputHMFjPUn8VRp2pJQMgEF -[bip39-master-public-keys] -## Add master public keys in the bip39 standard to this section -## Most hardware wallet keys go here - [watch-only-addresses] ## Add addresses to this section @@ -35,7 +32,7 @@ poll_interval_connected = 5 # Parameters for dealing with deterministic wallets # how many addresses to import first time, should be big because if you import too little you may have to rescan again -initial_import_count = 200 +initial_import_count = 1000 # number of unused addresses kept at the head of the wallet gap_limit = 25 diff --git a/deterministicwallet.py b/deterministicwallet.py @@ -2,16 +2,40 @@ import bitcoin as btc import util +# the class hierarchy for deterministic wallets in this file: +# subclasses are written towards the right +# each class knows how to create the scriptPubKeys of that wallet +# +# |-- SingleSigOldMnemonicWallet +# |-- SingleSigP2PKHWallet +# |-- SingleSigP2WPKHWallet +# SingleSigWallet --| +# / |-- SingleSigP2WPKH_P2SHWallet +# DeterministicWallet +# \ |-- MultisigP2SHWallet +# MultisigWallet --| +# |-- MultisigP2WSHWallet +# |-- MultisigP2WSH_P2SHWallet + #the wallet types are here #https://github.com/spesmilo/electrum/blob/3.0.6/RELEASE-NOTES #and #https://github.com/spesmilo/electrum-docs/blob/master/xpub_version_bytes.rst +def is_string_parsable_as_hex_int(s): + try: + int(s, 16) + return True + except: + return False + def parse_electrum_master_public_key(keydata, gaplimit): if keydata[:4] in ("xpub", "tpub"): - return SingleSigP2PKHWallet(keydata, gaplimit) + wallet = SingleSigP2PKHWallet(keydata) elif keydata[:4] in ("zpub", "vpub"): - return SingleSigP2WPKHWallet(keydata, gaplimit) + wallet = SingleSigP2WPKHWallet(keydata) + elif keydata[:4] in ("ypub", "upub"): + wallet = SingleSigP2WPKH_P2SHWallet(keydata) elif keydata.find(" ") != -1: #multiple keys = multisig chunks = keydata.split(" ") try: @@ -23,15 +47,21 @@ def parse_electrum_master_public_key(keydata, gaplimit): if not all([pubkeys[0][:4] == pub[:4] for pub in pubkeys[1:]]): raise ValueError("inconsistent bip32 pubkey types") if pubkeys[0][:4] in ("xpub", "tpub"): - return MultisigP2SHWallet(m, pubkeys, gaplimit) - if pubkeys[0][:4] in("Zpub", "Vpub"): - return MultisigP2WSHWallet(m, pubkeys, gaplimit) + wallet = MultisigP2SHWallet(m, pubkeys) + elif pubkeys[0][:4] in ("Zpub", "Vpub"): + wallet = MultisigP2WSHWallet(m, pubkeys) + elif pubkeys[0][:4] in ("Ypub", "Upub"): + wallet = MultisigP2WSH_P2SHWallet(m, pubkeys) + elif is_string_parsable_as_hex_int(keydata) and len(keydata) == 128: + wallet = SingleSigOldMnemonicWallet(keydata) else: raise ValueError("Unrecognized electrum mpk format: " + keydata[:4]) + wallet.gaplimit = gaplimit + return wallet class DeterministicWallet(object): - def __init__(self, gaplimit): - self.gaplimit = gaplimit + def __init__(self): + self.gaplimit = 0 self.next_index = [0, 0] self.scriptpubkey_index = {} @@ -73,19 +103,22 @@ class DeterministicWallet(object): self.next_index[change] -= 1 class SingleSigWallet(DeterministicWallet): - def __init__(self, mpk, gaplimit): - super(SingleSigWallet, self).__init__(gaplimit) + def __init__(self, mpk): + super(SingleSigWallet, self).__init__() self.branches = (btc.bip32_ckd(mpk, 0), btc.bip32_ckd(mpk, 1)) #m/change/i def pubkey_to_scriptpubkey(self, pubkey): raise RuntimeError() + def get_pubkey(self, change, index): + return btc.bip32_extract_key(btc.bip32_ckd(self.branches[change], + index)) + def get_scriptpubkeys(self, change, from_index, count): result = [] for index in range(from_index, from_index + count): - pubkey = btc.bip32_extract_key(btc.bip32_ckd(self.branches[change], - index)) + pubkey = self.get_pubkey(change, index) scriptpubkey = self.pubkey_to_scriptpubkey(pubkey) self.scriptpubkey_index[scriptpubkey] = (change, index) result.append(scriptpubkey) @@ -105,9 +138,30 @@ class SingleSigP2WPKHWallet(SingleSigWallet): #witness version is always 0, length is always 0x14 return "0014" + pkh +class SingleSigP2WPKH_P2SHWallet(SingleSigWallet): + def pubkey_to_scriptpubkey(self, pubkey): + #witness-version length pubkeyhash + #witness version is always 0, length is always 0x14 + redeem_script = '0014' + util.bh2u(util.hash_160(util.bfh(pubkey))) + sh = util.bh2u(util.hash_160(util.bfh(redeem_script))) + return "a914" + sh + "87" + +class SingleSigOldMnemonicWallet(SingleSigWallet): + def __init__(self, mpk): + super(SingleSigWallet, self).__init__() + self.mpk = mpk + + def get_pubkey(self, change, index): + return btc.electrum_pubkey(self.mpk, index, change) + + def pubkey_to_scriptpubkey(self, pubkey): + pkh = util.bh2u(util.hash_160(util.bfh(pubkey))) + #op_dup op_hash_160 length hash160 op_equalverify op_checksig + return "76a914" + pkh + "88ac" + class MultisigWallet(DeterministicWallet): - def __init__(self, m, mpk_list, gaplimit): - super(MultisigWallet, self).__init__(gaplimit) + def __init__(self, m, mpk_list): + super(MultisigWallet, self).__init__() self.m = m self.pubkey_branches = [(btc.bip32_ckd(mpk, 0), btc.bip32_ckd(mpk, 1)) for mpk in mpk_list] @@ -138,15 +192,28 @@ class MultisigWallet(DeterministicWallet): class MultisigP2SHWallet(MultisigWallet): def redeem_script_to_scriptpubkey(self, redeem_script): sh = util.bh2u(util.hash_160(util.bfh(redeem_script))) - return "a914" + sh + "87" #op_hash160 length hash160 op_equal + return "a914" + sh + "87" class MultisigP2WSHWallet(MultisigWallet): def redeem_script_to_scriptpubkey(self, redeem_script): sh = util.bh2u(util.sha256(util.bfh(redeem_script))) + #witness-version length sha256 + #witness version is always 0, length is always 0x20 return "0020" + sh + +class MultisigP2WSH_P2SHWallet(MultisigWallet): + def redeem_script_to_scriptpubkey(self, redeem_script): #witness-version length sha256 #witness version is always 0, length is always 0x20 + nested_redeemScript = "0020" + util.bh2u(util.sha256( + util.bfh(redeem_script))) + sh = util.bh2u(util.hash_160(util.bfh(nested_redeemScript))) + #op_hash160 length hash160 op_equal + return "a914" + sh + "87" + +# electrum has its own tests here +#https://github.com/spesmilo/electrum/blob/03b40a3c0a7dd84e76bc0d0ea2ad390dafc92250/lib/tests/test_wallet_vertical.py electrum_keydata_test_vectors = [ #p2pkh wallet @@ -267,6 +334,52 @@ electrum_keydata_test_vectors = [ '00207a3e478266e5fe49fe22e3d8f04d3adda3b6a0835806a0db1f77b84d0ba7f79c', '002059e66462023ecd54e20d4dce286795e7d5823af511989736edc0c7a844e249f5', '0020bd8077906dd367d6d107d960397e46db2daba5793249f1f032d8d7e12e6f193c']) + , #p2wpkh-p2sh + ("upub5E4QEumGPNTmSKD95TrYX2xqLwwvBULbRzzHkrpW9WKKCB1y9DEfPXDnUyQjLjmVs" + + "7gSd7k5vRb1FoSb6BjyiWNg4arkJLaqk1jULzbwA5q", + ["a914ae8f84a06668742f713d0743c1f54d248040e63387", #recv + "a914c2e9bdcc48596b8cce418042ade72198fddf3cd987", + "a914a44b6ad63ccef0ae1741eaccee99bf2fa83f842987", + "a9148cf1c891d96a0be07893d0bddcf00ed5dad2c46e87", + "a91414d677b32f2409f4dfb3073d382c302bcd6ed33587", + "a9141b284bee7198d5134512f37ef60e4048864b4bd687"], + ["a914a5aacff65860440893107b01912dc8f60cadab2b87", #change + "a914dcd74ebc8bfc5cf0535717a3e833592d54b3c48687", + "a91446793cae4c2b8149ade61c1627b96b90599bc08787", + "a91439f3776831f321125bdb5099fbbd654923f8316c87"]) + , #p2wpkh-p2sh + ("ypub6XrRLtXNB7NQo3vDaMNnffXVJe1WVaebXcb4ncpTHHADLuFYmf2CcPn96YzUbMt8s" + + "HSMmtr1mCcMgCBLqNdY2hrXXcdiLxCdD9e2dChBLun", + ["a91429c2ad045bbb162ef3c2d9cacb9812bec463061787", #recv + "a91433ec6bb67b113978d9cfd307a97fd15bc0a5a62087", + "a91450523020275ccbf4e916a0d8523ae42391ad988a87", + "a91438c2e5e76a874d86cfc914fe9fc1868b6afb5c5487"], + ["a91475f608698bb735120a17699fee854bce9a8dc8d387", + "a91477e69344ef53587051c85a06a52a646457b44e6c87", + "a914607c98ea34fbdffe39fee161ae2ffd5517bf1a5587"]) + , #old mnemonic mpk + ("e9d4b7866dd1e91c862aebf62a49548c7dbf7bcc6e4b7b8c9da820c7737968df9c09d" + + "5a3e271dc814a29981f81b3faaf2737b551ef5dcc6189cf0f8252c442b3", + ["76a9149cd3dfb0d87a861770ae4e268e74b45335cf00ab88ac", #recv + "76a914c30f2af6a79296b6531bf34dba14c8419be8fb7d88ac", + "76a9145eb4eeaefcf9a709f8671444933243fbd05366a388ac", + "76a914f96669095e6df76cfdf5c7e49a1909f002e123d088ac"], + ["76a914ca14915184a2662b5d1505ce7142c8ca066c70e288ac", #change + "76a9148942ac692ace81019176c4fb0ac408b18b49237f88ac", + "76a914e1232622a96a04f5e5a24ca0792bb9c28b089d6e88ac"]) + , #p2wsh-p2sh 2of2 multisig + ("2 Ypub6hWbqA2p47QgsLt5J4nxrR3ngu8xsPGb7PdV8CDh48KyNngNqPKSqertAqYhQ4u" + + "mELu1UsZUCYfj9XPA6AdSMZWDZQobwF7EJ8uNrECaZg1 Ypub6iNDhL4WWq5kFZcdFqHHw" + + "X4YTH4rYGp8xbndpRrY7WNZFFRfogSrL7wRTajmVHgR46AT1cqUG1mrcRd7h1WXwBsgX2Q" + + "vT3zFbBCDiSDLkau", + ["a91428060ade179c792fac07fc8817fd150ce7cdd3f987", #recv + "a9145ba5ed441b9f3e22f71193d4043b645183e6aeee87", + "a91484cc1f317b7d5afff115916f1e27319919601d0187", + "a9144001695a154cac4d118af889d3fdcaf929af315787", + "a914897888f3152a27cbd7611faf6aa01085931e542a87"], + ["a91454dbb52de65795d144f3c4faeba0e37d9765c85687", #change + "a914f725cbd61c67f34ed40355f243b5bb0650ce61c587", + "a9143672bcd3d02d3ea7c3205ddbc825028a0d2a781987"]) ] electrum_bad_keydata_test_vectors = [ diff --git a/server.py b/server.py @@ -444,6 +444,7 @@ def check_for_new_txes(rpc, address_history, unconfirmed_txes, #finding a confirmed and unconfirmed tx, in that order, then both confirm #finding an unconfirmed and confirmed tx, in that order, then both confirm #send a tx to an address which hasnt been used before + #import two addresses, transaction from one to the other, unc then confirm obtained_txids = set() updated_scripthashes = [] for tx in new_txes: @@ -581,12 +582,11 @@ def get_scriptpubkeys_to_monitor(rpc, config): [ADDRESSES_LABEL])) deterministic_wallets = [] - for key in config.options("electrum-master-public-keys"): + for key in config.options("master-public-keys"): wal = deterministicwallet.parse_electrum_master_public_key( - config.get("electrum-master-public-keys", key), + config.get("master-public-keys", key), int(config.get("bitcoin-rpc", "gap_limit"))) deterministic_wallets.append(wal) - #add bip39 wallets here #check whether these deterministic wallets have already been imported import_needed = False @@ -649,14 +649,18 @@ def get_scriptpubkeys_to_monitor(rpc, config): def import_addresses(rpc, addrs): debug("importing addrs = " + str(addrs)) + log("Importing " + str(len(addrs)) + " addresses in total") + st = time.time() for a in addrs: rpc.call("importaddress", [a, ADDRESSES_LABEL, False]) + et = time.time() + debug("imported addresses in " + str(et - st) + " sec") def main(): try: config = ConfigParser() config.read(["config.cfg"]) - config.options("electrum-master-public-keys") + config.options("master-public-keys") except NoSectionError: log("Non-existant configuration file `config.cfg`") return