diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..ef05ebc2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,2 @@ + diff --git a/.travis.yml b/.travis.yml index 48355183..4646822a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,11 +4,28 @@ python: - 3.5 - 3.6 install: - - pip install -r requirements_travis.txt + - pip install -r contrib/requirements/requirements-travis.txt cache: - - pip + - pip: true + - directories: + - /tmp/electrum-build script: - tox after_success: - if [ "$TRAVIS_BRANCH" = "master" ]; then pip install pycurl requests && contrib/make_locale; fi - coveralls +jobs: + include: + - stage: windows build + sudo: true + python: 3.5 + install: + - sudo dpkg --add-architecture i386 + - wget -nc https://dl.winehq.org/wine-builds/Release.key + - sudo apt-key add Release.key + - sudo apt-add-repository https://dl.winehq.org/wine-builds/ubuntu/ + - sudo apt-get update -qq + - sudo apt-get install -qq winehq-stable dirmngr gnupg2 p7zip-full + before_script: ls -lah /tmp/electrum-build + script: ./contrib/build-wine/build.sh + after_success: true diff --git a/Dockerfile b/Dockerfile index 6ae27add..4bdeabe1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM ubuntu:18.04 -ENV VERSION 1.0.6 +ENV VERSION 1.0.7 RUN set -x \ && apt-get update \ diff --git a/README.rst b/README.rst index e84a0392..a35d9fb3 100644 --- a/README.rst +++ b/README.rst @@ -101,30 +101,18 @@ Run the docker image:: ./run-docker.sh -Building Releases -================= - - -MacOS ------- - -Simply - :: - - ./setup-mac.sh - - sudo ./create-dmg.sh +See `contrib/build-osx/`. Windows ------- -See `contrib/build-wine/README` file. +See `contrib/build-wine/`. Android ------- See `gui/kivy/Readme.txt` file. -UPSTREAM PATCH: https://github.com/spesmilo/electrum/blob/master/gui/kivy/Readme.md --- diff --git a/RELEASE-NOTES b/RELEASE-NOTES index 2a06061e..258efb5d 100644 --- a/RELEASE-NOTES +++ b/RELEASE-NOTES @@ -1,3 +1,80 @@ + +# Release 3.1 - (March 5, 2018) + + * Memory-pool based fee estimation. Dynamic fees can target a desired + depth in the memory pool. This feature is optional, and ETA-based + estimates from Bitcoin Core are still available. Note that miners + could exploit this feature, if they conspired and filled the memory + pool with expensive transactions that never get mined. However, + since the Electrum client already trusts an Electrum server with + fee estimates, activating this feature does not introduce any new + vulnerability. In addition, the client uses a hard threshold to + protect itself from servers sending excessive fee estimates. In + practice, ETA-based estimates have resulted in sticky fees, and + caused many users to overpay for transactions. Advanced users tend + to visit (and trust) websites that display memory-pool data in + order to set their fees. + * Capital gains: For each outgoing transaction, the difference + between the acquisition and liquidation prices of outgoing coins is + displayed in the wallet history. By default, historical exchange + rates are used to compute acquisition and liquidation prices. These + values can also be entered manually, in order to match the actual + price realized by the user. The order of liquidation of coins is + the natural order defined by the blockchain; this results in + capital gain values that are invariant to changes in the set of + addresses that are in the wallet. Any other ordering strategy (such + as FIFO, LIFO) would result in capital gain values that depend on + the presence of other addresses in the wallet. + * Local transactions: Transactions can be saved in the wallet without + being broadcast. The inputs of local transactions are considered as + spent, and their change outputs can be re-used in subsequent + transactions. This can be combined with cold storage, in order to + create several transactions before broadcasting them. Outgoing + transactions that have been removed from the memory pool are also + saved in the wallet, and can be broadcast again. + * Checkpoints: The initial download of a headers file was replaced + with hardcoded checkpoints. The wallet uses one checkpoint per + retargeting period. The headers for a retargeting period are + downloaded only if transactions need to be verified in this period. + * The 'privacy' and 'priority' coin selection policies have been + merged into one. Previously, the 'privacy' policy has been unusable + because it was was not prioritizing confirmed coins. The new policy + is similar to 'privacy', except that it de-prioritizes addresses + that have unconfirmed coins. + * The 'Send' tab of the Qt GUI displays how transaction fees are + computed from transaction size. + * The wallet history can be filtered by time interval. + * Replace-by-fee is enabled by default. Note that this might cause + some issues with wallets that do not display RBF transactions until + they are confirmed. + * Watching-only wallets and hardware wallets can be encrypted. + * Semi-automated crash reporting + * The SSL checkbox option was removed from the GUI. + * The Trezor T hardware wallet is now supported. + * BIP84: native segwit p2wpkh scripts for bip39 seeds and hardware + wallets can now be created when specifying a BIP84 derivation + path. This is usable with Trezor and Ledger. + * Windows: the binaries now include ZBar, and QR code scanning should work. + * The Wallet Import Format (WIF) for private keys that was extended in 3.0 + is changed. Keys in the previous format can be imported, compatibility + is maintained. Newly exported keys will be serialized as + "script_type:original_wif_format_key". + * BIP32 master keys for testnet once again have different version bytes than + on mainnet. For the mainnet prefixes {x,y,Y,z,Z}|{pub,prv}, the + corresponding testnet prefixes are {t,u,U,v,V}|{pub,prv}. + More details and exact version bytes are specified at: + https://github.com/spesmilo/electrum-docs/blob/master/xpub_version_bytes.rst + Note that due to this change, testnet wallet files created with previous + versions of Electrum must be considered broken, and they need to be + recreated from seed words. + * A new version of the Electrum protocol is required by the client + (version 1.2). Servers using older versions of the protocol will + not be displayed in the GUI. + + +# Release 3.0.6 : + * Fix transaction parsing bug #3788 + # Release 3.0.5 : (Security update) This is a follow-up to the 3.0.4 release, which did not completely fix diff --git a/contrib/build-osx/README.md b/contrib/build-osx/README.md new file mode 100644 index 00000000..af43e05c --- /dev/null +++ b/contrib/build-osx/README.md @@ -0,0 +1,17 @@ +Building Mac OS binaries +======================== + +This guide explains how to build Electrum binaries for macOS systems. +We build our binaries on El Capitan (10.11.6) as building it on High Sierra +makes the binaries incompatible with older versions. + +This assumes that the Xcode command line tools (and thus git) are already installed. + + +## 1. Run the script + + + + ./make_osx + +## 2. Done diff --git a/contrib/build-osx/make_osx b/contrib/build-osx/make_osx new file mode 100755 index 00000000..96c4b385 --- /dev/null +++ b/contrib/build-osx/make_osx @@ -0,0 +1,84 @@ +#!/bin/bash +RED='\033[0;31m' +BLUE='\033[0,34m' +NC='\033[0m' # No Color +function info { + printf "\r💬 ${BLUE}INFO:${NC} ${1}\n" +} +function fail { + printf "\r🗯 ${RED}ERROR:${NC} ${1}\n" + exit 1 +} + +build_dir=$(dirname "$0") +test -n "$build_dir" -a -d "$build_dir" || exit +cd $build_dir/../.. + +export PYTHONHASHSEED=22 +VERSION=`git describe --tags` + +# Paramterize +PYTHON_VERSION=3.6.4 +BUILDDIR=/tmp/electrum-build +PACKAGE=Electrum-ZCL +GIT_REPO=https://github.com/z-classic/electrum-zcl + + +info "Installing Python $PYTHON_VERSION" +export PATH="~/.pyenv/bin:~/.pyenv/shims:~/Library/Python/3.6/bin:$PATH" +if [ -d "~/.pyenv" ]; then + pyenv update +else + curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash > /dev/null 2>&1 +fi +PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install -s $PYTHON_VERSION && \ +pyenv global $PYTHON_VERSION || \ +fail "Unable to use Python $PYTHON_VERSION" + + +info "Installing pyinstaller" +python3 -m pip install git+https://github.com/ecdsa/pyinstaller@fix_2952 -I --user || fail "Could not install pyinstaller" + +info "Using these versions for building $PACKAGE:" +sw_vers +python3 --version +echo -n "Pyinstaller " +pyinstaller --version + +rm -rf ./dist + + +rm -rf $BUILDDIR > /dev/null 2>&1 +mkdir $BUILDDIR + +info "Downloading icons and locale..." +for repo in icons locale; do + git clone $GIT_REPO-$repo $BUILDDIR/electrum-$repo +done + +cp -R $BUILDDIR/electrum-locale/locale/ ./lib/locale/ +cp $BUILDDIR/electrum-icons/icons_rc.py ./gui/qt/ + + +info "Downloading libusb..." +curl https://homebrew.bintray.com/bottles/libusb-1.0.21.el_capitan.bottle.tar.gz | \ +tar xz --directory $BUILDDIR +cp $BUILDDIR/libusb/1.0.21/lib/libusb-1.0.dylib contrib/build-osx + +info "Installing requirements..." +python3 -m pip install -Ir ./contrib/deterministic-build/requirements.txt --user && \ +python3 -m pip install -Ir ./contrib/deterministic-build/requirements-binaries.txt --user || \ +fail "Could not install requirements" + +info "Installing hardware wallet requirements..." +python3 -m pip install -Ir ./contrib/deterministic-build/requirements-hw.txt --user || \ +fail "Could not install hardware wallet requirements" + +info "Building $PACKAGE..." +python3 setup.py install --user > /dev/null || fail "Could not build $PACKAGE" + +info "Building binary" +pyinstaller --noconfirm --ascii --name $VERSION contrib/build-osx/osx.spec || fail "Could not build binary" + +info "Creating .DMG" +hdiutil create -fs HFS+ -volname $PACKAGE -srcfolder dist/$PACKAGE.app dist/Electrum-ZCL-$VERSION.dmg || fail "Could not create .DMG" diff --git a/contrib/build-osx/osx.spec b/contrib/build-osx/osx.spec new file mode 100644 index 00000000..2977b685 --- /dev/null +++ b/contrib/build-osx/osx.spec @@ -0,0 +1,99 @@ +# -*- mode: python -*- + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs + +import sys +import os + +PACKAGE='Electrum-ZCL' +PYPKG='electrum' +MAIN_SCRIPT='electrum' +ICONS_FILE='electrum.icns' + +for i, x in enumerate(sys.argv): + if x == '--name': + VERSION = sys.argv[i+1] + break +else: + raise BaseException('no version') + +electrum = os.path.abspath(".") + "/" +block_cipher = None + +# see https://github.com/pyinstaller/pyinstaller/issues/2005 +hiddenimports = [] +hiddenimports += collect_submodules('trezorlib') +hiddenimports += collect_submodules('btchip') +hiddenimports += collect_submodules('keepkeylib') +hiddenimports += collect_submodules('websocket') + +datas = [ + (electrum+'lib/currencies.json', PYPKG), + (electrum+'lib/servers.json', PYPKG), + (electrum+'lib/checkpoints.json', PYPKG), + (electrum+'lib/servers_testnet.json', PYPKG), + (electrum+'lib/checkpoints_testnet.json', PYPKG), + (electrum+'lib/wordlist/english.txt', PYPKG + '/wordlist'), + (electrum+'lib/locale', PYPKG + '/locale'), + (electrum+'plugins', PYPKG + '_plugins'), +] +datas += collect_data_files('trezorlib') +datas += collect_data_files('btchip') +datas += collect_data_files('keepkeylib') + +# Add libusb so Trezor will work +binaries = [(electrum + "contrib/build-osx/libusb-1.0.dylib", ".")] + +# Workaround for "Retro Look": +binaries += [b for b in collect_dynamic_libs('PyQt5') if 'macstyle' in b[0]] + +# We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports +a = Analysis([electrum+MAIN_SCRIPT, + electrum+'gui/qt/main_window.py', + electrum+'gui/text.py', + electrum+'lib/util.py', + electrum+'lib/wallet.py', + electrum+'lib/simple_config.py', + electrum+'lib/bitcoin.py', + electrum+'lib/dnssec.py', + electrum+'lib/commands.py', + electrum+'plugins/cosigner_pool/qt.py', + electrum+'plugins/email_requests/qt.py', + electrum+'plugins/trezor/client.py', + electrum+'plugins/trezor/qt.py', + electrum+'plugins/keepkey/qt.py', + electrum+'plugins/ledger/qt.py', + ], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[]) + +# http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal +for d in a.datas: + if 'pyconfig' in d[0]: + a.datas.remove(d) + break + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.datas, + name=PACKAGE, + debug=False, + strip=False, + upx=True, + icon=electrum+ICONS_FILE, + console=False) + +app = BUNDLE(exe, + version = VERSION, + name=PACKAGE + '.app', + icon=electrum+ICONS_FILE, + bundle_identifier=None, + info_plist = { + 'NSHighResolutionCapable':'True' + } +) diff --git a/contrib/build-wine/README.md b/contrib/build-wine/README.md index b63485e8..6c289282 100644 --- a/contrib/build-wine/README.md +++ b/contrib/build-wine/README.md @@ -2,7 +2,7 @@ Windows Binary Builds ===================== These scripts can be used for cross-compilation of Windows Electrum executables from Linux/Wine. -Produced binaries are deterministic so you should be able to generate binaries that match the official releases. +Produced binaries are deterministic, so you should be able to generate binaries that match the official releases. Usage: @@ -12,6 +12,7 @@ Usage: - dirmngr - gpg + - 7Zip - Wine (>= v2) @@ -19,8 +20,7 @@ For example: ``` -$ sudo apt-get install wine-development dirmngr gnupg2 -$ sudo ln -sf /usr/bin/wine-development /usr/local/bin/wine +$ sudo apt-get install wine-development dirmngr gnupg2 p7zip-full $ wine --version wine-2.0 (Debian 2.0-3+b2) ``` diff --git a/contrib/build-wine/build-electrum-git.sh b/contrib/build-wine/build-electrum-git.sh index c849a672..2fd6ea00 100755 --- a/contrib/build-wine/build-electrum-git.sh +++ b/contrib/build-wine/build-electrum-git.sh @@ -16,6 +16,7 @@ PYTHON="wine $PYHOME/python.exe -OO -B" cd `dirname $0` set -e +mkdir -p tmp cd tmp for repo in electrum electrum-locale electrum-icons; do @@ -55,7 +56,9 @@ cp -r electrum-locale/locale $WINEPREFIX/drive_c/electrum/lib/ cp electrum-icons/icons_rc.py $WINEPREFIX/drive_c/electrum/gui/qt/ # Install frozen dependencies -$PYTHON -m pip install -r ../../requirements.txt +$PYTHON -m pip install -r ../../deterministic-build/requirements.txt + +$PYTHON -m pip install -r ../../deterministic-build/requirements-hw.txt pushd $WINEPREFIX/drive_c/electrum $PYTHON setup.py install diff --git a/contrib/build-wine/build.sh b/contrib/build-wine/build.sh index 5f00e824..8bf65062 100755 --- a/contrib/build-wine/build.sh +++ b/contrib/build-wine/build.sh @@ -7,14 +7,13 @@ if [ ! -z "$1" ]; then fi here=$(dirname "$0") +test -n "$here" -a -d "$here" || exit echo "Clearing $here/build and $here/dist..." -rm $here/build/* -rf -rm $here/dist/* -rf +rm "$here"/build/* -rf +rm "$here"/dist/* -rf -$here/prepare-wine.sh && \ -$here/prepare-pyinstaller.sh && \ -$here/prepare-hw.sh || exit 1 +$here/prepare-wine.sh || exit 1 echo "Resetting modification time in C:\Python..." # (Because of some bugs in pyinstaller) diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec index 12c381a6..0bdb50f5 100644 --- a/contrib/build-wine/deterministic.spec +++ b/contrib/build-wine/deterministic.spec @@ -1,9 +1,8 @@ # -*- mode: python -*- -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs import sys -import os for i, x in enumerate(sys.argv): if x == '--name': cmdline_name = sys.argv[i+1] @@ -11,13 +10,21 @@ for i, x in enumerate(sys.argv): else: raise BaseException('no name') -home = os.getcwd()+'\\' + +home = 'C:\\electrum-zcl\\' # see https://github.com/pyinstaller/pyinstaller/issues/2005 hiddenimports = [] -# hiddenimports += collect_submodules('trezorlib') -# hiddenimports += collect_submodules('btchip') -# hiddenimports += collect_submodules('keepkeylib') +hiddenimports += collect_submodules('trezorlib') +hiddenimports += collect_submodules('btchip') +hiddenimports += collect_submodules('keepkeylib') +hiddenimports += collect_submodules('websocket') + +# Add libusb binary +binaries = [("c:/python3.5.4/libusb-1.0.dll", ".")] + +# Workaround for "Retro Look": +binaries += [b for b in collect_dynamic_libs('PyQt5') if 'qwindowsvista' in b[0]] datas = [ (home+'lib/currencies.json', 'electrum'), @@ -26,12 +33,13 @@ datas = [ (home+'lib/servers_testnet.json', 'electrum'), (home+'lib/checkpoints_testnet.json', 'electrum'), (home+'lib/wordlist/english.txt', 'electrum/wordlist'), -# (home+'lib/locale', 'electrum/locale'), + (home+'lib/locale', 'electrum-zcl/locale'), (home+'plugins', 'electrum_plugins'), + ('C:\\Program Files (x86)\\ZBar\\bin\\', '.') ] -# datas += collect_data_files('trezorlib') -# datas += collect_data_files('btchip') -# datas += collect_data_files('keepkeylib') +datas += collect_data_files('trezorlib') +datas += collect_data_files('btchip') +datas += collect_data_files('keepkeylib') # We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports a = Analysis([home+'electrum-zcl', @@ -45,12 +53,13 @@ a = Analysis([home+'electrum-zcl', home+'lib/commands.py', home+'plugins/cosigner_pool/qt.py', home+'plugins/email_requests/qt.py', - #home+'plugins/trezor/client.py', - #home+'plugins/trezor/qt.py', - #home+'plugins/keepkey/qt.py', - #home+'plugins/ledger/qt.py', + home+'plugins/trezor/client.py', + home+'plugins/trezor/qt.py', + home+'plugins/keepkey/qt.py', + home+'plugins/ledger/qt.py', #home+'packages/requests/utils.py' ], + binaries=binaries, datas=datas, #pathex=[home+'lib', home+'gui', home+'plugins'], hiddenimports=hiddenimports, @@ -85,40 +94,40 @@ exe_standalone = EXE( console=False) # console=True makes an annoying black box pop up, but it does make Electrum output command line commands, with this turned off no output will be given but commands can still be used -# exe_portable = EXE( - # pyz, - # a.scripts, - # a.binaries, - # a.datas, - # name=os.path.join('build\\pyi.win32\\electrum', cmdline_name + "-portable.exe"), - # debug=False, - # strip=None, - # upx=False, - # icon=home+'icons/electrum.ico', - # console=False) +exe_portable = EXE( + pyz, + a.scripts, + a.binaries, + a.datas + [ ('is_portable', 'README.md', 'DATA' ) ], + name=os.path.join('build\\pyi.win32\\electrum', cmdline_name + "-portable.exe"), + debug=False, + strip=None, + upx=False, + icon=home+'icons/electrum.ico', + console=False) -# ##### -# # exe and separate files that NSIS uses to build installer "setup" exe +##### +# exe and separate files that NSIS uses to build installer "setup" exe -# exe_dependent = EXE( - # pyz, - # a.scripts, - # exclude_binaries=True, - # name=os.path.join('build\\pyi.win32\\electrum', cmdline_name), - # debug=False, - # strip=None, - # upx=False, - # icon=home+'icons/electrum.ico', - # console=False) +exe_dependent = EXE( + pyz, + a.scripts, + exclude_binaries=True, + name=os.path.join('build\\pyi.win32\\electrum', cmdline_name), + debug=False, + strip=None, + upx=False, + icon=home+'icons/electrum.ico', + console=False) -# coll = COLLECT( - # exe_dependent, - # a.binaries, - # a.zipfiles, - # a.datas, - # strip=None, - # upx=True, - # debug=False, - # icon=home+'icons/electrum.ico', - # console=False, - # name=os.path.join('dist', 'electrum')) +coll = COLLECT( + exe_dependent, + a.binaries, + a.zipfiles, + a.datas, + strip=None, + upx=True, + debug=False, + icon=home+'icons/electrum.ico', + console=False, + name=os.path.join('dist', 'electrum')) diff --git a/contrib/build-wine/prepare-hw.sh b/contrib/build-wine/prepare-hw.sh deleted file mode 100755 index 1851b7b0..00000000 --- a/contrib/build-wine/prepare-hw.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -TREZOR_GIT_URL=https://github.com/trezor/python-trezor.git -KEEPKEY_GIT_URL=https://github.com/keepkey/python-keepkey.git -BTCHIP_GIT_URL=https://github.com/LedgerHQ/btchip-python.git - -BRANCH=master - -PYTHON_VERSION=3.5.4 - -# These settings probably don't need any change -export WINEPREFIX=/opt/wine64 - -PYHOME=c:/python$PYTHON_VERSION -PYTHON="wine $PYHOME/python.exe -OO -B" - -# Let's begin! -cd `dirname $0` -set -e - -cd tmp - -$PYTHON -m pip install setuptools --upgrade -$PYTHON -m pip install cython --upgrade -$PYTHON -m pip install trezor==0.7.16 --upgrade -$PYTHON -m pip install keepkey==4.0.0 --upgrade -$PYTHON -m pip install btchip-python==0.1.23 --upgrade - diff --git a/contrib/build-wine/prepare-pyinstaller.sh b/contrib/build-wine/prepare-pyinstaller.sh deleted file mode 100755 index cf8a326c..00000000 --- a/contrib/build-wine/prepare-pyinstaller.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -PYTHON_VERSION=3.5.4 - -PYINSTALLER_GIT_URL=https://github.com/ecdsa/pyinstaller.git -BRANCH=fix_2952 - -export WINEPREFIX=/opt/wine64 -PYHOME=c:/python$PYTHON_VERSION -PYTHON="wine $PYHOME/python.exe -OO -B" - -cd `dirname $0` -set -e -cd tmp -if [ ! -d "pyinstaller" ]; then - git clone -b $BRANCH $PYINSTALLER_GIT_URL pyinstaller -fi - -cd pyinstaller -git pull -git checkout $BRANCH -$PYTHON setup.py install -cd .. - -wine "C:/python$PYTHON_VERSION/scripts/pyinstaller.exe" -v diff --git a/contrib/build-wine/prepare-wine.sh b/contrib/build-wine/prepare-wine.sh index 542a2085..c1bed9be 100755 --- a/contrib/build-wine/prepare-wine.sh +++ b/contrib/build-wine/prepare-wine.sh @@ -1,8 +1,18 @@ #!/bin/bash # Please update these carefully, some versions won't work under Wine -NSIS_URL=https://prdownloads.sourceforge.net/nsis/nsis-3.02.1-setup.exe?download +NSIS_FILENAME=nsis-3.02.1-setup.exe +NSIS_URL=https://prdownloads.sourceforge.net/nsis/$NSIS_FILENAME?download NSIS_SHA256=736c9062a02e297e335f82252e648a883171c98e0d5120439f538c81d429552e + +ZBAR_FILENAME=zbarw-20121031-setup.exe +ZBAR_URL=https://sourceforge.net/projects/zbarw/files/$ZBAR_FILENAME/download +ZBAR_SHA256=177e32b272fa76528a3af486b74e9cb356707be1c5ace4ed3fcee9723e2c2c02 + +LIBUSB_FILENAME=libusb-1.0.21.7z +LIBUSB_URL=https://prdownloads.sourceforge.net/project/libusb/libusb-1.0/libusb-1.0.21/$LIBUSB_FILENAME?download +LIBUSB_SHA256=acdde63a40b1477898aee6153f9d91d1a2e8a5d93f832ca8ab876498f3a6d2b8 + PYTHON_VERSION=3.5.4 ## These settings probably don't need change @@ -21,23 +31,31 @@ verify_signature() { return 0 else echo "$out" >&2 - exit 0 + exit 1 fi } verify_hash() { - local file=$1 expected_hash=$2 out= + local file=$1 expected_hash=$2 actual_hash=$(sha256sum $file | awk '{print $1}') if [ "$actual_hash" == "$expected_hash" ]; then return 0 else echo "$file $actual_hash (unexpected hash)" >&2 - exit 0 + rm "$file" + exit 1 + fi +} + +download_if_not_exist() { + local file_name=$1 url=$2 + if [ ! -e $file_name ] ; then + wget -O $PWD/$file_name "$url" fi } # Let's begin! -cd `dirname $0` +here=$(dirname $(readlink -e $0)) set -e # Clean up Wine environment @@ -47,22 +65,21 @@ echo "done" wine 'wineboot' -echo "Cleaning tmp" -rm -rf tmp -mkdir -p tmp -echo "done" +mkdir -p /tmp/electrum-build -cd tmp +cd /tmp/electrum-build # Install Python # note: you might need "sudo apt-get install dirmngr" for the following # keys from https://www.python.org/downloads/#pubkeys -KEYRING_PYTHON_DEV=keyring-electrum-build-python-dev.gpg -gpg --no-default-keyring --keyring $KEYRING_PYTHON_DEV --recv-keys 531F072D39700991925FED0C0EDDC5F26A45C816 26DEA9D4613391EF3E25C9FF0A5B101836580288 CBC547978A3964D14B9AB36A6AF053F07D9DC8D2 C01E1CAD5EA2C4F0B8E3571504C367C218ADD4FF 12EF3DC38047DA382D18A5B999CDEA9DA4135B38 8417157EDBE73D9EAC1E539B126EB563A74B06BF DBBF2EEBF925FAADCF1F3FFFD9866941EA5BBD71 2BA0DB82515BBB9EFFAC71C5C9BE28DEE6DF025C 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D C9B104B3DD3AA72D7CCB1066FB9921286F5E1540 97FC712E4C024BBEA48A61ED3A5CA953F73C700D 7ED10B6531D7C8E1BC296021FC624643487034E5 +KEYLIST_PYTHON_DEV="531F072D39700991925FED0C0EDDC5F26A45C816 26DEA9D4613391EF3E25C9FF0A5B101836580288 CBC547978A3964D14B9AB36A6AF053F07D9DC8D2 C01E1CAD5EA2C4F0B8E3571504C367C218ADD4FF 12EF3DC38047DA382D18A5B999CDEA9DA4135B38 8417157EDBE73D9EAC1E539B126EB563A74B06BF DBBF2EEBF925FAADCF1F3FFFD9866941EA5BBD71 2BA0DB82515BBB9EFFAC71C5C9BE28DEE6DF025C 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D C9B104B3DD3AA72D7CCB1066FB9921286F5E1540 97FC712E4C024BBEA48A61ED3A5CA953F73C700D 7ED10B6531D7C8E1BC296021FC624643487034E5" +KEYRING_PYTHON_DEV="keyring-electrum-build-python-dev.gpg" +KEYSERVER_PYTHON_DEV="hkp://keys.gnupg.net" +gpg --no-default-keyring --keyring $KEYRING_PYTHON_DEV --keyserver $KEYSERVER_PYTHON_DEV --recv-keys $KEYLIST_PYTHON_DEV for msifile in core dev exe lib pip tools; do echo "Installing $msifile..." - wget "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi" - wget "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi.asc" + wget -nc "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi" + wget -nc "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi.asc" verify_signature "${msifile}.msi.asc" $KEYRING_PYTHON_DEV wine msiexec /i "${msifile}.msi" /qb TARGETDIR=C:/python$PYTHON_VERSION done @@ -70,36 +87,35 @@ done # upgrade pip $PYTHON -m pip install pip --upgrade -# Install PyWin32 -$PYTHON -m pip install pypiwin32 - -# Install PyQt -$PYTHON -m pip install PyQt5 - -## Install pyinstaller -#$PYTHON -m pip install pyinstaller==3.3 - - -# Install ZBar -#wget -q -O zbar.exe "https://sourceforge.net/projects/zbar/files/zbar/0.10/zbar-0.10-setup.exe/download" -#wine zbar.exe - -# install Cryptodome -$PYTHON -m pip install pycryptodomex +# Install pywin32-ctypes (needed by pyinstaller) +$PYTHON -m pip install pywin32-ctypes==0.1.2 # install PySocks -$PYTHON -m pip install win_inet_pton +$PYTHON -m pip install win_inet_pton==1.0.1 -# install websocket (python2) -$PYTHON -m pip install websocket-client +$PYTHON -m pip install -r $here/../deterministic-build/requirements-binaries.txt + +# Install PyInstaller +$PYTHON -m pip install https://github.com/ecdsa/pyinstaller/archive/fix_2952.zip + +# Install ZBar +download_if_not_exist $ZBAR_FILENAME "$ZBAR_URL" +verify_hash $ZBAR_FILENAME "$ZBAR_SHA256" +wine "$PWD/$ZBAR_FILENAME" /S # Upgrade setuptools (so Electrum can be installed later) $PYTHON -m pip install setuptools --upgrade # Install NSIS installer -wget -q -O nsis.exe "$NSIS_URL" -verify_hash nsis.exe $NSIS_SHA256 -wine nsis.exe /S +download_if_not_exist $NSIS_FILENAME "$NSIS_URL" +verify_hash $NSIS_FILENAME "$NSIS_SHA256" +wine "$PWD/$NSIS_FILENAME" /S + +download_if_not_exist $LIBUSB_FILENAME "$LIBUSB_URL" +verify_hash $LIBUSB_FILENAME "$LIBUSB_SHA256" +7z x -olibusb $LIBUSB_FILENAME -aos + +cp libusb/MS32/dll/libusb-1.0.dll $WINEPREFIX/drive_c/python$PYTHON_VERSION/ # Install UPX #wget -O upx.zip "https://downloads.sourceforge.net/project/upx/upx/3.08/upx308w.zip" @@ -109,5 +125,4 @@ wine nsis.exe /S # add dlls needed for pyinstaller: cp $WINEPREFIX/drive_c/python$PYTHON_VERSION/Lib/site-packages/PyQt5/Qt/bin/* $WINEPREFIX/drive_c/python$PYTHON_VERSION/ - -echo "Wine is configured. Please run prepare-pyinstaller.sh" +echo "Wine is configured." diff --git a/contrib/deterministic-build/requirements-binaries.txt b/contrib/deterministic-build/requirements-binaries.txt new file mode 100644 index 00000000..381b4378 --- /dev/null +++ b/contrib/deterministic-build/requirements-binaries.txt @@ -0,0 +1,5 @@ +pycryptodomex==3.4.12 +PyQt5==5.10 +sip==4.19.7 +six==1.11.0 +websocket-client==0.46.0 diff --git a/contrib/deterministic-build/requirements-hw.txt b/contrib/deterministic-build/requirements-hw.txt new file mode 100644 index 00000000..8e0ba52f --- /dev/null +++ b/contrib/deterministic-build/requirements-hw.txt @@ -0,0 +1,18 @@ +btchip-python==0.1.24 +certifi==2018.1.18 +chardet==3.0.4 +click==6.7 +Cython==0.27.3 +ecdsa==0.13 +hidapi==0.7.99.post21 +idna==2.6 +keepkey==4.0.2 +libusb1==1.6.4 +mnemonic==0.18 +pbkdf2==1.3 +protobuf==3.5.1 +pyblake2==1.1.0 +requests==2.18.4 +six==1.11.0 +trezor==0.9.0 +urllib3==1.22 diff --git a/contrib/requirements.txt b/contrib/deterministic-build/requirements.txt similarity index 73% rename from contrib/requirements.txt rename to contrib/deterministic-build/requirements.txt index 52fada94..e7d8925b 100644 --- a/contrib/requirements.txt +++ b/contrib/deterministic-build/requirements.txt @@ -1,13 +1,13 @@ -certifi==2017.11.5 +certifi==2018.1.18 chardet==3.0.4 dnspython==1.15.0 ecdsa==0.13 idna==2.6 jsonrpclib-pelix==0.3.1 pbkdf2==1.3 -protobuf==3.5.0.post1 +protobuf==3.5.1 pyaes==1.6.1 -PySocks==1.6.7 +PySocks==1.6.8 qrcode==5.3 requests==2.18.4 six==1.11.0 diff --git a/contrib/freeze_packages.sh b/contrib/freeze_packages.sh index 139fb6ee..3471e528 100755 --- a/contrib/freeze_packages.sh +++ b/contrib/freeze_packages.sh @@ -6,17 +6,17 @@ contrib=$(dirname "$0") which virtualenv > /dev/null 2>&1 || { echo "Please install virtualenv" && exit 1; } -rm $venv_dir -rf -virtualenv $venv_dir +for i in '' '-hw' '-binaries'; do + rm "$venv_dir" -rf + virtualenv -p $(which python3) $venv_dir -source $venv_dir/bin/activate + source $venv_dir/bin/activate -echo "Installing dependencies" + echo "Installing $i dependencies" -pushd $contrib/.. -python setup.py install -popd + python -m pip install -r $contrib/requirements/requirements${i}.txt --upgrade -pip freeze | sed '/^Electrum/ d' > $contrib/requirements.txt + pip freeze | sed '/^Electrum/ d' > $contrib/deterministic-build/requirements${i}.txt +done -echo "Updated requirements" +echo "Done. Updated requirements" diff --git a/contrib/make_packages b/contrib/make_packages index 1f7c1fa9..9cfd32bb 100755 --- a/contrib/make_packages +++ b/contrib/make_packages @@ -1,12 +1,13 @@ #!/bin/bash contrib=$(dirname "$0") +test -n "$contrib" -a -d "$contrib" || exit whereis pip3 if [ $? -ne 0 ] ; then echo "Install pip3" ; exit ; fi -rm $contrib/../packages/ -r +rm "$contrib"/../packages/ -r #Install pure python modules in electrum directory -pip3 install -r $contrib/requirements.txt -t $contrib/../packages +pip3 install -r $contrib/deterministic-build/requirements.txt -t $contrib/../packages diff --git a/contrib/requirements/requirements-binaries.txt b/contrib/requirements/requirements-binaries.txt new file mode 100644 index 00000000..68181dd3 --- /dev/null +++ b/contrib/requirements/requirements-binaries.txt @@ -0,0 +1,3 @@ +PyQt5 +pycryptodomex +websocket-client \ No newline at end of file diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt new file mode 100644 index 00000000..5647f3ca --- /dev/null +++ b/contrib/requirements/requirements-hw.txt @@ -0,0 +1,4 @@ +Cython>=0.27 +trezor>=0.9.0 +keepkey +btchip-python diff --git a/requirements_travis.txt b/contrib/requirements/requirements-travis.txt similarity index 100% rename from requirements_travis.txt rename to contrib/requirements/requirements-travis.txt diff --git a/contrib/requirements/requirements.txt b/contrib/requirements/requirements.txt new file mode 100644 index 00000000..227ec1cd --- /dev/null +++ b/contrib/requirements/requirements.txt @@ -0,0 +1,9 @@ +pyaes>=0.1a1 +ecdsa>=0.9 +pbkdf2 +requests +qrcode +protobuf +dnspython +jsonrpclib-pelix +PySocks>=1.6.6 diff --git a/electrum-env b/electrum-env index 42220eda..c05b2d1a 100755 --- a/electrum-env +++ b/electrum-env @@ -9,6 +9,8 @@ # python-qt and its dependencies will still need to be installed with # your package manager. +PYTHON_VER="$(python3 -c 'import sys; print(sys.version[:3])')" + if [ -e ./env/bin/activate ]; then source ./env/bin/activate else @@ -17,7 +19,7 @@ else python3 setup.py install fi -export PYTHONPATH="/usr/local/lib/python3.5/site-packages:$PYTHONPATH" +export PYTHONPATH="/usr/local/lib/python${PYTHON_VER}/site-packages:$PYTHONPATH" ./electrum "$@" diff --git a/electrum-zcl b/electrum-zcl index 7f79862d..887bc658 100755 --- a/electrum-zcl +++ b/electrum-zcl @@ -90,10 +90,12 @@ if is_local or is_android or is_macOS: imp.load_module('electrum_plugins', *imp.find_module('plugins')) -from electrum import bitcoin + +from electrum import bitcoin, util +from electrum import constants from electrum import SimpleConfig, Network from electrum.wallet import Wallet, Imported_Wallet -from electrum.storage import WalletStorage +from electrum.storage import WalletStorage, get_derivation_used_for_hw_device_encryption from electrum.util import print_msg, print_stderr, json_encode, json_decode from electrum.util import set_verbosity, InvalidPassword from electrum.commands import get_parser, known_commands, Commands, config_variables @@ -163,6 +165,8 @@ def run_non_RPC(config): print_msg("Recovering wallet...") wallet.synchronize() wallet.wait_until_synchronized() + wallet.stop_threads() + # note: we don't wait for SPV msg = "Recovery successful" if wallet.is_found() else "Found no history for this wallet" else: msg = "This wallet was restored offline. It may contain more addresses than displayed." @@ -195,7 +199,10 @@ def init_daemon(config_options): print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option") sys.exit(0) if storage.is_encrypted(): - if config.get('password'): + if storage.is_encrypted_with_hw_device(): + plugins = init_plugins(config, 'cmdline') + password = get_password_for_hw_device_encrypted_storage(plugins) + elif config.get('password'): password = config.get('password') else: password = prompt_password('Password:', False) @@ -222,7 +229,7 @@ def init_cmdline(config_options, server): if cmdname in ['payto', 'paytomany'] and config.get('broadcast'): cmd.requires_network = True - # instanciate wallet for command-line + # instantiate wallet for command-line storage = WalletStorage(config.get_wallet_path()) if cmd.requires_wallet and not storage.file_exists(): @@ -239,7 +246,10 @@ def init_cmdline(config_options, server): # commands needing password if (cmd.requires_wallet and storage.is_encrypted() and server is None)\ or (cmd.requires_password and (storage.get('use_encryption') or storage.is_encrypted())): - if config.get('password'): + if storage.is_encrypted_with_hw_device(): + # this case is handled later in the control flow + password = None + elif config.get('password'): password = config.get('password') else: password = prompt_password('Password:', False) @@ -258,19 +268,57 @@ def init_cmdline(config_options, server): return cmd, password -def run_offline_command(config, config_options): +def get_connected_hw_devices(plugins): + support = plugins.get_hardware_support() + if not support: + print_msg('No hardware wallet support found on your system.') + sys.exit(1) + # scan devices + devices = [] + devmgr = plugins.device_manager + for name, description, plugin in support: + try: + u = devmgr.unpaired_device_infos(None, plugin) + except: + devmgr.print_error("error", name) + continue + devices += list(map(lambda x: (name, x), u)) + return devices + + +def get_password_for_hw_device_encrypted_storage(plugins): + devices = get_connected_hw_devices(plugins) + if len(devices) == 0: + print_msg("Error: No connected hw device found. Can not decrypt this wallet.") + sys.exit(1) + elif len(devices) > 1: + print_msg("Warning: multiple hardware devices detected. " + "The first one will be used to decrypt the wallet.") + # FIXME we use the "first" device, in case of multiple ones + name, device_info = devices[0] + plugin = plugins.get_plugin(name) + derivation = get_derivation_used_for_hw_device_encryption() + xpub = plugin.get_xpub(device_info.device.id_, derivation, 'standard', plugin.handler) + password = keystore.Xpub.get_pubkey_from_xpub(xpub, ()) + return password + + +def run_offline_command(config, config_options, plugins): cmdname = config.get('cmd') cmd = known_commands[cmdname] password = config_options.get('password') if cmd.requires_wallet: storage = WalletStorage(config.get_wallet_path()) if storage.is_encrypted(): + if storage.is_encrypted_with_hw_device(): + password = get_password_for_hw_device_encrypted_storage(plugins) + config_options['password'] = password storage.decrypt(password) wallet = Wallet(storage) else: wallet = None # check password - if cmd.requires_password and storage.get('use_encryption'): + if cmd.requires_password and wallet.has_password(): try: seed = wallet.check_password(password) except InvalidPassword: @@ -281,7 +329,8 @@ def run_offline_command(config, config_options): # arguments passed to function args = [config.get(x) for x in cmd.params] # decode json arguments - args = list(map(json_decode, args)) + if cmdname not in ('setconfig',): + args = list(map(json_decode, args)) # options kwargs = {} for x in cmd.options: @@ -298,8 +347,10 @@ def init_plugins(config, gui_name): from electrum.plugins import Plugins return Plugins(config, is_local or is_android, gui_name) -if __name__ == '__main__': +if __name__ == '__main__': + # The hook will only be used in the Qt GUI right now + util.setup_thread_excepthook() # on osx, delete Process Serial Number arg generated for apps launched in Finder sys.argv = list(filter(lambda x: not x.startswith('-psn'), sys.argv)) @@ -364,7 +415,7 @@ if __name__ == '__main__': cmdname = config.get('cmd') if config.get('testnet'): - bitcoin.NetworkConstants.set_testnet() + constants.set_testnet() # run non-RPC commands separately if cmdname in ['create', 'restore']: @@ -430,8 +481,8 @@ if __name__ == '__main__': print_msg("Daemon not running; try 'electrum daemon start'") sys.exit(1) else: - init_plugins(config, 'cmdline') - result = run_offline_command(config, config_options) + plugins = init_plugins(config, 'cmdline') + result = run_offline_command(config, config_options, plugins) # print result if isinstance(result, str): print_msg(result) diff --git a/gui/kivy/Readme.md b/gui/kivy/Readme.md new file mode 100644 index 00000000..3e66d207 --- /dev/null +++ b/gui/kivy/Readme.md @@ -0,0 +1,80 @@ +# Kivy GUI + +The Kivy GUI is used with Electrum on Android devices. To generate an APK file, follow these instructions. + +## 1. Install python-for-android (p4a) +p4a is used to package Electrum, Python, SDL and a bootstrap Java app into an APK file. +We patched p4a to add some functionality we need for Electrum. Until those changes are +merged into p4a, you need to merge them locally (into the master branch): + +2. [kivy/python-for-android#1217](https://github.com/kivy/python-for-android/pull/1217) + +Something like this should work: + +```sh +cd /opt +git clone https://github.com/kivy/python-for-android +cd python-for-android +git remote add agilewalker https://github.com/agilewalker/python-for-android +git checkout a036f4442b6a23 +git fetch agilewalker +git merge agilewalker/master +``` + +## 2. Install buildozer +2.1 Buildozer is a frontend to p4a. Luckily we don't need to patch it: + +```sh +cd /opt +git clone https://github.com/kivy/buildozer +cd buildozer +sudo python3 setup.py install +``` + +2.2 Download the [Crystax NDK](https://www.crystax.net/en/download) manually. +Extract into `/opt/crystax-ndk-10.3.2` + +## 3. Update the Android SDK build tools + +### Method 1: Using the GUI + + Start the Android SDK manager in GUI mode: + + ~/.buildozer/android/platform/android-sdk-20/tools/android + + Check the latest SDK available and install it. + Close the SDK manager. + Reopen the SDK manager, scroll to the bottom and install the latest build tools (probably v27) + Install "Android Support Library Repository" from the SDK manager. + +### Method 2: Using the command line: + + Repeat the following command until there is nothing to install: + + ~/.buildozer/android/platform/android-sdk-20/tools/android update sdk -u -t tools,platform-tools + + Install Build Tools, android API 19 and Android Support Library: + + ~/.buildozer/android/platform/android-sdk-20/tools/android update sdk -u -t build-tools-27.0.3,android-19,extra-android-m2repository + + + +## 5. Create the UI Atlas +In the `gui/kivy` directory of Electrum, run `make theming`. + +## 6. Download Electrum dependencies +Run `contrib/make_packages`. + +## 7. Build the APK +Run `contrib/make_apk`. + +# FAQ +## Why do I get errors like `package me.dm7.barcodescanner.zxing does not exist` while compiling? +Update your Android build tools to version 27 like described above. + +## Why do I get errors like `(use -source 7 or higher to enable multi-catch statement)` while compiling? +Make sure that your p4a installation includes commit a3cc78a6d1a107cd3b6bd28db8b80f89e3ecddd2. +Also make sure you have recent SDK tools and platform-tools + +## I changed something but I don't see any differences on the phone. What did I do wrong? +You probably need to clear the cache: `rm -rf .buildozer/android/platform/build/{build,dists}` diff --git a/gui/kivy/Readme.txt b/gui/kivy/Readme.txt deleted file mode 100644 index ce85523c..00000000 --- a/gui/kivy/Readme.txt +++ /dev/null @@ -1,24 +0,0 @@ -Before compiling, create packages: `contrib/make_packages` - -Commands:: - - `make theming` to make a atlas out of a list of pngs - - `make apk` to make a apk - - -If something in included modules like kivy or any other module changes -then you need to rebuild the distribution. To do so: - - rm -rf .buildozer/android/platform/python-for-android/dist - - -how to build with ssl: - - rm -rf .buildozer/android/platform/build/ - ./contrib/make_apk - pushd /opt/electrum/.buildozer/android/platform/build/build/libs_collections/Electrum/armeabi-v7a - cp libssl1.0.2g.so /opt/crystax-ndk-10.3.2/sources/openssl/1.0.2g/libs/armeabi-v7a/libssl.so - cp libcrypto1.0.2g.so /opt/crystax-ndk-10.3.2/sources/openssl/1.0.2g/libs/armeabi-v7a/libcrypto.so - popd - ./contrib/make_apk diff --git a/gui/kivy/i18n.py b/gui/kivy/i18n.py index e0be3908..733249d3 100644 --- a/gui/kivy/i18n.py +++ b/gui/kivy/i18n.py @@ -1,21 +1,22 @@ import gettext + class _(str): observers = set() lang = None - def __new__(cls, s, *args, **kwargs): + def __new__(cls, s): if _.lang is None: _.switch_lang('en') - t = _.translate(s, *args, **kwargs) + t = _.translate(s) o = super(_, cls).__new__(cls, t) o.source_text = s return o @staticmethod def translate(s, *args, **kwargs): - return _.lang(s).format(args, kwargs) + return _.lang(s) @staticmethod def bind(label): diff --git a/gui/kivy/main.kv b/gui/kivy/main.kv index 853ddd94..95cfe8de 100644 --- a/gui/kivy/main.kv +++ b/gui/kivy/main.kv @@ -239,7 +239,7 @@ self.screen.show_menu(args[0]) if self.state == 'down' else self.screen.hide_menu() canvas.before: Color: - rgba: (0.192, .498, 0.745, 1) if self.state == 'down' else (0.3, 0.3, 0.3, 1) + rgba: (0.192, .498, 0.745, 1) if self.state == 'down' else (0.15, 0.15, 0.17, 1) Rectangle: size: self.size pos: self.pos @@ -397,7 +397,7 @@ slide: 1 CleanHeader: id: history_tab - text: _('History') + text: _('Balance') slide: 2 CleanHeader: id: receive_tab diff --git a/gui/kivy/main_window.py b/gui/kivy/main_window.py index eae1343a..6940d71e 100644 --- a/gui/kivy/main_window.py +++ b/gui/kivy/main_window.py @@ -82,6 +82,10 @@ class ElectrumWindow(App): server_port = StringProperty('') num_chains = NumericProperty(0) blockchain_name = StringProperty('') + fee_status = StringProperty('Fee') + balance = StringProperty('') + fiat_balance = StringProperty('') + is_fiat = BooleanProperty(False) blockchain_checkpoint = NumericProperty(0) auto_connect = BooleanProperty(False) @@ -95,8 +99,8 @@ class ElectrumWindow(App): from .uix.dialogs.choice_dialog import ChoiceDialog protocol = 's' def cb2(host): - from electrum.bitcoin import NetworkConstants - pp = servers.get(host, NetworkConstants.DEFAULT_PORTS) + from electrum import constants + pp = servers.get(host, constants.net.DEFAULT_PORTS) port = pp.get(protocol, '') popup.ids.host.text = host popup.ids.port.text = port @@ -175,8 +179,10 @@ class ElectrumWindow(App): def btc_to_fiat(self, amount_str): if not amount_str: return '' + if not self.fx.is_enabled(): + return '' rate = self.fx.exchange_rate() - if not rate: + if rate.is_nan(): return '' fiat_amount = self.get_amount(amount_str + ' ' + self.base_unit) * rate / pow(10, 8) return "{:.2f}".format(fiat_amount).rstrip('0').rstrip('.') @@ -185,7 +191,7 @@ class ElectrumWindow(App): if not fiat_amount: return '' rate = self.fx.exchange_rate() - if not rate: + if rate.is_nan(): return '' satoshis = int(pow(10,8) * Decimal(fiat_amount) / Decimal(rate)) return format_satoshis_plain(satoshis, self.decimal_point()) @@ -271,6 +277,7 @@ class ElectrumWindow(App): # cached dialogs self._settings_dialog = None self._password_dialog = None + self.fee_status = self.electrum_config.get_fee_status() def wallet_name(self): return os.path.basename(self.wallet.storage.path) if self.wallet else ' ' @@ -457,6 +464,7 @@ class ElectrumWindow(App): if self.network: interests = ['updated', 'status', 'new_transaction', 'verified', 'interfaces'] self.network.register_callback(self.on_network_event, interests) + self.network.register_callback(self.on_fee, ['fee']) self.network.register_callback(self.on_quotes, ['on_quotes']) self.network.register_callback(self.on_history, ['on_history']) # URI passed in config @@ -631,17 +639,17 @@ class ElectrumWindow(App): if not self.wallet.up_to_date or server_height == 0: status = _("Synchronizing...") elif server_lag > 1: - status = _("Server lagging (%d blocks)"%server_lag) + status = _("Server lagging") else: - c, u, x = self.wallet.get_balance() - text = self.format_amount(c+x+u) - status = str(text.strip() + ' ' + self.base_unit) + status = '' else: status = _("Disconnected") - - n = self.wallet.basename() - self.status = '[size=15dp]%s[/size]\n%s' %(n, status) - #fiat_balance = self.fx.format_amount_and_units(c+u+x) or '' + self.status = self.wallet.basename() + (' [size=15dp](%s)[/size]'%status if status else '') + # balance + c, u, x = self.wallet.get_balance() + text = self.format_amount(c+x+u) + self.balance = str(text.strip()) + ' [size=22dp]%s[/size]'% self.base_unit + self.fiat_balance = self.fx.format_amount(c+u+x) + ' [size=22dp]%s[/size]'% self.fx.ccy def get_max_amount(self): inputs = self.wallet.get_spendable_coins(None, self.electrum_config) @@ -684,9 +692,6 @@ class ElectrumWindow(App): def on_resume(self): if self.nfcscanner: self.nfcscanner.nfc_enable() - # workaround p4a bug: - # show an empty info bubble, to refresh the display - self.show_info_bubble('', duration=0.1, pos=(0,0), width=1, arrow_pos=None) def on_size(self, instance, value): width, height = value @@ -831,6 +836,16 @@ class ElectrumWindow(App): popup = AmountDialog(show_max, amount, cb) popup.open() + def fee_dialog(self, label, dt): + from .uix.dialogs.fee_dialog import FeeDialog + def cb(): + self.fee_status = self.electrum_config.get_fee_status() + fee_dialog = FeeDialog(self, self.electrum_config, cb) + fee_dialog.open() + + def on_fee(self, event, *arg): + self.fee_status = self.electrum_config.get_fee_status() + def protected(self, msg, f, args): if self.wallet.has_password(): self.password_dialog(msg, f, args) @@ -846,7 +861,7 @@ class ElectrumWindow(App): def _delete_wallet(self, b): if b: basename = os.path.basename(self.wallet.storage.path) - self.protected(_("Enter your PIN code to confirm deletion of %s") % basename, self.__delete_wallet, ()) + self.protected(_("Enter your PIN code to confirm deletion of {}").format(basename), self.__delete_wallet, ()) def __delete_wallet(self, pw): wallet_path = self.get_wallet_path() @@ -928,6 +943,10 @@ class ElectrumWindow(App): return if not self.wallet.can_export(): return - key = str(self.wallet.export_private_key(addr, password)[0]) - pk_label.data = key + try: + key = str(self.wallet.export_private_key(addr, password)[0]) + pk_label.data = key + except InvalidPassword: + self.show_error("Invalid PIN") + return self.protected(_("Enter your PIN code in order to decrypt your private key"), show_private_key, (addr, pk_label)) diff --git a/gui/kivy/tools/buildozer.spec b/gui/kivy/tools/buildozer.spec index b3679889..1e910314 100644 --- a/gui/kivy/tools/buildozer.spec +++ b/gui/kivy/tools/buildozer.spec @@ -31,7 +31,7 @@ version.filename = %(source.dir)s/contrib/versions.py #version = 1.9.8 # (list) Application requirements -requirements = python3crystax, android, openssl, plyer, kivy==master +requirements = python3crystax==3.6, android, openssl, plyer, kivy==master # (str) Presplash of the application #presplash.filename = %(source.dir)s/gui/kivy/theming/splash.png diff --git a/gui/kivy/uix/dialogs/amount_dialog.py b/gui/kivy/uix/dialogs/amount_dialog.py index cdb8ae44..244f8e61 100644 --- a/gui/kivy/uix/dialogs/amount_dialog.py +++ b/gui/kivy/uix/dialogs/amount_dialog.py @@ -13,21 +13,35 @@ Builder.load_string(''' anchor_x: 'center' BoxLayout: orientation: 'vertical' - size_hint: 0.8, 1 + size_hint: 0.9, 1 + Widget: + size_hint: 1, 0.2 BoxLayout: size_hint: 1, None height: '80dp' - Label: - id: a - btc_text: (kb.amount + ' ' + app.base_unit) if kb.amount else '' - fiat_text: (kb.fiat_amount + ' ' + app.fiat_unit) if kb.fiat_amount else '' - text1: ((self.fiat_text if kb.is_fiat else self.btc_text) if app.fiat_unit else self.btc_text) if self.btc_text else '' - text2: ((self.btc_text if kb.is_fiat else self.fiat_text) if app.fiat_unit else '') if self.btc_text else '' - text: self.text1 + "\\n" + "[color=#8888ff]" + self.text2 + "[/color]" + Button: + background_color: 0, 0, 0, 0 + id: btc + text: kb.amount + ' ' + app.base_unit + color: (0.7, 0.7, 1, 1) if kb.is_fiat else (1, 1, 1, 1) halign: 'right' size_hint: 1, None - font_size: '22dp' - height: '80dp' + font_size: '20dp' + height: '48dp' + on_release: + kb.is_fiat = False + Button: + background_color: 0, 0, 0, 0 + id: fiat + text: kb.fiat_amount + ' ' + app.fiat_unit + color: (1, 1, 1, 1) if kb.is_fiat else (0.7, 0.7, 1, 1) + halign: 'right' + size_hint: 1, None + font_size: '20dp' + height: '48dp' + disabled: not app.fx.is_enabled() + on_release: + kb.is_fiat = True Widget: size_hint: 1, 0.2 GridLayout: @@ -65,6 +79,9 @@ Builder.load_string(''' text: '0' KButton: text: '<' + Widget: + size_hint: 1, None + height: '48dp' Button: id: but_max opacity: 1 if root.show_max else 0 @@ -75,13 +92,6 @@ Builder.load_string(''' on_release: kb.is_fiat = False kb.amount = app.get_max_amount() - Button: - id: button_fiat - size_hint: 1, None - height: '48dp' - text: (app.base_unit if not kb.is_fiat else app.fiat_unit) if app.fiat_unit else '' - on_release: - if app.fiat_unit: popup.toggle_fiat(kb) Button: size_hint: 1, None height: '48dp' @@ -102,7 +112,7 @@ Builder.load_string(''' height: '48dp' text: _('OK') on_release: - root.callback(a.btc_text) + root.callback(btc.text if kb.amount else '') popup.dismiss() ''') @@ -117,9 +127,6 @@ class AmountDialog(Factory.Popup): if amount: self.ids.kb.amount = amount - def toggle_fiat(self, a): - a.is_fiat = not a.is_fiat - def update_amount(self, c): kb = self.ids.kb amount = kb.fiat_amount if kb.is_fiat else kb.amount diff --git a/gui/kivy/uix/dialogs/bump_fee_dialog.py b/gui/kivy/uix/dialogs/bump_fee_dialog.py index a5c74cee..e27c9e54 100644 --- a/gui/kivy/uix/dialogs/bump_fee_dialog.py +++ b/gui/kivy/uix/dialogs/bump_fee_dialog.py @@ -3,7 +3,6 @@ from kivy.factory import Factory from kivy.properties import ObjectProperty from kivy.lang import Builder -from electrum.util import fee_levels from electrum_gui.kivy.i18n import _ Builder.load_string(''' @@ -29,7 +28,11 @@ Builder.load_string(''' text: _('New Fee') value: '' Label: - id: tooltip + id: tooltip1 + text: '' + size_hint_y: None + Label: + id: tooltip2 text: '' size_hint_y: None Slider: @@ -72,39 +75,39 @@ class BumpFeeDialog(Factory.Popup): self.tx_size = size self.callback = callback self.config = app.electrum_config - self.fee_step = self.config.max_fee_rate() / 10 - self.dynfees = self.config.get('dynamic_fees', True) and self.app.network + self.mempool = self.config.use_mempool_fees() + self.dynfees = self.config.is_dynfee() and self.app.network and self.config.has_dynamic_fees_ready() self.ids.old_fee.value = self.app.format_amount_and_units(self.init_fee) self.update_slider() self.update_text() def update_text(self): - value = int(self.ids.slider.value) - self.ids.new_fee.value = self.app.format_amount_and_units(self.get_fee()) - if self.dynfees: - value = int(self.ids.slider.value) - self.ids.tooltip.text = fee_levels[value] + fee = self.get_fee() + self.ids.new_fee.value = self.app.format_amount_and_units(fee) + pos = int(self.ids.slider.value) + fee_rate = self.get_fee_rate() + text, tooltip = self.config.get_fee_text(pos, self.dynfees, self.mempool, fee_rate) + self.ids.tooltip1.text = text + self.ids.tooltip2.text = tooltip def update_slider(self): slider = self.ids.slider + maxp, pos, fee_rate = self.config.get_fee_slider(self.dynfees, self.mempool) + slider.range = (0, maxp) + slider.step = 1 + slider.value = pos + + def get_fee_rate(self): + pos = int(self.ids.slider.value) if self.dynfees: - slider.range = (0, 4) - slider.step = 1 - slider.value = 3 + fee_rate = self.config.depth_to_fee(pos) if self.mempool else self.config.eta_to_fee(pos) else: - slider.range = (1, 10) - slider.step = 1 - rate = self.init_fee*1000//self.tx_size - slider.value = min( rate * 2 // self.fee_step, 10) + fee_rate = self.config.static_fee(pos) + return fee_rate def get_fee(self): - value = int(self.ids.slider.value) - if self.dynfees: - if self.config.has_fee_estimates(): - dynfee = self.config.dynfee(value) - return int(dynfee * self.tx_size // 1000) - else: - return int(value*self.fee_step * self.tx_size // 1000) + fee_rate = self.get_fee_rate() + return int(fee_rate * self.tx_size // 1000) def on_ok(self): new_fee = self.get_fee() diff --git a/gui/kivy/uix/dialogs/fee_dialog.py b/gui/kivy/uix/dialogs/fee_dialog.py index 792ce60d..2694a6b8 100644 --- a/gui/kivy/uix/dialogs/fee_dialog.py +++ b/gui/kivy/uix/dialogs/fee_dialog.py @@ -3,7 +3,6 @@ from kivy.factory import Factory from kivy.properties import ObjectProperty from kivy.lang import Builder -from electrum.util import fee_levels from electrum_gui.kivy.i18n import _ Builder.load_string(''' @@ -12,29 +11,46 @@ Builder.load_string(''' title: _('Transaction Fees') size_hint: 0.8, 0.8 pos_hint: {'top':0.9} + method: 0 BoxLayout: orientation: 'vertical' BoxLayout: orientation: 'horizontal' size_hint: 1, 0.5 Label: - id: fee_per_kb + text: _('Method') + ':' + Button: + text: _('Mempool') if root.method == 2 else _('ETA') if root.method == 1 else _('Static') + background_color: (0,0,0,0) + bold: True + on_release: + root.method = (root.method + 1) % 3 + root.update_slider() + root.update_text() + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: (_('Target') if root.method > 0 else _('Fee')) + ':' + Label: + id: fee_target text: '' Slider: id: slider range: 0, 4 step: 1 on_value: root.on_slider(self.value) + Widget: + size_hint: 1, 0.5 BoxLayout: orientation: 'horizontal' size_hint: 1, 0.5 - Label: - text: _('Dynamic Fees') - CheckBox: - id: dynfees - on_active: root.on_checkbox(self.active) + TopLabel: + id: fee_estimate + text: '' + font_size: '14dp' Widget: - size_hint: 1, 1 + size_hint: 1, 0.5 BoxLayout: orientation: 'horizontal' size_hint: 1, 0.5 @@ -60,53 +76,57 @@ class FeeDialog(Factory.Popup): self.config = config self.fee_rate = self.config.fee_per_kb() self.callback = callback - self.dynfees = self.config.get('dynamic_fees', True) - self.ids.dynfees.active = self.dynfees + mempool = self.config.use_mempool_fees() + dynfees = self.config.is_dynfee() + self.method = (2 if mempool else 1) if dynfees else 0 self.update_slider() self.update_text() def update_text(self): - value = int(self.ids.slider.value) - self.ids.fee_per_kb.text = self.get_fee_text(value) + pos = int(self.ids.slider.value) + dynfees, mempool = self.get_method() + if self.method == 2: + fee_rate = self.config.depth_to_fee(pos) + target, estimate = self.config.get_fee_text(pos, dynfees, mempool, fee_rate) + msg = 'In the current network conditions, in order to be positioned %s, a transaction will require a fee of %s.' % (target, estimate) + elif self.method == 1: + fee_rate = self.config.eta_to_fee(pos) + target, estimate = self.config.get_fee_text(pos, dynfees, mempool, fee_rate) + msg = 'In the last few days, transactions that confirmed %s usually paid a fee of at least %s.' % (target.lower(), estimate) + else: + fee_rate = self.config.static_fee(pos) + target, estimate = self.config.get_fee_text(pos, dynfees, True, fee_rate) + msg = 'In the current network conditions, a transaction paying %s would be positioned %s.' % (target, estimate) + + self.ids.fee_target.text = target + self.ids.fee_estimate.text = msg + + def get_method(self): + dynfees = self.method > 0 + mempool = self.method == 2 + return dynfees, mempool def update_slider(self): slider = self.ids.slider - if self.dynfees: - slider.range = (0, 4) - slider.step = 1 - slider.value = self.config.get('fee_level', 2) - else: - slider.range = (0, 9) - slider.step = 1 - slider.value = self.config.static_fee_index(self.fee_rate) - - def get_fee_text(self, value): - if self.ids.dynfees.active: - tooltip = fee_levels[value] - if self.config.has_fee_estimates(): - dynfee = self.config.dynfee(value) - tooltip += '\n' + (self.app.format_amount_and_units(dynfee)) + '/kB' - else: - fee_rate = self.config.static_fee(value) - tooltip = self.app.format_amount_and_units(fee_rate) + '/kB' - if self.config.has_fee_estimates(): - i = self.config.reverse_dynfee(fee_rate) - tooltip += '\n' + (_('low fee') if i < 0 else 'Within %d blocks'%i) - return tooltip + dynfees, mempool = self.get_method() + maxp, pos, fee_rate = self.config.get_fee_slider(dynfees, mempool) + slider.range = (0, maxp) + slider.step = 1 + slider.value = pos def on_ok(self): value = int(self.ids.slider.value) - self.config.set_key('dynamic_fees', self.dynfees, False) - if self.dynfees: - self.config.set_key('fee_level', value, True) + dynfees, mempool = self.get_method() + self.config.set_key('dynamic_fees', dynfees, False) + self.config.set_key('mempool_fees', mempool, False) + if dynfees: + if mempool: + self.config.set_key('depth_level', value, True) + else: + self.config.set_key('fee_level', value, True) else: self.config.set_key('fee_per_kb', self.config.static_fee(value), True) self.callback() def on_slider(self, value): self.update_text() - - def on_checkbox(self, b): - self.dynfees = b - self.update_slider() - self.update_text() diff --git a/gui/kivy/uix/dialogs/fx_dialog.py b/gui/kivy/uix/dialogs/fx_dialog.py index 51846792..35a34be9 100644 --- a/gui/kivy/uix/dialogs/fx_dialog.py +++ b/gui/kivy/uix/dialogs/fx_dialog.py @@ -106,4 +106,6 @@ class FxDialog(Factory.Popup): if ccy != self.fx.get_currency(): self.fx.set_currency(ccy) self.app.fiat_unit = ccy + else: + self.app.is_fiat = False Clock.schedule_once(lambda dt: self.add_exchanges()) diff --git a/gui/kivy/uix/dialogs/installwizard.py b/gui/kivy/uix/dialogs/installwizard.py index a8dade46..da76ef75 100644 --- a/gui/kivy/uix/dialogs/installwizard.py +++ b/gui/kivy/uix/dialogs/installwizard.py @@ -135,7 +135,7 @@ Builder.load_string(''' height: self.minimum_height Label: color: root.text_color - text: _('From %d cosigners')%n.value + text: _('From {} cosigners').format(n.value) Slider: id: n range: 2, 5 @@ -143,7 +143,7 @@ Builder.load_string(''' value: 2 Label: color: root.text_color - text: _('Require %d signatures')%m.value + text: _('Require {} signatures').format(m.value) Slider: id: m range: 1, n.value @@ -613,7 +613,7 @@ class RestoreSeedDialog(WizardDialog): for c in line.children: if isinstance(c, Button): if c.text in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': - c.disabled = (c.text.lower() not in p) and last_word + c.disabled = (c.text.lower() not in p) and bool(last_word) elif c.text == ' ': c.disabled = not enable_space @@ -807,7 +807,7 @@ class InstallWizard(BaseWizard, Widget): popup.init(message, callback) popup.open() - def request_password(self, run_next): + def request_password(self, run_next, force_disable_encrypt_cb=False): def callback(pin): if pin: self.run('confirm_password', pin, run_next) diff --git a/gui/kivy/uix/dialogs/settings.py b/gui/kivy/uix/dialogs/settings.py index 4a6e7518..bc72cb84 100644 --- a/gui/kivy/uix/dialogs/settings.py +++ b/gui/kivy/uix/dialogs/settings.py @@ -8,7 +8,6 @@ from electrum.i18n import languages from electrum_gui.kivy.i18n import _ from electrum.plugins import run_hook from electrum import coinchooser -from electrum.util import fee_levels from .choice_dialog import ChoiceDialog @@ -49,12 +48,6 @@ Builder.load_string(''' description: _("Base unit for Zclassic amounts.") action: partial(root.unit_dialog, self) CardSeparator - SettingsItem: - status: root.fee_status() - title: _('Fees') + ': ' + self.status - description: _("Fees paid to the Zclassic miners.") - action: partial(root.fee_dialog, self) - CardSeparator SettingsItem: status: root.fx_status() title: _('Fiat Currency') + ': ' + self.status @@ -113,7 +106,6 @@ class SettingsDialog(Factory.Popup): layout.bind(minimum_height=layout.setter('height')) # cached dialogs self._fx_dialog = None - self._fee_dialog = None self._proxy_dialog = None self._language_dialog = None self._unit_dialog = None @@ -204,18 +196,7 @@ class SettingsDialog(Factory.Popup): d.open() def fee_status(self): - if self.config.get('dynamic_fees', True): - return fee_levels[self.config.get('fee_level', 2)] - else: - return self.app.format_amount_and_units(self.config.fee_per_kb()) + '/kB' - - def fee_dialog(self, label, dt): - if self._fee_dialog is None: - from .fee_dialog import FeeDialog - def cb(): - label.status = self.fee_status() - self._fee_dialog = FeeDialog(self.app, self.config, cb) - self._fee_dialog.open() + return self.config.get_fee_status() def boolean_dialog(self, name, title, message, dt): from .checkbox_dialog import CheckBoxDialog diff --git a/gui/kivy/uix/dialogs/tx_dialog.py b/gui/kivy/uix/dialogs/tx_dialog.py index d5b87699..fbbdf0a2 100644 --- a/gui/kivy/uix/dialogs/tx_dialog.py +++ b/gui/kivy/uix/dialogs/tx_dialog.py @@ -20,6 +20,7 @@ Builder.load_string(''' can_rbf: False fee_str: '' date_str: '' + date_label:'' amount_str: '' tx_hash: '' status_str: '' @@ -46,7 +47,7 @@ Builder.load_string(''' text: _('Description') if root.description else '' value: root.description BoxLabel: - text: _('Date') if root.date_str else '' + text: root.date_label value: root.date_str BoxLabel: text: _('Amount sent') if root.is_mine else _('Amount received') @@ -110,10 +111,13 @@ class TxDialog(Factory.Popup): tx_hash, self.status_str, self.description, self.can_broadcast, self.can_rbf, amount, fee, height, conf, timestamp, exp_n = self.wallet.get_tx_info(self.tx) self.tx_hash = tx_hash or '' if timestamp: + self.date_label = _('Date') self.date_str = datetime.fromtimestamp(timestamp).isoformat(' ')[:-3] elif exp_n: - self.date_str = _('Within %d blocks') % exp_n if exp_n > 0 else _('unknown (low fee)') + self.date_label = _('Mempool depth') + self.date_str = _('{} from tip').format('%.2f MB'%(exp_n/1000000)) else: + self.date_label = '' self.date_str = '' if amount is None: diff --git a/gui/kivy/uix/screens.py b/gui/kivy/uix/screens.py index aa6bc687..0ce88050 100644 --- a/gui/kivy/uix/screens.py +++ b/gui/kivy/uix/screens.py @@ -87,9 +87,9 @@ class CScreen(Factory.Screen): self.add_widget(self.context_menu) +# note: this list needs to be kept in sync with another in qt TX_ICONS = [ - "close", - "close", + "unconfirmed", "close", "unconfirmed", "close", @@ -143,14 +143,13 @@ class HistoryScreen(CScreen): ri.icon = icon ri.date = status_str ri.message = label - ri.value = value or 0 - ri.amount = self.app.format_amount(value, True) if value is not None else '--' ri.confirmations = conf - if self.app.fiat_unit and date: - rate = self.app.fx.history_rate(date) - if rate: - s = self.app.fx.value_str(value, rate) - ri.quote_text = '' if s is None else s + ' ' + self.app.fiat_unit + if value is not None: + ri.is_mine = value < 0 + if value < 0: value = - value + ri.amount = self.app.format_amount_and_units(value) + if self.app.fiat_unit and date: + ri.quote_text = self.app.fx.historical_value_str(value, date) + ' ' + self.app.fx.ccy return ri def update(self, see_all=False): @@ -162,13 +161,8 @@ class HistoryScreen(CScreen): count = 0 for item in history: ri = self.get_card(*item) - count += 1 history_card.add_widget(ri) - if count == 0: - msg = _('This screen shows your list of transactions. It is currently empty.') - history_card.add_widget(EmptyLabel(text=msg)) - class SendScreen(CScreen): @@ -379,6 +373,8 @@ class ReceiveScreen(CScreen): def save_request(self): addr = self.screen.address + if not addr: + return amount = self.screen.amount message = self.screen.message amount = self.app.get_amount(amount) if amount else 0 @@ -521,7 +517,12 @@ class AddressScreen(CScreen): def update(self): self.menu_actions = [('Receive', self.do_show), ('Details', self.do_view)] wallet = self.app.wallet - _list = wallet.get_change_addresses() if self.screen.show_change else wallet.get_receiving_addresses() + if self.screen.show_change == 0: + _list = wallet.get_receiving_addresses() + elif self.screen.show_change == 1: + _list = wallet.get_change_addresses() + else: + _list = wallet.get_addresses() search = self.screen.message container = self.screen.ids.search_container container.clear_widgets() diff --git a/gui/kivy/uix/ui_screens/address.kv b/gui/kivy/uix/ui_screens/address.kv index 3d594c9c..07bb4367 100644 --- a/gui/kivy/uix/ui_screens/address.kv +++ b/gui/kivy/uix/ui_screens/address.kv @@ -24,33 +24,18 @@ shorten: True Widget AddressLabel: - text: root.memo + text: (root.amount if root.status == 'Funded' else root.status) + ' ' + root.memo color: .699, .699, .699, 1 font_size: '13sp' shorten: True Widget - BoxLayout: - spacing: '8dp' - height: '32dp' - orientation: 'vertical' - Widget - AddressLabel: - text: root.amount - halign: 'right' - font_size: '15sp' - Widget - AddressLabel: - text: root.status - halign: 'right' - font_size: '13sp' - color: .699, .699, .699, 1 AddressScreen: id: addr_screen name: 'address' message: '' pr_status: 'Pending' - show_change: False + show_change: 0 show_used: 0 on_message: self.parent.update() @@ -70,9 +55,9 @@ AddressScreen: spacing: '5dp' AddressButton: id: search - text: _('Change') if root.show_change else _('Receiving') + text: {0:_('Receiving'), 1:_('Change'), 2:_('All')}[root.show_change] on_release: - root.show_change = not root.show_change + root.show_change = (root.show_change + 1) % 3 Clock.schedule_once(lambda dt: app.address_screen.update()) AddressFilter: opacity: 1 @@ -103,4 +88,3 @@ AddressScreen: id: search_container size_hint_y: None height: self.minimum_height - spacing: '2dp' diff --git a/gui/kivy/uix/ui_screens/history.kv b/gui/kivy/uix/ui_screens/history.kv index ad06c5ea..3b454080 100644 --- a/gui/kivy/uix/ui_screens/history.kv +++ b/gui/kivy/uix/ui_screens/history.kv @@ -19,56 +19,57 @@ icon: 'atlas://gui/kivy/theming/light/important' message: '' - value: 0 + is_mine: True amount: '--' - amount_color: '#FF6657' if self.value < 0 else '#2EA442' + action: _('Sent') if self.is_mine else _('Received') + amount_color: '#FF6657' if self.is_mine else '#2EA442' confirmations: 0 date: '' quote_text: '' - spacing: '9dp' Image: id: icon source: root.icon size_hint: None, 1 - width: self.height *.54 + allow_stretch: True + width: self.height*1.5 mipmap: True BoxLayout: orientation: 'vertical' Widget CardLabel: - text: root.date - font_size: '14sp' + text: + u'[color={color}]{s}[/color]'.format(s='<<' if root.is_mine else '>>', color=root.amount_color)\ + + ' ' + root.action + ' ' + (root.quote_text if app.is_fiat else root.amount) + font_size: '15sp' CardLabel: color: .699, .699, .699, 1 - font_size: '13sp' + font_size: '14sp' shorten: True - text: root.message + text: root.date + ' ' + root.message Widget - CardLabel: - halign: 'right' - font_size: '15sp' - size_hint: None, 1 - width: '110sp' - markup: True - font_name: font_light - text: - u'[color={amount_color}]{sign}{amount} {unit}[/color]\n'\ - u'[color=#B2B3B3][size=13sp]{qt}[/size]'\ - u'[/color]'.format(amount_color=root.amount_color,\ - amount=root.amount[1:], qt=root.quote_text, sign=root.amount[0],\ - unit=app.base_unit) + HistoryScreen: name: 'history' content: content - ScrollView: - id: content - do_scroll_x: False - GridLayout - id: history_container - cols: 1 - size_hint: 1, None - height: self.minimum_height - padding: '12dp' - spacing: '2dp' + BoxLayout: + orientation: 'vertical' + Button: + background_color: 0, 0, 0, 0 + text: app.fiat_balance if app.is_fiat else app.balance + markup: True + color: .9, .9, .9, 1 + font_size: '30dp' + bold: True + size_hint: 1, 0.25 + on_release: app.is_fiat = not app.is_fiat if app.fx.is_enabled() else False + ScrollView: + id: content + do_scroll_x: False + size_hint: 1, 0.75 + GridLayout + id: history_container + cols: 1 + size_hint: 1, None + height: self.minimum_height diff --git a/gui/kivy/uix/ui_screens/network.kv b/gui/kivy/uix/ui_screens/network.kv index 71e18ab4..f499618a 100644 --- a/gui/kivy/uix/ui_screens/network.kv +++ b/gui/kivy/uix/ui_screens/network.kv @@ -11,7 +11,7 @@ Popup: height: self.minimum_height padding: '10dp' SettingsItem: - value: _("%d connections.")% app.num_nodes if app.num_nodes else _("Not connected") + value: _("{} connections.").format(app.num_nodes) if app.num_nodes else _("Not connected") title: _("Status") + ': ' + self.value description: _("Connections with Electrum servers") action: lambda x: None @@ -46,7 +46,7 @@ Popup: CardSeparator SettingsItem: - title: _('Fork detected at block %d')%app.blockchain_checkpoint if app.num_chains>1 else _('No fork detected') + title: _('Fork detected at block {}').format(app.blockchain_checkpoint) if app.num_chains>1 else _('No fork detected') fork_description: (_('You are following branch') if app.auto_connect else _("Your server is on branch")) + ' ' + app.blockchain_name description: self.fork_description if app.num_chains>1 else _('Connected nodes are on the same chain') action: app.choose_blockchain_dialog diff --git a/gui/kivy/uix/ui_screens/send.kv b/gui/kivy/uix/ui_screens/send.kv index 8aa70c07..f2a361e3 100644 --- a/gui/kivy/uix/ui_screens/send.kv +++ b/gui/kivy/uix/ui_screens/send.kv @@ -71,6 +71,24 @@ SendScreen: text: s.message if s.message else (_('No Description') if root.is_pr else _('Description')) disabled: root.is_pr on_release: Clock.schedule_once(lambda dt: app.description_dialog(s)) + CardSeparator: + opacity: int(not root.is_pr) + color: blue_bottom.foreground_color + BoxLayout: + size_hint: 1, None + height: blue_bottom.item_height + spacing: '5dp' + Image: + source: 'atlas://gui/kivy/theming/light/star_big_inactive' + opacity: 0.7 + size_hint: None, None + size: '22dp', '22dp' + pos_hint: {'center_y': .5} + BlueButton: + id: fee_e + default_text: _('Fee') + text: app.fee_status + on_release: Clock.schedule_once(lambda dt: app.fee_dialog(s, True)) BoxLayout: size_hint: 1, None height: '48dp' diff --git a/gui/qt/__init__.py b/gui/qt/__init__.py index 16d07948..0879208f 100644 --- a/gui/qt/__init__.py +++ b/gui/qt/__init__.py @@ -25,6 +25,7 @@ import signal import sys +import traceback try: @@ -94,6 +95,8 @@ class ElectrumGui: QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_X11InitThreads) if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"): QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) + if hasattr(QGuiApplication, 'setDesktopFileName'): + QGuiApplication.setDesktopFileName('electrum.desktop') self.config = config self.daemon = daemon self.plugins = plugins @@ -190,8 +193,10 @@ class ElectrumGui: else: try: wallet = self.daemon.load_wallet(path, None) - except BaseException as e: - d = QMessageBox(QMessageBox.Warning, _('Error'), 'Cannot load wallet:\n' + str(e)) + except BaseException as e: + traceback.print_exc(file=sys.stdout) + d = QMessageBox(QMessageBox.Warning, _('Error'), + _('Cannot load wallet:') + '\n' + str(e)) d.exec_() return if not wallet: @@ -208,7 +213,14 @@ class ElectrumGui: return wallet.start_threads(self.daemon.network) self.daemon.add_wallet(wallet) - w = self.create_window_for_wallet(wallet) + try: + w = self.create_window_for_wallet(wallet) + except BaseException as e: + traceback.print_exc(file=sys.stdout) + d = QMessageBox(QMessageBox.Warning, _('Error'), + _('Cannot create window for wallet:') + '\n' + str(e)) + d.exec_() + return if uri: w.pay_to_URI(uri) w.bring_to_top() @@ -241,8 +253,7 @@ class ElectrumGui: return except GoBack: return - except: - import traceback + except BaseException as e: traceback.print_exc(file=sys.stdout) return self.timer.start() diff --git a/gui/qt/address_list.py b/gui/qt/address_list.py index b78793ec..104f367a 100644 --- a/gui/qt/address_list.py +++ b/gui/qt/address_list.py @@ -24,47 +24,56 @@ # SOFTWARE. import webbrowser -from .util import * from electrum.i18n import _ from electrum.util import block_explorer_URL from electrum.plugins import run_hook from electrum.bitcoin import is_address +from .util import * + class AddressList(MyTreeWidget): - filter_columns = [0, 1, 2] # Address, Label, Balance + filter_columns = [0, 1, 2, 3] # Type, Address, Label, Balance def __init__(self, parent=None): - MyTreeWidget.__init__(self, parent, self.create_menu, [], 1) + MyTreeWidget.__init__(self, parent, self.create_menu, [], 2) self.refresh_headers() self.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.show_change = False + self.setSortingEnabled(True) + self.show_change = 0 self.show_used = 0 self.change_button = QComboBox(self) self.change_button.currentIndexChanged.connect(self.toggle_change) - for t in [_('Receiving'), _('Change')]: + for t in [_('All'), _('Receiving'), _('Change')]: self.change_button.addItem(t) self.used_button = QComboBox(self) self.used_button.currentIndexChanged.connect(self.toggle_used) for t in [_('All'), _('Unused'), _('Funded'), _('Used')]: self.used_button.addItem(t) - def get_list_header(self): - return QLabel(_("Filter ")), self.change_button, self.used_button + def get_toolbar_buttons(self): + return QLabel(_("Filter:")), self.change_button, self.used_button + + def on_hide_toolbar(self): + self.show_change = 0 + self.show_used = 0 + self.update() + + def save_toolbar_state(self, state, config): + config.set_key('show_toolbar_addresses', state) def refresh_headers(self): - headers = [ _('Address'), _('Label'), _('Balance')] + headers = [_('Type'), _('Address'), _('Label'), _('Balance')] fx = self.parent.fx if fx and fx.get_fiat_address_config(): headers.extend([_(fx.get_currency()+' Balance')]) headers.extend([_('Tx')]) self.update_headers(headers) - def toggle_change(self, show): - show = bool(show) - if show == self.show_change: + def toggle_change(self, state): + if state == self.show_change: return - self.show_change = show + self.show_change = state self.update() def toggle_used(self, state): @@ -77,10 +86,15 @@ class AddressList(MyTreeWidget): self.wallet = self.parent.wallet item = self.currentItem() current_address = item.data(0, Qt.UserRole) if item else None - addr_list = self.wallet.get_change_addresses() if self.show_change else self.wallet.get_receiving_addresses() + if self.show_change == 1: + addr_list = self.wallet.get_receiving_addresses() + elif self.show_change == 2: + addr_list = self.wallet.get_change_addresses() + else: + addr_list = self.wallet.get_addresses() self.clear() for address in addr_list: - num = len(self.wallet.history.get(address,[])) + num = len(self.wallet.get_address_history(address)) is_used = self.wallet.is_used(address) label = self.wallet.labels.get(address, '') c, u, x = self.wallet.get_addr_balance(address) @@ -91,23 +105,29 @@ class AddressList(MyTreeWidget): continue if self.show_used == 3 and not is_used: continue - balance_text = self.parent.format_amount(balance) + balance_text = self.parent.format_amount(balance, whitespaces=True) fx = self.parent.fx if fx and fx.get_fiat_address_config(): rate = fx.exchange_rate() fiat_balance = fx.value_str(balance, rate) - address_item = QTreeWidgetItem([address, label, balance_text, fiat_balance, "%d"%num]) - address_item.setTextAlignment(3, Qt.AlignRight) + address_item = SortableTreeWidgetItem(['', address, label, balance_text, fiat_balance, "%d"%num]) + address_item.setTextAlignment(4, Qt.AlignRight) + address_item.setFont(4, QFont(MONOSPACE_FONT)) else: - address_item = QTreeWidgetItem([address, label, balance_text, "%d"%num]) - address_item.setTextAlignment(2, Qt.AlignRight) - address_item.setFont(0, QFont(MONOSPACE_FONT)) - address_item.setData(0, Qt.UserRole, address) - address_item.setData(0, Qt.UserRole+1, True) # label can be edited + address_item = SortableTreeWidgetItem(['', address, label, balance_text, "%d"%num]) + address_item.setFont(3, QFont(MONOSPACE_FONT)) + if self.wallet.is_change(address): + address_item.setText(0, _('change')) + address_item.setBackground(0, ColorScheme.YELLOW.as_color(True)) + else: + address_item.setText(0, _('receiving')) + address_item.setBackground(0, ColorScheme.GREEN.as_color(True)) + address_item.setFont(1, QFont(MONOSPACE_FONT)) + address_item.setData(1, Qt.UserRole, address) if self.wallet.is_frozen(address): - address_item.setBackground(0, ColorScheme.BLUE.as_color(True)) - if self.wallet.is_beyond_limit(address, self.show_change): - address_item.setBackground(0, ColorScheme.RED.as_color(True)) + address_item.setBackground(1, ColorScheme.BLUE.as_color(True)) + if self.wallet.is_beyond_limit(address): + address_item.setBackground(1, ColorScheme.RED.as_color(True)) self.addChild(address_item) if address == current_address: self.setCurrentItem(address_item) @@ -118,7 +138,7 @@ class AddressList(MyTreeWidget): can_delete = self.wallet.can_delete_address() selected = self.selectedItems() multi_select = len(selected) > 1 - addrs = [item.text(0) for item in selected] + addrs = [item.text(1) for item in selected] if not addrs: return if not multi_select: @@ -135,10 +155,10 @@ class AddressList(MyTreeWidget): if not multi_select: column_title = self.headerItem().text(col) copy_text = item.text(col) - menu.addAction(_("Copy %s")%column_title, lambda: self.parent.app.clipboard().setText(copy_text)) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(copy_text)) menu.addAction(_('Details'), lambda: self.parent.show_address(addr)) if col in self.editable_columns: - menu.addAction(_("Edit %s")%column_title, lambda: self.editItem(item, col)) + menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, col)) menu.addAction(_("Request payment"), lambda: self.parent.receive_at(addr)) if self.wallet.can_export(): menu.addAction(_("Private key"), lambda: self.parent.show_private_key(addr)) diff --git a/gui/qt/amountedit.py b/gui/qt/amountedit.py index 9ffbd4b4..91326f08 100644 --- a/gui/qt/amountedit.py +++ b/gui/qt/amountedit.py @@ -106,12 +106,7 @@ class BTCAmountEdit(AmountEdit): class FeerateEdit(BTCAmountEdit): def _base_unit(self): - p = self.decimal_point() - if p == 2: - return 'mZCL/kB' - if p == 0: - return 'zat/byte' - raise Exception('Unknown base unit') + return 'zat/byte' def get_amount(self): sat_per_byte_amount = BTCAmountEdit.get_amount(self) diff --git a/gui/qt/console.py b/gui/qt/console.py index 5016e451..bde05a3d 100644 --- a/gui/qt/console.py +++ b/gui/qt/console.py @@ -203,7 +203,8 @@ class Console(QtWidgets.QPlainTextEdit): self.skip = not self.skip if type(self.namespace.get(command)) == type(lambda:None): - self.appendPlainText("'%s' is a function. Type '%s()' to use it in the Python console."%(command, command)) + self.appendPlainText("'{}' is a function. Type '{}()' to use it in the Python console." + .format(command, command)) self.newPrompt() return diff --git a/gui/qt/contact_list.py b/gui/qt/contact_list.py index 8c1b3aa7..27c9efb5 100644 --- a/gui/qt/contact_list.py +++ b/gui/qt/contact_list.py @@ -32,7 +32,7 @@ from PyQt5.QtGui import * from PyQt5.QtCore import * from PyQt5.QtWidgets import ( QAbstractItemView, QFileDialog, QMenu, QTreeWidgetItem) -from .util import MyTreeWidget +from .util import MyTreeWidget, import_meta_gui, export_meta_gui class ContactList(MyTreeWidget): @@ -53,12 +53,10 @@ class ContactList(MyTreeWidget): self.parent.set_contact(item.text(0), item.text(1)) def import_contacts(self): - wallet_folder = self.parent.get_wallet_folder() - filename, __ = QFileDialog.getOpenFileName(self.parent, "Select your wallet file", wallet_folder) - if not filename: - return - self.parent.contacts.import_file(filename) - self.on_update() + import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.on_update) + + def export_contacts(self): + export_meta_gui(self.parent, _('contacts'), self.parent.contacts.export_file) def create_menu(self, position): menu = QMenu() @@ -66,16 +64,17 @@ class ContactList(MyTreeWidget): if not selected: menu.addAction(_("New contact"), lambda: self.parent.new_contact_dialog()) menu.addAction(_("Import file"), lambda: self.import_contacts()) + menu.addAction(_("Export file"), lambda: self.export_contacts()) else: names = [item.text(0) for item in selected] keys = [item.text(1) for item in selected] column = self.currentColumn() column_title = self.headerItem().text(column) column_data = '\n'.join([item.text(column) for item in selected]) - menu.addAction(_("Copy %s")%column_title, lambda: self.parent.app.clipboard().setText(column_data)) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) if column in self.editable_columns: item = self.currentItem() - menu.addAction(_("Edit %s")%column_title, lambda: self.editItem(item, column)) + menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, column)) menu.addAction(_("Pay to"), lambda: self.parent.payto_contacts(keys)) menu.addAction(_("Delete"), lambda: self.parent.delete_contacts(keys)) URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, keys)] diff --git a/gui/qt/exception_window.py b/gui/qt/exception_window.py new file mode 100644 index 00000000..fa291283 --- /dev/null +++ b/gui/qt/exception_window.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import json +import locale +import platform +import traceback +import os +import sys +import subprocess + +import requests +from PyQt5.QtCore import QObject +import PyQt5.QtCore as QtCore +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import * + +from electrum.i18n import _ +from electrum import ELECTRUM_VERSION, bitcoin, constants + +issue_template = """

Traceback

+
+{traceback}
+
+ +

Additional information

+ +""" +report_server = "https://crashhub.electrum.org/crash" + + +class Exception_Window(QWidget): + _active_window = None + + def __init__(self, main_window, exctype, value, tb): + self.exc_args = (exctype, value, tb) + self.main_window = main_window + QWidget.__init__(self) + self.setWindowTitle('Electrum - ' + _('An Error Occured')) + self.setMinimumSize(600, 300) + + main_box = QVBoxLayout() + + heading = QLabel('

' + _('Sorry!') + '

') + main_box.addWidget(heading) + main_box.addWidget(QLabel(_('Something went wrong while executing Electrum.'))) + + main_box.addWidget(QLabel( + _('To help us diagnose and fix the problem, you can send us a bug report that contains useful debug ' + 'information:'))) + + collapse_info = QPushButton(_("Show report contents")) + collapse_info.clicked.connect(lambda: QMessageBox.about(self, "Report contents", self.get_report_string())) + main_box.addWidget(collapse_info) + + main_box.addWidget(QLabel(_("Please briefly describe what led to the error (optional):"))) + + self.description_textfield = QTextEdit() + self.description_textfield.setFixedHeight(50) + main_box.addWidget(self.description_textfield) + + main_box.addWidget(QLabel(_("Do you want to send this report?"))) + + buttons = QHBoxLayout() + + report_button = QPushButton(_('Send Bug Report')) + report_button.clicked.connect(self.send_report) + report_button.setIcon(QIcon(":icons/tab_send.png")) + buttons.addWidget(report_button) + + never_button = QPushButton(_('Never')) + never_button.clicked.connect(self.show_never) + buttons.addWidget(never_button) + + close_button = QPushButton(_('Not Now')) + close_button.clicked.connect(self.close) + buttons.addWidget(close_button) + + main_box.addLayout(buttons) + + self.setLayout(main_box) + self.show() + + def send_report(self): + if constants.net.GENESIS[-4:] not in ["4943", "e26f"] and ".electrum.org" in report_server: + # Gah! Some kind of altcoin wants to send us crash reports. + self.main_window.show_critical(_("Please report this issue manually.")) + return + report = self.get_traceback_info() + report.update(self.get_additional_info()) + report = json.dumps(report) + try: + response = requests.post(report_server, data=report, timeout=20) + except BaseException as e: + traceback.print_exc(file=sys.stderr) + self.main_window.show_critical(_('There was a problem with the automatic reporting:') + '\n' + + str(e) + '\n' + + _("Please report this issue manually.")) + return + else: + QMessageBox.about(self, "Crash report", response.text) + self.close() + + def on_close(self): + Exception_Window._active_window = None + sys.__excepthook__(*self.exc_args) + self.close() + + def show_never(self): + self.main_window.config.set_key("show_crash_reporter", False) + self.close() + + def closeEvent(self, event): + self.on_close() + event.accept() + + def get_traceback_info(self): + exc_string = str(self.exc_args[1]) + stack = traceback.extract_tb(self.exc_args[2]) + readable_trace = "".join(traceback.format_list(stack)) + id = { + "file": stack[-1].filename, + "name": stack[-1].name, + "type": self.exc_args[0].__name__ + } + return { + "exc_string": exc_string, + "stack": readable_trace, + "id": id + } + + def get_additional_info(self): + args = { + "app_version": ELECTRUM_VERSION, + "os": platform.platform(), + "wallet_type": "unknown", + "locale": locale.getdefaultlocale()[0], + "description": self.description_textfield.toPlainText() + } + try: + args["wallet_type"] = self.main_window.wallet.wallet_type + except: + # Maybe the wallet isn't loaded yet + pass + try: + args["app_version"] = self.get_git_version() + except: + # This is probably not running from source + pass + return args + + def get_report_string(self): + info = self.get_additional_info() + info["traceback"] = "".join(traceback.format_exception(*self.exc_args)) + return issue_template.format(**info) + + @staticmethod + def get_git_version(): + dir = os.path.dirname(os.path.realpath(sys.argv[0])) + version = subprocess.check_output(['git', 'describe', '--always'], cwd=dir) + return str(version, "utf8").strip() + + +def _show_window(*args): + if not Exception_Window._active_window: + Exception_Window._active_window = Exception_Window(*args) + + +class Exception_Hook(QObject): + _report_exception = QtCore.pyqtSignal(object, object, object, object) + + def __init__(self, main_window, *args, **kwargs): + super(Exception_Hook, self).__init__(*args, **kwargs) + if not main_window.config.get("show_crash_reporter", default=True): + return + self.main_window = main_window + sys.excepthook = self.handler + self._report_exception.connect(_show_window) + + def handler(self, *args): + self._report_exception.emit(self.main_window, *args) diff --git a/gui/qt/fee_slider.py b/gui/qt/fee_slider.py index c8b6637f..04911d87 100644 --- a/gui/qt/fee_slider.py +++ b/gui/qt/fee_slider.py @@ -1,6 +1,4 @@ - from electrum.i18n import _ - from PyQt5.QtGui import * from PyQt5.QtCore import * from PyQt5.QtWidgets import QSlider, QToolTip @@ -18,47 +16,43 @@ class FeeSlider(QSlider): self.lock = threading.RLock() self.update() self.valueChanged.connect(self.moved) + self._active = True def moved(self, pos): with self.lock: - fee_rate = self.config.dynfee(pos) if self.dyn else self.config.static_fee(pos) + if self.dyn: + fee_rate = self.config.depth_to_fee(pos) if self.config.use_mempool_fees() else self.config.eta_to_fee(pos) + else: + fee_rate = self.config.static_fee(pos) tooltip = self.get_tooltip(pos, fee_rate) QToolTip.showText(QCursor.pos(), tooltip, self) self.setToolTip(tooltip) self.callback(self.dyn, pos, fee_rate) def get_tooltip(self, pos, fee_rate): - from electrum.util import fee_levels - rate_str = self.window.format_fee_rate(fee_rate) if fee_rate else _('unknown') + mempool = self.config.use_mempool_fees() + target, estimate = self.config.get_fee_text(pos, self.dyn, mempool, fee_rate) if self.dyn: - tooltip = fee_levels[pos] + '\n' + rate_str + return _('Target') + ': ' + target + '\n' + _('Current rate') + ': ' + estimate else: - tooltip = 'Fixed rate: ' + rate_str - if self.config.has_fee_estimates(): - i = self.config.reverse_dynfee(fee_rate) - tooltip += '\n' + (_('Low fee') if i < 0 else 'Within %d blocks'%i) - return tooltip + return _('Fixed rate') + ': ' + target + '\n' + _('Estimate') + ': ' + estimate def update(self): with self.lock: self.dyn = self.config.is_dynfee() - if self.dyn: - pos = self.config.get('fee_level', 2) - fee_rate = self.config.dynfee(pos) - self.setRange(0, 4) - self.setValue(pos) - else: - fee_rate = self.config.fee_per_kb() - pos = self.config.static_fee_index(fee_rate) - self.setRange(0, 9) - self.setValue(pos) + mempool = self.config.use_mempool_fees() + maxp, pos, fee_rate = self.config.get_fee_slider(self.dyn, mempool) + self.setRange(0, maxp) + self.setValue(pos) tooltip = self.get_tooltip(pos, fee_rate) self.setToolTip(tooltip) def activate(self): + self._active = True self.setStyleSheet('') def deactivate(self): + self._active = False # TODO it would be nice to find a platform-independent solution # that makes the slider look as if it was disabled self.setStyleSheet( @@ -79,3 +73,6 @@ class FeeSlider(QSlider): } """ ) + + def is_active(self): + return self._active diff --git a/gui/qt/history_list.py b/gui/qt/history_list.py index 6e17037b..3140af00 100644 --- a/gui/qt/history_list.py +++ b/gui/qt/history_list.py @@ -24,19 +24,24 @@ # SOFTWARE. import webbrowser +import datetime +from electrum.wallet import AddTransactionException, TX_HEIGHT_LOCAL from .util import * from electrum.i18n import _ -from electrum.util import block_explorer_URL -from electrum.util import timestamp_to_datetime, profiler +from electrum.util import block_explorer_URL, profiler +try: + from electrum.plot import plot_history, NothingToPlotException +except: + plot_history = None +# note: this list needs to be kept in sync with another in kivy TX_ICONS = [ - "warning.png", - "warning.png", + "unconfirmed.png", "warning.png", "unconfirmed.png", - "unconfirmed.png", + "offline_tx.png", "clock1.png", "clock2.png", "clock3.png", @@ -46,51 +51,204 @@ TX_ICONS = [ ] -class HistoryList(MyTreeWidget): +class HistoryList(MyTreeWidget, AcceptFileDragDrop): filter_columns = [2, 3, 4] # Date, Description, Amount def __init__(self, parent=None): MyTreeWidget.__init__(self, parent, self.create_menu, [], 3) + AcceptFileDragDrop.__init__(self, ".txn") self.refresh_headers() self.setColumnHidden(1, True) + self.setSortingEnabled(True) + self.sortByColumn(0, Qt.AscendingOrder) + self.start_timestamp = None + self.end_timestamp = None + self.years = [] + self.create_toolbar_buttons() + + def format_date(self, d): + return str(datetime.date(d.year, d.month, d.day)) if d else _('None') def refresh_headers(self): - headers = ['', '', _('Date'), _('Description') , _('Amount'), _('Balance')] + headers = ['', '', _('Date'), _('Description'), _('Amount'), _('Balance')] fx = self.parent.fx if fx and fx.show_history(): - headers.extend(['%s '%fx.ccy + _('Amount'), '%s '%fx.ccy + _('Balance')]) + headers.extend(['%s '%fx.ccy + _('Value')]) + self.editable_columns |= {6} + if fx.get_history_capital_gains_config(): + headers.extend(['%s '%fx.ccy + _('Acquisition price')]) + headers.extend(['%s '%fx.ccy + _('Capital Gains')]) + else: + self.editable_columns -= {6} self.update_headers(headers) def get_domain(self): '''Replaced in address_dialog.py''' return self.wallet.get_addresses() + def on_combo(self, x): + s = self.period_combo.itemText(x) + x = s == _('Custom') + self.start_button.setEnabled(x) + self.end_button.setEnabled(x) + if s == _('All'): + self.start_timestamp = None + self.end_timestamp = None + self.start_button.setText("-") + self.end_button.setText("-") + else: + try: + year = int(s) + except: + return + start_date = datetime.datetime(year, 1, 1) + end_date = datetime.datetime(year+1, 1, 1) + self.start_timestamp = time.mktime(start_date.timetuple()) + self.end_timestamp = time.mktime(end_date.timetuple()) + self.start_button.setText(_('From') + ' ' + self.format_date(start_date)) + self.end_button.setText(_('To') + ' ' + self.format_date(end_date)) + self.update() + + def create_toolbar_buttons(self): + self.period_combo = QComboBox() + self.start_button = QPushButton('-') + self.start_button.pressed.connect(self.select_start_date) + self.start_button.setEnabled(False) + self.end_button = QPushButton('-') + self.end_button.pressed.connect(self.select_end_date) + self.end_button.setEnabled(False) + self.period_combo.addItems([_('All'), _('Custom')]) + self.period_combo.activated.connect(self.on_combo) + + def get_toolbar_buttons(self): + return self.period_combo, self.start_button, self.end_button + + def on_hide_toolbar(self): + self.start_timestamp = None + self.end_timestamp = None + self.update() + + def save_toolbar_state(self, state, config): + config.set_key('show_toolbar_history', state) + + def select_start_date(self): + self.start_timestamp = self.select_date(self.start_button) + self.update() + + def select_end_date(self): + self.end_timestamp = self.select_date(self.end_button) + self.update() + + def select_date(self, button): + d = WindowModalDialog(self, _("Select date")) + d.setMinimumSize(600, 150) + d.date = None + vbox = QVBoxLayout() + def on_date(date): + d.date = date + cal = QCalendarWidget() + cal.setGridVisible(True) + cal.clicked[QDate].connect(on_date) + vbox.addWidget(cal) + vbox.addLayout(Buttons(OkButton(d), CancelButton(d))) + d.setLayout(vbox) + if d.exec_(): + if d.date is None: + return None + date = d.date.toPyDate() + button.setText(self.format_date(date)) + return time.mktime(date.timetuple()) + + def show_summary(self): + h = self.summary + start_date = h.get('start_date') + end_date = h.get('end_date') + format_amount = lambda x: self.parent.format_amount(x.value) + ' ' + self.parent.base_unit() + d = WindowModalDialog(self, _("Summary")) + d.setMinimumSize(600, 150) + vbox = QVBoxLayout() + grid = QGridLayout() + grid.addWidget(QLabel(_("Start")), 0, 0) + grid.addWidget(QLabel(self.format_date(start_date)), 0, 1) + grid.addWidget(QLabel(_("End")), 1, 0) + grid.addWidget(QLabel(self.format_date(end_date)), 1, 1) + grid.addWidget(QLabel(_("Initial balance")), 2, 0) + grid.addWidget(QLabel(format_amount(h['start_balance'])), 2, 1) + grid.addWidget(QLabel(str(h.get('start_fiat_balance'))), 2, 2) + grid.addWidget(QLabel(_("Final balance")), 4, 0) + grid.addWidget(QLabel(format_amount(h['end_balance'])), 4, 1) + grid.addWidget(QLabel(str(h.get('end_fiat_balance'))), 4, 2) + grid.addWidget(QLabel(_("Income")), 5, 0) + grid.addWidget(QLabel(format_amount(h.get('income'))), 5, 1) + grid.addWidget(QLabel(str(h.get('fiat_income'))), 5, 2) + grid.addWidget(QLabel(_("Expenditures")), 6, 0) + grid.addWidget(QLabel(format_amount(h.get('expenditures'))), 6, 1) + grid.addWidget(QLabel(str(h.get('fiat_expenditures'))), 6, 2) + grid.addWidget(QLabel(_("Capital gains")), 7, 0) + grid.addWidget(QLabel(str(h.get('capital_gains'))), 7, 2) + grid.addWidget(QLabel(_("Unrealized gains")), 8, 0) + grid.addWidget(QLabel(str(h.get('unrealized_gains', ''))), 8, 2) + vbox.addLayout(grid) + vbox.addLayout(Buttons(CloseButton(d))) + d.setLayout(vbox) + d.exec_() + + def plot_history_dialog(self): + if plot_history is None: + self.parent.show_message( + _("Can't plot history.") + '\n' + + _("Perhaps some dependencies are missing...") + " (matplotlib?)") + return + try: + plt = plot_history(self.transactions) + plt.show() + except NothingToPlotException as e: + self.parent.show_message(str(e)) + @profiler def on_update(self): self.wallet = self.parent.wallet - h = self.wallet.get_history(self.get_domain()) + fx = self.parent.fx + r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=self.start_timestamp, to_timestamp=self.end_timestamp, fx=fx) + self.transactions = r['transactions'] + self.summary = r['summary'] + if not self.years and self.transactions: + from datetime import date + start_date = self.transactions[0].get('date') or date.today() + end_date = self.transactions[-1].get('date') or date.today() + self.years = [str(i) for i in range(start_date.year, end_date.year + 1)] + self.period_combo.insertItems(1, self.years) item = self.currentItem() current_tx = item.data(0, Qt.UserRole) if item else None self.clear() - fx = self.parent.fx if fx: fx.history_used_spot = False - for h_item in h: - tx_hash, height, conf, timestamp, value, balance = h_item + for tx_item in self.transactions: + tx_hash = tx_item['txid'] + height = tx_item['height'] + conf = tx_item['confirmations'] + timestamp = tx_item['timestamp'] + value = tx_item['value'].value + balance = tx_item['balance'].value + label = tx_item['label'] status, status_str = self.wallet.get_tx_status(tx_hash, height, conf, timestamp) has_invoice = self.wallet.invoices.paid.get(tx_hash) icon = QIcon(":icons/" + TX_ICONS[status]) v_str = self.parent.format_amount(value, True, whitespaces=True) balance_str = self.parent.format_amount(balance, whitespaces=True) - label = self.wallet.get_label(tx_hash) entry = ['', tx_hash, status_str, label, v_str, balance_str] - if fx and fx.show_history(): - date = timestamp_to_datetime(time.time() if conf <= 0 else timestamp) - for amount in [value, balance]: - text = fx.historical_value_str(amount, date) - entry.append(text) - item = QTreeWidgetItem(entry) + fiat_value = None + if value is not None and fx and fx.show_history(): + fiat_value = tx_item['fiat_value'].value + value_str = fx.format_fiat(fiat_value) + entry.append(value_str) + # fixme: should use is_mine + if value < 0: + entry.append(fx.format_fiat(tx_item['acquisition_price'].value)) + entry.append(fx.format_fiat(tx_item['capital_gain'].value)) + item = SortableTreeWidgetItem(entry) item.setIcon(0, icon) item.setToolTip(0, str(conf) + " confirmation" + ("s" if conf != 1 else "")) + item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf)) if has_invoice: item.setIcon(3, QIcon(":icons/seal")) for i in range(len(entry)): @@ -101,12 +259,27 @@ class HistoryList(MyTreeWidget): if value and value < 0: item.setForeground(3, QBrush(QColor("#BC1E1E"))) item.setForeground(4, QBrush(QColor("#BC1E1E"))) + if fiat_value and not tx_item['fiat_default']: + item.setForeground(6, QBrush(QColor("#1E1EFF"))) if tx_hash: item.setData(0, Qt.UserRole, tx_hash) self.insertTopLevelItem(0, item) if current_tx == tx_hash: self.setCurrentItem(item) + def on_edited(self, item, column, prior): + '''Called only when the text actually changes''' + key = item.data(0, Qt.UserRole) + text = item.text(column) + # fixme + if column == 3: + self.parent.wallet.set_label(key, text) + self.update_labels() + self.parent.update_completions() + elif column == 6: + self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text) + self.on_update() + def on_doubleclick(self, item, column): if self.permit_edit(item, column): super(HistoryList, self).on_doubleclick(item, column) @@ -131,6 +304,7 @@ class HistoryList(MyTreeWidget): if items: item = items[0] item.setIcon(0, icon) + item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf)) item.setText(2, status_str) def create_menu(self, position): @@ -148,20 +322,18 @@ class HistoryList(MyTreeWidget): else: column_title = self.headerItem().text(column) column_data = item.text(column) - tx_URL = block_explorer_URL(self.config, 'tx', tx_hash) height, conf, timestamp = self.wallet.get_tx_height(tx_hash) tx = self.wallet.transactions.get(tx_hash) is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) is_unconfirmed = height <= 0 pr_key = self.wallet.invoices.paid.get(tx_hash) - menu = QMenu() - - menu.addAction(_("Copy %s")%column_title, lambda: self.parent.app.clipboard().setText(column_data)) - if column in self.editable_columns: - menu.addAction(_("Edit %s")%column_title, lambda: self.editItem(item, column)) - + if height == TX_HEIGHT_LOCAL: + menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash)) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) + for c in self.editable_columns: + menu.addAction(_("Edit {}").format(self.headerItem().text(c)), lambda: self.editItem(item, c)) menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx)) if is_unconfirmed and tx: rbf = is_mine and not tx.is_final() @@ -176,3 +348,73 @@ class HistoryList(MyTreeWidget): if tx_URL: menu.addAction(_("View on block explorer"), lambda: webbrowser.open(tx_URL)) menu.exec_(self.viewport().mapToGlobal(position)) + + def remove_local_tx(self, delete_tx): + to_delete = {delete_tx} + to_delete |= self.wallet.get_depending_transactions(delete_tx) + question = _("Are you sure you want to remove this transaction?") + if len(to_delete) > 1: + question = _( + "Are you sure you want to remove this transaction and {} child transactions?".format(len(to_delete) - 1) + ) + answer = QMessageBox.question(self.parent, _("Please confirm"), question, QMessageBox.Yes, QMessageBox.No) + if answer == QMessageBox.No: + return + for tx in to_delete: + self.wallet.remove_transaction(tx) + self.wallet.save_transactions(write=True) + # need to update at least: history_list, utxo_list, address_list + self.parent.need_update.set() + + def onFileAdded(self, fn): + try: + with open(fn) as f: + tx = self.parent.tx_from_text(f.read()) + self.parent.save_transaction_into_wallet(tx) + except IOError as e: + self.parent.show_error(e) + + def export_history_dialog(self): + d = WindowModalDialog(self, _('Export History')) + d.setMinimumSize(400, 200) + vbox = QVBoxLayout(d) + defaultname = os.path.expanduser('~/electrum-history.csv') + select_msg = _('Select file to export your wallet transactions to') + hbox, filename_e, csv_button = filename_field(self, self.config, defaultname, select_msg) + vbox.addLayout(hbox) + vbox.addStretch(1) + hbox = Buttons(CancelButton(d), OkButton(d, _('Export'))) + vbox.addLayout(hbox) + #run_hook('export_history_dialog', self, hbox) + self.update() + if not d.exec_(): + return + filename = filename_e.text() + if not filename: + return + try: + self.do_export_history(self.wallet, filename, csv_button.isChecked()) + except (IOError, os.error) as reason: + export_error_label = _("Electrum was unable to produce a transaction export.") + self.parent.show_critical(export_error_label + "\n" + str(reason), title=_("Unable to export history")) + return + self.parent.show_message(_("Your wallet history has been successfully exported.")) + + def do_export_history(self, wallet, fileName, is_csv): + history = self.transactions + lines = [] + for item in history: + if is_csv: + lines.append([item['txid'], item.get('label', ''), item['confirmations'], item['value'], item['date']]) + else: + lines.append(item) + with open(fileName, "w+") as f: + if is_csv: + import csv + transaction = csv.writer(f, lineterminator='\n') + transaction.writerow(["transaction_hash","label", "confirmations", "value", "timestamp"]) + for line in lines: + transaction.writerow(line) + else: + from electrum.util import json_encode + f.write(json_encode(history)) diff --git a/gui/qt/installwizard.py b/gui/qt/installwizard.py index 4b3e8aea..b845eb33 100644 --- a/gui/qt/installwizard.py +++ b/gui/qt/installwizard.py @@ -10,29 +10,25 @@ from PyQt5.QtWidgets import * from electrum import Wallet, WalletStorage from electrum.util import UserCancelled, InvalidPassword -from electrum.base_wizard import BaseWizard +from electrum.base_wizard import BaseWizard, HWD_SETUP_DECRYPT_WALLET from electrum.i18n import _ from .seed_dialog import SeedLayout, KeysLayout from .network_dialog import NetworkChoiceLayout from .util import * -from .password_dialog import PasswordLayout, PW_NEW +from .password_dialog import PasswordLayout, PasswordLayoutForHW, PW_NEW class GoBack(Exception): pass -MSG_GENERATING_WAIT = _("Generating your addresses, please wait...") -MSG_ENTER_ANYTHING = _("Please enter a seed phrase, a master key, a list of " - "Zclassic addresses, or a list of private keys") -MSG_ENTER_SEED_OR_MPK = _("Please enter a seed phrase or a master key (xpub or xprv):") -MSG_COSIGNER = _("Please enter the master public key of cosigner #%d:") + MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\ + _("Leave this field empty if you want to disable encryption.") -MSG_RESTORE_PASSPHRASE = \ - _("Please enter your seed derivation passphrase. " - "Note: this is NOT your encryption password. " - "Leave this field empty if you did not use one or are unsure.") +MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ + + _("Your wallet file does not contain secrets, mostly just metadata. ") \ + + _("It also contains your master public key that allows watching your addresses.") + '\n\n'\ + + _("Note: If you enable this setting, you will need your hardware device to open your wallet.") class CosignWidget(QWidget): @@ -196,12 +192,18 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): msg =_("This file does not exist.") + '\n' \ + _("Press 'Next' to create this wallet, or choose another file.") pw = False - elif self.storage.file_exists() and self.storage.is_encrypted(): - msg = _("This file is encrypted.") + '\n' + _('Enter your password or choose another file.') - pw = True else: - msg = _("Press 'Next' to open this wallet.") - pw = False + if self.storage.is_encrypted_with_user_pw(): + msg = _("This file is encrypted with a password.") + '\n' \ + + _('Enter your password or choose another file.') + pw = True + elif self.storage.is_encrypted_with_hw_device(): + msg = _("This file is encrypted using a hardware device.") + '\n' \ + + _("Press 'Next' to choose device to decrypt.") + pw = False + else: + msg = _("Press 'Next' to open this wallet.") + pw = False else: msg = _('Cannot read file') pw = False @@ -227,23 +229,46 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): if not self.storage.file_exists(): break if self.storage.file_exists() and self.storage.is_encrypted(): - password = self.pw_e.text() - try: - self.storage.decrypt(password) - break - except InvalidPassword as e: - QMessageBox.information(None, _('Error'), str(e)) - continue - except BaseException as e: - traceback.print_exc(file=sys.stdout) - QMessageBox.information(None, _('Error'), str(e)) - return + if self.storage.is_encrypted_with_user_pw(): + password = self.pw_e.text() + try: + self.storage.decrypt(password) + break + except InvalidPassword as e: + QMessageBox.information(None, _('Error'), str(e)) + continue + except BaseException as e: + traceback.print_exc(file=sys.stdout) + QMessageBox.information(None, _('Error'), str(e)) + return + elif self.storage.is_encrypted_with_hw_device(): + try: + self.run('choose_hw_device', HWD_SETUP_DECRYPT_WALLET) + except InvalidPassword as e: + # FIXME if we get here because of mistyped passphrase + # then that passphrase gets "cached" + QMessageBox.information( + None, _('Error'), + _('Failed to decrypt using this hardware device.') + '\n' + + _('If you use a passphrase, make sure it is correct.')) + self.stack = [] + return self.run_and_get_wallet() + except BaseException as e: + traceback.print_exc(file=sys.stdout) + QMessageBox.information(None, _('Error'), str(e)) + return + if self.storage.is_past_initial_decryption(): + break + else: + return + else: + raise Exception('Unexpected encryption version') path = self.storage.path if self.storage.requires_split(): self.hide() - msg = _("The wallet '%s' contains multiple accounts, which are no longer supported since Electrum 2.7.\n\n" - "Do you want to split your wallet into multiple files?"%path) + msg = _("The wallet '{}' contains multiple accounts, which are no longer supported since Electrum 2.7.\n\n" + "Do you want to split your wallet into multiple files?").format(path) if not self.question(msg): return file_list = '\n'.join(self.storage.split_accounts()) @@ -261,10 +286,10 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): action = self.storage.get_action() if action and action != 'new': self.hide() - msg = _("The file '%s' contains an incompletely created wallet.\n" - "Do you want to complete its creation now?") % path + msg = _("The file '{}' contains an incompletely created wallet.\n" + "Do you want to complete its creation now?").format(path) if not self.question(msg): - if self.question(_("Do you want to delete '%s'?") % path): + if self.question(_("Do you want to delete '{}'?").format(path)): os.remove(path) self.show_warning(_('The file was removed')) return @@ -386,17 +411,25 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): self.exec_layout(slayout) return slayout.is_ext - def pw_layout(self, msg, kind): - playout = PasswordLayout(None, msg, kind, self.next_button) + def pw_layout(self, msg, kind, force_disable_encrypt_cb): + playout = PasswordLayout(None, msg, kind, self.next_button, + force_disable_encrypt_cb=force_disable_encrypt_cb) playout.encrypt_cb.setChecked(True) self.exec_layout(playout.layout()) return playout.new_password(), playout.encrypt_cb.isChecked() @wizard_dialog - def request_password(self, run_next): + def request_password(self, run_next, force_disable_encrypt_cb=False): """Request the user enter a new password and confirm it. Return the password or None for no password.""" - return self.pw_layout(MSG_ENTER_PASSWORD, PW_NEW) + return self.pw_layout(MSG_ENTER_PASSWORD, PW_NEW, force_disable_encrypt_cb) + + @wizard_dialog + def request_storage_encryption(self, run_next): + playout = PasswordLayoutForHW(None, MSG_HW_STORAGE_ENCRYPTION, PW_NEW, self.next_button) + playout.encrypt_cb.setChecked(True) + self.exec_layout(playout.layout()) + return playout.encrypt_cb.isChecked() def show_restore(self, wallet, network): # FIXME: these messages are shown after the install wizard is @@ -437,7 +470,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): self.accept_signal.emit() def waiting_dialog(self, task, msg): - self.please_wait.setText(MSG_GENERATING_WAIT) + self.please_wait.setText(msg) self.refresh_gui() t = threading.Thread(target = task) t.start() diff --git a/gui/qt/invoice_list.py b/gui/qt/invoice_list.py index 997f7262..63143e27 100644 --- a/gui/qt/invoice_list.py +++ b/gui/qt/invoice_list.py @@ -23,10 +23,11 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from .util import * from electrum.i18n import _ from electrum.util import format_time +from .util import * + class InvoiceList(MyTreeWidget): filter_columns = [0, 1, 2, 3] # Date, Requested By, Description, Amount @@ -57,12 +58,10 @@ class InvoiceList(MyTreeWidget): self.parent.invoices_label.setVisible(len(inv_list)) def import_invoices(self): - wallet_folder = self.parent.get_wallet_folder() - filename, __ = QFileDialog.getOpenFileName(self.parent, "Select your wallet file", wallet_folder) - if not filename: - return - self.parent.invoices.import_file(filename) - self.on_update() + import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.on_update) + + def export_invoices(self): + export_meta_gui(self.parent, _('invoices'), self.parent.invoices.export_file) def create_menu(self, position): menu = QMenu() @@ -76,7 +75,7 @@ class InvoiceList(MyTreeWidget): pr = self.parent.invoices.get(key) status = self.parent.invoices.get_status(key) if column_data: - menu.addAction(_("Copy %s")%column_title, lambda: self.parent.app.clipboard().setText(column_data)) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) menu.addAction(_("Details"), lambda: self.parent.show_invoice(key)) if status == PR_UNPAID: menu.addAction(_("Pay Now"), lambda: self.parent.do_pay_invoice(key)) diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py index 7414795a..cf4bf526 100644 --- a/gui/qt/main_window.py +++ b/gui/qt/main_window.py @@ -32,34 +32,32 @@ from decimal import Decimal import base64 from functools import partial -from PyQt5.QtCore import Qt from PyQt5.QtGui import * +from PyQt5.QtCore import * +import PyQt5.QtCore as QtCore + +from .exception_window import Exception_Hook from PyQt5.QtWidgets import * -from electrum.util import bh2u, bfh - from electrum import keystore, simple_config -from electrum.bitcoin import COIN, is_address, TYPE_ADDRESS, NetworkConstants +from electrum.bitcoin import COIN, is_address, TYPE_ADDRESS +from electrum import constants from electrum.plugins import run_hook from electrum.i18n import _ from electrum.util import (format_time, format_satoshis, PrintError, format_satoshis_plain, NotEnoughFunds, - UserCancelled, NoDynamicFeeEstimates) + UserCancelled, NoDynamicFeeEstimates, profiler, + export_meta, import_meta, bh2u, bfh) from electrum import Transaction from electrum import util, bitcoin, commands, coinchooser from electrum import paymentrequest -from electrum.wallet import Multisig_Wallet -try: - from electrum.plot import plot_history -except: - plot_history = None +from electrum.wallet import Multisig_Wallet, AddTransactionException from .amountedit import AmountEdit, BTCAmountEdit, MyLineEdit, FeerateEdit from .qrcodewidget import QRCodeWidget, QRDialog from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit from .transaction_dialog import show_transaction from .fee_slider import FeeSlider - from .util import * @@ -102,6 +100,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.gui_object = gui_object self.config = config = gui_object.config + + self.setup_exception_hook() + self.network = gui_object.daemon.network self.fx = gui_object.daemon.fx self.invoices = wallet.invoices @@ -123,9 +124,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.create_status_bar() self.need_update = threading.Event() - self.decimal_point = config.get('decimal_point', 8) - self.fee_unit = config.get('fee_unit', 0) - self.num_zeros = int(config.get('num_zeros', 0)) + self.decimal_point = config.get('decimal_point', 5) + self.num_zeros = int(config.get('num_zeros',0)) self.completions = QStringListModel() @@ -203,6 +203,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def on_history(self, b): self.new_fx_history_signal.emit() + def setup_exception_hook(self): + Exception_Hook(self) + def on_fx_history(self): self.history_list.refresh_headers() self.history_list.update() @@ -283,7 +286,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.need_update.set() self.gui_object.network_updated_signal_obj.network_updated_signal \ .emit(event, args) - elif event == 'new_transaction': self.tx_notifications.append(args[0]) self.notify_transactions_signal.emit() @@ -305,6 +307,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if self.config.is_dynfee(): self.fee_slider.update() self.do_update_fee() + elif event == 'fee_histogram': + if self.config.is_dynfee(): + self.fee_slider.update() + self.do_update_fee() + # todo: update only unconfirmed tx + self.history_list.update() else: self.print_error("unexpected network_qt signal:", event, args) @@ -325,6 +333,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.print_error('close_wallet', self.wallet.storage.path) run_hook('close_wallet', self.wallet) + @profiler def load_wallet(self, wallet): wallet.thread = TaskThread(self, self.on_error) self.wallet = wallet @@ -363,7 +372,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.setGeometry(100, 100, 840, 400) def watching_only_changed(self): - name = "[TESTNET] Zclassic Electrum" if NetworkConstants.TESTNET else "Zclassic Electrum" + name = "Zclassic Electrum TESTNET" if constants.net.TESTNET else "Zclassic Electrum" title = '%s %s - %s' % (name, self.wallet.electrum_version, self.wallet.basename()) extra = [self.wallet.storage.get('wallet_type', '?')] @@ -372,7 +381,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): extra.append(_('watching only')) title += ' [%s]'% ', '.join(extra) self.setWindowTitle(title) - self.password_menu.setEnabled(self.wallet.can_change_password()) + self.password_menu.setEnabled(self.wallet.may_have_password()) self.import_privkey_menu.setVisible(self.wallet.can_import_privkey()) self.import_address_menu.setVisible(self.wallet.can_import_address()) self.export_menu.setEnabled(self.wallet.can_export()) @@ -467,17 +476,23 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.import_address_menu = wallet_menu.addAction(_("Import Addresses"), self.import_addresses) wallet_menu.addSeparator() + addresses_menu = wallet_menu.addMenu(_("&Addresses")) + addresses_menu.addAction(_("&Filter"), lambda: self.address_list.toggle_toolbar(self.config)) labels_menu = wallet_menu.addMenu(_("&Labels")) labels_menu.addAction(_("&Import"), self.do_import_labels) labels_menu.addAction(_("&Export"), self.do_export_labels) + history_menu = wallet_menu.addMenu(_("&History")) + history_menu.addAction(_("&Filter"), lambda: self.history_list.toggle_toolbar(self.config)) + history_menu.addAction(_("&Summary"), self.history_list.show_summary) + history_menu.addAction(_("&Plot"), self.history_list.plot_history_dialog) + history_menu.addAction(_("&Export"), self.history_list.export_history_dialog) contacts_menu = wallet_menu.addMenu(_("Contacts")) contacts_menu.addAction(_("&New"), self.new_contact_dialog) contacts_menu.addAction(_("Import"), lambda: self.contact_list.import_contacts()) + contacts_menu.addAction(_("Export"), lambda: self.contact_list.export_contacts()) invoices_menu = wallet_menu.addMenu(_("Invoices")) invoices_menu.addAction(_("Import"), lambda: self.invoice_list.import_invoices()) - hist_menu = wallet_menu.addMenu(_("&History")) - hist_menu.addAction("Plot", self.plot_history_dialog).setEnabled(plot_history is not None) - hist_menu.addAction("Export", self.export_history_dialog) + invoices_menu.addAction(_("Export"), lambda: self.invoice_list.export_invoices()) wallet_menu.addSeparator() wallet_menu.addAction(_("Find"), self.toggle_search).setShortcut(QKeySequence("Ctrl+F")) @@ -559,24 +574,24 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): return self.print_error("Notifying GUI") if len(self.tx_notifications) > 0: - # Combine the transactions if there are more then three - tx_amount = len(self.tx_notifications) - if(tx_amount >= 3): + # Combine the transactions if there are at least three + num_txns = len(self.tx_notifications) + if num_txns >= 3: total_amount = 0 for tx in self.tx_notifications: is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) - if(v > 0): + if v > 0: total_amount += v - self.notify(_("%(txs)s new transactions received: Total amount received in the new transactions %(amount)s") \ - % { 'txs' : tx_amount, 'amount' : self.format_amount_and_units(total_amount)}) + self.notify(_("{} new transactions received: Total amount received in the new transactions {}") + .format(num_txns, self.format_amount_and_units(total_amount))) self.tx_notifications = [] else: - for tx in self.tx_notifications: - if tx: - self.tx_notifications.remove(tx) - is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) - if(v > 0): - self.notify(_("Inbound Transaction - %(amount)s") % { 'amount' : self.format_amount_and_units(v)}) + for tx in self.tx_notifications: + if tx: + self.tx_notifications.remove(tx) + is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) + if v > 0: + self.notify(_("New Transaction: {}").format(self.format_amount_and_units(v))) def notify(self, message): if self.tray: @@ -625,16 +640,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def format_amount_and_units(self, amount): text = self.format_amount(amount) + ' '+ self.base_unit() - x = self.fx.format_amount_and_units(amount) + x = self.fx.format_amount_and_units(amount) if self.fx else None if text and x: text += ' (%s)'%x return text def format_fee_rate(self, fee_rate): - if self.fee_unit == 0: - return format_satoshis(fee_rate/1000, False, self.num_zeros, 0, False) + ' zat/byte' - else: - return self.format_amount(fee_rate) + ' ' + self.base_unit() + '/kB' + return format_satoshis(fee_rate/1000, False, self.num_zeros, 0, False) + ' zat/byte' def get_decimal_point(self): return self.decimal_point @@ -704,7 +716,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): text = _("Synchronizing...") icon = QIcon(":icons/status_waiting.png") elif server_lag > 1: - text = _("Server is lagging (%d blocks)"%server_lag) + text = _("Server is lagging ({} blocks)").format(server_lag) icon = QIcon(":icons/status_lagging.png") else: c, u, x = self.wallet.get_balance() @@ -749,7 +761,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): from .history_list import HistoryList self.history_list = l = HistoryList(self) l.searchable_list = l - return l + toolbar = l.create_toolbar(self.config) + toolbar_shown = self.config.get('show_toolbar_history', False) + l.show_toolbar(toolbar_shown) + return self.create_list_tab(l, toolbar) def show_address(self, addr): from . import address_dialog @@ -883,14 +898,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if alias_addr: if self.wallet.is_mine(alias_addr): msg = _('This payment request will be signed.') + '\n' + _('Please enter your password') - password = self.password_dialog(msg) - if password: - try: - self.wallet.sign_payment_request(addr, alias, alias_addr, password) - except Exception as e: - self.show_error(str(e)) + password = None + if self.wallet.has_keystore_encryption(): + password = self.password_dialog(msg) + if not password: return - else: + try: + self.wallet.sign_payment_request(addr, alias, alias_addr, password) + except Exception as e: + self.show_error(str(e)) return else: return @@ -905,11 +921,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): i = self.expires_combo.currentIndex() expiration = list(map(lambda x: x[1], expiration_values))[i] req = self.wallet.make_payment_request(addr, amount, message, expiration) - self.wallet.add_payment_request(req, self.config) - self.sign_payment_request(addr) - self.request_list.update() - self.address_list.update() - self.save_request_button.setEnabled(False) + try: + self.wallet.add_payment_request(req, self.config) + except Exception as e: + traceback.print_exc(file=sys.stderr) + self.show_error(_('Error adding payment request') + ':\n' + str(e)) + else: + self.sign_payment_request(addr) + self.save_request_button.setEnabled(False) + finally: + self.request_list.update() + self.address_list.update() def view_and_paste(self, title, msg, data): dialog = WindowModalDialog(self, title) @@ -1005,6 +1027,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if self.qr_window and self.qr_window.isVisible(): self.qr_window.set_content(addr, amount, message, uri) + def set_feerounding_text(self, num_satoshis_added): + self.feerounding_text = (_('Additional {} satoshis are going to be added.') + .format(num_satoshis_added)) + def create_send_tab(self): # A 4-column grid layout. All the stretch is in the last column. # The exchange rate plugin adds a fiat widget in column 2 @@ -1070,12 +1096,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def fee_cb(dyn, pos, fee_rate): if dyn: - self.config.set_key('fee_level', pos, False) + if self.config.use_mempool_fees(): + self.config.set_key('depth_level', pos, False) + else: + self.config.set_key('fee_level', pos, False) else: self.config.set_key('fee_per_kb', fee_rate, False) if fee_rate: self.feerate_e.setAmount(fee_rate // 1000) + else: + self.feerate_e.setAmount(None) self.fee_e.setModified(False) self.fee_slider.activate() @@ -1108,7 +1139,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.size_e.setFixedWidth(140) self.size_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) - self.feerate_e = FeerateEdit(lambda: 2 if self.fee_unit else 0) + self.feerate_e = FeerateEdit(lambda: 0) + self.feerate_e.setAmount(self.config.fee_per_byte()) self.feerate_e.textEdited.connect(partial(on_fee_or_feerate, self.feerate_e, False)) self.feerate_e.editingFinished.connect(partial(on_fee_or_feerate, self.feerate_e, True)) @@ -1116,6 +1148,20 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.fee_e.textEdited.connect(partial(on_fee_or_feerate, self.fee_e, False)) self.fee_e.editingFinished.connect(partial(on_fee_or_feerate, self.fee_e, True)) + def feerounding_onclick(): + text = (self.feerounding_text + '\n\n' + + _('To somewhat protect your privacy, Electrum tries to create change with similar precision to other outputs.') + ' ' + + _('At most 100 satoshis might be lost due to this rounding.') + ' ' + + _("You can disable this setting in '{}'.").format(_('Preferences')) + '\n' + + _('Also, dust is not kept as change, but added to the fee.')) + QMessageBox.information(self, 'Fee rounding', text) + + self.feerounding_icon = QPushButton(QIcon(':icons/info.png'), '') + self.feerounding_icon.setFixedWidth(20) + self.feerounding_icon.setFlat(True) + self.feerounding_icon.clicked.connect(feerounding_onclick) + self.feerounding_icon.setVisible(False) + self.connect_fields(self, self.amount_e, self.fiat_send_e, self.fee_e) vbox_feelabel = QVBoxLayout() @@ -1129,14 +1175,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): hbox.addWidget(self.feerate_e) hbox.addWidget(self.size_e) hbox.addWidget(self.fee_e) + hbox.addWidget(self.feerounding_icon, Qt.AlignLeft) + hbox.addStretch(1) vbox_feecontrol = QVBoxLayout() vbox_feecontrol.addWidget(self.fee_adv_controls) vbox_feecontrol.addWidget(self.fee_slider) - grid.addLayout(vbox_feecontrol, 5, 1, 1, 3) + grid.addLayout(vbox_feecontrol, 5, 1, 1, -1) - if not self.config.get('show_fee', True): + if not self.config.get('show_fee', False): self.fee_adv_controls.setVisible(False) self.preview_button = EnterButton(_("Preview"), self.do_preview) @@ -1233,9 +1281,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): '''Recalculate the fee. If the fee was manually input, retain it, but still build the TX to see if there are enough funds. ''' - if not self.config.get('offline') and self.config.is_dynfee() and not self.config.has_fee_estimates(): - self.statusBar().showMessage(_('Waiting for fee estimates...')) - return False freeze_fee = self.is_send_fee_frozen() freeze_feerate = self.is_send_feerate_frozen() amount = '!' if self.is_max else self.amount_e.get_amount() @@ -1258,15 +1303,22 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): try: tx = make_tx(fee_estimator) self.not_enough_funds = False - except NotEnoughFunds: - self.not_enough_funds = True + except (NotEnoughFunds, NoDynamicFeeEstimates) as e: if not freeze_fee: self.fee_e.setAmount(None) - return - except NoDynamicFeeEstimates: - tx = make_tx(0) - size = tx.estimated_size() - self.size_e.setAmount(size) + if not freeze_feerate: + self.feerate_e.setAmount(None) + self.feerounding_icon.setVisible(False) + + if isinstance(e, NotEnoughFunds): + self.not_enough_funds = True + elif isinstance(e, NoDynamicFeeEstimates): + try: + tx = make_tx(0) + size = tx.estimated_size() + self.size_e.setAmount(size) + except BaseException: + pass return except BaseException: traceback.print_exc(file=sys.stderr) @@ -1276,12 +1328,32 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.size_e.setAmount(size) fee = tx.get_fee() - if not freeze_fee: - fee = None if self.not_enough_funds else fee - self.fee_e.setAmount(fee) - if not freeze_feerate: - fee_rate = fee // size if fee is not None else None - self.feerate_e.setAmount(fee_rate) + fee = None if self.not_enough_funds else fee + + # Displayed fee/fee_rate values are set according to user input. + # Due to rounding or dropping dust in CoinChooser, + # actual fees often differ somewhat. + if freeze_feerate or self.fee_slider.is_active(): + displayed_feerate = self.feerate_e.get_amount() + displayed_feerate = displayed_feerate // 1000 if displayed_feerate else 0 + displayed_fee = displayed_feerate * size + self.fee_e.setAmount(displayed_fee) + else: + if freeze_fee: + displayed_fee = self.fee_e.get_amount() + else: + # fallback to actual fee if nothing is frozen + displayed_fee = fee + self.fee_e.setAmount(displayed_fee) + displayed_fee = displayed_fee if displayed_fee else 0 + displayed_feerate = displayed_fee // size if displayed_fee is not None else None + self.feerate_e.setAmount(displayed_feerate) + + # show/hide fee rounding icon + feerounding = (fee - displayed_fee) if fee else 0 + self.set_feerounding_text(feerounding) + self.feerounding_icon.setToolTip(self.feerounding_text) + self.feerounding_icon.setVisible(bool(feerounding)) if self.is_max: amount = tx.output_value() @@ -1331,7 +1403,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def request_password(self, *args, **kwargs): parent = self.top_level_window() password = None - while self.wallet.has_password(): + while self.wallet.has_keystore_encryption(): password = self.password_dialog(parent=parent) if password is None: # User cancelled password input @@ -1360,7 +1432,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): fee_estimator = self.fee_e.get_amount() elif self.is_send_feerate_frozen(): amount = self.feerate_e.get_amount() - amount = 0 if amount is None else float(amount) + amount = 0 if amount is None else amount fee_estimator = partial( simple_config.SimpleConfig.estimate_fee_for_feerate, amount) else: @@ -1369,7 +1441,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def read_send_tab(self): if self.payment_request and self.payment_request.has_expired(): - self.show_error(_('Payment request has expired.')) + self.show_error(_('Payment request has expired')) return label = self.message_e.text() @@ -1384,7 +1456,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if self.payto_e.is_alias and self.payto_e.validated is False: alias = self.payto_e.toPlainText() - msg = _('WARNING: the alias "%s" could not be validated via an additional security check, DNSSEC, and thus may not be correct.'%alias) + '\n' + msg = _('WARNING: the alias "{}" could not be validated via an additional ' + 'security check, DNSSEC, and thus may not be correct.').format(alias) + '\n' msg += _('Do you wish to continue?') if not self.question(msg): return @@ -1439,13 +1512,20 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): tx.set_rbf(True) if fee < self.wallet.relayfee() * tx.estimated_size() / 1000: - self.show_error(_("This transaction requires a higher fee, or it will not be propagated by the network")) + self.show_error('\n'.join([ + _("This transaction requires a higher fee, or it will not be propagated by your current server"), + _("Try to raise your transaction fee, or use a server with a lower relay fee.") + ])) return if preview: self.show_transaction(tx, tx_desc) return + if not self.network: + self.show_error(_("You can't broadcast a transaction without a live network connection.")) + return + # confirmation dialog msg = [ _("Amount to be sent") + ": " + self.format_amount_and_units(amount), @@ -1457,11 +1537,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): x_fee_address, x_fee_amount = x_fee msg.append( _("Additional fees") + ": " + self.format_amount_and_units(x_fee_amount) ) - confirm_rate = 2 * self.config.max_fee_rate() + confirm_rate = simple_config.FEERATE_WARNING_HIGH_FEE if fee > confirm_rate * tx.estimated_size() / 1000: msg.append(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) - if self.wallet.has_password(): + if self.wallet.has_keystore_encryption(): msg.append("") msg.append(_("Enter your password to proceed")) password = self.password_dialog('\n'.join(msg)) @@ -1514,7 +1594,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): pr = self.payment_request if pr and pr.has_expired(): self.payment_request = None - return False, _("Payment request has expired.") + return False, _("Payment request has expired") status, msg = self.network.broadcast(tx) if pr and status is True: self.invoices.set_paid(pr, tx.txid()) @@ -1606,7 +1686,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.payment_request_error_signal.emit() def pay_to_URI(self, URI): - if not URI or not isinstance(URI, str): + if not URI: return try: out = util.parse_URI(URI, self.on_pr) @@ -1646,7 +1726,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): e.setText('') e.setFrozen(False) self.fee_slider.activate() + self.feerate_e.setAmount(self.config.fee_per_byte()) self.size_e.setAmount(0) + self.feerounding_icon.setVisible(False) self.set_pay_from([]) self.tx_external_keypairs = {} self.update_status() @@ -1658,26 +1740,25 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.utxo_list.update() self.update_fee() - def create_list_tab(self, l, list_header=None): + def create_list_tab(self, l, toolbar=None): w = QWidget() w.searchable_list = l vbox = QVBoxLayout() w.setLayout(vbox) vbox.setContentsMargins(0, 0, 0, 0) vbox.setSpacing(0) - if list_header: - hbox = QHBoxLayout() - for b in list_header: - hbox.addWidget(b) - hbox.addStretch() - vbox.addLayout(hbox) + if toolbar: + vbox.addLayout(toolbar) vbox.addWidget(l) return w def create_addresses_tab(self): from .address_list import AddressList self.address_list = l = AddressList(self) - return self.create_list_tab(l, l.get_list_header()) + toolbar = l.create_toolbar(self.config) + toolbar_shown = self.config.get('show_toolbar_addresses', False) + l.show_toolbar(toolbar_shown) + return self.create_list_tab(l, toolbar) def create_utxo_tab(self): from .utxo_list import UTXOList @@ -1741,8 +1822,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): return True def delete_contacts(self, labels): - if not self.question(_("Remove %s from your list of contacts?") - % " + ".join(labels)): + if not self.question(_("Remove {} from your list of contacts?") + .format(" + ".join(labels))): return for label in labels: self.contacts.pop(label) @@ -1862,17 +1943,37 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def update_buttons_on_seed(self): self.seed_button.setVisible(self.wallet.has_seed()) - self.password_button.setVisible(self.wallet.can_change_password()) + self.password_button.setVisible(self.wallet.may_have_password()) self.send_button.setVisible(not self.wallet.is_watching_only()) def change_password_dialog(self): - from .password_dialog import ChangePasswordDialog - d = ChangePasswordDialog(self, self.wallet) - ok, password, new_password, encrypt_file = d.run() + from electrum.storage import STO_EV_XPUB_PW + if self.wallet.get_available_storage_encryption_version() == STO_EV_XPUB_PW: + from .password_dialog import ChangePasswordDialogForHW + d = ChangePasswordDialogForHW(self, self.wallet) + ok, encrypt_file = d.run() + if not ok: + return + + try: + hw_dev_pw = self.wallet.keystore.get_password_for_storage_encryption() + except UserCancelled: + return + except BaseException as e: + traceback.print_exc(file=sys.stderr) + self.show_error(str(e)) + return + old_password = hw_dev_pw if self.wallet.has_password() else None + new_password = hw_dev_pw if encrypt_file else None + else: + from .password_dialog import ChangePasswordDialogForSW + d = ChangePasswordDialogForSW(self, self.wallet) + ok, old_password, new_password, encrypt_file = d.run() + if not ok: return try: - self.wallet.update_password(password, new_password, encrypt_file) + self.wallet.update_password(old_password, new_password, encrypt_file) except BaseException as e: self.show_error(str(e)) return @@ -1880,11 +1981,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): traceback.print_exc(file=sys.stdout) self.show_error(_('Failed to update password')) return - msg = _('Password was updated successfully') if new_password else _('Password is disabled, this wallet is not protected') + msg = _('Password was updated successfully') if self.wallet.has_password() else _('Password is disabled, this wallet is not protected') self.show_message(msg, title=_("Success")) self.update_lock_icon() def toggle_search(self): + tab = self.tabs.currentWidget() + #if hasattr(tab, 'searchable_list'): + # tab.searchable_list.toggle_toolbar() + #return self.search_box.setHidden(not self.search_box.isHidden()) if not self.search_box.isHidden(): self.search_box.setFocus(1) @@ -2017,8 +2122,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): rds_e = ShowQRTextEdit(text=redeem_script) rds_e.addCopyButton(self.app) vbox.addWidget(rds_e) - if xtype in ['p2wpkh', 'p2wsh', 'p2wpkh-p2sh', 'p2wsh-p2sh']: - vbox.addWidget(WWLabel(_("Warning: the format of private keys associated to segwit addresses may not be compatible with other wallets"))) vbox.addLayout(Buttons(CloseButton(d))) d.setLayout(vbox) d.exec_() @@ -2036,6 +2139,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if not bitcoin.is_address(address): self.show_message(_('Invalid Zclassic address.')) return + if self.wallet.is_watching_only(): + self.show_message(_('This is a watching-only wallet.')) + return if not self.wallet.is_mine(address): self.show_message(_('Address not in wallet.')) return @@ -2047,7 +2153,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): task = partial(self.wallet.sign_message, address, message, password) def show_signed_message(sig): - signature.setText(base64.b64encode(sig).decode('ascii')) + try: + signature.setText(base64.b64encode(sig).decode('ascii')) + except RuntimeError: + # (signature) wrapped C/C++ object has been deleted + pass + self.wallet.thread.add(task, on_success=show_signed_message) def do_verify(self, address, message, signature): @@ -2106,9 +2217,20 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): @protected def do_decrypt(self, message_e, pubkey_e, encrypted_e, password): + if self.wallet.is_watching_only(): + self.show_message(_('This is a watching-only wallet.')) + return cyphertext = encrypted_e.toPlainText() task = partial(self.wallet.decrypt_message, pubkey_e.text(), cyphertext, password) - self.wallet.thread.add(task, on_success=lambda text: message_e.setText(text.decode('utf-8'))) + + def setText(text): + try: + message_e.setText(text.decode('utf-8')) + except RuntimeError: + # (message_e) wrapped C/C++ object has been deleted + pass + + self.wallet.thread.add(task, on_success=setText) def do_encrypt(self, message_e, pubkey_e, encrypted_e): message = message_e.toPlainText() @@ -2207,25 +2329,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): return self.tx_from_text(file_content) def do_process_from_text(self): - from electrum.transaction import SerializationError text = text_dialog(self, _('Input raw transaction'), _("Transaction:"), _("Load transaction")) if not text: return - try: - tx = self.tx_from_text(text) - if tx: - self.show_transaction(tx) - except SerializationError as e: - self.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)) + tx = self.tx_from_text(text) + if tx: + self.show_transaction(tx) def do_process_from_file(self): - from electrum.transaction import SerializationError - try: - tx = self.read_tx_from_file() - if tx: - self.show_transaction(tx) - except SerializationError as e: - self.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)) + tx = self.read_tx_from_file() + if tx: + self.show_transaction(tx) def do_process_from_txid(self): from electrum import transaction @@ -2251,7 +2365,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): _('It can not be "backed up" by simply exporting these private keys.')) d = WindowModalDialog(self, _('Private keys')) - d.setMinimumSize(850, 300) + d.setMinimumSize(980, 300) vbox = QVBoxLayout(d) msg = "%s\n%s\n%s" % (_("WARNING: ALL your private keys are secret."), @@ -2344,102 +2458,23 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): f.write(json.dumps(pklist, indent = 4)) def do_import_labels(self): - labelsFile = self.getOpenFileName(_("Open Labels File"), "*.json") - if not labelsFile: return - try: - with open(labelsFile, 'r') as f: - data = f.read() - for key, value in json.loads(data).items(): - self.wallet.set_label(key, value) - self.show_message(_("Your labels were imported from") + " '%s'" % str(labelsFile)) - except (IOError, os.error) as reason: - self.show_critical(_("Electrum was unable to import your labels.") + "\n" + str(reason)) - self.address_list.update() - self.history_list.update() + def import_labels(path): + def _validate(data): + return data # TODO + + def import_labels_assign(data): + for key, value in data.items(): + self.wallet.set_label(key, value) + import_meta(path, _validate, import_labels_assign) + + def on_import(): + self.need_update.set() + import_meta_gui(self, _('labels'), import_labels, on_import) def do_export_labels(self): - labels = self.wallet.labels - try: - fileName = self.getSaveFileName(_("Select destination file for your labels:"), 'electrum_labels.json', "*.json") - if fileName: - with open(fileName, 'w+') as f: - json.dump(labels, f, indent=4, sort_keys=True) - self.show_message(_("Your labels were exported to") + " '%s'" % str(fileName)) - except (IOError, os.error) as reason: - self.show_critical(_("Electrum was unable to export your labels.") + "\n" + str(reason)) - - def export_history_dialog(self): - d = WindowModalDialog(self, _('Export History')) - d.setMinimumSize(400, 200) - vbox = QVBoxLayout(d) - defaultname = os.path.expanduser('~/electrum-history.csv') - select_msg = _('Select destination file for your wallet transaction history:') - hbox, filename_e, csv_button = filename_field(self, self.config, defaultname, select_msg) - vbox.addLayout(hbox) - vbox.addStretch(1) - hbox = Buttons(CancelButton(d), OkButton(d, _('Export'))) - vbox.addLayout(hbox) - run_hook('export_history_dialog', self, hbox) - self.update() - if not d.exec_(): - return - filename = filename_e.text() - if not filename: - return - try: - self.do_export_history(self.wallet, filename, csv_button.isChecked()) - except (IOError, os.error) as reason: - export_error_label = _("Electrum was unable to produce a transaction export.") - self.show_critical(export_error_label + "\n" + str(reason), title=_("Unable to export history")) - return - self.show_message(_("Your wallet history has been successfully exported.")) - - def plot_history_dialog(self): - if plot_history is None: - return - wallet = self.wallet - history = wallet.get_history() - if len(history) > 0: - plt = plot_history(self.wallet, history) - plt.show() - - def do_export_history(self, wallet, fileName, is_csv): - history = wallet.get_history() - lines = [] - for item in history: - tx_hash, height, confirmations, timestamp, value, balance = item - if height>0: - if timestamp is not None: - time_string = format_time(timestamp) - else: - time_string = _("unverified") - else: - time_string = _("unconfirmed") - - if value is not None: - value_string = format_satoshis(value, True) - else: - value_string = '--' - - if tx_hash: - label = wallet.get_label(tx_hash) - else: - label = "" - - if is_csv: - lines.append([tx_hash, label, confirmations, value_string, time_string]) - else: - lines.append({'txid':tx_hash, 'date':"%16s"%time_string, 'label':label, 'value':value_string}) - - with open(fileName, "w+") as f: - if is_csv: - transaction = csv.writer(f, lineterminator='\n') - transaction.writerow(["transaction_hash","label", "confirmations", "value", "timestamp"]) - for line in lines: - transaction.writerow(line) - else: - import json - f.write(json.dumps(lines, indent = 4)) + def export_labels(filename): + export_meta(self.wallet.labels, filename) + export_meta_gui(self, _('labels'), export_labels) def sweep_key_dialog(self): d = WindowModalDialog(self, title=_('Sweep private keys')) @@ -2590,6 +2625,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): nz.valueChanged.connect(on_nz) gui_widgets.append((nz_label, nz)) + msg = '\n'.join([ + _('Time based: fee rate is based on average confirmation time estimates'), + _('Mempool based: fee rate is targetting a depth in the memory pool') + ] + ) + fee_type_label = HelpLabel(_('Fee estimation') + ':', msg) + fee_type_combo = QComboBox() + fee_type_combo.addItems([_('Static'), _('ETA'), _('Mempool')]) + fee_type_combo.setCurrentIndex(1 if self.config.use_mempool_fees() else 0) + def on_fee_type(x): + self.config.set_key('mempool_fees', x==2) + self.config.set_key('dynamic_fees', x>0) + self.fee_slider.update() + fee_type_combo.currentIndexChanged.connect(on_fee_type) + fee_widgets.append((fee_type_label, fee_type_combo)) use_rbf_cb = QCheckBox(_('Use Replace-By-Fee')) use_rbf_cb.setChecked(self.config.get('use_rbf', True)) @@ -2602,18 +2652,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): use_rbf_cb.stateChanged.connect(on_use_rbf) fee_widgets.append((use_rbf_cb, None)) - self.fee_unit = self.config.get('fee_unit', 0) - fee_unit_label = HelpLabel(_('Fee Unit') + ':', '') - fee_unit_combo = QComboBox() - fee_unit_combo.addItems([_('zat/byte'), _('mZCL/kB')]) - fee_unit_combo.setCurrentIndex(self.fee_unit) - def on_fee_unit(x): - self.fee_unit = x - self.config.set_key('fee_unit', x) - self.fee_slider.update() - fee_unit_combo.currentIndexChanged.connect(on_fee_unit) - fee_widgets.append((fee_unit_label, fee_unit_combo)) - msg = _('OpenAlias record, used to receive coins and to sign payment requests.') + '\n\n'\ + _('The following alias providers are available:') + '\n'\ + '\n'.join(['https://cryptoname.co/', 'http://xmr.link']) + '\n\n'\ @@ -2672,7 +2710,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): unit_combo = QComboBox() unit_combo.addItems(units) unit_combo.setCurrentIndex(units.index(self.base_unit())) - def on_unit(x): + def on_unit(x, nz): unit_result = units[unit_combo.currentIndex()] if self.base_unit() == unit_result: return @@ -2687,13 +2725,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): else: raise Exception('Unknown base unit') self.config.set_key('decimal_point', self.decimal_point, True) + nz.setMaximum(self.decimal_point) self.history_list.update() self.request_list.update() self.address_list.update() for edit, amount in zip(edits, amounts): edit.setAmount(amount) self.update_status() - unit_combo.currentIndexChanged.connect(on_unit) + unit_combo.currentIndexChanged.connect(lambda x: on_unit(x, nz)) gui_widgets.append((unit_label, unit_combo)) block_explorers = sorted(util.block_explorer_info().keys()) @@ -2783,8 +2822,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): unconf_cb.stateChanged.connect(on_unconf) tx_widgets.append((unconf_cb, None)) + def on_outrounding(x): + self.config.set_key('coin_chooser_output_rounding', bool(x)) + enable_outrounding = self.config.get('coin_chooser_output_rounding', False) + outrounding_cb = QCheckBox(_('Enable output value rounding')) + outrounding_cb.setToolTip( + _('Set the value of the change output so that it has similar precision to the other outputs.') + '\n' + + _('This might improve your privacy somewhat.') + '\n' + + _('If enabled, at most 100 zatoshis might be lost due to this, per transaction.')) + outrounding_cb.setChecked(enable_outrounding) + outrounding_cb.stateChanged.connect(on_outrounding) + tx_widgets.append((outrounding_cb, None)) + # Fiat Currency hist_checkbox = QCheckBox() + hist_capgains_checkbox = QCheckBox() fiat_address_checkbox = QCheckBox() ccy_combo = QComboBox() ex_combo = QComboBox() @@ -2806,6 +2858,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if not self.fx: return fiat_address_checkbox.setChecked(self.fx.get_fiat_address_config()) + def update_history_capgains_cb(): + if not self.fx: return + hist_capgains_checkbox.setChecked(self.fx.get_history_capital_gains_config()) + hist_capgains_checkbox.setEnabled(hist_checkbox.isChecked()) + def update_exchanges(): if not self.fx: return b = self.fx.is_enabled() @@ -2844,6 +2901,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if self.fx.is_enabled() and checked: # reset timeout to get historical rates self.fx.timeout = 0 + update_history_capgains_cb() + + def on_history_capgains(checked): + if not self.fx: return + self.fx.set_history_capital_gains_config(checked) + self.history_list.refresh_headers() def on_fiat_address(checked): if not self.fx: return @@ -2853,16 +2916,19 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): update_currencies() update_history_cb() + update_history_capgains_cb() update_fiat_address_cb() update_exchanges() ccy_combo.currentIndexChanged.connect(on_currency) hist_checkbox.stateChanged.connect(on_history) + hist_capgains_checkbox.stateChanged.connect(on_history_capgains) fiat_address_checkbox.stateChanged.connect(on_fiat_address) ex_combo.currentIndexChanged.connect(on_exchange) fiat_widgets = [] fiat_widgets.append((QLabel(_('Fiat currency')), ccy_combo)) fiat_widgets.append((QLabel(_('Show history rates')), hist_checkbox)) + fiat_widgets.append((QLabel(_('Show capital gains in history')), hist_capgains_checkbox)) fiat_widgets.append((QLabel(_('Show Fiat balance for addresses')), fiat_address_checkbox)) fiat_widgets.append((QLabel(_('Source')), ex_combo)) @@ -3017,6 +3083,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): grid.addWidget(QLabel(_('Output amount') + ':'), 2, 0) grid.addWidget(output_amount, 2, 1) fee_e = BTCAmountEdit(self.get_decimal_point) + # FIXME with dyn fees, without estimates, there are all kinds of crashes here def f(x): a = max_fee - fee_e.get_amount() output_amount.setText((self.format_amount(a) + ' ' + self.base_unit()) if a else '') @@ -3081,3 +3148,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if is_final: new_tx.set_rbf(False) self.show_transaction(new_tx, tx_label) + + def save_transaction_into_wallet(self, tx): + try: + if not self.wallet.add_transaction(tx.txid(), tx): + self.show_error(_("Transaction could not be saved.") + "\n" + + _("It conflicts with current history.")) + return False + except AddTransactionException as e: + self.show_error(e) + return False + else: + self.wallet.save_transactions(write=True) + # need to update at least: history_list, utxo_list, address_list + self.need_update.set() + self.msg_box(QPixmap(":icons/offline_tx.png"), None, _('Success'), _("Transaction added to wallet history")) + return True + + diff --git a/gui/qt/network_dialog.py b/gui/qt/network_dialog.py index d70eb128..4cadee45 100644 --- a/gui/qt/network_dialog.py +++ b/gui/qt/network_dialog.py @@ -31,8 +31,9 @@ from PyQt5.QtWidgets import * import PyQt5.QtCore as QtCore from electrum.i18n import _ -from electrum.bitcoin import NetworkConstants +from electrum import constants from electrum.util import print_error +from electrum.network import serialize_server, deserialize_server from .util import * @@ -145,7 +146,7 @@ class ServerListWidget(QTreeWidget): menu.exec_(self.viewport().mapToGlobal(position)) def set_server(self, s): - host, port, protocol = s.split(':') + host, port, protocol = deserialize_server(s) self.parent.server_host.setText(host) self.parent.server_port.setText(port) self.parent.set_server() @@ -170,7 +171,7 @@ class ServerListWidget(QTreeWidget): port = d.get(protocol) if port: x = QTreeWidgetItem([_host, port]) - server = _host+':'+port+':'+protocol + server = serialize_server(_host, port, protocol) x.setData(1, Qt.UserRole, server) self.addTopLevelItem(x) @@ -395,7 +396,7 @@ class NetworkChoiceLayout(object): def change_protocol(self, use_ssl): p = 's' if use_ssl else 't' host = self.server_host.text() - pp = self.servers.get(host, NetworkConstants.DEFAULT_PORTS) + pp = self.servers.get(host, constants.net.DEFAULT_PORTS) if p not in pp.keys(): p = list(pp.keys())[0] port = pp[p] @@ -411,7 +412,7 @@ class NetworkChoiceLayout(object): def follow_server(self, server): self.network.switch_to_interface(server) host, port, protocol, proxy, auto_connect = self.network.get_parameters() - host, port, protocol = server.split(':') + host, port, protocol = deserialize_server(server) self.network.set_parameters(host, port, protocol, proxy, auto_connect) self.update() @@ -420,7 +421,7 @@ class NetworkChoiceLayout(object): self.change_server(str(x.text(0)), self.protocol) def change_server(self, host, protocol): - pp = self.servers.get(host, NetworkConstants.DEFAULT_PORTS) + pp = self.servers.get(host, constants.net.DEFAULT_PORTS) if protocol and protocol not in protocol_letters: protocol = None if protocol: @@ -444,7 +445,6 @@ class NetworkChoiceLayout(object): host, port, protocol, proxy, auto_connect = self.network.get_parameters() host = str(self.server_host.text()) port = str(self.server_port.text()) - protocol = 't' if self.config.get('nossl') else 's' auto_connect = self.autoconnect_cb.isChecked() self.network.set_parameters(host, port, protocol, proxy, auto_connect) diff --git a/gui/qt/password_dialog.py b/gui/qt/password_dialog.py index be4ea635..e0da4302 100644 --- a/gui/qt/password_dialog.py +++ b/gui/qt/password_dialog.py @@ -57,7 +57,7 @@ class PasswordLayout(object): titles = [_("Enter Password"), _("Change Password"), _("Enter Passphrase")] - def __init__(self, wallet, msg, kind, OK_button): + def __init__(self, wallet, msg, kind, OK_button, force_disable_encrypt_cb=False): self.wallet = wallet self.pw = QLineEdit() @@ -126,7 +126,8 @@ class PasswordLayout(object): def enable_OK(): ok = self.new_pw.text() == self.conf_pw.text() OK_button.setEnabled(ok) - self.encrypt_cb.setEnabled(ok and bool(self.new_pw.text())) + self.encrypt_cb.setEnabled(ok and bool(self.new_pw.text()) + and not force_disable_encrypt_cb) self.new_pw.textChanged.connect(enable_OK) self.conf_pw.textChanged.connect(enable_OK) @@ -163,11 +164,84 @@ class PasswordLayout(object): return pw -class ChangePasswordDialog(WindowModalDialog): +class PasswordLayoutForHW(object): + + def __init__(self, wallet, msg, kind, OK_button): + self.wallet = wallet + + self.kind = kind + self.OK_button = OK_button + + vbox = QVBoxLayout() + label = QLabel(msg + "\n") + label.setWordWrap(True) + + grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnMinimumWidth(0, 150) + grid.setColumnMinimumWidth(1, 100) + grid.setColumnStretch(1,1) + + logo_grid = QGridLayout() + logo_grid.setSpacing(8) + logo_grid.setColumnMinimumWidth(0, 70) + logo_grid.setColumnStretch(1,1) + + logo = QLabel() + logo.setAlignment(Qt.AlignCenter) + + logo_grid.addWidget(logo, 0, 0) + logo_grid.addWidget(label, 0, 1, 1, 2) + vbox.addLayout(logo_grid) + + if wallet and wallet.has_storage_encryption(): + lockfile = ":icons/lock.png" + else: + lockfile = ":icons/unlock.png" + logo.setPixmap(QPixmap(lockfile).scaledToWidth(36)) + + vbox.addLayout(grid) + + self.encrypt_cb = QCheckBox(_('Encrypt wallet file')) + grid.addWidget(self.encrypt_cb, 1, 0, 1, 2) + + self.vbox = vbox + + def title(self): + return _("Toggle Encryption") + + def layout(self): + return self.vbox + + +class ChangePasswordDialogBase(WindowModalDialog): def __init__(self, parent, wallet): WindowModalDialog.__init__(self, parent) - is_encrypted = wallet.storage.is_encrypted() + is_encrypted = wallet.has_storage_encryption() + OK_button = OkButton(self) + + self.create_password_layout(wallet, is_encrypted, OK_button) + + self.setWindowTitle(self.playout.title()) + vbox = QVBoxLayout(self) + vbox.addLayout(self.playout.layout()) + vbox.addStretch(1) + vbox.addLayout(Buttons(CancelButton(self), OK_button)) + self.playout.encrypt_cb.setChecked(is_encrypted) + + def create_password_layout(self, wallet, is_encrypted, OK_button): + raise NotImplementedError() + + +class ChangePasswordDialogForSW(ChangePasswordDialogBase): + + def __init__(self, parent, wallet): + ChangePasswordDialogBase.__init__(self, parent, wallet) + if not wallet.has_password(): + self.playout.encrypt_cb.setChecked(True) + + def create_password_layout(self, wallet, is_encrypted, OK_button): if not wallet.has_password(): msg = _('Your wallet is not protected.') msg += ' ' + _('Use this dialog to add a password to your wallet.') @@ -177,14 +251,9 @@ class ChangePasswordDialog(WindowModalDialog): else: msg = _('Your wallet is password protected and encrypted.') msg += ' ' + _('Use this dialog to change your password.') - OK_button = OkButton(self) - self.playout = PasswordLayout(wallet, msg, PW_CHANGE, OK_button) - self.setWindowTitle(self.playout.title()) - vbox = QVBoxLayout(self) - vbox.addLayout(self.playout.layout()) - vbox.addStretch(1) - vbox.addLayout(Buttons(CancelButton(self), OK_button)) - self.playout.encrypt_cb.setChecked(is_encrypted or not wallet.has_password()) + self.playout = PasswordLayout( + wallet, msg, PW_CHANGE, OK_button, + force_disable_encrypt_cb=not wallet.can_have_keystore_encryption()) def run(self): if not self.exec_(): @@ -192,6 +261,26 @@ class ChangePasswordDialog(WindowModalDialog): return True, self.playout.old_password(), self.playout.new_password(), self.playout.encrypt_cb.isChecked() +class ChangePasswordDialogForHW(ChangePasswordDialogBase): + + def __init__(self, parent, wallet): + ChangePasswordDialogBase.__init__(self, parent, wallet) + + def create_password_layout(self, wallet, is_encrypted, OK_button): + if not is_encrypted: + msg = _('Your wallet file is NOT encrypted.') + else: + msg = _('Your wallet file is encrypted.') + msg += '\n' + _('Note: If you enable this setting, you will need your hardware device to open your wallet.') + msg += '\n' + _('Use this dialog to toggle encryption.') + self.playout = PasswordLayoutForHW(wallet, msg, PW_CHANGE, OK_button) + + def run(self): + if not self.exec_(): + return False, None + return True, self.playout.encrypt_cb.isChecked() + + class PasswordDialog(WindowModalDialog): def __init__(self, parent=None, msg=None): diff --git a/gui/qt/request_list.py b/gui/qt/request_list.py index f8d5ea92..59b084ff 100644 --- a/gui/qt/request_list.py +++ b/gui/qt/request_list.py @@ -115,7 +115,7 @@ class RequestList(MyTreeWidget): column_title = self.headerItem().text(column) column_data = item.text(column) menu = QMenu(self) - menu.addAction(_("Copy %s")%column_title, lambda: self.parent.app.clipboard().setText(column_data)) + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) menu.addAction(_("Copy URI"), lambda: self.parent.view_and_paste('URI', '', self.parent.get_request_URI(addr))) menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr)) menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr)) diff --git a/gui/qt/transaction_dialog.py b/gui/qt/transaction_dialog.py index dc201bb4..4fc589dd 100644 --- a/gui/qt/transaction_dialog.py +++ b/gui/qt/transaction_dialog.py @@ -25,6 +25,7 @@ import copy import datetime import json +import traceback from PyQt5.QtCore import * from PyQt5.QtGui import * @@ -33,16 +34,27 @@ from PyQt5.QtWidgets import * from electrum.bitcoin import base_encode from electrum.i18n import _ from electrum.plugins import run_hook +from electrum import simple_config from electrum.util import bfh +from electrum.wallet import AddTransactionException +from electrum.transaction import SerializationError + from .util import * dialogs = [] # Otherwise python randomly garbage collects the dialogs... + def show_transaction(tx, parent, desc=None, prompt_if_unsaved=False): - d = TxDialog(tx, parent, desc, prompt_if_unsaved) - dialogs.append(d) - d.show() + try: + d = TxDialog(tx, parent, desc, prompt_if_unsaved) + except SerializationError as e: + traceback.print_exc(file=sys.stderr) + parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)) + else: + dialogs.append(d) + d.show() + class TxDialog(QDialog, MessageBoxMixin): @@ -56,7 +68,10 @@ class TxDialog(QDialog, MessageBoxMixin): # e.g. the FX plugin. If this happens during or after a long # sign operation the signatures are lost. self.tx = copy.deepcopy(tx) - self.tx.deserialize() + try: + self.tx.deserialize() + except BaseException as e: + raise SerializationError(e) self.main_window = parent self.wallet = parent.wallet self.prompt_if_unsaved = prompt_if_unsaved @@ -98,8 +113,17 @@ class TxDialog(QDialog, MessageBoxMixin): self.broadcast_button = b = QPushButton(_("Broadcast")) b.clicked.connect(self.do_broadcast) - self.save_button = b = QPushButton(_("Save")) - b.clicked.connect(self.save) + self.save_button = QPushButton(_("Save")) + save_button_disabled = not tx.is_complete() + self.save_button.setDisabled(save_button_disabled) + if save_button_disabled: + self.save_button.setToolTip(_("Please sign this transaction in order to save it")) + else: + self.save_button.setToolTip("") + self.save_button.clicked.connect(self.save) + + self.export_button = b = QPushButton(_("Export")) + b.clicked.connect(self.export) self.cancel_button = b = QPushButton(_("Close")) b.clicked.connect(self.close) @@ -112,9 +136,9 @@ class TxDialog(QDialog, MessageBoxMixin): self.copy_button = CopyButton(lambda: str(self.tx), parent.app) # Action buttons - self.buttons = [self.sign_button, self.broadcast_button, self.cancel_button] + self.buttons = [self.sign_button, self.broadcast_button, self.save_button, self.cancel_button] # Transaction sharing buttons - self.sharing_buttons = [self.copy_button, self.qr_button, self.save_button] + self.sharing_buttons = [self.copy_button, self.qr_button, self.export_button] run_hook('transaction_dialog', self) @@ -136,11 +160,14 @@ class TxDialog(QDialog, MessageBoxMixin): def closeEvent(self, event): if (self.prompt_if_unsaved and not self.saved - and not self.question(_('This transaction is not saved. Close anyway?'), title=_("Warning"))): + and not self.question(_('This transaction is not saved. Close anyway?'), title=_("Warning"))): event.ignore() else: event.accept() - dialogs.remove(self) + try: + dialogs.remove(self) + except ValueError: + pass # was not in list already def show_qr(self): text = bfh(str(self.tx)) @@ -155,6 +182,8 @@ class TxDialog(QDialog, MessageBoxMixin): if success: self.prompt_if_unsaved = True self.saved = False + self.save_button.setDisabled(False) + self.save_button.setToolTip("") self.update() self.main_window.pop_top_level_window(self) @@ -163,12 +192,18 @@ class TxDialog(QDialog, MessageBoxMixin): self.main_window.sign_tx(self.tx, sign_done) def save(self): + if self.main_window.save_transaction_into_wallet(self.tx): + self.save_button.setDisabled(True) + self.saved = True + + + def export(self): name = 'signed_%s.txn' % (self.tx.txid()[0:8]) if self.tx.is_complete() else 'unsigned.txn' fileName = self.main_window.getSaveFileName(_("Select where to save your signed transaction"), name, "*.txn") if fileName: with open(fileName, "w+") as f: f.write(json.dumps(self.tx.as_dict(), indent=4) + '\n') - self.show_message(_("Transaction saved successfully")) + self.show_message(_("Transaction exported successfully")) self.saved = True def update(self): @@ -191,11 +226,11 @@ class TxDialog(QDialog, MessageBoxMixin): if timestamp: time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3] - self.date_label.setText(_("Date: %s")%time_str) + self.date_label.setText(_("Date: {}").format(time_str)) self.date_label.show() elif exp_n: - text = '%d blocks'%(exp_n) if exp_n > 0 else _('unknown (low fee)') - self.date_label.setText(_('Expected confirmation time') + ': ' + text) + text = '%.2f MB'%(exp_n/1000000) + self.date_label.setText(_('Position in mempool') + ': ' + text + ' ' + _('from tip')) self.date_label.show() else: self.date_label.hide() @@ -206,9 +241,13 @@ class TxDialog(QDialog, MessageBoxMixin): else: amount_str = _("Amount sent:") + ' %s'% format_amount(-amount) + ' ' + base_unit size_str = _("Size:") + ' %d bytes'% size - fee_str = _("Fee") + ': %s'% (format_amount(fee) + ' ' + base_unit if fee is not None else _('Unknown')) + fee_str = _("Fee") + ': %s' % (format_amount(fee) + ' ' + base_unit if fee is not None else _('unknown')) if fee is not None: - fee_str += ' ( %s ) '% self.main_window.format_fee_rate(fee/size*1000) + fee_rate = fee/size*1000 + fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate) + confirm_rate = simple_config.FEERATE_WARNING_HIGH_FEE + if fee_rate > confirm_rate: + fee_str += ' - ' + _('Warning') + ': ' + _("high fee") + '!' self.amount_label.setText(amount_str) self.fee_label.setText(fee_str) self.size_label.setText(size_str) @@ -224,7 +263,7 @@ class TxDialog(QDialog, MessageBoxMixin): rec.setBackground(QBrush(ColorScheme.GREEN.as_color(background=True))) rec.setToolTip(_("Wallet receive address")) chg = QTextCharFormat() - chg.setBackground(QBrush(QColor("yellow"))) + chg.setBackground(QBrush(ColorScheme.YELLOW.as_color(background=True))) chg.setToolTip(_("Wallet change address")) def text_format(addr): @@ -254,7 +293,7 @@ class TxDialog(QDialog, MessageBoxMixin): if _addr: addr = _addr if addr is None: - addr = _('Unknown') + addr = _('unknown') cursor.insertText(addr, text_format(addr)) if x.get('value'): cursor.insertText(format_amount(x['value']), ext) diff --git a/gui/qt/util.py b/gui/qt/util.py index 1fa9d0c1..6af105a1 100644 --- a/gui/qt/util.py +++ b/gui/qt/util.py @@ -6,11 +6,15 @@ import queue from collections import namedtuple from functools import partial -from electrum.i18n import _ from PyQt5.QtGui import * from PyQt5.QtCore import * from PyQt5.QtWidgets import * +from electrum.i18n import _ +from electrum.util import FileImportFailed, FileExportFailed +from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED + + if platform.system() == 'Windows': MONOSPACE_FONT = 'Lucida Console' elif platform.system() == 'Darwin': @@ -21,8 +25,6 @@ else: dialogs = [] -from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED - pr_icons = { PR_UNPAID:":icons/unpaid.png", PR_PAID:":icons/confirmed.png", @@ -200,9 +202,14 @@ class MessageBoxMixin(object): def msg_box(self, icon, parent, title, text, buttons=QMessageBox.Ok, defaultButton=QMessageBox.NoButton): parent = parent or self.top_level_window() - d = QMessageBox(icon, title, str(text), buttons, parent) + if type(icon) is QPixmap: + d = QMessageBox(QMessageBox.Information, title, str(text), buttons, parent) + d.setIconPixmap(icon) + else: + d = QMessageBox(icon, title, str(text), buttons, parent) d.setWindowModality(Qt.WindowModal) d.setDefaultButton(defaultButton) + d.setTextInteractionFlags(Qt.TextSelectableByMouse) return d.exec_() class WindowModalDialog(QDialog, MessageBoxMixin): @@ -216,7 +223,7 @@ class WindowModalDialog(QDialog, MessageBoxMixin): class WaitingDialog(WindowModalDialog): - '''Shows a please wait dialog whilst runnning a task. It is not + '''Shows a please wait dialog whilst running a task. It is not necessary to maintain a reference to this dialog.''' def __init__(self, parent, message, task, on_success=None, on_error=None): assert parent @@ -228,6 +235,7 @@ class WaitingDialog(WindowModalDialog): self.accepted.connect(self.on_accepted) self.show() self.thread = TaskThread(self) + self.thread.finished.connect(self.deleteLater) # see #3956 self.thread.add(task, on_success, self.accept, on_error) def wait(self): @@ -254,7 +262,7 @@ def line_dialog(parent, title, label, ok_label, default=None): def text_dialog(parent, title, label, ok_label, default=None, allow_multi=False): from .qrtextedit import ScanQRTextEdit dialog = WindowModalDialog(parent, title) - dialog.setMinimumWidth(500) + dialog.setMinimumWidth(600) l = QVBoxLayout() dialog.setLayout(l) l.addWidget(QLabel(label)) @@ -389,13 +397,18 @@ class MyTreeWidget(QTreeWidget): self.editor = None self.pending_update = False if editable_columns is None: - editable_columns = [stretch_column] + editable_columns = {stretch_column} + else: + editable_columns = set(editable_columns) self.editable_columns = editable_columns self.setItemDelegate(ElectrumItemDelegate(self)) self.itemDoubleClicked.connect(self.on_doubleclick) self.update_headers(headers) self.current_filter = "" + self.setRootIsDecorated(False) # remove left margin + self.toolbar_shown = False + def update_headers(self, headers): self.setColumnCount(len(headers)) self.setHeaderLabels(headers) @@ -406,11 +419,15 @@ class MyTreeWidget(QTreeWidget): def editItem(self, item, column): if column in self.editable_columns: - self.editing_itemcol = (item, column, item.text(column)) - # Calling setFlags causes on_changed events for some reason - item.setFlags(item.flags() | Qt.ItemIsEditable) - QTreeWidget.editItem(self, item, column) - item.setFlags(item.flags() & ~Qt.ItemIsEditable) + try: + self.editing_itemcol = (item, column, item.text(column)) + # Calling setFlags causes on_changed events for some reason + item.setFlags(item.flags() | Qt.ItemIsEditable) + QTreeWidget.editItem(self, item, column) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + except RuntimeError: + # (item) wrapped C/C++ object has been deleted + pass def keyPressEvent(self, event): if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None: @@ -478,8 +495,12 @@ class MyTreeWidget(QTreeWidget): self.pending_update = True else: self.setUpdatesEnabled(False) + scroll_pos = self.verticalScrollBar().value() self.on_update() self.setUpdatesEnabled(True) + # To paint the list before resetting the scroll position + self.parent.app.processEvents() + self.verticalScrollBar().setValue(scroll_pos) if self.current_filter: self.filter(self.current_filter) @@ -503,6 +524,37 @@ class MyTreeWidget(QTreeWidget): item.setHidden(all([item.text(column).lower().find(p) == -1 for column in columns])) + def create_toolbar(self, config=None): + hbox = QHBoxLayout() + buttons = self.get_toolbar_buttons() + for b in buttons: + b.setVisible(False) + hbox.addWidget(b) + hide_button = QPushButton('x') + hide_button.setVisible(False) + hide_button.pressed.connect(lambda: self.show_toolbar(False, config)) + self.toolbar_buttons = buttons + (hide_button,) + hbox.addStretch() + hbox.addWidget(hide_button) + return hbox + + def save_toolbar_state(self, state, config): + pass # implemented in subclasses + + def show_toolbar(self, state, config=None): + if state == self.toolbar_shown: + return + self.toolbar_shown = state + if config: + self.save_toolbar_state(state, config) + for b in self.toolbar_buttons: + b.setVisible(state) + if not state: + self.on_hide_toolbar() + + def toggle_toolbar(self, config=None): + self.show_toolbar(not self.toolbar_shown, config) + class ButtonsWidget(QWidget): @@ -589,12 +641,12 @@ class TaskThread(QThread): except BaseException: self.doneSig.emit(sys.exc_info(), task.cb_done, task.cb_error) - def on_done(self, result, cb_done, cb): + def on_done(self, result, cb_done, cb_result): # This runs in the parent's thread. if cb_done: cb_done() - if cb: - cb(result) + if cb_result: + cb_result(result) def stop(self): self.tasks.put(None) @@ -621,6 +673,7 @@ class ColorScheme: dark_scheme = False GREEN = ColorSchemeItem("#117c11", "#8af296") + YELLOW = ColorSchemeItem("#ffff00", "#ffff00") RED = ColorSchemeItem("#7c1111", "#f18c8c") BLUE = ColorSchemeItem("#123b7c", "#8cb3f2") DEFAULT = ColorSchemeItem("black", "white") @@ -635,6 +688,97 @@ class ColorScheme: if ColorScheme.has_dark_background(widget): ColorScheme.dark_scheme = True + +class AcceptFileDragDrop: + def __init__(self, file_type=""): + assert isinstance(self, QWidget) + self.setAcceptDrops(True) + self.file_type = file_type + + def validateEvent(self, event): + if not event.mimeData().hasUrls(): + event.ignore() + return False + for url in event.mimeData().urls(): + if not url.toLocalFile().endswith(self.file_type): + event.ignore() + return False + event.accept() + return True + + def dragEnterEvent(self, event): + self.validateEvent(event) + + def dragMoveEvent(self, event): + if self.validateEvent(event): + event.setDropAction(Qt.CopyAction) + + def dropEvent(self, event): + if self.validateEvent(event): + for url in event.mimeData().urls(): + self.onFileAdded(url.toLocalFile()) + + def onFileAdded(self, fn): + raise NotImplementedError() + + +def import_meta_gui(electrum_window, title, importer, on_success): + filter_ = "JSON (*.json);;All files (*)" + filename = electrum_window.getOpenFileName(_("Open {} file").format(title), filter_) + if not filename: + return + try: + importer(filename) + except FileImportFailed as e: + electrum_window.show_critical(str(e)) + else: + electrum_window.show_message(_("Your {} were successfully imported").format(title)) + on_success() + + +def export_meta_gui(electrum_window, title, exporter): + filter_ = "JSON (*.json);;All files (*)" + filename = electrum_window.getSaveFileName(_("Select file to save your {}").format(title), + 'electrum_{}.json'.format(title), filter_) + if not filename: + return + try: + exporter(filename) + except FileExportFailed as e: + electrum_window.show_critical(str(e)) + else: + electrum_window.show_message(_("Your {0} were exported to '{1}'") + .format(title, str(filename))) + + +def get_parent_main_window(widget): + """Returns a reference to the ElectrumWindow this widget belongs to.""" + from .main_window import ElectrumWindow + for _ in range(100): + if widget is None: + return None + if not isinstance(widget, ElectrumWindow): + widget = widget.parentWidget() + else: + return widget + return None + +class SortableTreeWidgetItem(QTreeWidgetItem): + DataRole = Qt.UserRole + 1 + + def __lt__(self, other): + column = self.treeWidget().sortColumn() + if None not in [x.data(column, self.DataRole) for x in [self, other]]: + # We have set custom data to sort by + return self.data(column, self.DataRole) < other.data(column, self.DataRole) + try: + # Is the value something numeric? + return float(self.text(column)) < float(other.text(column)) + except ValueError: + # If not, we will just do string comparison + return self.text(column) < other.text(column) + + if __name__ == "__main__": app = QApplication([]) t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done")) diff --git a/gui/qt/utxo_list.py b/gui/qt/utxo_list.py index 40f9fa20..78e86536 100644 --- a/gui/qt/utxo_list.py +++ b/gui/qt/utxo_list.py @@ -32,6 +32,7 @@ class UTXOList(MyTreeWidget): def __init__(self, parent=None): MyTreeWidget.__init__(self, parent, self.create_menu, [ _('Address'), _('Label'), _('Amount'), _('Height'), _('Output point')], 1) self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setSortingEnabled(True) def get_name(self, x): return x.get('prevout_hash') + ":%d"%x.get('prevout_n') @@ -46,9 +47,10 @@ class UTXOList(MyTreeWidget): height = x.get('height') name = self.get_name(x) label = self.wallet.get_label(x.get('prevout_hash')) - amount = self.parent.format_amount(x['value']) - utxo_item = QTreeWidgetItem([address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]]) + amount = self.parent.format_amount(x['value'], whitespaces=True) + utxo_item = SortableTreeWidgetItem([address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]]) utxo_item.setFont(0, QFont(MONOSPACE_FONT)) + utxo_item.setFont(2, QFont(MONOSPACE_FONT)) utxo_item.setFont(4, QFont(MONOSPACE_FONT)) utxo_item.setData(0, Qt.UserRole, name) if self.wallet.is_frozen(address): diff --git a/icons.qrc b/icons.qrc index e1007e58..195e9af9 100644 --- a/icons.qrc +++ b/icons.qrc @@ -14,6 +14,7 @@ icons/electrum_light_icon.png icons/electrum_dark_icon.png icons/file.png + icons/info.png icons/keepkey.png icons/keepkey_unpaired.png icons/key.png @@ -22,6 +23,7 @@ icons/lock.png icons/microphone.png icons/network.png + icons/offline_tx.png icons/qrcode.png icons/qrcode_white.png icons/preferences.png diff --git a/icons/info.png b/icons/info.png new file mode 100644 index 00000000..f11f9969 Binary files /dev/null and b/icons/info.png differ diff --git a/icons/offline_tx.png b/icons/offline_tx.png new file mode 100644 index 00000000..32fee54d Binary files /dev/null and b/icons/offline_tx.png differ diff --git a/icons/unpaid.png b/icons/unpaid.png index e0f3639d..579ec4eb 100644 Binary files a/icons/unpaid.png and b/icons/unpaid.png differ diff --git a/lib/base_wizard.py b/lib/base_wizard.py index 7ff0d411..0b203c68 100644 --- a/lib/base_wizard.py +++ b/lib/base_wizard.py @@ -24,12 +24,19 @@ # SOFTWARE. import os +import sys +import traceback + from . import bitcoin from . import keystore from .keystore import bip44_derivation from .wallet import Imported_Wallet, Standard_Wallet, Multisig_Wallet, wallet_types +from .storage import STO_EV_USER_PW, STO_EV_XPUB_PW, get_derivation_used_for_hw_device_encryption from .i18n import _ +from .util import UserCancelled +# hardware device setup purpose +HWD_SETUP_NEW_WALLET, HWD_SETUP_DECRYPT_WALLET = range(0, 2) class ScriptTypeNotSupported(Exception): pass @@ -123,7 +130,7 @@ class BaseWizard(object): choices = [ ('choose_seed_type', _('Create a new seed')), ('restore_from_seed', _('I already have a seed')), - ('restore_from_key', _('Use public or private keys')), + ('restore_from_key', _('Use a master key')), ] if not self.is_kivy: choices.append(('choose_hw_device', _('Use a hardware device'))) @@ -146,17 +153,22 @@ class BaseWizard(object): is_valid=v, allow_multi=True) def on_import(self, text): + # create a temporary wallet and exploit that modifications + # will be reflected on self.storage if keystore.is_address_list(text): - self.wallet = Imported_Wallet(self.storage) + w = Imported_Wallet(self.storage) for x in text.split(): - self.wallet.import_address(x) + w.import_address(x) elif keystore.is_private_key_list(text): k = keystore.Imported_KeyStore({}) self.storage.put('keystore', k.dump()) - self.wallet = Imported_Wallet(self.storage) + w = Imported_Wallet(self.storage) for x in text.split(): - self.wallet.import_private_key(x, None) - self.terminate() + w.import_private_key(x, None) + self.keystores.append(w.keystore) + else: + return self.terminate() + return self.run('create_wallet') def restore_from_key(self): if self.wallet_type == 'standard': @@ -175,7 +187,7 @@ class BaseWizard(object): k = keystore.from_master_key(text) self.on_keystore(k) - def choose_hw_device(self): + def choose_hw_device(self, purpose=HWD_SETUP_NEW_WALLET): title = _('Hardware Keystore') # check available plugins support = self.plugins.get_hardware_support() @@ -184,7 +196,7 @@ class BaseWizard(object): _('No hardware wallet support found on your system.'), _('Please install the relevant libraries (eg python-trezor for Trezor).'), ]) - self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device()) + self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device(purpose)) return # scan devices devices = [] @@ -204,34 +216,51 @@ class BaseWizard(object): _('If your device is not detected on Windows, go to "Settings", "Devices", "Connected devices", and do "Remove device". Then, plug your device again.') + ' ', _('On Linux, you might have to add a new permission to your udev rules.'), ]) - self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device()) + self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device(purpose)) return # select device self.devices = devices choices = [] for name, info in devices: state = _("initialized") if info.initialized else _("wiped") - label = info.label or _("An unnamed %s")%name + label = info.label or _("An unnamed {}").format(name) descr = "%s [%s, %s]" % (label, name, state) choices.append(((name, info), descr)) msg = _('Select a device') + ':' - self.choice_dialog(title=title, message=msg, choices=choices, run_next=self.on_device) + self.choice_dialog(title=title, message=msg, choices=choices, run_next= lambda *args: self.on_device(*args, purpose=purpose)) - def on_device(self, name, device_info): + def on_device(self, name, device_info, *, purpose): self.plugin = self.plugins.get_plugin(name) try: - self.plugin.setup_device(device_info, self) + self.plugin.setup_device(device_info, self, purpose) + except OSError as e: + self.show_error(_('We encountered an error while connecting to your device:') + + '\n' + str(e) + '\n' + + _('To try to fix this, we will now re-pair with your device.') + '\n' + + _('Please try again.')) + devmgr = self.plugins.device_manager + devmgr.unpair_id(device_info.device.id_) + self.choose_hw_device(purpose) + return except BaseException as e: self.show_error(str(e)) - self.choose_hw_device() + self.choose_hw_device(purpose) return - if self.wallet_type=='multisig': - # There is no general standard for HD multisig. - # This is partially compatible with BIP45; assumes index=0 - self.on_hw_derivation(name, device_info, "m/45'/0") + if purpose == HWD_SETUP_NEW_WALLET: + if self.wallet_type=='multisig': + # There is no general standard for HD multisig. + # This is partially compatible with BIP45; assumes index=0 + self.on_hw_derivation(name, device_info, "m/45'/0") + else: + f = lambda x: self.run('on_hw_derivation', name, device_info, str(x)) + self.derivation_dialog(f) + elif purpose == HWD_SETUP_DECRYPT_WALLET: + derivation = get_derivation_used_for_hw_device_encryption() + xpub = self.plugin.get_xpub(device_info.device.id_, derivation, 'standard', self) + password = keystore.Xpub.get_pubkey_from_xpub(xpub, ()) + self.storage.decrypt(password) else: - f = lambda x: self.run('on_hw_derivation', name, device_info, str(x)) - self.derivation_dialog(f) + raise Exception('unknown purpose: %s' % purpose) def derivation_dialog(self, f): default = bip44_derivation(0, bip43_purpose=44) @@ -364,13 +393,45 @@ class BaseWizard(object): self.run('create_wallet') def create_wallet(self): - if any(k.may_have_password() for k in self.keystores): - self.request_password(run_next=self.on_password) + encrypt_keystore = any(k.may_have_password() for k in self.keystores) + # note: the following condition ("if") is duplicated logic from + # wallet.get_available_storage_encryption_version() + if self.wallet_type == 'standard' and isinstance(self.keystores[0], keystore.Hardware_KeyStore): + # offer encrypting with a pw derived from the hw device + k = self.keystores[0] + try: + k.handler = self.plugin.create_handler(self) + password = k.get_password_for_storage_encryption() + except UserCancelled: + devmgr = self.plugins.device_manager + devmgr.unpair_xpub(k.xpub) + self.choose_hw_device() + return + except BaseException as e: + traceback.print_exc(file=sys.stderr) + self.show_error(str(e)) + return + self.request_storage_encryption( + run_next=lambda encrypt_storage: self.on_password( + password, + encrypt_storage=encrypt_storage, + storage_enc_version=STO_EV_XPUB_PW, + encrypt_keystore=False)) else: - self.on_password(None, False) + # prompt the user to set an arbitrary password + self.request_password( + run_next=lambda password, encrypt_storage: self.on_password( + password, + encrypt_storage=encrypt_storage, + storage_enc_version=STO_EV_USER_PW, + encrypt_keystore=encrypt_keystore), + force_disable_encrypt_cb=not encrypt_keystore) - def on_password(self, password, encrypt): - self.storage.set_password(password, encrypt) + def on_password(self, password, *, encrypt_storage, + storage_enc_version=STO_EV_USER_PW, encrypt_keystore): + self.storage.set_keystore_encryption(bool(password) and encrypt_keystore) + if encrypt_storage: + self.storage.set_password(password, enc_version=storage_enc_version) for k in self.keystores: if k.may_have_password(): k.update_password(None, password) @@ -386,6 +447,13 @@ class BaseWizard(object): self.storage.write() self.wallet = Multisig_Wallet(self.storage) self.run('create_addresses') + elif self.wallet_type == 'imported': + if len(self.keystores) > 0: + keys = self.keystores[0].dump() + self.storage.put('keystore', keys) + self.wallet = Imported_Wallet(self.storage) + self.wallet.storage.write() + self.terminate() def show_xpub_and_add_cosigners(self, xpub): self.show_xpub_dialog(xpub=xpub, run_next=lambda x: self.run('choose_keystore')) @@ -444,5 +512,5 @@ class BaseWizard(object): self.wallet.synchronize() self.wallet.storage.write() self.terminate() - msg = _("Electrum is generating your addresses, please wait.") + msg = _("Electrum is generating your addresses, please wait...") self.waiting_dialog(task, msg) diff --git a/lib/bitcoin.py b/lib/bitcoin.py index 71d79163..aee77791 100644 --- a/lib/bitcoin.py +++ b/lib/bitcoin.py @@ -37,89 +37,11 @@ from .util import bfh, bh2u, to_string from . import version from .util import print_error, InvalidPassword, assert_bytes, to_bytes, inv_dict from . import segwit_addr +from . import constants -def read_json(filename, default): - path = os.path.join(os.path.dirname(__file__), filename) - try: - with open(path, 'r') as f: - r = json.loads(f.read()) - except: - r = default - return r - - - - -# Version numbers for BIP32 extended keys -# standard: xprv, xpub -# segwit in p2sh: yprv, ypub -# native segwit: zprv, zpub -XPRV_HEADERS = { - 'standard': 0x0488ade4, - 'p2wpkh-p2sh': 0x049d7878, - 'p2wsh-p2sh': 0x295b005, - 'p2wpkh': 0x4b2430c, - 'p2wsh': 0x2aa7a99 -} -XPUB_HEADERS = { - 'standard': 0x0488b21e, - 'p2wpkh-p2sh': 0x049d7cb2, - 'p2wsh-p2sh': 0x295b43f, - 'p2wpkh': 0x4b24746, - 'p2wsh': 0x2aa7ed3 -} - - -class NetworkConstants: - - # https://github.com/z-classic/zclassic/blob/master/src/chainparams.cpp#L103 - @classmethod - def set_mainnet(cls): - cls.TESTNET = False - cls.WIF_PREFIX = 0x80 - cls.ADDRTYPE_P2PKH = [0x1C, 0xB8] - cls.ADDRTYPE_P2SH = [0x1C, 0xBD] - cls.ADDRTYPE_SHIELDED = [0x16, 0x9A] - cls.SEGWIT_HRP = "bc" #TODO zcl has no segwit - cls.GENESIS = "0007104ccda289427919efc39dc9e4d499804b7bebc22df55f8b834301260602" - cls.DEFAULT_PORTS = {'t': '50001', 's': '50002'} - cls.DEFAULT_SERVERS = read_json('servers.json', {}) - cls.CHECKPOINTS = read_json('checkpoints.json', []) - cls.EQUIHASH_N = 200 - cls.EQUIHASH_K = 9 - cls.HEADERS_URL = "http://headers.zcl-electrum.com/blockchain_headers" - - cls.CHUNK_SIZE = 200 - - # https://github.com/z-classic/zclassic/blob/master/src/chainparams.cpp#L234 - @classmethod - def set_testnet(cls): - cls.TESTNET = True - cls.WIF_PREFIX = 0xef - cls.ADDRTYPE_P2PKH = [0x1D, 0x25] - cls.ADDRTYPE_P2SH = [0x1C, 0xBA] - cls.ADDRTYPE_SHIELDED = [0x16, 0xB6] - cls.SEGWIT_HRP = "tb" #TODO zcl has no segwit - cls.GENESIS = "03e1c4bb705c871bf9bfda3e74b7f8f86bff267993c215a89d5795e3708e5e1f" - cls.DEFAULT_PORTS = {'t': '51001', 's': '51002'} - cls.DEFAULT_SERVERS = read_json('servers_testnet.json', {}) - cls.CHECKPOINTS = read_json('checkpoints_testnet.json', []) - cls.EQUIHASH_N = 200 - cls.EQUIHASH_K = 9 - - #cls.HEADERS_URL = "http://35.224.186.7/blockchain_headers" - - cls.CHUNK_SIZE = 200 - -NetworkConstants.set_mainnet() ################################## transactions -FEE_STEP = 10000 -DEFAULT_FEE_RATE = 10000 -MAX_FEE_RATE = 300000 -FEE_TARGETS = [25, 10, 5, 2] - COINBASE_MATURITY = 100 COIN = 100000000 @@ -332,11 +254,11 @@ def ser_uint256(u): return rs def sha256(x): - if isinstance(x, str): - x = x.encode('utf8') + x = to_bytes(x, 'utf8') return bytes(hashlib.sha256(x).digest()) def Hash(x): + x = to_bytes(x, 'utf8') out = bytes(sha256(sha256(x))) return out @@ -429,16 +351,16 @@ def b58_address_to_hash160(addr): def hash160_to_p2pkh(h160): - return hash160_to_b58_address(h160, NetworkConstants.ADDRTYPE_P2PKH) + return hash160_to_b58_address(h160, constants.net.ADDRTYPE_P2PKH) def hash160_to_p2sh(h160): - return hash160_to_b58_address(h160, NetworkConstants.ADDRTYPE_P2SH) + return hash160_to_b58_address(h160, constants.net.ADDRTYPE_P2SH) def public_key_to_p2pkh(public_key): return hash160_to_p2pkh(hash_160(public_key)) def hash_to_segwit_addr(h): - return segwit_addr.encode(NetworkConstants.SEGWIT_HRP, 0, h) + return segwit_addr.encode(constants.net.SEGWIT_HRP, 0, h) def public_key_to_p2wpkh(public_key): return hash_to_segwit_addr(hash_160(public_key)) @@ -484,7 +406,7 @@ def script_to_address(script): return addr def address_to_script(addr): - witver, witprog = segwit_addr.decode(NetworkConstants.SEGWIT_HRP, addr) + witver, witprog = segwit_addr.decode(constants.net.SEGWIT_HRP, addr) if witprog is not None: assert (0 <= witver <= 16) OP_n = witver + 0x50 if witver > 0 else 0 @@ -492,11 +414,11 @@ def address_to_script(addr): script += push_script(bh2u(bytes(witprog))) return script addrtype, hash_160 = b58_address_to_hash160(addr) - if addrtype == NetworkConstants.ADDRTYPE_P2PKH: + if addrtype == constants.net.ADDRTYPE_P2PKH: script = '76a9' # op_dup, op_hash_160 script += push_script(bh2u(hash_160)) script += '88ac' # op_equalverify, op_checksig - elif addrtype == NetworkConstants.ADDRTYPE_P2SH: + elif addrtype == constants.net.ADDRTYPE_P2SH: script = 'a9' # op_hash_160 script += push_script(bh2u(hash_160)) script += '87' # op_equal @@ -600,9 +522,8 @@ def DecodeBase58Check(psz): return key - -# extended key export format for segwit - +# backwards compat +# extended WIF for segwit (used in 3.0.x; but still used internally) SCRIPT_TYPES = { 'p2pkh':0, 'p2wpkh':1, @@ -613,26 +534,43 @@ SCRIPT_TYPES = { } -def serialize_privkey(secret, compressed, txin_type): - prefix = bytes([(SCRIPT_TYPES[txin_type]+NetworkConstants.WIF_PREFIX)&255]) +def serialize_privkey(secret, compressed, txin_type, internal_use=False): + if internal_use: + prefix = bytes([(SCRIPT_TYPES[txin_type] + constants.net.WIF_PREFIX) & 255]) + else: + prefix = bytes([constants.net.WIF_PREFIX]) suffix = b'\01' if compressed else b'' vchIn = prefix + secret + suffix - return EncodeBase58Check(vchIn) + base58_wif = EncodeBase58Check(vchIn) + if internal_use: + return base58_wif + else: + return '{}:{}'.format(txin_type, base58_wif) def deserialize_privkey(key): - # whether the pubkey is compressed should be visible from the keystore - vch = DecodeBase58Check(key) if is_minikey(key): return 'p2pkh', minikey_to_private_key(key), True - elif vch: - txin_type = inv_dict(SCRIPT_TYPES)[vch[0] - NetworkConstants.WIF_PREFIX] - assert len(vch) in [33, 34] - compressed = len(vch) == 34 - return txin_type, vch[1:33], compressed - else: + + txin_type = None + if ':' in key: + txin_type, key = key.split(sep=':', maxsplit=1) + assert txin_type in SCRIPT_TYPES + vch = DecodeBase58Check(key) + if not vch: raise BaseException("cannot deserialize", key) + if txin_type is None: + # keys exported in version 3.0.x encoded script type in first byte + txin_type = inv_dict(SCRIPT_TYPES)[vch[0] - constants.net.WIF_PREFIX] + else: + assert vch[0] == constants.net.WIF_PREFIX + + assert len(vch) in [33, 34] + compressed = len(vch) == 34 + return txin_type, vch[1:33], compressed + + def regenerate_key(pk): assert len(pk) == 32 return EC_KEY(pk) @@ -662,7 +600,7 @@ def address_from_private_key(sec): def is_segwit_address(addr): try: - witver, witprog = segwit_addr.decode(NetworkConstants.SEGWIT_HRP, addr) + witver, witprog = segwit_addr.decode(constants.net.SEGWIT_HRP, addr) except Exception as e: return False return witprog is not None @@ -672,7 +610,7 @@ def is_b58_address(addr): addrtype, h = b58_address_to_hash160(addr) except Exception as e: return False - if addrtype not in [NetworkConstants.ADDRTYPE_P2PKH, NetworkConstants.ADDRTYPE_P2SH]: + if addrtype not in [constants.net.ADDRTYPE_P2PKH, constants.net.ADDRTYPE_P2SH]: return False return addr == hash160_to_b58_address(h, addrtype) @@ -735,8 +673,8 @@ def verify_message(address, sig, message): return False -def encrypt_message(message, pubkey): - return EC_KEY.encrypt_message(message, bfh(pubkey)) +def encrypt_message(message, pubkey, magic=b'BIE1'): + return EC_KEY.encrypt_message(message, bfh(pubkey), magic) def chunks(l, n): @@ -881,7 +819,7 @@ class EC_KEY(object): # ECIES encryption/decryption methods; AES-128-CBC with PKCS7 is used as the cipher; hmac-sha256 is used as the mac @classmethod - def encrypt_message(self, message, pubkey): + def encrypt_message(self, message, pubkey, magic=b'BIE1'): assert_bytes(message) pk = ser_to_point(pubkey) @@ -895,20 +833,20 @@ class EC_KEY(object): iv, key_e, key_m = key[0:16], key[16:32], key[32:] ciphertext = aes_encrypt_with_iv(key_e, iv, message) ephemeral_pubkey = bfh(ephemeral.get_public_key(compressed=True)) - encrypted = b'BIE1' + ephemeral_pubkey + ciphertext + encrypted = magic + ephemeral_pubkey + ciphertext mac = hmac.new(key_m, encrypted, hashlib.sha256).digest() return base64.b64encode(encrypted + mac) - def decrypt_message(self, encrypted): + def decrypt_message(self, encrypted, magic=b'BIE1'): encrypted = base64.b64decode(encrypted) if len(encrypted) < 85: raise Exception('invalid ciphertext: length') - magic = encrypted[:4] + magic_found = encrypted[:4] ephemeral_pubkey = encrypted[4:37] ciphertext = encrypted[37:-32] mac = encrypted[-32:] - if magic != b'BIE1': + if magic_found != magic: raise Exception('invalid ciphertext: invalid magic bytes') try: ephemeral_pubkey = ser_to_point(ephemeral_pubkey) @@ -984,25 +922,35 @@ def _CKD_pub(cK, c, s): return cK_n, c_n -def xprv_header(xtype): - return bfh("%08x" % XPRV_HEADERS[xtype]) +def xprv_header(xtype, *, net=None): + if net is None: + net = constants.net + return bfh("%08x" % net.XPRV_HEADERS[xtype]) -def xpub_header(xtype): - return bfh("%08x" % XPUB_HEADERS[xtype]) +def xpub_header(xtype, *, net=None): + if net is None: + net = constants.net + return bfh("%08x" % net.XPUB_HEADERS[xtype]) -def serialize_xprv(xtype, c, k, depth=0, fingerprint=b'\x00'*4, child_number=b'\x00'*4): - xprv = xprv_header(xtype) + bytes([depth]) + fingerprint + child_number + c + bytes([0]) + k +def serialize_xprv(xtype, c, k, depth=0, fingerprint=b'\x00'*4, + child_number=b'\x00'*4, *, net=None): + xprv = xprv_header(xtype, net=net) \ + + bytes([depth]) + fingerprint + child_number + c + bytes([0]) + k return EncodeBase58Check(xprv) -def serialize_xpub(xtype, c, cK, depth=0, fingerprint=b'\x00'*4, child_number=b'\x00'*4): - xpub = xpub_header(xtype) + bytes([depth]) + fingerprint + child_number + c + cK +def serialize_xpub(xtype, c, cK, depth=0, fingerprint=b'\x00'*4, + child_number=b'\x00'*4, *, net=None): + xpub = xpub_header(xtype, net=net) \ + + bytes([depth]) + fingerprint + child_number + c + cK return EncodeBase58Check(xpub) -def deserialize_xkey(xkey, prv): +def deserialize_xkey(xkey, prv, *, net=None): + if net is None: + net = constants.net xkey = DecodeBase58Check(xkey) if len(xkey) != 78: raise BaseException('Invalid length') @@ -1011,7 +959,7 @@ def deserialize_xkey(xkey, prv): child_number = xkey[9:13] c = xkey[13:13+32] header = int('0x' + bh2u(xkey[0:4]), 16) - headers = XPRV_HEADERS if prv else XPUB_HEADERS + headers = net.XPRV_HEADERS if prv else net.XPUB_HEADERS if header not in headers.values(): raise BaseException('Invalid xpub format', hex(header)) xtype = list(headers.keys())[list(headers.values()).index(header)] @@ -1020,11 +968,11 @@ def deserialize_xkey(xkey, prv): return xtype, depth, fingerprint, child_number, c, K_or_k -def deserialize_xpub(xkey): - return deserialize_xkey(xkey, False) +def deserialize_xpub(xkey, *, net=None): + return deserialize_xkey(xkey, False, net=net) -def deserialize_xprv(xkey): - return deserialize_xkey(xkey, True) +def deserialize_xprv(xkey, *, net=None): + return deserialize_xkey(xkey, True, net=net) def xpub_type(x): return deserialize_xpub(x)[0] diff --git a/lib/blockchain.py b/lib/blockchain.py index 45ecdfbb..81809c48 100644 --- a/lib/blockchain.py +++ b/lib/blockchain.py @@ -27,6 +27,7 @@ from io import BytesIO from . import util from . import bitcoin +from . import constants from .bitcoin import * import base64 @@ -84,7 +85,10 @@ def hash_header(header): return '0' * 64 if header.get('prev_block_hash') is None: header['prev_block_hash'] = '00'*64 - return hash_to_str(Hash(serialize_header(header))) + ''' + TODO 32? + ''' + return hash_encode(Hash(bfh(serialize_header(header)))) blockchains = {} @@ -131,7 +135,7 @@ class Blockchain(util.PrintError): self.config = config self.catch_up = None # interface catching up self.checkpoint = checkpoint - self.checkpoints = NetworkConstants.CHECKPOINTS + self.checkpoints = constants.net.CHECKPOINTS self.parent_id = parent_id self.lock = threading.Lock() with self.lock: @@ -175,34 +179,34 @@ class Blockchain(util.PrintError): def update_size(self): p = self.path() - self._size = os.path.getsize(p)//bitcoin.HEADER_SIZE if os.path.exists(p) else 0 + self._size = os.path.getsize(p)//80 if os.path.exists(p) else 0 - def verify_header(self, header, prev_header): - if prev_header: - prev_hash = hash_header(prev_header) - if prev_hash != header.get('prev_block_hash'): - raise BaseException("prev hash mismatch: %s vs %s" % (prev_hash, header.get('prev_block_hash'))) - _powhash = sha256_header(header) - target = self.bits_to_target(header['bits']) - if _powhash > target: - raise BaseException("insufficient proof of work: %s vs target %s" % (int('0x' + _powhash, 16), target)) + def verify_header(self, header, prev_hash, target): + _hash = hash_header(header) + if prev_hash != header.get('prev_block_hash'): + raise BaseException("prev hash mismatch: %s vs %s" % (prev_hash, header.get('prev_block_hash'))) + if constants.net.TESTNET: + return + bits = self.target_to_bits(target) + if bits != header.get('bits'): + raise BaseException("bits mismatch: %s vs %s" % (bits, header.get('bits'))) + if int('0x' + _hash, 16) > target: + raise BaseException("insufficient proof of work: %s vs target %s" % (int('0x' + _hash, 16), target)) nonce = uint256_from_bytes(str_to_hash(header.get('nonce'))) n_solution = vector_from_bytes(base64.b64decode(header.get('n_solution').encode('utf8'))) if not is_gbp_valid(serialize_header(header), nonce, n_solution, - NetworkConstants.EQUIHASH_N, NetworkConstants.EQUIHASH_K): + constants.net.EQUIHASH_N, constants.net.EQUIHASH_K): raise BaseException("Equihash invalid") def verify_chunk(self, index, data): num = len(data) // bitcoin.HEADER_SIZE - prev_header = None - if index != 0: - prev_header = self.read_header(index * NetworkConstants.CHUNK_SIZE - 1) - + prev_hash = self.get_hash(index * constants.net.CHUNK_SIZE - 1) + target = self.get_target(index-1) for i in range(num): raw_header = data[i*bitcoin.HEADER_SIZE:(i+1) * bitcoin.HEADER_SIZE] - header = deserialize_header(raw_header, index*NetworkConstants.CHUNK_SIZE + i) - self.verify_header(header, prev_header) - prev_header = header + header = deserialize_header(raw_header, index*constants.net.CHUNK_SIZE + i) + self.verify_header(header, prev_hash, target) + prev_hash = hash_header(header) def path(self): d = util.get_headers_dir(self.config) @@ -211,11 +215,12 @@ class Blockchain(util.PrintError): def save_chunk(self, index, chunk): filename = self.path() - d = (index * NetworkConstants.CHUNK_SIZE - self.checkpoint) * bitcoin.HEADER_SIZE + d = (index * constants.net.CHUNK_SIZE - self.checkpoint) * bitcoin.HEADER_SIZE if d < 0: chunk = chunk[-d:] d = 0 - self.write(chunk, d) + truncate = index >= len(self.checkpoints) + self.write(chunk, d, truncate) self.swap_with_parent() def swap_with_parent(self): @@ -253,11 +258,11 @@ class Blockchain(util.PrintError): blockchains[self.checkpoint] = self blockchains[parent.checkpoint] = parent - def write(self, data, offset): + def write(self, data, offset, truncate=True): filename = self.path() with self.lock: with open(filename, 'rb+') as f: - if offset != self._size*bitcoin.HEADER_SIZE: + if truncate and offset != self._size*bitcoin.HEADER_SIZE: f.seek(offset) f.truncate() f.seek(offset) @@ -268,7 +273,7 @@ class Blockchain(util.PrintError): def save_header(self, header): delta = header.get('block_height') - self.checkpoint - data = serialize_header(header) + data = bfh(serialize_header(header)) assert delta == self.size() assert len(data) == bitcoin.HEADER_SIZE self.write(data, delta*bitcoin.HEADER_SIZE) @@ -289,25 +294,52 @@ class Blockchain(util.PrintError): with open(name, 'rb') as f: f.seek(delta * bitcoin.HEADER_SIZE) h = f.read(bitcoin.HEADER_SIZE) + if h == bytes([0])*bitcoin.HEADER_SIZE: + return None return deserialize_header(h, height) def get_hash(self, height): - return self.hash_header(self.read_header(height)) + if height == -1: + return '0000000000000000000000000000000000000000000000000000000000000000' + elif height == 0: + return constants.net.GENESIS + elif height < len(self.checkpoints) * constants.net.CHUNK_SIZE: + assert (height+1) % constants.net.CHUNK_SIZE == 0, height + index = height // constants.net.CHUNK_SIZE + h, t = self.checkpoints[index] + return h + else: + return hash_header(self.read_header(height)) - def hash_header(self, header): - return hash_header(header) + def get_target(self, index): + # compute target from chunk x, used in chunk x+1 + if constants.net.TESTNET: + return 0 + if index == -1: + return MAX_TARGET + if index < len(self.checkpoints): + h, t = self.checkpoints[index] + return t + # new target + first = self.read_header(index * constants.net.CHUNK_SIZE) + last = self.read_header(index * constants.net.CHUNK_SIZE + (constants.net.CHUNK_SIZE - 1)) + bits = last.get('bits') + target = self.bits_to_target(bits) + nActualTimespan = last.get('timestamp') - first.get('timestamp') + nTargetTimespan = 14 * 24 * 60 * 60 + nActualTimespan = max(nActualTimespan, nTargetTimespan // 4) + nActualTimespan = min(nActualTimespan, nTargetTimespan * 4) + new_target = min(MAX_TARGET, (target * nActualTimespan) // nTargetTimespan) + return new_target def bits_to_target(self, bits): bitsN = (bits >> 24) & 0xff - # if not (bitsN >= 0x03 and bitsN <= 0x1d): - # raise BaseException("First part of bits should be in [0x03, 0x1d]") + if not (bitsN >= 0x03 and bitsN <= 0x1d): + raise BaseException("First part of bits should be in [0x03, 0x1d]") bitsBase = bits & 0xffffff - # if not (bitsBase >= 0x8000 and bitsBase <= 0x7fffff): - # raise BaseException("Second part of bits should be in [0x8000, 0x7fffff]") - if bitsN <= 3: - return bitsBase >> (8 * (3 - bitsN)) - else: - return bitsBase << (8 * (bitsN - 3)) + if not (bitsBase >= 0x8000 and bitsBase <= 0x7fffff): + raise BaseException("Second part of bits should be in [0x8000, 0x7fffff]") + return bitsBase << (8 * (bitsN-3)) def target_to_bits(self, target): c = ("%064x" % target)[2:] @@ -320,38 +352,42 @@ class Blockchain(util.PrintError): return bitsN << 24 | bitsBase def can_connect(self, header, check_height=True): - # import pdb; pdb.set_trace() height = header['block_height'] if check_height and self.height() != height - 1: self.print_error("cannot connect at height", height) return False if height == 0: - return hash_header(header) == NetworkConstants.GENESIS + return hash_header(header) == constants.net.GENESIS try: - prev_header = self.read_header(height - 1) - prev_hash = self.hash_header(prev_header) + prev_hash = self.get_hash(height - 1) except: return False if prev_hash != header.get('prev_block_hash'): return False + target = self.get_target(height // constants.net.CHUNK_SIZE - 1) try: - self.verify_header(header, prev_header) + self.verify_header(header, prev_hash, target) except BaseException as e: - import traceback - traceback.print_exc() - self.print_error('verify_header failed', str(e)) return False return True def connect_chunk(self, idx, hexdata): try: - data = bytes.fromhex(hexdata) + data = bfh(hexdata) self.verify_chunk(idx, data) - self.print_error("validated chunk %d" % idx) + #self.print_error("validated chunk %d" % idx) self.save_chunk(idx, data) return True except BaseException as e: - import traceback - traceback.print_exc() - self.print_error('verify_chunk failed', str(e)) + self.print_error('verify_chunk %d failed'%idx, str(e)) return False + + def get_checkpoints(self): + # for each chunk, store the hash of the last block and the target after the chunk + cp = [] + n = self.height() // 2016 + for index in range(n): + h = self.get_hash((index+1) * 2016 -1) + target = self.get_target(index) + cp.append((h, target)) + return cp diff --git a/lib/coinchooser.py b/lib/coinchooser.py index c4004c87..c4ca7a15 100644 --- a/lib/coinchooser.py +++ b/lib/coinchooser.py @@ -25,7 +25,7 @@ from collections import defaultdict, namedtuple from math import floor, log10 -from .bitcoin import sha256, COIN, TYPE_ADDRESS +from .bitcoin import sha256, COIN, TYPE_ADDRESS, is_address from .transaction import Transaction from .util import NotEnoughFunds, PrintError @@ -87,6 +87,8 @@ def strip_unneeded(bkts, sufficient_funds): class CoinChooserBase(PrintError): + enable_output_value_rounding = False + def keys(self, coins): raise NotImplementedError @@ -135,7 +137,13 @@ class CoinChooserBase(PrintError): zeroes = [trailing_zeroes(i) for i in output_amounts] min_zeroes = min(zeroes) max_zeroes = max(zeroes) - zeroes = range(max(0, min_zeroes - 1), (max_zeroes + 1) + 1) + + if n > 1: + zeroes = range(max(0, min_zeroes - 1), (max_zeroes + 1) + 1) + else: + # if there is only one change output, this will ensure that we aim + # to have one that is exactly as precise as the most precise output + zeroes = [min_zeroes] # Calculate change; randomize it a bit if using more than 1 output remaining = change_amount @@ -150,8 +158,10 @@ class CoinChooserBase(PrintError): n -= 1 # Last change output. Round down to maximum precision but lose - # no more than 100 satoshis to fees (2dp) - N = pow(10, min(2, zeroes[0])) + # no more than 10**max_dp_to_round_for_privacy + # e.g. a max of 2 decimal places means losing 100 satoshis to fees + max_dp_to_round_for_privacy = 2 if self.enable_output_value_rounding else 0 + N = pow(10, min(max_dp_to_round_for_privacy, zeroes[0])) amount = (remaining // N) * N amounts.append(amount) @@ -230,6 +240,13 @@ class CoinChooserBase(PrintError): tx.add_inputs([coin for b in buckets for coin in b.coins]) tx_weight = get_tx_weight(buckets) + # change is sent back to sending address unless specified + if not change_addrs: + change_addrs = [tx.inputs()[0]['address']] + # note: this is not necessarily the final "first input address" + # because the inputs had not been sorted at this point + assert is_address(change_addrs[0]) + # This takes a count of change outputs and returns a tx fee output_weight = 4 * Transaction.estimated_output_size(change_addrs[0]) fee = lambda count: fee_estimator_w(tx_weight + count * output_weight) @@ -273,6 +290,8 @@ class CoinChooserRandom(CoinChooserBase): candidates.add(tuple(sorted(permutation[:count + 1]))) break else: + # FIXME this assumes that the effective value of any bkt is >= 0 + # we should make sure not to choose buckets with <= 0 eff. val. raise NotEnoughFunds() candidates = [[buckets[n] for n in c] for c in candidates] @@ -284,7 +303,7 @@ class CoinChooserRandom(CoinChooserBase): Any bucket can be: 1. "confirmed" if it only contains confirmed coins; else 2. "unconfirmed" if it does not contain coins with unconfirmed parents - 3. "unconfirmed parent" otherwise + 3. other: e.g. "unconfirmed parent" or "local" This method tries to only use buckets of type 1, and if the coins there are not enough, tries to use the next type but while also selecting @@ -292,9 +311,9 @@ class CoinChooserRandom(CoinChooserBase): """ conf_buckets = [bkt for bkt in buckets if bkt.min_height > 0] unconf_buckets = [bkt for bkt in buckets if bkt.min_height == 0] - unconf_par_buckets = [bkt for bkt in buckets if bkt.min_height == -1] + other_buckets = [bkt for bkt in buckets if bkt.min_height < 0] - bucket_sets = [conf_buckets, unconf_buckets, unconf_par_buckets] + bucket_sets = [conf_buckets, unconf_buckets, other_buckets] already_selected_buckets = [] for bkts_choose_from in bucket_sets: @@ -368,4 +387,6 @@ def get_name(config): def get_coin_chooser(config): klass = COIN_CHOOSERS[get_name(config)] - return klass() + coinchooser = klass() + coinchooser.enable_output_value_rounding = config.get('coin_chooser_output_rounding', False) + return coinchooser diff --git a/lib/commands.py b/lib/commands.py index 1ab2a3b1..9f2bb889 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -34,7 +34,7 @@ from functools import wraps from decimal import Decimal from .import util -from .util import bfh, bh2u, format_satoshis +from .util import bfh, bh2u, format_satoshis, json_decode, print_error from .import bitcoin from .bitcoin import is_address, hash_160, COIN, TYPE_ADDRESS from .i18n import _ @@ -82,7 +82,7 @@ def command(s): password = kwargs.get('password') if c.requires_wallet and wallet is None: raise BaseException("wallet not loaded. Use 'electrum daemon load_wallet'") - if c.requires_password and password is None and wallet.storage.get('use_encryption'): + if c.requires_password and password is None and wallet.has_password(): return {'error': 'Password required' } return func(*args, **kwargs) return func_wrapper @@ -138,6 +138,8 @@ class Commands: @command('wp') def password(self, password=None, new_password=None): """Change wallet password. """ + if self.wallet.storage.is_encrypted_with_hw_device() and new_password: + raise Exception("Can't change the password of a wallet encrypted with a hw device.") b = self.wallet.storage.is_encrypted() self.wallet.update_password(password, new_password, b) self.wallet.storage.write() @@ -151,10 +153,8 @@ class Commands: @command('') def setconfig(self, key, value): """Set a configuration variable. 'value' may be a string or a Python expression.""" - try: - value = ast.literal_eval(value) - except: - pass + if key not in ('rpcuser', 'rpcpassword'): + value = json_decode(value) self.config.set_key(key, value) return True @@ -177,7 +177,8 @@ class Commands: """Return the transaction history of any address. Note: This is a walletless server query, results are not checked by SPV. """ - return self.network.synchronous_get(('blockchain.address.get_history', [address])) + sh = bitcoin.address_to_scripthash(address) + return self.network.synchronous_get(('blockchain.scripthash.get_history', [sh])) @command('w') def listunspent(self): @@ -194,7 +195,8 @@ class Commands: """Returns the UTXO list of any address. Note: This is a walletless server query, results are not checked by SPV. """ - return self.network.synchronous_get(('blockchain.address.listunspent', [address])) + sh = bitcoin.address_to_scripthash(address) + return self.network.synchronous_get(('blockchain.scripthash.listunspent', [sh])) @command('') def serialize(self, jsontx): @@ -316,20 +318,12 @@ class Commands: """Return the balance of any address. Note: This is a walletless server query, results are not checked by SPV. """ - out = self.network.synchronous_get(('blockchain.address.get_balance', [address])) + sh = bitcoin.address_to_scripthash(address) + out = self.network.synchronous_get(('blockchain.scripthash.get_balance', [sh])) out["confirmed"] = str(Decimal(out["confirmed"])/COIN) out["unconfirmed"] = str(Decimal(out["unconfirmed"])/COIN) return out - @command('n') - def getproof(self, address): - """Get Merkle branch of an address in the UTXO set""" - p = self.network.synchronous_get(('blockchain.address.get_proof', [address])) - out = [] - for i,s in p: - out.append(i) - return out - @command('n') def getmerkle(self, txid, height): """Get Merkle branch of a transaction included in a block. Electrum @@ -448,46 +442,20 @@ class Commands: return tx.as_dict() @command('w') - def history(self): + def history(self, year=None, show_addresses=False, show_fiat=False): """Wallet history. Returns the transaction history of your wallet.""" - balance = 0 - out = [] - for item in self.wallet.get_history(): - tx_hash, height, conf, timestamp, value, balance = item - if timestamp: - date = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3] - else: - date = "----" - label = self.wallet.get_label(tx_hash) - tx = self.wallet.transactions.get(tx_hash) - tx.deserialize() - input_addresses = [] - output_addresses = [] - for x in tx.inputs(): - if x['type'] == 'coinbase': continue - addr = x.get('address') - if addr == None: continue - if addr == "(pubkey)": - prevout_hash = x.get('prevout_hash') - prevout_n = x.get('prevout_n') - _addr = self.wallet.find_pay_to_pubkey_address(prevout_hash, prevout_n) - if _addr: - addr = _addr - input_addresses.append(addr) - for addr, v in tx.get_outputs(): - output_addresses.append(addr) - out.append({ - 'txid': tx_hash, - 'timestamp': timestamp, - 'date': date, - 'input_addresses': input_addresses, - 'output_addresses': output_addresses, - 'label': label, - 'value': str(Decimal(value)/COIN) if value is not None else None, - 'height': height, - 'confirmations': conf - }) - return out + kwargs = {'show_addresses': show_addresses} + if year: + import time + start_date = datetime.datetime(year, 1, 1) + end_date = datetime.datetime(year+1, 1, 1) + kwargs['from_timestamp'] = time.mktime(start_date.timetuple()) + kwargs['to_timestamp'] = time.mktime(end_date.timetuple()) + if show_fiat: + from .exchange_rate import FxThread + fx = FxThread(self.config, None) + kwargs['fx'] = fx + return self.wallet.get_full_history(**kwargs) @command('w') def setlabel(self, key, label): @@ -631,6 +599,15 @@ class Commands: out = self.wallet.get_payment_request(addr, self.config) return self._format_request(out) + @command('w') + def addtransaction(self, tx): + """ Add a transaction to the wallet history """ + tx = Transaction(tx) + if not self.wallet.add_transaction(tx.txid(), tx): + return False + self.wallet.save_transactions() + return tx.txid() + @command('wp') def signrequest(self, address, password=None): "Sign payment request with an OpenAlias" @@ -658,13 +635,15 @@ class Commands: import urllib.request headers = {'content-type':'application/json'} data = {'address':address, 'status':x.get('result')} + serialized_data = util.to_bytes(json.dumps(data)) try: - req = urllib.request.Request(URL, json.dumps(data), headers) + req = urllib.request.Request(URL, serialized_data, headers) response_stream = urllib.request.urlopen(req, timeout=5) util.print_error('Got Response for %s' % address) except BaseException as e: util.print_error(str(e)) - self.network.send([('blockchain.address.subscribe', [address])], callback) + h = self.network.addr_to_scripthash(address) + self.network.send([('blockchain.scripthash.subscribe', [h])], callback) return True @command('wn') @@ -672,6 +651,12 @@ class Commands: """ return wallet synchronization status """ return self.wallet.is_up_to_date() + @command('n') + def getfeerate(self): + """Return current optimal fee rate per kilobyte, according + to config settings (static/dynamic)""" + return self.config.fee_per_kb() + @command('') def help(self): # for the python console @@ -727,6 +712,9 @@ command_options = { 'pending': (None, "Show only pending requests."), 'expired': (None, "Show only expired requests."), 'paid': (None, "Show only paid requests."), + 'show_addresses': (None, "Show input and output addresses"), + 'show_fiat': (None, "Show fiat value of transactions"), + 'year': (None, "Show history for a given year"), } @@ -737,6 +725,7 @@ arg_types = { 'num': int, 'nbits': int, 'imax': int, + 'year': int, 'entropy': int, 'tx': tx_from_str, 'pubkeys': json_loads, @@ -800,7 +789,7 @@ def subparser_call(self, parser, namespace, values, option_string=None): parser = self._name_parser_map[parser_name] except KeyError: tup = parser_name, ', '.join(self._name_parser_map) - msg = _('unknown parser %r (choices: %s)') % tup + msg = _('unknown parser {!r} (choices: {})').format(*tup) raise ArgumentError(self, msg) # parse all the remaining options into the namespace # store any unrecognized options on the object, so that the top @@ -825,7 +814,6 @@ def add_global_options(parser): group.add_argument("-P", "--portable", action="store_true", dest="portable", default=False, help="Use local 'electrum_data' directory") group.add_argument("-w", "--wallet", dest="wallet_path", help="wallet path") group.add_argument("--testnet", action="store_true", dest="testnet", default=False, help="Use Testnet") - group.add_argument("--nossl", action="store_true", dest="nossl", default=False, help="Disable SSL") def get_parser(): # create main parser diff --git a/lib/constants.py b/lib/constants.py new file mode 100644 index 00000000..1d90ae3f --- /dev/null +++ b/lib/constants.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2018 The Electrum developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import json + + +def read_json(filename, default): + path = os.path.join(os.path.dirname(__file__), filename) + try: + with open(path, 'r') as f: + r = json.loads(f.read()) + except: + r = default + return r + + +class BitcoinMainnet: + + TESTNET = False + WIF_PREFIX = 0x80 + ADDRTYPE_P2PKH = [0x1C, 0xB8] + ADDRTYPE_P2SH = [0x1C, 0xBD] + ADDRTYPE_SHIELDED = [0x16, 0x9A] + SEGWIT_HRP = "bc" # (No ZCL Segwit) + GENESIS = "0007104ccda289427919efc39dc9e4d499804b7bebc22df55f8b834301260602" + DEFAULT_PORTS = {'t': '50001', 's': '50002'} + DEFAULT_SERVERS = read_json('servers.json', {}) + CHECKPOINTS = read_json('checkpoints.json', []) + + EQUIHASH_N = 200 + EQUIHASH_K = 9 + + CHUNK_SIZE = 200 + + HEADERS_URL = "http://headers.zcl-electrum.com/blockchain_headers" + + XPRV_HEADERS = { + 'standard': 0x0488ade4, # xprv + 'p2wpkh-p2sh': 0x049d7878, # yprv + 'p2wsh-p2sh': 0x0295b005, # Yprv + 'p2wpkh': 0x04b2430c, # zprv + 'p2wsh': 0x02aa7a99, # Zprv + } + XPUB_HEADERS = { + 'standard': 0x0488b21e, # xpub + 'p2wpkh-p2sh': 0x049d7cb2, # ypub + 'p2wsh-p2sh': 0x0295b43f, # Ypub + 'p2wpkh': 0x04b24746, # zpub + 'p2wsh': 0x02aa7ed3, # Zpub + } + + +class BitcoinTestnet: + + TESTNET = True + WIF_PREFIX = 0xef + ADDRTYPE_P2PKH = [0x1D, 0x25] + ADDRTYPE_P2SH = [0x1C, 0xBA] + ADDTYPE_SHIELDED = [0x16, 0xB6] + SEGWIT_HRP = "tb" # (ZCL has no Segwit) + GENESIS = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" + DEFAULT_PORTS = {'t': '51001', 's': '51002'} + DEFAULT_SERVERS = read_json('servers_testnet.json', {}) + CHECKPOINTS = read_json('checkpoints_testnet.json', []) + + EQUIHASH_N = 200 + EQUIHASH_K = 9 + + CHUNK_SIZE = 200 + + XPRV_HEADERS = { + 'standard': 0x04358394, # tprv + 'p2wpkh-p2sh': 0x044a4e28, # uprv + 'p2wsh-p2sh': 0x024285b5, # Uprv + 'p2wpkh': 0x045f18bc, # vprv + 'p2wsh': 0x02575048, # Vprv + } + XPUB_HEADERS = { + 'standard': 0x043587cf, # tpub + 'p2wpkh-p2sh': 0x044a5262, # upub + 'p2wsh-p2sh': 0x024285ef, # Upub + 'p2wpkh': 0x045f1cf6, # vpub + 'p2wsh': 0x02575483, # Vpub + } + + +# don't import net directly, import the module instead (so that net is singleton) +net = BitcoinMainnet + + +def set_mainnet(): + global net + net = BitcoinMainnet + + +def set_testnet(): + global net + net = BitcoinTestnet diff --git a/lib/contacts.py b/lib/contacts.py index 297cf272..5af0b78f 100644 --- a/lib/contacts.py +++ b/lib/contacts.py @@ -23,9 +23,12 @@ import re import dns import json +import traceback +import sys from . import bitcoin from . import dnssec +from .util import export_meta, import_meta class Contacts(dict): @@ -48,14 +51,15 @@ class Contacts(dict): self.storage.put('contacts', dict(self)) def import_file(self, path): - try: - with open(path, 'r') as f: - d = self._validate(json.loads(f.read())) - except: - return - self.update(d) + import_meta(path, self._validate, self.load_meta) + + def load_meta(self, data): + self.update(data) self.save() + def export_file(self, filename): + export_meta(self, filename) + def __setitem__(self, key, value): dict.__setitem__(self, key, value) self.save() @@ -113,13 +117,13 @@ class Contacts(dict): return None def _validate(self, data): - for k,v in list(data.items()): + for k, v in list(data.items()): if k == 'contacts': return self._validate(v) if not bitcoin.is_address(k): data.pop(k) else: - _type,_ = v + _type, _ = v if _type != 'address': data.pop(k) return data diff --git a/lib/currencies.json b/lib/currencies.json index 725f260b..315940df 100644 --- a/lib/currencies.json +++ b/lib/currencies.json @@ -2,6 +2,7 @@ "CoinMarketCap": [ "USD", "EUR", - "CNY" + "RUB", + "USD" ] -} + } diff --git a/lib/daemon.py b/lib/daemon.py index 38baeb15..b3242215 100644 --- a/lib/daemon.py +++ b/lib/daemon.py @@ -25,6 +25,8 @@ import ast import os import time +import traceback +import sys # from jsonrpc import JSONRPCResponseManager import jsonrpclib @@ -121,13 +123,12 @@ class Daemon(DaemonThread): self.config = config if config.get('offline'): self.network = None - self.fx = None else: self.network = Network(config) self.network.start() - self.fx = FxThread(config, self.network) + self.fx = FxThread(config, self.network) + if self.network: self.network.add_jobs([self.fx]) - self.gui = None self.wallets = {} # Setup JSONRPC server @@ -173,7 +174,7 @@ class Daemon(DaemonThread): path = config.get_wallet_path() wallet = self.load_wallet(path, config.get('password')) self.cmd_runner.wallet = wallet - response = True + response = wallet is not None elif sub == 'close_wallet': path = config.get_wallet_path() if path in self.wallets: @@ -301,4 +302,8 @@ class Daemon(DaemonThread): gui_name = 'qt' gui = __import__('electrum_gui.' + gui_name, fromlist=['electrum_gui']) self.gui = gui.ElectrumGui(config, self, plugins) - self.gui.main() + try: + self.gui.main() + except BaseException as e: + traceback.print_exc(file=sys.stdout) + # app will exit now diff --git a/lib/exchange_rate.py b/lib/exchange_rate.py index 14217e7c..69a53070 100644 --- a/lib/exchange_rate.py +++ b/lib/exchange_rate.py @@ -2,9 +2,12 @@ from datetime import datetime import inspect import requests import sys +import os +import json from threading import Thread import time import csv +import decimal from decimal import Decimal from .bitcoin import COIN @@ -13,15 +16,12 @@ from .util import PrintError, ThreadJob # See https://en.wikipedia.org/wiki/ISO_4217 -CCY_PRECISIONS = {} -''' -{'BHD': 3, 'BIF': 0, 'BYR': 0, 'CLF': 4, 'CLP': 0, -'CVE': 0, 'DJF': 0, 'GNF': 0, 'IQD': 3, 'ISK': 0, -'JOD': 3, 'JPY': 0, 'KMF': 0, 'KRW': 0, 'KWD': 3, -'LYD': 3, 'MGA': 1, 'MRO': 1, 'OMR': 3, 'PYG': 0, -'RWF': 0, 'TND': 3, 'UGX': 0, 'UYI': 0, 'VND': 0, -'VUV': 0, 'XAF': 0, 'XAU': 4, 'XOF': 0, 'XPF': 0} -''' +CCY_PRECISIONS = {'BHD': 3, 'BIF': 0, 'BYR': 0, 'CLF': 4, 'CLP': 0, + 'CVE': 0, 'DJF': 0, 'GNF': 0, 'IQD': 3, 'ISK': 0, + 'JOD': 3, 'JPY': 0, 'KMF': 0, 'KRW': 0, 'KWD': 3, + 'LYD': 3, 'MGA': 1, 'MRO': 1, 'OMR': 3, 'PYG': 0, + 'RWF': 0, 'TND': 3, 'UGX': 0, 'UYI': 0, 'VND': 0, + 'VUV': 0, 'XAF': 0, 'XAU': 4, 'XOF': 0, 'XPF': 0} class ExchangeBase(PrintError): @@ -34,7 +34,7 @@ class ExchangeBase(PrintError): def get_json(self, site, get_string): # APIs must have https url = ''.join(['https://', site, get_string]) - response = requests.request('GET', url, headers={'User-Agent' : 'Electrum'}) + response = requests.request('GET', url, headers={'User-Agent' : 'Electrum'}, timeout=10) return response.json() def get_csv(self, site, get_string): @@ -60,28 +60,54 @@ class ExchangeBase(PrintError): t.setDaemon(True) t.start() - def get_historical_rates_safe(self, ccy): + def read_historical_rates(self, ccy, cache_dir): + filename = os.path.join(cache_dir, self.name() + '_'+ ccy) + if os.path.exists(filename): + timestamp = os.stat(filename).st_mtime + try: + with open(filename, 'r') as f: + h = json.loads(f.read()) + h['timestamp'] = timestamp + except: + h = None + else: + h = None + if h: + self.history[ccy] = h + self.on_history() + return h + + def get_historical_rates_safe(self, ccy, cache_dir): try: self.print_error("requesting fx history for", ccy) - self.history[ccy] = self.historical_rates(ccy) + h = self.request_history(ccy) self.print_error("received fx history for", ccy) - self.on_history() except BaseException as e: self.print_error("failed fx history:", e) + return + filename = os.path.join(cache_dir, self.name() + '_' + ccy) + with open(filename, 'w') as f: + f.write(json.dumps(h)) + h['timestamp'] = time.time() + self.history[ccy] = h + self.on_history() - def get_historical_rates(self, ccy): - result = self.history.get(ccy) - if not result and ccy in self.history_ccys(): - t = Thread(target=self.get_historical_rates_safe, args=(ccy,)) + def get_historical_rates(self, ccy, cache_dir): + if ccy not in self.history_ccys(): + return + h = self.history.get(ccy) + if h is None: + h = self.read_historical_rates(ccy, cache_dir) + if h is None or h['timestamp'] < time.time() - 24*3600: + t = Thread(target=self.get_historical_rates_safe, args=(ccy, cache_dir)) t.setDaemon(True) t.start() - return result def history_ccys(self): return [] def historical_rate(self, ccy, d_t): - return self.history.get(ccy, {}).get(d_t.strftime('%Y-%m-%d')) + return self.history.get(ccy, {}).get(d_t.strftime('%Y-%m-%d'), 'NaN') def get_currencies(self): rates = self.get_rates('') @@ -126,7 +152,9 @@ def get_exchanges_and_currencies(): exchange = klass(None, None) try: d[name] = exchange.get_currencies() + print(name, "ok") except: + print(name, "error") continue with open(path, 'w') as f: f.write(json.dumps(d, indent=4, sort_keys=True)) @@ -157,7 +185,10 @@ class FxThread(ThreadJob): self.history_used_spot = False self.ccy_combo = None self.hist_checkbox = None + self.cache_dir = os.path.join(config.path, 'cache') self.set_exchange(self.config_exchange()) + if not os.path.exists(self.cache_dir): + os.mkdir(self.cache_dir) def get_currencies(self, h): d = get_exchanges_by_ccy(h) @@ -170,13 +201,17 @@ class FxThread(ThreadJob): def ccy_amount_str(self, amount, commas): prec = CCY_PRECISIONS.get(self.ccy, 2) fmt_str = "{:%s.%df}" % ("," if commas else "", max(0, prec)) - return fmt_str.format(round(amount, prec)) + try: + rounded_amount = round(amount, prec) + except decimal.InvalidOperation: + rounded_amount = amount + return fmt_str.format(rounded_amount) def run(self): # This runs from the plugins thread which catches exceptions if self.is_enabled(): if self.timeout ==0 and self.show_history(): - self.exchange.get_historical_rates(self.ccy) + self.exchange.get_historical_rates(self.ccy, self.cache_dir) if self.timeout <= time.time(): self.timeout = time.time() + 150 self.exchange.update(self.ccy) @@ -193,6 +228,12 @@ class FxThread(ThreadJob): def set_history_config(self, b): self.config.set_key('history_rates', bool(b)) + def get_history_capital_gains_config(self): + return bool(self.config.get('history_rates_capital_gains', False)) + + def set_history_capital_gains_config(self, b): + self.config.set_key('history_rates_capital_gains', bool(b)) + def get_fiat_address_config(self): return bool(self.config.get('fiat_address')) @@ -224,45 +265,65 @@ class FxThread(ThreadJob): # A new exchange means new fx quotes, initially empty. Force # a quote refresh self.timeout = 0 + self.exchange.read_historical_rates(self.ccy, self.cache_dir) def on_quotes(self): - self.network.trigger_callback('on_quotes') + if self.network: + self.network.trigger_callback('on_quotes') def on_history(self): - self.network.trigger_callback('on_history') + if self.network: + self.network.trigger_callback('on_history') def exchange_rate(self): '''Returns None, or the exchange rate as a Decimal''' rate = self.exchange.quotes.get(self.ccy) - if rate: - return Decimal(rate) + if rate is None: + return Decimal('NaN') + return Decimal(rate) + + def format_amount(self, btc_balance): + rate = self.exchange_rate() + return '' if rate.is_nan() else "%s" % self.value_str(btc_balance, rate) def format_amount_and_units(self, btc_balance): rate = self.exchange_rate() - return '' if rate is None else "%s %s" % (self.value_str(btc_balance, rate), self.ccy) + return '' if rate.is_nan() else "%s %s" % (self.value_str(btc_balance, rate), self.ccy) def get_fiat_status_text(self, btc_balance, base_unit, decimal_point): rate = self.exchange_rate() - return _(" (No exchange rate available)") if rate is None else " 1 %s=%s %s" % (base_unit, + return _(" (No FX rate available)") if rate.is_nan() else " 1 %s~%s %s" % (base_unit, self.value_str(COIN / (10**(8 - decimal_point)), rate), self.ccy) + def fiat_value(self, satoshis, rate): + return Decimal('NaN') if satoshis is None else Decimal(satoshis) / COIN * Decimal(rate) + def value_str(self, satoshis, rate): - if satoshis is None: # Can happen with incomplete history - return _("Unknown") - if rate: - value = Decimal(satoshis) / COIN * Decimal(rate) - return "%s" % (self.ccy_amount_str(value, True)) - return _("No data") + return self.format_fiat(self.fiat_value(satoshis, rate)) + + def format_fiat(self, value): + if value.is_nan(): + return _("No data") + return "%s" % (self.ccy_amount_str(value, True)) def history_rate(self, d_t): + if d_t is None: + return Decimal('NaN') rate = self.exchange.historical_rate(self.ccy, d_t) # Frequently there is no rate for today, until tomorrow :) # Use spot quotes in that case - if rate is None and (datetime.today().date() - d_t.date()).days <= 2: - rate = self.exchange.quotes.get(self.ccy) + if rate == 'NaN' and (datetime.today().date() - d_t.date()).days <= 2: + rate = self.exchange.quotes.get(self.ccy, 'NaN') self.history_used_spot = True - return rate + return Decimal(rate) def historical_value_str(self, satoshis, d_t): - rate = self.history_rate(d_t) - return self.value_str(satoshis, rate) + return self.format_fiat(self.historical_value(satoshis, d_t)) + + def historical_value(self, satoshis, d_t): + return self.fiat_value(satoshis, self.history_rate(d_t)) + + def timestamp_rate(self, timestamp): + from electrum.util import timestamp_to_datetime + date = timestamp_to_datetime(timestamp) + return self.history_rate(date) diff --git a/lib/interface.py b/lib/interface.py index 6a741bff..81382826 100644 --- a/lib/interface.py +++ b/lib/interface.py @@ -31,9 +31,7 @@ import threading import time import traceback -from .util import print_error, get_cert_path - -ca_path = get_cert_path() +from .util import print_error from . import util from . import x509 diff --git a/lib/keystore.py b/lib/keystore.py index f58cd89b..8819a283 100644 --- a/lib/keystore.py +++ b/lib/keystore.py @@ -28,7 +28,7 @@ from unicodedata import normalize from . import bitcoin from .bitcoin import * - +from . import constants from .util import PrintError, InvalidPassword, hfu from .mnemonic import Mnemonic, load_wordlist from .plugins import run_hook @@ -45,6 +45,10 @@ class KeyStore(PrintError): def can_import(self): return False + def may_have_password(self): + """Returns whether the keystore can be encrypted with a password.""" + raise NotImplementedError() + def get_tx_derivations(self, tx): keypairs = {} for txin in tx.inputs(): @@ -116,9 +120,6 @@ class Imported_KeyStore(Software_KeyStore): def is_deterministic(self): return False - def can_change_password(self): - return True - def get_master_public_key(self): return None @@ -138,7 +139,10 @@ class Imported_KeyStore(Software_KeyStore): def import_privkey(self, sec, password): txin_type, privkey, compressed = deserialize_privkey(sec) pubkey = public_key_from_private_key(privkey, compressed) - self.keypairs[pubkey] = pw_encode(sec, password) + # re-serialize the key so the internal storage format is consistent + serialized_privkey = serialize_privkey( + privkey, compressed, txin_type, internal_use=True) + self.keypairs[pubkey] = pw_encode(serialized_privkey, password) return txin_type, pubkey def delete_imported_key(self, key): @@ -196,9 +200,6 @@ class Deterministic_KeyStore(Software_KeyStore): def is_watching_only(self): return not self.has_seed() - def can_change_password(self): - return not self.is_watching_only() - def add_seed(self, seed): if self.seed: raise Exception("a seed exists") @@ -522,9 +523,13 @@ class Hardware_KeyStore(KeyStore, Xpub): assert not self.has_seed() return False - def can_change_password(self): - return False - + def get_password_for_storage_encryption(self): + from .storage import get_derivation_used_for_hw_device_encryption + client = self.plugin.get_client(self) + derivation = get_derivation_used_for_hw_device_encryption() + xpub = client.get_xpub(derivation, "standard") + password = self.get_pubkey_from_xpub(xpub, ()) + return password def bip39_normalize_passphrase(passphrase): @@ -683,7 +688,7 @@ is_bip32_key = lambda x: is_xprv(x) or is_xpub(x) def bip44_derivation(account_id, bip43_purpose=44): - coin = 1 if bitcoin.NetworkConstants.TESTNET else 147 # ZCL - SLIP0044 + coin = 1 if constants.net.TESTNET else 147 # ZCL - SLIP0044 return "m/%d'/%d'/%d'" % (bip43_purpose, coin, int(account_id)) def from_seed(seed, passphrase, is_p2sh): diff --git a/lib/network.py b/lib/network.py index 1c2cd843..fe0358f4 100644 --- a/lib/network.py +++ b/lib/network.py @@ -37,9 +37,11 @@ import socks from . import util from . import bitcoin from .bitcoin import * +from . import constants from .interface import Connection, Interface from . import blockchain from .version import ELECTRUM_VERSION, PROTOCOL_VERSION +from .i18n import _ NODES_RETRY_INTERVAL = 60 @@ -59,7 +61,7 @@ def parse_servers(result): for v in item[2]: if re.match("[st]\d*", v): protocol, port = v[0], v[1:] - if port == '': port = NetworkConstants.DEFAULT_PORTS[protocol] + if port == '': port = constants.net.DEFAULT_PORTS[protocol] out[protocol] = port elif re.match("v(.?)+", v): version = v[1:] @@ -93,7 +95,7 @@ def filter_protocol(hostmap, protocol = 's'): def pick_random_server(hostmap = None, protocol = 's', exclude_set = set()): if hostmap is None: - hostmap = NetworkConstants.DEFAULT_SERVERS + hostmap = constants.net.DEFAULT_SERVERS eligible = list(set(filter_protocol(hostmap, protocol)) - exclude_set) return random.choice(eligible) if eligible else None @@ -137,7 +139,7 @@ def deserialize_proxy(s): def deserialize_server(server_str): - host, port, protocol = str(server_str).split(':') + host, port, protocol = str(server_str).rsplit(':', 2) assert protocol in 'st' int(port) # Throw if cannot be converted to int return host, port, protocol @@ -171,17 +173,17 @@ class Network(util.DaemonThread): self.blockchain_index = config.get('blockchain_index', 0) if self.blockchain_index not in self.blockchains.keys(): self.blockchain_index = 0 - self.protocol = 't' if self.config.get('nossl') else 's' # Server for addresses and transactions - self.default_server = self.config.get('server') + self.default_server = self.config.get('server', None) # Sanitize default server - try: - host, port, protocol = deserialize_server(self.default_server) - assert protocol == self.protocol - except: - self.default_server = None + if self.default_server: + try: + deserialize_server(self.default_server) + except: + self.print_error('Warning: failed to parse server-string; falling back to random.') + self.default_server = None if not self.default_server: - self.default_server = pick_random_server(protocol=self.protocol) + self.default_server = pick_random_server() self.lock = threading.Lock() self.pending_sends = [] self.message_id = 0 @@ -220,7 +222,8 @@ class Network(util.DaemonThread): self.connecting = set() self.requested_chunks = set() self.socket_queue = queue.Queue() - self.start_network(self.protocol, deserialize_proxy(self.config.get('proxy'))) + self.start_network(deserialize_server(self.default_server)[2], + deserialize_proxy(self.config.get('proxy'))) def register_callback(self, callback, events): with self.lock: @@ -305,6 +308,9 @@ class Network(util.DaemonThread): # Resend unanswered requests requests = self.unanswered_requests.values() self.unanswered_requests = {} + if self.interface.ping_required(): + params = [ELECTRUM_VERSION, PROTOCOL_VERSION] + self.queue_request('server.version', params, self.interface) for request in requests: message_id = self.queue_request(request[0], request[1]) self.unanswered_requests[message_id] = request @@ -313,15 +319,14 @@ class Network(util.DaemonThread): self.queue_request('server.peers.subscribe', []) self.request_fee_estimates() self.queue_request('blockchain.relayfee', []) - if self.interface.ping_required(): - params = [ELECTRUM_VERSION, PROTOCOL_VERSION] - self.queue_request('server.version', params, self.interface) for h in self.subscribed_addresses: self.queue_request('blockchain.scripthash.subscribe', [h]) def request_fee_estimates(self): + from .simple_config import FEE_ETA_TARGETS self.config.requested_fee_estimates() - for i in bitcoin.FEE_TARGETS: + self.queue_request('mempool.get_fee_histogram', []) + for i in FEE_ETA_TARGETS: self.queue_request('blockchain.estimatefee', [i]) def get_status_value(self, key): @@ -331,6 +336,8 @@ class Network(util.DaemonThread): value = self.banner elif key == 'fee': value = self.config.fee_estimates + elif key == 'fee_histogram': + value = self.config.mempool_fees elif key == 'updated': value = (self.get_local_height(), self.get_server_height()) elif key == 'servers': @@ -358,7 +365,7 @@ class Network(util.DaemonThread): return list(self.interfaces.keys()) def get_servers(self): - out = NetworkConstants.DEFAULT_SERVERS + out = constants.net.DEFAULT_SERVERS if self.irc_servers: out.update(filter_version(self.irc_servers.copy())) else: @@ -542,6 +549,11 @@ class Network(util.DaemonThread): elif method == 'server.donation_address': if error is None: self.donation_address = result + elif method == 'mempool.get_fee_histogram': + if error is None: + self.print_error('fee_histogram', result) + self.config.mempool_fees = result + self.notify('fee_histogram') elif method == 'blockchain.estimatefee': if error is None and result > 0: i = params[0] @@ -767,29 +779,33 @@ class Network(util.DaemonThread): error = response.get('error') result = response.get('result') params = response.get('params') + blockchain = interface.blockchain if result is None or params is None or error is not None: interface.print_error(error or 'bad response') return index = params[0] # Ignore unsolicited chunks if index not in self.requested_chunks: + interface.print_error("received chunk %d (unsolicited)" % index) return + else: + interface.print_error("received chunk %d" % index) self.requested_chunks.remove(index) - connect = interface.blockchain.connect_chunk(index, result) + connect = blockchain.connect_chunk(index, result) if not connect: self.connection_down(interface.server) return # If not finished, get the next chunk - if interface.blockchain.height() < interface.tip: + if index >= len(blockchain.checkpoints) and blockchain.height() < interface.tip: self.request_chunk(interface, index+1) else: interface.mode = 'default' - interface.print_error('catch up done', interface.blockchain.height()) - interface.blockchain.catch_up = None + interface.print_error('catch up done', blockchain.height()) + blockchain.catch_up = None self.notify('updated') def request_header(self, interface, height): - interface.print_error("requesting header %d" % height) + #interface.print_error("requesting header %d" % height) self.queue_request('blockchain.block.get_header', [height], interface) interface.request = height interface.req_time = time.time() @@ -802,11 +818,10 @@ class Network(util.DaemonThread): self.connection_down(interface.server) return height = header.get('block_height') - if int(interface.request) != height: + if interface.request != height: interface.print_error("unsolicited header",interface.request, height) self.connection_down(interface.server) return - interface.print_error("interface.mode %s" % interface.mode) chain = blockchain.check_header(header) if interface.mode == 'backward': can_connect = blockchain.can_connect(header) @@ -822,6 +837,7 @@ class Network(util.DaemonThread): interface.blockchain = chain interface.good = height next_height = (interface.bad + interface.good) // 2 + assert next_height >= self.max_checkpoint(), (interface.bad, interface.good) else: if height == 0: self.connection_down(interface.server) @@ -830,7 +846,8 @@ class Network(util.DaemonThread): interface.bad = height interface.bad_header = header delta = interface.tip - height - next_height = max(0, interface.tip - 2 * delta) + next_height = max(self.max_checkpoint(), interface.tip - 2 * delta) + elif interface.mode == 'binary': if chain: interface.good = height @@ -840,6 +857,7 @@ class Network(util.DaemonThread): interface.bad_header = header if interface.bad != interface.good + 1: next_height = (interface.bad + interface.good) // 2 + assert next_height >= self.max_checkpoint() elif not interface.blockchain.can_connect(interface.bad_header, check_height=False): self.connection_down(interface.server) next_height = None @@ -908,7 +926,7 @@ class Network(util.DaemonThread): # If not finished, get the next header if next_height: if interface.mode == 'catch_up' and interface.tip > next_height + 50: - self.request_chunk(interface, next_height // NetworkConstants.CHUNK_SIZE) + self.request_chunk(interface, next_height // constants.net.CHUNK_SIZE) else: self.request_header(interface, next_height) else: @@ -949,36 +967,18 @@ class Network(util.DaemonThread): def init_headers_file(self): b = self.blockchains[0] - print(b.get_hash(0), NetworkConstants.GENESIS) - if b.get_hash(0) == NetworkConstants.GENESIS: - self.downloading_headers = False - return filename = b.path() - def download_thread(): - try: - import urllib, socket - socket.setdefaulttimeout(30) - self.print_error("downloading ", NetworkConstants.HEADERS_URL) - urllib.request.urlretrieve(NetworkConstants.HEADERS_URL, filename) - self.print_error("done.") - except Exception: - import traceback - traceback.print_exc() - self.print_error("download failed. creating file", filename) - open(filename, 'wb+').close() - b = self.blockchains[0] - with b.lock: b.update_size() - self.downloading_headers = False - - self.downloading_headers = True - t = threading.Thread(target = download_thread) - t.daemon = True - t.start() + length = bitcoin.HEADER_SIZE * len(constants.net.CHECKPOINTS) * constants.net.CHUNK_SIZE + if not os.path.exists(filename) or os.path.getsize(filename) < length: + with open(filename, 'wb') as f: + if length>0: + f.seek(length-1) + f.write(b'\x00') + with b.lock: + b.update_size() def run(self): self.init_headers_file() - while self.is_running() and self.downloading_headers: - time.sleep(1) while self.is_running(): self.maintain_sockets() self.wait_on_sockets() @@ -990,10 +990,11 @@ class Network(util.DaemonThread): def on_notify_header(self, interface, header): height = header.get('block_height') - if not height: return - + if height < self.max_checkpoint(): + self.connection_down(interface.server) + return interface.tip_header = header interface.tip = height @@ -1078,7 +1079,7 @@ class Network(util.DaemonThread): try: r = q.get(True, timeout) except queue.Empty: - raise BaseException('Server did not answer') + raise util.TimeoutException(_('Server did not answer')) if r.get('error'): raise BaseException(r.get('error')) return r.get('result') @@ -1092,3 +1093,12 @@ class Network(util.DaemonThread): if out != tx_hash: return False, "error: " + out return True, out + + def export_checkpoints(self, path): + # run manually from the console to generate checkpoints + cp = self.blockchain().get_checkpoints() + with open(path, 'w') as f: + f.write(json.dumps(cp, indent=4)) + + def max_checkpoint(self): + return max(0, len(constants.net.CHECKPOINTS) * 2016 - 1) diff --git a/lib/paymentrequest.py b/lib/paymentrequest.py index af950203..47118670 100644 --- a/lib/paymentrequest.py +++ b/lib/paymentrequest.py @@ -39,7 +39,8 @@ except ImportError: from . import bitcoin from . import util -from .util import print_error, bh2u, bfh, get_cert_path +from .util import print_error, bh2u, bfh +from .util import export_meta, import_meta from . import transaction from . import x509 from . import rsakey @@ -49,7 +50,7 @@ from .bitcoin import TYPE_ADDRESS REQUEST_HEADERS = {'Accept': 'application/bitcoin-paymentrequest', 'User-Agent': 'Electrum'} ACK_HEADERS = {'Content-Type':'application/bitcoin-payment','Accept':'application/bitcoin-paymentack','User-Agent':'Electrum'} -ca_path = get_cert_path() +ca_path = requests.certs.where() ca_list = None ca_keyID = None @@ -467,24 +468,29 @@ class InvoiceStore(object): continue def import_file(self, path): - try: - with open(path, 'r') as f: - d = json.loads(f.read()) - self.load(d) - except: - traceback.print_exc(file=sys.stderr) - return + def validate(data): + return data # TODO + import_meta(path, validate, self.on_import) + + def on_import(self, data): + self.load(data) self.save() - def save(self): - l = {} + def export_file(self, filename): + export_meta(self.dump(), filename) + + def dump(self): + d = {} for k, pr in self.invoices.items(): - l[k] = { + d[k] = { 'hex': bh2u(pr.raw), 'requestor': pr.requestor, 'txid': pr.tx } - self.storage.put('invoices', l) + return d + + def save(self): + self.storage.put('invoices', self.dump()) def get_status(self, key): pr = self.get(key) diff --git a/lib/plot.py b/lib/plot.py index 06f8edd7..5bd6add6 100644 --- a/lib/plot.py +++ b/lib/plot.py @@ -14,17 +14,23 @@ from matplotlib.patches import Ellipse from matplotlib.offsetbox import AnchoredOffsetbox, TextArea, DrawingArea, HPacker -def plot_history(wallet, history): +class NothingToPlotException(Exception): + def __str__(self): + return _("Nothing to plot.") + + +def plot_history(history): + if len(history) == 0: + raise NothingToPlotException() hist_in = defaultdict(int) hist_out = defaultdict(int) for item in history: - tx_hash, height, confirmations, timestamp, value, balance = item - if not confirmations: + if not item['confirmations']: continue - if timestamp is None: + if item['timestamp'] is None: continue - value = value*1./COIN - date = datetime.datetime.fromtimestamp(timestamp) + value = item['value'].value/COIN + date = item['date'] datenum = int(md.date2num(datetime.date(date.year, date.month, 1))) if value > 0: hist_in[datenum] += value @@ -43,12 +49,19 @@ def plot_history(wallet, history): xfmt = md.DateFormatter('%Y-%m') ax.xaxis.set_major_formatter(xfmt) width = 20 - dates, values = zip(*sorted(hist_in.items())) - r1 = axarr[0].bar(dates, values, width, label='incoming') - axarr[0].legend(loc='upper left') + + r1 = None + r2 = None + dates_values = list(zip(*sorted(hist_in.items()))) + if dates_values and len(dates_values) == 2: + dates, values = dates_values + r1 = axarr[0].bar(dates, values, width, label='incoming') + axarr[0].legend(loc='upper left') dates_values = list(zip(*sorted(hist_out.items()))) if dates_values and len(dates_values) == 2: dates, values = dates_values r2 = axarr[1].bar(dates, values, width, color='r', label='outgoing') axarr[1].legend(loc='upper left') + if r1 is None and r2 is None: + raise NothingToPlotException() return plt diff --git a/lib/plugins.py b/lib/plugins.py index c35d1746..9f58611f 100644 --- a/lib/plugins.py +++ b/lib/plugins.py @@ -312,6 +312,8 @@ class DeviceMgr(ThreadJob, PrintError): # What we recognise. Each entry is a (vendor_id, product_id) # pair. self.recognised_hardware = set() + # Custom enumerate functions for devices we don't know about. + self.enumerate_func = set() # For synchronization self.lock = threading.RLock() self.hid_lock = threading.RLock() @@ -334,6 +336,9 @@ class DeviceMgr(ThreadJob, PrintError): for pair in device_pairs: self.recognised_hardware.add(pair) + def register_enumerate_func(self, func): + self.enumerate_func.add(func) + def create_client(self, device, handler, plugin): # Get from cache first client = self.client_lookup(device.id_) @@ -362,15 +367,20 @@ class DeviceMgr(ThreadJob, PrintError): if not xpub in self.xpub_ids: return _id = self.xpub_ids.pop(xpub) - client = self.client_lookup(_id) - self.clients.pop(client, None) - if client: - client.close() + self._close_client(_id) def unpair_id(self, id_): xpub = self.xpub_by_id(id_) if xpub: self.unpair_xpub(xpub) + else: + self._close_client(id_) + + def _close_client(self, id_): + client = self.client_lookup(id_) + self.clients.pop(client, None) + if client: + client.close() def pair_xpub(self, xpub, id_): with self.lock: @@ -442,11 +452,11 @@ class DeviceMgr(ThreadJob, PrintError): # The user input has wrong PIN or passphrase, or cancelled input, # or it is not pairable raise DeviceUnpairableError( - _('Electrum cannot pair with your %s.\n\n' + _('Electrum cannot pair with your {}.\n\n' 'Before you request bitcoins to be sent to addresses in this ' 'wallet, ensure you can pair with your device, or that you have ' 'its seed (and passphrase, if any). Otherwise all bitcoins you ' - 'receive will be unspendable.') % plugin.device) + 'receive will be unspendable.').format(plugin.device)) def unpaired_device_infos(self, handler, plugin, devices=None): '''Returns a list of DeviceInfo objects: one for each connected, @@ -472,9 +482,9 @@ class DeviceMgr(ThreadJob, PrintError): infos = self.unpaired_device_infos(handler, plugin, devices) if infos: break - msg = _('Please insert your %s. Verify the cable is ' + msg = _('Please insert your {}. Verify the cable is ' 'connected and that no other application is using it.\n\n' - 'Try to connect again?') % plugin.device + 'Try to connect again?').format(plugin.device) if not handler.yes_no_question(msg): raise UserCancelled() devices = None @@ -484,7 +494,7 @@ class DeviceMgr(ThreadJob, PrintError): for info in infos: if info.label == keystore.label: return info - msg = _("Please select which %s device to use:") % plugin.device + msg = _("Please select which {} device to use:").format(plugin.device) descriptions = [info.label + ' (%s)'%(_("initialized") if info.initialized else _("wiped")) for info in infos] c = handler.query_choice(msg, descriptions) if c is None: @@ -492,7 +502,8 @@ class DeviceMgr(ThreadJob, PrintError): info = infos[c] # save new label keystore.set_label(info.label) - handler.win.wallet.save_keystore() + if handler.win.wallet is not None: + handler.win.wallet.save_keystore() return info def scan_devices(self): @@ -504,6 +515,7 @@ class DeviceMgr(ThreadJob, PrintError): self.print_error("scanning devices...") with self.hid_lock: hid_list = hid.enumerate(0, 0) + # First see what's connected that we know about devices = [] for d in hid_list: @@ -519,6 +531,10 @@ class DeviceMgr(ThreadJob, PrintError): devices.append(Device(d['path'], interface_number, id_, product_key, usage_page)) + # Let plugin handlers enumerate devices we don't know about + for f in self.enumerate_func: + devices.extend(f()) + # Now find out what was disconnected pairs = [(dev.path, dev.id_) for dev in devices] disconnected_ids = [] diff --git a/lib/qrscanner.py b/lib/qrscanner.py index 7516d55d..6212b4e6 100644 --- a/lib/qrscanner.py +++ b/lib/qrscanner.py @@ -29,8 +29,8 @@ import ctypes if sys.platform == 'darwin': name = 'libzbar.dylib' -elif sys.platform == 'windows': - name = 'libzbar.dll' +elif sys.platform in ('windows', 'win32'): + name = 'libzbar-0.dll' else: name = 'libzbar.so.0' @@ -40,7 +40,7 @@ except OSError: libzbar = None -def scan_barcode(device='', timeout=-1, display=True, threaded=False): +def scan_barcode(device='', timeout=-1, display=True, threaded=False, try_again=True): if libzbar is None: raise RuntimeError("Cannot start QR scanner; zbar not available.") libzbar.zbar_symbol_get_data.restype = ctypes.c_char_p @@ -50,6 +50,10 @@ def scan_barcode(device='', timeout=-1, display=True, threaded=False): proc = libzbar.zbar_processor_create(threaded) libzbar.zbar_processor_request_size(proc, 640, 480) if libzbar.zbar_processor_init(proc, device.encode('utf-8'), display) != 0: + if try_again: + # workaround for a bug in "ZBar for Windows" + # libzbar.zbar_processor_init always seem to fail the first time around + return scan_barcode(device, timeout, display, threaded, try_again=False) raise RuntimeError("Can not start QR scanner; initialization failed.") libzbar.zbar_processor_set_visible(proc) if libzbar.zbar_process_one(proc, timeout): diff --git a/lib/servers-orig.json b/lib/servers-orig.json deleted file mode 100644 index 1f639858..00000000 --- a/lib/servers-orig.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "E-X.not.fyi": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "ELECTRUMX.not.fyi": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "ELEX01.blackpole.online": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "VPS.hsmiths.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "bitcoin.freedomnode.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "btc.smsys.me": { - "pruning": "-", - "s": "995", - "version": "1.1" - }, - "currentlane.lovebitco.in": { - "pruning": "-", - "t": "50001", - "version": "1.1" - }, - "daedalus.bauerj.eu": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "de01.hamster.science": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "ecdsa.net": { - "pruning": "-", - "s": "110", - "t": "50001", - "version": "1.1" - }, - "elec.luggs.co": { - "pruning": "-", - "s": "443", - "version": "1.1" - }, - "electrum.akinbo.org": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.antumbra.se": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.be": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.coinucopia.io": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.cutie.ga": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.festivaldelhumor.org": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.hsmiths.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.qtornado.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum.vom-stausee.de": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrum3.hachre.de": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrumx.bot.nu": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "electrumx.westeurope.cloudapp.azure.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "elx01.knas.systems": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "ex-btc.server-on.net": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "helicarrier.bauerj.eu": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "mooo.not.fyi": { - "pruning": "-", - "s": "50012", - "t": "50011", - "version": "1.1" - }, - "ndnd.selfhost.eu": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "node.arihanc.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "node.xbt.eu": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "node1.volatilevictory.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "noserver4u.de": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "qmebr.spdns.org": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "raspi.hsmiths.com": { - "pruning": "-", - "s": "51002", - "t": "51001", - "version": "1.1" - }, - "s2.noip.pl": { - "pruning": "-", - "s": "50102", - "version": "1.1" - }, - "s5.noip.pl": { - "pruning": "-", - "s": "50105", - "version": "1.1" - }, - "songbird.bauerj.eu": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "us.electrum.be": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - }, - "us01.hamster.science": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.1" - } -} diff --git a/lib/servers.json b/lib/servers.json index acd28702..e3004e9b 100644 --- a/lib/servers.json +++ b/lib/servers.json @@ -1,4 +1,4 @@ { - "electrum.zclassic.community": {"s":"50002"}, - "zclele.duckdns.org": {"s":"50002"} + "electrum.zclassic.community": {"s":"50002", "pruning": "-", "version": "1.1"}, + "zclele.duckdns.org": {"s":"50002", "pruning": "-", "version": "1.1"} } diff --git a/lib/servers_testnet.json b/lib/servers_testnet.json index 767280a9..d7c0d52d 100644 --- a/lib/servers_testnet.json +++ b/lib/servers_testnet.json @@ -1,3 +1,3 @@ { - "localhost": {"t":"51001", "s":"51002"} + "localhost": {"t":"51001", "s":"51002", "pruning": "-", "version": "1.1"} } diff --git a/lib/simple_config.py b/lib/simple_config.py index 1c912575..b09cbdb9 100644 --- a/lib/simple_config.py +++ b/lib/simple_config.py @@ -5,11 +5,21 @@ import os import stat from copy import deepcopy -from .util import user_dir, print_error, print_stderr, PrintError -from .bitcoin import DEFAULT_FEE_RATE, MAX_FEE_RATE, FEE_TARGETS +from .util import (user_dir, print_error, PrintError, + NoDynamicFeeEstimates, format_satoshis) +from .i18n import _ + +FEE_ETA_TARGETS = [25, 10, 5, 2] +FEE_DEPTH_TARGETS = [10000000, 5000000, 2000000, 1000000, 500000, 200000, 100000] + +# satoshi per kbyte +FEERATE_MAX_DYNAMIC = 1500000 +FEERATE_WARNING_HIGH_FEE = 600000 +FEERATE_FALLBACK_STATIC_FEE = 150000 +FEERATE_DEFAULT_RELAY = 1000 +FEERATE_STATIC_VALUES = [5000, 10000, 20000, 30000, 50000, 70000, 100000, 150000, 200000, 300000] -SYSTEM_CONFIG_PATH = "/etc/electrum.conf" config = None @@ -24,35 +34,37 @@ def set_config(c): config = c +FINAL_CONFIG_VERSION = 2 + + class SimpleConfig(PrintError): """ The SimpleConfig class is responsible for handling operations involving configuration files. - There are 3 different sources of possible configuration values: + There are two different sources of possible configuration values: 1. Command line options. 2. User configuration (in the user's config directory) - 3. System configuration (in /etc/) - They are taken in order (1. overrides config options set in 2., that - override config set in 3.) + They are taken in order (1. overrides config options set in 2.) """ - fee_rates = [5000, 10000, 20000, 30000, 50000, 70000, 100000, 150000, 200000, 300000] - def __init__(self, options={}, read_system_config_function=None, - read_user_config_function=None, read_user_dir_function=None): + def __init__(self, options=None, read_user_config_function=None, + read_user_dir_function=None): + + if options is None: + options = {} # This lock needs to be acquired for updating and reading the config in # a thread-safe way. self.lock = threading.RLock() + self.mempool_fees = {} self.fee_estimates = {} self.fee_estimates_last_updated = {} self.last_time_fee_estimates_requested = 0 # zero ensures immediate fees # The following two functions are there for dependency injection when # testing. - if read_system_config_function is None: - read_system_config_function = read_system_config if read_user_config_function is None: read_user_config_function = read_user_config if read_user_dir_function is None: @@ -62,24 +74,30 @@ class SimpleConfig(PrintError): # The command line options self.cmdline_options = deepcopy(options) - - # Portable wallets don't use a system config - if self.cmdline_options.get('portable', False): - self.system_config = {} - else: - self.system_config = read_system_config_function() + # don't allow to be set on CLI: + self.cmdline_options.pop('config_version', None) # Set self.path and read the user config self.user_config = {} # for self.get in electrum_path() self.path = self.electrum_path() self.user_config = read_user_config_function(self.path) - # Upgrade obsolete keys - self.fixup_keys({'auto_cycle': 'auto_connect'}) + if not self.user_config: + # avoid new config getting upgraded + self.user_config = {'config_version': FINAL_CONFIG_VERSION} + + # config "upgrade" - CLI options + self.rename_config_keys( + self.cmdline_options, {'auto_cycle': 'auto_connect'}, True) + + # config upgrade - user config + if self.requires_upgrade(): + self.upgrade() + # Make a singleton instance of 'self' set_config(self) def electrum_path(self): - # Read electrum_path from command line / system configuration + # Read electrum_path from command line # Otherwise use the user's default data directory. path = self.get('electrum_path') if path is None: @@ -101,45 +119,92 @@ class SimpleConfig(PrintError): self.print_error("electrum directory", path) return path - def fixup_config_keys(self, config, keypairs): + def rename_config_keys(self, config, keypairs, deprecation_warning=False): + """Migrate old key names to new ones""" updated = False for old_key, new_key in keypairs.items(): if old_key in config: - if not new_key in config: + if new_key not in config: config[new_key] = config[old_key] + if deprecation_warning: + self.print_stderr('Note that the {} variable has been deprecated. ' + 'You should use {} instead.'.format(old_key, new_key)) del config[old_key] updated = True return updated - def fixup_keys(self, keypairs): - '''Migrate old key names to new ones''' - self.fixup_config_keys(self.cmdline_options, keypairs) - self.fixup_config_keys(self.system_config, keypairs) - if self.fixup_config_keys(self.user_config, keypairs): - self.save_user_config() - - def set_key(self, key, value, save = True): + def set_key(self, key, value, save=True): if not self.is_modifiable(key): - print_stderr("Warning: not changing config key '%s' set on the command line" % key) + self.print_stderr("Warning: not changing config key '%s' set on the command line" % key) return + self._set_key_in_user_config(key, value, save) + def _set_key_in_user_config(self, key, value, save=True): with self.lock: - self.user_config[key] = value + if value is not None: + self.user_config[key] = value + else: + self.user_config.pop(key, None) if save: self.save_user_config() - return def get(self, key, default=None): with self.lock: out = self.cmdline_options.get(key) if out is None: - out = self.user_config.get(key) - if out is None: - out = self.system_config.get(key, default) + out = self.user_config.get(key, default) return out + def requires_upgrade(self): + return self.get_config_version() < FINAL_CONFIG_VERSION + + def upgrade(self): + with self.lock: + self.print_error('upgrading config') + + self.convert_version_2() + + self.set_key('config_version', FINAL_CONFIG_VERSION, save=True) + + def convert_version_2(self): + if not self._is_upgrade_method_needed(1, 1): + return + + self.rename_config_keys(self.user_config, {'auto_cycle': 'auto_connect'}) + + try: + # change server string FROM host:port:proto TO host:port:s + server_str = self.user_config.get('server') + host, port, protocol = str(server_str).rsplit(':', 2) + assert protocol in ('s', 't') + int(port) # Throw if cannot be converted to int + server_str = '{}:{}:s'.format(host, port) + self._set_key_in_user_config('server', server_str) + except BaseException: + self._set_key_in_user_config('server', None) + + self.set_key('config_version', 2) + + def _is_upgrade_method_needed(self, min_version, max_version): + cur_version = self.get_config_version() + if cur_version > max_version: + return False + elif cur_version < min_version: + raise BaseException( + ('config upgrade: unexpected version %d (should be %d-%d)' + % (cur_version, min_version, max_version))) + else: + return True + + def get_config_version(self): + config_version = self.get('config_version', 1) + if config_version > FINAL_CONFIG_VERSION: + self.print_stderr('WARNING: config version ({}) is higher than ours ({})' + .format(config_version, FINAL_CONFIG_VERSION)) + return config_version + def is_modifiable(self, key): - return not key in self.cmdline_options + return key not in self.cmdline_options def save_user_config(self): if not self.path: @@ -203,68 +268,197 @@ class SimpleConfig(PrintError): path = wallet.storage.path self.set_key('gui_last_wallet', path) - def default_fee_rate(self): - f = self.get('default_fee_rate', DEFAULT_FEE_RATE) - if f==0: - f = DEFAULT_FEE_RATE - return f + def impose_hard_limits_on_fee(func): + def get_fee_within_limits(self, *args, **kwargs): + fee = func(self, *args, **kwargs) + if fee is None: + return fee + fee = min(FEERATE_MAX_DYNAMIC, fee) + fee = max(FEERATE_DEFAULT_RELAY, fee) + return fee + return get_fee_within_limits - def max_fee_rate(self): - f = self.get('max_fee_rate', MAX_FEE_RATE) - if f==0: - f = MAX_FEE_RATE - return f - - def dynfee(self, i): + @impose_hard_limits_on_fee + def eta_to_fee(self, i): + """Returns fee in sat/kbyte.""" if i < 4: - j = FEE_TARGETS[i] + j = FEE_ETA_TARGETS[i] fee = self.fee_estimates.get(j) else: assert i == 4 fee = self.fee_estimates.get(2) if fee is not None: fee += fee/2 - if fee is not None: - fee = min(5*MAX_FEE_RATE, fee) return fee - def reverse_dynfee(self, fee_per_kb): + def fee_to_depth(self, target_fee): + depth = 0 + for fee, s in self.mempool_fees: + depth += s + if fee <= target_fee: + break + else: + return 0 + return depth + + @impose_hard_limits_on_fee + def depth_to_fee(self, i): + """Returns fee in zat/kbyte.""" + target = self.depth_target(i) + depth = 0 + for fee, s in self.mempool_fees: + depth += s + if depth > target: + break + else: + return 0 + return fee * 1000 + + def depth_target(self, i): + return FEE_DEPTH_TARGETS[i] + + def eta_target(self, i): + if i == len(FEE_ETA_TARGETS): + return 1 + return FEE_ETA_TARGETS[i] + + def fee_to_eta(self, fee_per_kb): import operator - l = list(self.fee_estimates.items()) + [(1, self.dynfee(4))] + l = list(self.fee_estimates.items()) + [(1, self.eta_to_fee(4))] dist = map(lambda x: (x[0], abs(x[1] - fee_per_kb)), l) min_target, min_value = min(dist, key=operator.itemgetter(1)) if fee_per_kb < self.fee_estimates.get(25)/2: min_target = -1 return min_target + def depth_tooltip(self, depth): + return "%.1f MB from tip"%(depth/1000000) + + def eta_tooltip(self, x): + if x < 0: + return _('Low fee') + elif x == 1: + return _('In the next block') + else: + return _('Within {} blocks').format(x) + + def get_fee_status(self): + dyn = self.is_dynfee() + mempool = self.use_mempool_fees() + pos = self.get_depth_level() if mempool else self.get_fee_level() + fee_rate = self.fee_per_kb() + target, tooltip = self.get_fee_text(pos, dyn, mempool, fee_rate) + return tooltip + ' [%s]'%target if dyn else target + ' [Static]' + + def get_fee_text(self, pos, dyn, mempool, fee_rate): + """Returns (text, tooltip) where + text is what we target: static fee / num blocks to confirm in / mempool depth + tooltip is the corresponding estimate (e.g. num blocks for a static fee) + """ + rate_str = (format_satoshis(fee_rate/1000, False, 0, 0, False) + ' sat/byte') if fee_rate is not None else 'unknown' + if dyn: + if mempool: + depth = self.depth_target(pos) + text = self.depth_tooltip(depth) + else: + eta = self.eta_target(pos) + text = self.eta_tooltip(eta) + tooltip = rate_str + else: + text = rate_str + if mempool and self.has_fee_mempool(): + depth = self.fee_to_depth(fee_rate) + tooltip = self.depth_tooltip(depth) + elif not mempool and self.has_fee_etas(): + eta = self.fee_to_eta(fee_rate) + tooltip = self.eta_tooltip(eta) + else: + tooltip = '' + return text, tooltip + + def get_depth_level(self): + maxp = len(FEE_DEPTH_TARGETS) - 1 + return min(maxp, self.get('depth_level', 2)) + + def get_fee_level(self): + maxp = len(FEE_ETA_TARGETS) # not (-1) to have "next block" + return min(maxp, self.get('fee_level', 2)) + + def get_fee_slider(self, dyn, mempool): + if dyn: + if mempool: + pos = self.get_depth_level() + maxp = len(FEE_DEPTH_TARGETS) - 1 + fee_rate = self.depth_to_fee(pos) + else: + pos = self.get_fee_level() + maxp = len(FEE_ETA_TARGETS) # not (-1) to have "next block" + fee_rate = self.eta_to_fee(pos) + else: + fee_rate = self.fee_per_kb() + pos = self.static_fee_index(fee_rate) + maxp = 9 + return maxp, pos, fee_rate + def static_fee(self, i): - return self.fee_rates[i] + return FEERATE_STATIC_VALUES[i] def static_fee_index(self, value): - dist = list(map(lambda x: abs(x - value), self.fee_rates)) + dist = list(map(lambda x: abs(x - value), FEERATE_STATIC_VALUES)) return min(range(len(dist)), key=dist.__getitem__) - def has_fee_estimates(self): - return len(self.fee_estimates)==4 + def has_fee_etas(self): + return len(self.fee_estimates) == 4 + + def has_fee_mempool(self): + return bool(self.mempool_fees) + + def has_dynamic_fees_ready(self): + if self.use_mempool_fees(): + return self.has_fee_mempool() + else: + return self.has_fee_etas() - # 'dynamic' fees are disabled - we use 'static' (but adjustable) fees def is_dynfee(self): - return self.get('dynamic_fees', False) + return bool(self.get('dynamic_fees', True)) + + def use_mempool_fees(self): + return bool(self.get('mempool_fees', False)) def fee_per_kb(self): - dyn = self.is_dynfee() - if dyn: - fee_rate = self.dynfee(self.get('fee_level', 2)) + """Returns sat/kvB fee to pay for a txn. + Note: might return None. + """ + if self.is_dynfee(): + if self.use_mempool_fees(): + fee_rate = self.depth_to_fee(self.get_depth_level()) + else: + fee_rate = self.eta_to_fee(self.get_fee_level()) else: - fee_rate = self.get('fee_per_kb', self.max_fee_rate()/2) + fee_rate = self.get('fee_per_kb', FEERATE_FALLBACK_STATIC_FEE) return fee_rate + def fee_per_byte(self): + """Returns sat/vB fee to pay for a txn. + Note: might return None. + """ + fee_per_kb = self.fee_per_kb() + return fee_per_kb / 1000 if fee_per_kb is not None else None + def estimate_fee(self, size): - return self.estimate_fee_for_feerate(self.fee_per_kb(), size) + fee_per_kb = self.fee_per_kb() + if fee_per_kb is None: + raise NoDynamicFeeEstimates() + return self.estimate_fee_for_feerate(fee_per_kb, size) @classmethod def estimate_fee_for_feerate(cls, fee_per_kb, size): - return int(fee_per_kb * size / 1000.) + # note: We only allow integer sat/byte values atm. + # The GUI for simplicity reasons only displays integer sat/byte, + # and for the sake of consistency, we thus only use integer sat/byte in + # the backend too. + fee_per_byte = int(fee_per_kb / 1000) + return int(fee_per_byte * size) def update_fee_estimates(self, key, value): self.fee_estimates[key] = value @@ -275,11 +469,7 @@ class SimpleConfig(PrintError): Returns True if an update should be requested. """ now = time.time() - prev_updates = self.fee_estimates_last_updated.values() - oldest_fee_time = min(prev_updates) if prev_updates else 0 - stale_fees = now - oldest_fee_time > 7200 - old_request = now - self.last_time_fee_estimates_requested > 60 - return stale_fees and old_request + return now - self.last_time_fee_estimates_requested > 60 def requested_fee_estimates(self): self.last_time_fee_estimates_requested = time.time() @@ -291,21 +481,6 @@ class SimpleConfig(PrintError): return device -def read_system_config(path=SYSTEM_CONFIG_PATH): - """Parse and return the system config settings in /etc/electrum.conf.""" - result = {} - if os.path.exists(path): - import configparser - p = configparser.ConfigParser() - try: - p.read(path) - for k, v in p.items('client'): - result[k] = v - except (configparser.NoSectionError, configparser.MissingSectionHeaderError): - pass - - return result - def read_user_config(path): """Parse and store the user config settings in electrum.conf into user_config[].""" if not path: diff --git a/lib/storage.py b/lib/storage.py index fa991114..d87a339a 100644 --- a/lib/storage.py +++ b/lib/storage.py @@ -33,7 +33,7 @@ import pbkdf2, hmac, hashlib import base64 import zlib -from .util import PrintError, profiler +from .util import PrintError, profiler, InvalidPassword from .plugins import run_hook, plugin_loaders from .keystore import bip44_derivation from . import bitcoin @@ -56,6 +56,13 @@ def multisig_type(wallet_type): match = [int(x) for x in match.group(1, 2)] return match +def get_derivation_used_for_hw_device_encryption(): + return ("m" + "/4541509'" # ascii 'ELE' as decimal ("BIP43 purpose") + "/1112098098'") # ascii 'BIE2' as decimal + +# storage encryption version +STO_EV_PLAINTEXT, STO_EV_USER_PW, STO_EV_XPUB_PW = range(0, 3) class WalletStorage(PrintError): @@ -70,9 +77,11 @@ class WalletStorage(PrintError): if self.file_exists(): with open(self.path, "r") as f: self.raw = f.read() + self._encryption_version = self._init_encryption_version() if not self.is_encrypted(): self.load_data(self.raw) else: + self._encryption_version = STO_EV_PLAINTEXT # avoid new wallets getting 'upgraded' self.put('seed_version', FINAL_SEED_VERSION) @@ -106,11 +115,47 @@ class WalletStorage(PrintError): if self.requires_upgrade(): self.upgrade() + def is_past_initial_decryption(self): + """Return if storage is in a usable state for normal operations. + + The value is True exactly + if encryption is disabled completely (self.is_encrypted() == False), + or if encryption is enabled but the contents have already been decrypted. + """ + return bool(self.data) + def is_encrypted(self): + """Return if storage encryption is currently enabled.""" + return self.get_encryption_version() != STO_EV_PLAINTEXT + + def is_encrypted_with_user_pw(self): + return self.get_encryption_version() == STO_EV_USER_PW + + def is_encrypted_with_hw_device(self): + return self.get_encryption_version() == STO_EV_XPUB_PW + + def get_encryption_version(self): + """Return the version of encryption used for this storage. + + 0: plaintext / no encryption + + ECIES, private key derived from a password, + 1: password is provided by user + 2: password is derived from an xpub; used with hw wallets + """ + return self._encryption_version + + def _init_encryption_version(self): try: - return base64.b64decode(self.raw)[0:4] == b'BIE1' + magic = base64.b64decode(self.raw)[0:4] + if magic == b'BIE1': + return STO_EV_USER_PW + elif magic == b'BIE2': + return STO_EV_XPUB_PW + else: + return STO_EV_PLAINTEXT except: - return False + return STO_EV_PLAINTEXT def file_exists(self): return self.path and os.path.exists(self.path) @@ -120,20 +165,50 @@ class WalletStorage(PrintError): ec_key = bitcoin.EC_KEY(secret) return ec_key + def _get_encryption_magic(self): + v = self._encryption_version + if v == STO_EV_USER_PW: + return b'BIE1' + elif v == STO_EV_XPUB_PW: + return b'BIE2' + else: + raise Exception('no encryption magic for version: %s' % v) + def decrypt(self, password): ec_key = self.get_key(password) - s = zlib.decompress(ec_key.decrypt_message(self.raw)) if self.raw else None + if self.raw: + enc_magic = self._get_encryption_magic() + s = zlib.decompress(ec_key.decrypt_message(self.raw, enc_magic)) + else: + s = None self.pubkey = ec_key.get_public_key() s = s.decode('utf8') self.load_data(s) - def set_password(self, password, encrypt): - self.put('use_encryption', bool(password)) - if encrypt and password: + def check_password(self, password): + """Raises an InvalidPassword exception on invalid password""" + if not self.is_encrypted(): + return + if self.pubkey and self.pubkey != self.get_key(password).get_public_key(): + raise InvalidPassword() + + def set_keystore_encryption(self, enable): + self.put('use_encryption', enable) + + def set_password(self, password, enc_version=None): + """Set a password to be used for encrypting this storage.""" + if enc_version is None: + enc_version = self._encryption_version + if password and enc_version != STO_EV_PLAINTEXT: ec_key = self.get_key(password) self.pubkey = ec_key.get_public_key() + self._encryption_version = enc_version else: self.pubkey = None + self._encryption_version = STO_EV_PLAINTEXT + # make sure next storage.write() saves changes + with self.lock: + self.modified = True def get(self, key, default=None): with self.lock: @@ -175,7 +250,8 @@ class WalletStorage(PrintError): if self.pubkey: s = bytes(s, 'utf8') c = zlib.compress(s) - s = bitcoin.encrypt_message(c, self.pubkey) + enc_magic = self._get_encryption_magic() + s = bitcoin.encrypt_message(c, self.pubkey, enc_magic) s = s.decode('utf8') temp_path = "%s.tmp.%s" % (self.path, os.getpid()) @@ -263,6 +339,9 @@ class WalletStorage(PrintError): self.write() def convert_wallet_type(self): + if not self._is_upgrade_method_needed(0, 13): + return + wallet_type = self.get('wallet_type') if wallet_type == 'btchip': wallet_type = 'ledger' if self.get('keystore') or self.get('x1/') or wallet_type=='imported': @@ -446,6 +525,9 @@ class WalletStorage(PrintError): self.put('seed_version', 16) def convert_imported(self): + if not self._is_upgrade_method_needed(0, 13): + return + # '/x' is the internal ID for imported accounts d = self.get('accounts', {}).get('/x', {}).get('imported',{}) if not d: @@ -472,6 +554,9 @@ class WalletStorage(PrintError): raise BaseException('no addresses or privkeys') def convert_account(self): + if not self._is_upgrade_method_needed(0, 13): + return + self.put('accounts', None) def _is_upgrade_method_needed(self, min_version, max_version): diff --git a/lib/synchronizer.py b/lib/synchronizer.py index b6534e38..4b81810d 100644 --- a/lib/synchronizer.py +++ b/lib/synchronizer.py @@ -84,11 +84,13 @@ class Synchronizer(ThreadJob): return bh2u(hashlib.sha256(status.encode('ascii')).digest()) def on_address_status(self, response): + if self.wallet.synchronizer is None: + return # we have been killed, this was just an orphan callback params, result = self.parse_response(response) if not params: return addr = params[0] - history = self.wallet.get_address_history(addr) + history = self.wallet.history.get(addr, []) if self.get_status(history) != result: if self.requested_histories.get(addr) is None: self.requested_histories[addr] = result @@ -98,6 +100,8 @@ class Synchronizer(ThreadJob): self.requested_addrs.remove(addr) def on_address_history(self, response): + if self.wallet.synchronizer is None: + return # we have been killed, this was just an orphan callback params, result = self.parse_response(response) if not params: return @@ -127,6 +131,8 @@ class Synchronizer(ThreadJob): self.requested_histories.pop(addr) def tx_response(self, response): + if self.wallet.synchronizer is None: + return # we have been killed, this was just an orphan callback params, result = self.parse_response(response) if not params: return diff --git a/lib/tests/test_bitcoin.py b/lib/tests/test_bitcoin.py index dbae5005..a5f73435 100644 --- a/lib/tests/test_bitcoin.py +++ b/lib/tests/test_bitcoin.py @@ -11,8 +11,9 @@ from lib.bitcoin import ( var_int, op_push, address_to_script, regenerate_key, verify_message, deserialize_privkey, serialize_privkey, is_segwit_address, is_b58_address, address_to_scripthash, is_minikey, is_compressed, is_xpub, - xpub_type, is_xprv, is_bip32_derivation, seed_type, NetworkConstants) + xpub_type, is_xprv, is_bip32_derivation, seed_type) from lib.util import bfh +from lib import constants try: import ecdsa @@ -168,12 +169,12 @@ class Test_bitcoin_testnet(unittest.TestCase): @classmethod def setUpClass(cls): super().setUpClass() - NetworkConstants.set_testnet() + constants.set_testnet() @classmethod def tearDownClass(cls): super().tearDownClass() - NetworkConstants.set_mainnet() + constants.set_mainnet() def test_address_to_script(self): # bech32 native segwit @@ -271,6 +272,7 @@ class Test_keyImport(unittest.TestCase): priv_pub_addr = ( {'priv': 'KzMFjMC2MPadjvX5Cd7b8AKKjjpBSoRKUTpoAtN6B3J9ezWYyXS6', + 'exported_privkey': 'p2pkh:KzMFjMC2MPadjvX5Cd7b8AKKjjpBSoRKUTpoAtN6B3J9ezWYyXS6', 'pub': '02c6467b7e621144105ed3e4835b0b4ab7e35266a2ae1c4f8baa19e9ca93452997', 'address': '17azqT8T16coRmWKYFj3UjzJuxiYrYFRBR', 'minikey' : False, @@ -278,7 +280,17 @@ class Test_keyImport(unittest.TestCase): 'compressed': True, 'addr_encoding': 'base58', 'scripthash': 'c9aecd1fef8d661a42c560bf75c8163e337099800b8face5ca3d1393a30508a7'}, + {'priv': 'p2pkh:Kzj8VjwpZ99bQqVeUiRXrKuX9mLr1o6sWxFMCBJn1umC38BMiQTD', + 'exported_privkey': 'p2pkh:Kzj8VjwpZ99bQqVeUiRXrKuX9mLr1o6sWxFMCBJn1umC38BMiQTD', + 'pub': '0352d78b4b37e0f6d4e164423436f2925fa57817467178eca550a88f2821973c41', + 'address': '1GXgZ5Qi6gmXTHVSpUPZLy4Ci2nbfb3ZNb', + 'minikey': False, + 'txin_type': 'p2pkh', + 'compressed': True, + 'addr_encoding': 'base58', + 'scripthash': 'a9b2a76fc196c553b352186dfcca81fcf323a721cd8431328f8e9d54216818c1'}, {'priv': '5Hxn5C4SQuiV6e62A1MtZmbSeQyrLFhu5uYks62pU5VBUygK2KD', + 'exported_privkey': 'p2pkh:5Hxn5C4SQuiV6e62A1MtZmbSeQyrLFhu5uYks62pU5VBUygK2KD', 'pub': '04e5fe91a20fac945845a5518450d23405ff3e3e1ce39827b47ee6d5db020a9075422d56a59195ada0035e4a52a238849f68e7a325ba5b2247013e0481c5c7cb3f', 'address': '1GPHVTY8UD9my6jyP4tb2TYJwUbDetyNC6', 'minikey': False, @@ -286,7 +298,17 @@ class Test_keyImport(unittest.TestCase): 'compressed': False, 'addr_encoding': 'base58', 'scripthash': 'f5914651408417e1166f725a5829ff9576d0dbf05237055bf13abd2af7f79473'}, + {'priv': 'p2pkh:5KhYQCe1xd5g2tqpmmGpUWDpDuTbA8vnpbiCNDwMPAx29WNQYfN', + 'exported_privkey': 'p2pkh:5KhYQCe1xd5g2tqpmmGpUWDpDuTbA8vnpbiCNDwMPAx29WNQYfN', + 'pub': '048f0431b0776e8210376c81280011c2b68be43194cb00bd47b7e9aa66284b713ce09556cde3fee606051a07613f3c159ef3953b8927c96ae3dae94a6ba4182e0e', + 'address': '147kiRHHm9fqeMQSgqf4k35XzuWLP9fmmS', + 'minikey': False, + 'txin_type': 'p2pkh', + 'compressed': False, + 'addr_encoding': 'base58', + 'scripthash': '6dd2e07ad2de9ba8eec4bbe8467eb53f8845acff0d9e6f5627391acc22ff62df'}, {'priv': 'LHJnnvRzsdrTX2j5QeWVsaBkabK7gfMNqNNqxnbBVRaJYfk24iJz', + 'exported_privkey': 'p2wpkh-p2sh:Kz9XebiCXL2BZzhYJViiHDzn5iup1povWV8aqstzWU4sz1K5nVva', 'pub': '0279ad237ca0d812fb503ab86f25e15ebd5fa5dd95c193639a8a738dcd1acbad81', 'address': '3GeVJB3oKr7psgKR6BTXSxKtWUkfsHHhk7', 'minikey': False, @@ -294,7 +316,17 @@ class Test_keyImport(unittest.TestCase): 'compressed': True, 'addr_encoding': 'base58', 'scripthash': 'd7b04e882fa6b13246829ac552a2b21461d9152eb00f0a6adb58457a3e63d7c5'}, + {'priv': 'p2wpkh-p2sh:L3CZH1pm87X4bbE6mSGvZnAZ1KcFDRomBudUkrkBG7EZhDtBVXMW', + 'exported_privkey': 'p2wpkh-p2sh:L3CZH1pm87X4bbE6mSGvZnAZ1KcFDRomBudUkrkBG7EZhDtBVXMW', + 'pub': '0229da20a15b3363b2c28e3c5093c180b56c439df0b968a970366bb1f38435361e', + 'address': '3C79goMwT7zSTjXnPoCg6VFGAnUpZAkyus', + 'minikey': False, + 'txin_type': 'p2wpkh-p2sh', + 'compressed': True, + 'addr_encoding': 'base58', + 'scripthash': '714bf6bfe1083e69539f40d4c7a7dca85d187471b35642e55f20d7e866494cf7'}, {'priv': 'L8g5V8kFFeg2WbecahRSdobARbHz2w2STH9S8ePHVSY4fmia7Rsj', + 'exported_privkey': 'p2wpkh:Kz6SuyPM5VktY5dr2d2YqdVgBA6LCWkiHqXJaC3BzxnMPSUuYzmF', 'pub': '03e9f948421aaa89415dc5f281a61b60dde12aae3181b3a76cd2d849b164fc6d0b', 'address': 'bc1qqmpt7u5e9hfznljta5gnvhyvfd2kdd0r90hwue', 'minikey': False, @@ -302,8 +334,18 @@ class Test_keyImport(unittest.TestCase): 'compressed': True, 'addr_encoding': 'bech32', 'scripthash': '1929acaaef3a208c715228e9f1ca0318e3a6b9394ab53c8d026137f847ecf97b'}, + {'priv': 'p2wpkh:KyDWy5WbjLA58Zesh1o8m3pADGdJ3v33DKk4m7h8BD5zDKDmDFwo', + 'exported_privkey': 'p2wpkh:KyDWy5WbjLA58Zesh1o8m3pADGdJ3v33DKk4m7h8BD5zDKDmDFwo', + 'pub': '038c57657171c1f73e34d5b3971d05867d50221ad94980f7e87cbc2344425e6a1e', + 'address': 'bc1qpakeeg4d9ydyjxd8paqrw4xy9htsg532xzxn50', + 'minikey': False, + 'txin_type': 'p2wpkh', + 'compressed': True, + 'addr_encoding': 'bech32', + 'scripthash': '242f02adde84ebb2a7dd778b2f3a81b3826f111da4d8960d826d7a4b816cb261'}, # from http://bitscan.com/articles/security/spotlight-on-mini-private-keys {'priv': 'SzavMBLoXU6kDrqtUVmffv', + 'exported_privkey': 'p2pkh:L53fCHmQhbNp1B4JipfBtfeHZH7cAibzG9oK19XfiFzxHgAkz6JK', 'pub': '02588d202afcc1ee4ab5254c7847ec25b9a135bbda0f2bc69ee1a714749fd77dc9', 'address': '19GuvDvMMUZ8vq84wT79fvnvhMd5MnfTkR', 'minikey': True, @@ -344,6 +386,7 @@ class Test_keyImport(unittest.TestCase): def test_is_private_key(self): for priv_details in self.priv_pub_addr: self.assertTrue(is_private_key(priv_details['priv'])) + self.assertTrue(is_private_key(priv_details['exported_privkey'])) self.assertFalse(is_private_key(priv_details['pub'])) self.assertFalse(is_private_key(priv_details['address'])) self.assertFalse(is_private_key("not a privkey")) @@ -352,8 +395,7 @@ class Test_keyImport(unittest.TestCase): for priv_details in self.priv_pub_addr: txin_type, privkey, compressed = deserialize_privkey(priv_details['priv']) priv2 = serialize_privkey(privkey, compressed, txin_type) - if not priv_details['minikey']: - self.assertEqual(priv_details['priv'], priv2) + self.assertEqual(priv_details['exported_privkey'], priv2) def test_address_to_scripthash(self): for priv_details in self.priv_pub_addr: diff --git a/lib/tests/test_simple_config.py b/lib/tests/test_simple_config.py index c9c648dc..ad7fca2b 100644 --- a/lib/tests/test_simple_config.py +++ b/lib/tests/test_simple_config.py @@ -6,8 +6,7 @@ import tempfile import shutil from io import StringIO -from lib.simple_config import (SimpleConfig, read_system_config, - read_user_config) +from lib.simple_config import (SimpleConfig, read_user_config) class Test_SimpleConfig(unittest.TestCase): @@ -37,18 +36,15 @@ class Test_SimpleConfig(unittest.TestCase): def test_simple_config_key_rename(self): """auto_cycle was renamed auto_connect""" - fake_read_system = lambda : {} fake_read_user = lambda _: {"auto_cycle": True} read_user_dir = lambda : self.user_dir config = SimpleConfig(options=self.options, - read_system_config_function=fake_read_system, read_user_config_function=fake_read_user, read_user_dir_function=read_user_dir) self.assertEqual(config.get("auto_connect"), True) self.assertEqual(config.get("auto_cycle"), None) fake_read_user = lambda _: {"auto_connect": False, "auto_cycle": True} config = SimpleConfig(options=self.options, - read_system_config_function=fake_read_system, read_user_config_function=fake_read_user, read_user_dir_function=read_user_dir) self.assertEqual(config.get("auto_connect"), False) @@ -57,110 +53,51 @@ class Test_SimpleConfig(unittest.TestCase): def test_simple_config_command_line_overrides_everything(self): """Options passed by command line override all other configuration sources""" - fake_read_system = lambda : {"electrum_path": "a"} fake_read_user = lambda _: {"electrum_path": "b"} read_user_dir = lambda : self.user_dir config = SimpleConfig(options=self.options, - read_system_config_function=fake_read_system, read_user_config_function=fake_read_user, read_user_dir_function=read_user_dir) self.assertEqual(self.options.get("electrum_path"), config.get("electrum_path")) - def test_simple_config_user_config_overrides_system_config(self): - """Options passed in user config override system config.""" - fake_read_system = lambda : {"electrum_path": self.electrum_dir} - fake_read_user = lambda _: {"electrum_path": "b"} - read_user_dir = lambda : self.user_dir - config = SimpleConfig(options={}, - read_system_config_function=fake_read_system, - read_user_config_function=fake_read_user, - read_user_dir_function=read_user_dir) - self.assertEqual("b", config.get("electrum_path")) - - def test_simple_config_system_config_ignored_if_portable(self): - """If electrum is started with the "portable" flag, system - configuration is completely ignored.""" - fake_read_system = lambda : {"some_key": "some_value"} - fake_read_user = lambda _: {} - read_user_dir = lambda : self.user_dir - config = SimpleConfig(options={"portable": True}, - read_system_config_function=fake_read_system, - read_user_config_function=fake_read_user, - read_user_dir_function=read_user_dir) - self.assertEqual(config.get("some_key"), None) - def test_simple_config_user_config_is_used_if_others_arent_specified(self): """If no system-wide configuration and no command-line options are specified, the user configuration is used instead.""" - fake_read_system = lambda : {} fake_read_user = lambda _: {"electrum_path": self.electrum_dir} read_user_dir = lambda : self.user_dir config = SimpleConfig(options={}, - read_system_config_function=fake_read_system, read_user_config_function=fake_read_user, read_user_dir_function=read_user_dir) self.assertEqual(self.options.get("electrum_path"), config.get("electrum_path")) def test_cannot_set_options_passed_by_command_line(self): - fake_read_system = lambda : {} fake_read_user = lambda _: {"electrum_path": "b"} read_user_dir = lambda : self.user_dir config = SimpleConfig(options=self.options, - read_system_config_function=fake_read_system, read_user_config_function=fake_read_user, read_user_dir_function=read_user_dir) config.set_key("electrum_path", "c") self.assertEqual(self.options.get("electrum_path"), config.get("electrum_path")) - def test_can_set_options_from_system_config(self): - fake_read_system = lambda : {"electrum_path": self.electrum_dir} - fake_read_user = lambda _: {} - read_user_dir = lambda : self.user_dir - config = SimpleConfig(options={}, - read_system_config_function=fake_read_system, - read_user_config_function=fake_read_user, - read_user_dir_function=read_user_dir) - config.set_key("electrum_path", "c") - self.assertEqual("c", config.get("electrum_path")) - def test_can_set_options_set_in_user_config(self): another_path = tempfile.mkdtemp() - fake_read_system = lambda : {} fake_read_user = lambda _: {"electrum_path": self.electrum_dir} read_user_dir = lambda : self.user_dir config = SimpleConfig(options={}, - read_system_config_function=fake_read_system, - read_user_config_function=fake_read_user, - read_user_dir_function=read_user_dir) - config.set_key("electrum_path", another_path) - self.assertEqual(another_path, config.get("electrum_path")) - - def test_can_set_options_from_system_config_if_portable(self): - """If the "portable" flag is set, the user can overwrite system - configuration options.""" - another_path = tempfile.mkdtemp() - fake_read_system = lambda : {"electrum_path": self.electrum_dir} - fake_read_user = lambda _: {} - read_user_dir = lambda : self.user_dir - config = SimpleConfig(options={"portable": True}, - read_system_config_function=fake_read_system, read_user_config_function=fake_read_user, read_user_dir_function=read_user_dir) config.set_key("electrum_path", another_path) self.assertEqual(another_path, config.get("electrum_path")) def test_user_config_is_not_written_with_read_only_config(self): - """The user config does not contain command-line options or system - options when saved.""" - fake_read_system = lambda : {"something": "b"} + """The user config does not contain command-line options when saved.""" fake_read_user = lambda _: {"something": "a"} read_user_dir = lambda : self.user_dir self.options.update({"something": "c"}) config = SimpleConfig(options=self.options, - read_system_config_function=fake_read_system, read_user_config_function=fake_read_user, read_user_dir_function=read_user_dir) config.save_user_config() @@ -168,48 +105,10 @@ class Test_SimpleConfig(unittest.TestCase): with open(os.path.join(self.electrum_dir, "config"), "r") as f: contents = f.read() result = ast.literal_eval(contents) + result.pop('config_version', None) self.assertEqual({"something": "a"}, result) -class TestSystemConfig(unittest.TestCase): - - sample_conf = """ -[client] -gap_limit = 5 - -[something_else] -everything = 42 -""" - - def setUp(self): - super(TestSystemConfig, self).setUp() - self.thefile = tempfile.mkstemp(suffix=".electrum.test.conf")[1] - - def tearDown(self): - super(TestSystemConfig, self).tearDown() - os.remove(self.thefile) - - def test_read_system_config_file_does_not_exist(self): - somefile = "/foo/I/do/not/exist/electrum.conf" - result = read_system_config(somefile) - self.assertEqual({}, result) - - def test_read_system_config_file_returns_file_options(self): - with open(self.thefile, "w") as f: - f.write(self.sample_conf) - - result = read_system_config(self.thefile) - self.assertEqual({"gap_limit": "5"}, result) - - def test_read_system_config_file_no_sections(self): - - with open(self.thefile, "w") as f: - f.write("gap_limit = 5") # The file has no sections at all - - result = read_system_config(self.thefile) - self.assertEqual({}, result) - - class TestUserConfig(unittest.TestCase): def setUp(self): diff --git a/lib/tests/test_transaction.py b/lib/tests/test_transaction.py index 99c6d001..cc800454 100644 --- a/lib/tests/test_transaction.py +++ b/lib/tests/test_transaction.py @@ -231,6 +231,389 @@ class TestTransaction(unittest.TestCase): tx = transaction.Transaction('010000000001010d350cefa29138de18a2d63a93cffda63721b07a6ecfa80a902f9514104b55ca0000000000fdffffff012a4a824a00000000160014b869999d342a5d42d6dc7af1efc28456da40297a024730440220475bb55814a52ea1036919e4408218c693b8bf93637b9f54c821b5baa3b846e102207276ed7a79493142c11fb01808a4142bbdd525ae7bdccdf8ecb7b8e3c856b4d90121024cdeaca7a53a7e23a1edbe9260794eaa83063534b5f111ee3c67d8b0cb88f0eec8010000') self.assertEqual('51087ece75c697cc872d2e643d646b0f3e1f2666fa1820b7bff4343d50dd680e', tx.txid()) + def test_txid_input_p2wsh_p2sh_not_multisig(self): + tx = transaction.Transaction('0100000000010160f84fdcda039c3ca1b20038adea2d49a53db92f7c467e8def13734232bb610804000000232200202814720f16329ab81cb8867c4d447bd13255931f23e6655944c9ada1797fcf88ffffffff0ba3dcfc04000000001976a91488124a57c548c9e7b1dd687455af803bd5765dea88acc9f44900000000001976a914da55045a0ccd40a56ce861946d13eb861eb5f2d788ac49825e000000000017a914ca34d4b190e36479aa6e0023cfe0a8537c6aa8dd87680c0d00000000001976a914651102524c424b2e7c44787c4f21e4c54dffafc088acf02fa9000000000017a914ee6c596e6f7066466d778d4f9ba633a564a6e95d874d250900000000001976a9146ca7976b48c04fd23867748382ee8401b1d27c2988acf5119600000000001976a914cf47d5dcdba02fd547c600697097252d38c3214a88ace08a12000000000017a914017bef79d92d5ec08c051786bad317e5dd3befcf87e3d76201000000001976a9148ec1b88b66d142bcbdb42797a0fd402c23e0eec288ac718f6900000000001976a914e66344472a224ce6f843f2989accf435ae6a808988ac65e51300000000001976a914cad6717c13a2079066f876933834210ebbe68c3f88ac0347304402201a4907c4706104320313e182ecbb1b265b2d023a79586671386de86bb47461590220472c3db9fc99a728ebb9b555a72e3481d20b181bd059a9c1acadfb853d90c96c01210338a46f2a54112fef8803c8478bc17e5f8fc6a5ec276903a946c1fafb2e3a8b181976a914eda8660085bf607b82bd18560ca8f3a9ec49178588ac00000000') + self.assertEqual('e9933221a150f78f9f224899f8568ff6422ffcc28ca3d53d87936368ff7c4b1d', tx.txid()) + + # input: p2sh, not multisig + def test_txid_regression_issue_3899(self): + tx = transaction.Transaction('0100000004328685b0352c981d3d451b471ae3bfc78b82565dc2a54049a81af273f0a9fd9c010000000b0009630330472d5fae685bffffffff328685b0352c981d3d451b471ae3bfc78b82565dc2a54049a81af273f0a9fd9c020000000b0009630359646d5fae6858ffffffff328685b0352c981d3d451b471ae3bfc78b82565dc2a54049a81af273f0a9fd9c030000000b000963034bd4715fae6854ffffffff328685b0352c981d3d451b471ae3bfc78b82565dc2a54049a81af273f0a9fd9c040000000b000963036de8705fae6860ffffffff0130750000000000001976a914b5abca61d20f9062fb1fdbb880d9d93bac36675188ac00000000') + self.assertEqual('f570d5d1e965ee61bcc7005f8fefb1d3abbed9d7ddbe035e2a68fa07e5fc4a0d', tx.txid()) + + +# these transactions are from Bitcoin Core unit tests ---> +# https://github.com/bitcoin/bitcoin/blob/11376b5583a283772c82f6d32d0007cdbf5b8ef0/src/test/data/tx_valid.json + + def test_txid_bitcoin_core_0001(self): + tx = transaction.Transaction('0100000001b14bdcbc3e01bdaad36cc08e81e69c82e1060bc14e518db2b49aa43ad90ba26000000000490047304402203f16c6f40162ab686621ef3000b04e75418a0c0cb2d8aebeac894ae360ac1e780220ddc15ecdfc3507ac48e1681a33eb60996631bf6bf5bc0a0682c4db743ce7ca2b01ffffffff0140420f00000000001976a914660d4ef3a743e3e696ad990364e555c271ad504b88ac00000000') + self.assertEqual('23b397edccd3740a74adb603c9756370fafcde9bcc4483eb271ecad09a94dd63', tx.txid()) + + def test_txid_bitcoin_core_0002(self): + tx = transaction.Transaction('0100000001b14bdcbc3e01bdaad36cc08e81e69c82e1060bc14e518db2b49aa43ad90ba260000000004a0048304402203f16c6f40162ab686621ef3000b04e75418a0c0cb2d8aebeac894ae360ac1e780220ddc15ecdfc3507ac48e1681a33eb60996631bf6bf5bc0a0682c4db743ce7ca2bab01ffffffff0140420f00000000001976a914660d4ef3a743e3e696ad990364e555c271ad504b88ac00000000') + self.assertEqual('fcabc409d8e685da28536e1e5ccc91264d755cd4c57ed4cae3dbaa4d3b93e8ed', tx.txid()) + + def test_txid_bitcoin_core_0003(self): + tx = transaction.Transaction('0100000001b14bdcbc3e01bdaad36cc08e81e69c82e1060bc14e518db2b49aa43ad90ba260000000004a01ff47304402203f16c6f40162ab686621ef3000b04e75418a0c0cb2d8aebeac894ae360ac1e780220ddc15ecdfc3507ac48e1681a33eb60996631bf6bf5bc0a0682c4db743ce7ca2b01ffffffff0140420f00000000001976a914660d4ef3a743e3e696ad990364e555c271ad504b88ac00000000') + self.assertEqual('c9aa95f2c48175fdb70b34c23f1c3fc44f869b073a6f79b1343fbce30c3cb575', tx.txid()) + + def test_txid_bitcoin_core_0004(self): + tx = transaction.Transaction('0100000001b14bdcbc3e01bdaad36cc08e81e69c82e1060bc14e518db2b49aa43ad90ba26000000000495147304402203f16c6f40162ab686621ef3000b04e75418a0c0cb2d8aebeac894ae360ac1e780220ddc15ecdfc3507ac48e1681a33eb60996631bf6bf5bc0a0682c4db743ce7ca2b01ffffffff0140420f00000000001976a914660d4ef3a743e3e696ad990364e555c271ad504b88ac00000000') + self.assertEqual('da94fda32b55deb40c3ed92e135d69df7efc4ee6665e0beb07ef500f407c9fd2', tx.txid()) + + def test_txid_bitcoin_core_0005(self): + tx = transaction.Transaction('0100000001b14bdcbc3e01bdaad36cc08e81e69c82e1060bc14e518db2b49aa43ad90ba26000000000494f47304402203f16c6f40162ab686621ef3000b04e75418a0c0cb2d8aebeac894ae360ac1e780220ddc15ecdfc3507ac48e1681a33eb60996631bf6bf5bc0a0682c4db743ce7ca2b01ffffffff0140420f00000000001976a914660d4ef3a743e3e696ad990364e555c271ad504b88ac00000000') + self.assertEqual('f76f897b206e4f78d60fe40f2ccb542184cfadc34354d3bb9bdc30cc2f432b86', tx.txid()) + + def test_txid_bitcoin_core_0006(self): + tx = transaction.Transaction('01000000010276b76b07f4935c70acf54fbf1f438a4c397a9fb7e633873c4dd3bc062b6b40000000008c493046022100d23459d03ed7e9511a47d13292d3430a04627de6235b6e51a40f9cd386f2abe3022100e7d25b080f0bb8d8d5f878bba7d54ad2fda650ea8d158a33ee3cbd11768191fd004104b0e2c879e4daf7b9ab68350228c159766676a14f5815084ba166432aab46198d4cca98fa3e9981d0a90b2effc514b76279476550ba3663fdcaff94c38420e9d5000000000100093d00000000001976a9149a7b0f3b80c6baaeedce0a0842553800f832ba1f88ac00000000') + self.assertEqual('c99c49da4c38af669dea436d3e73780dfdb6c1ecf9958baa52960e8baee30e73', tx.txid()) + + def test_txid_bitcoin_core_0007(self): + tx = transaction.Transaction('01000000010001000000000000000000000000000000000000000000000000000000000000000000006a473044022067288ea50aa799543a536ff9306f8e1cba05b9c6b10951175b924f96732555ed022026d7b5265f38d21541519e4a1e55044d5b9e17e15cdbaf29ae3792e99e883e7a012103ba8c8b86dea131c22ab967e6dd99bdae8eff7a1f75a2c35f1f944109e3fe5e22ffffffff010000000000000000015100000000') + self.assertEqual('e41ffe19dff3cbedb413a2ca3fbbcd05cb7fd7397ffa65052f8928aa9c700092', tx.txid()) + + def test_txid_bitcoin_core_0008(self): + tx = transaction.Transaction('01000000023d6cf972d4dff9c519eff407ea800361dd0a121de1da8b6f4138a2f25de864b4000000008a4730440220ffda47bfc776bcd269da4832626ac332adfca6dd835e8ecd83cd1ebe7d709b0e022049cffa1cdc102a0b56e0e04913606c70af702a1149dc3b305ab9439288fee090014104266abb36d66eb4218a6dd31f09bb92cf3cfa803c7ea72c1fc80a50f919273e613f895b855fb7465ccbc8919ad1bd4a306c783f22cd3227327694c4fa4c1c439affffffff21ebc9ba20594737864352e95b727f1a565756f9d365083eb1a8596ec98c97b7010000008a4730440220503ff10e9f1e0de731407a4a245531c9ff17676eda461f8ceeb8c06049fa2c810220c008ac34694510298fa60b3f000df01caa244f165b727d4896eb84f81e46bcc4014104266abb36d66eb4218a6dd31f09bb92cf3cfa803c7ea72c1fc80a50f919273e613f895b855fb7465ccbc8919ad1bd4a306c783f22cd3227327694c4fa4c1c439affffffff01f0da5200000000001976a914857ccd42dded6df32949d4646dfa10a92458cfaa88ac00000000') + self.assertEqual('f7fdd091fa6d8f5e7a8c2458f5c38faffff2d3f1406b6e4fe2c99dcc0d2d1cbb', tx.txid()) + + def test_txid_bitcoin_core_0009(self): + tx = transaction.Transaction('01000000020002000000000000000000000000000000000000000000000000000000000000000000000151ffffffff0001000000000000000000000000000000000000000000000000000000000000000000006b483045022100c9cdd08798a28af9d1baf44a6c77bcc7e279f47dc487c8c899911bc48feaffcc0220503c5c50ae3998a733263c5c0f7061b483e2b56c4c41b456e7d2f5a78a74c077032102d5c25adb51b61339d2b05315791e21bbe80ea470a49db0135720983c905aace0ffffffff010000000000000000015100000000') + self.assertEqual('b56471690c3ff4f7946174e51df68b47455a0d29344c351377d712e6d00eabe5', tx.txid()) + + def test_txid_bitcoin_core_0010(self): + tx = transaction.Transaction('010000000100010000000000000000000000000000000000000000000000000000000000000000000009085768617420697320ffffffff010000000000000000015100000000') + self.assertEqual('99517e5b47533453cc7daa332180f578be68b80370ecfe84dbfff7f19d791da4', tx.txid()) + + def test_txid_bitcoin_core_0011(self): + tx = transaction.Transaction('01000000010001000000000000000000000000000000000000000000000000000000000000000000006e493046022100c66c9cdf4c43609586d15424c54707156e316d88b0a1534c9e6b0d4f311406310221009c0fe51dbc9c4ab7cc25d3fdbeccf6679fe6827f08edf2b4a9f16ee3eb0e438a0123210338e8034509af564c62644c07691942e0c056752008a173c89f60ab2a88ac2ebfacffffffff010000000000000000015100000000') + self.assertEqual('ab097537b528871b9b64cb79a769ae13c3c3cd477cc9dddeebe657eabd7fdcea', tx.txid()) + + def test_txid_bitcoin_core_0012(self): + tx = transaction.Transaction('01000000010001000000000000000000000000000000000000000000000000000000000000000000006e493046022100e1eadba00d9296c743cb6ecc703fd9ddc9b3cd12906176a226ae4c18d6b00796022100a71aef7d2874deff681ba6080f1b278bac7bb99c61b08a85f4311970ffe7f63f012321030c0588dc44d92bdcbf8e72093466766fdc265ead8db64517b0c542275b70fffbacffffffff010040075af0750700015100000000') + self.assertEqual('4d163e00f1966e9a1eab8f9374c3e37f4deb4857c247270e25f7d79a999d2dc9', tx.txid()) + + def test_txid_bitcoin_core_0013(self): + tx = transaction.Transaction('01000000010001000000000000000000000000000000000000000000000000000000000000000000006d483045022027deccc14aa6668e78a8c9da3484fbcd4f9dcc9bb7d1b85146314b21b9ae4d86022100d0b43dece8cfb07348de0ca8bc5b86276fa88f7f2138381128b7c36ab2e42264012321029bb13463ddd5d2cc05da6e84e37536cb9525703cfd8f43afdb414988987a92f6acffffffff020040075af075070001510000000000000000015100000000') + self.assertEqual('9fe2ef9dde70e15d78894a4800b7df3bbfb1addb9a6f7d7c204492fdb6ee6cc4', tx.txid()) + + def test_txid_bitcoin_core_0014(self): + tx = transaction.Transaction('01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff025151ffffffff010000000000000000015100000000') + self.assertEqual('99d3825137602e577aeaf6a2e3c9620fd0e605323dc5265da4a570593be791d4', tx.txid()) + + def test_txid_bitcoin_core_0015(self): + tx = transaction.Transaction('01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff6451515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151515151ffffffff010000000000000000015100000000') + self.assertEqual('c0d67409923040cc766bbea12e4c9154393abef706db065ac2e07d91a9ba4f84', tx.txid()) + + def test_txid_bitcoin_core_0016(self): + tx = transaction.Transaction('010000000200010000000000000000000000000000000000000000000000000000000000000000000049483045022100d180fd2eb9140aeb4210c9204d3f358766eb53842b2a9473db687fa24b12a3cc022079781799cd4f038b85135bbe49ec2b57f306b2bb17101b17f71f000fcab2b6fb01ffffffff0002000000000000000000000000000000000000000000000000000000000000000000004847304402205f7530653eea9b38699e476320ab135b74771e1c48b81a5d041e2ca84b9be7a802200ac8d1f40fb026674fe5a5edd3dea715c27baa9baca51ed45ea750ac9dc0a55e81ffffffff010100000000000000015100000000') + self.assertEqual('c610d85d3d5fdf5046be7f123db8a0890cee846ee58de8a44667cfd1ab6b8666', tx.txid()) + + def test_txid_bitcoin_core_0017(self): + tx = transaction.Transaction('01000000020001000000000000000000000000000000000000000000000000000000000000000000004948304502203a0f5f0e1f2bdbcd04db3061d18f3af70e07f4f467cbc1b8116f267025f5360b022100c792b6e215afc5afc721a351ec413e714305cb749aae3d7fee76621313418df101010000000002000000000000000000000000000000000000000000000000000000000000000000004847304402205f7530653eea9b38699e476320ab135b74771e1c48b81a5d041e2ca84b9be7a802200ac8d1f40fb026674fe5a5edd3dea715c27baa9baca51ed45ea750ac9dc0a55e81ffffffff010100000000000000015100000000') + self.assertEqual('a647a7b3328d2c698bfa1ee2dd4e5e05a6cea972e764ccb9bd29ea43817ca64f', tx.txid()) + + def test_txid_bitcoin_core_0018(self): + tx = transaction.Transaction('010000000370ac0a1ae588aaf284c308d67ca92c69a39e2db81337e563bf40c59da0a5cf63000000006a4730440220360d20baff382059040ba9be98947fd678fb08aab2bb0c172efa996fd8ece9b702201b4fb0de67f015c90e7ac8a193aeab486a1f587e0f54d0fb9552ef7f5ce6caec032103579ca2e6d107522f012cd00b52b9a65fb46f0c57b9b8b6e377c48f526a44741affffffff7d815b6447e35fbea097e00e028fb7dfbad4f3f0987b4734676c84f3fcd0e804010000006b483045022100c714310be1e3a9ff1c5f7cacc65c2d8e781fc3a88ceb063c6153bf950650802102200b2d0979c76e12bb480da635f192cc8dc6f905380dd4ac1ff35a4f68f462fffd032103579ca2e6d107522f012cd00b52b9a65fb46f0c57b9b8b6e377c48f526a44741affffffff3f1f097333e4d46d51f5e77b53264db8f7f5d2e18217e1099957d0f5af7713ee010000006c493046022100b663499ef73273a3788dea342717c2640ac43c5a1cf862c9e09b206fcb3f6bb8022100b09972e75972d9148f2bdd462e5cb69b57c1214b88fc55ca638676c07cfc10d8032103579ca2e6d107522f012cd00b52b9a65fb46f0c57b9b8b6e377c48f526a44741affffffff0380841e00000000001976a914bfb282c70c4191f45b5a6665cad1682f2c9cfdfb88ac80841e00000000001976a9149857cc07bed33a5cf12b9c5e0500b675d500c81188ace0fd1c00000000001976a91443c52850606c872403c0601e69fa34b26f62db4a88ac00000000') + self.assertEqual('afd9c17f8913577ec3509520bd6e5d63e9c0fd2a5f70c787993b097ba6ca9fae', tx.txid()) + + def test_txid_bitcoin_core_0019(self): + tx = transaction.Transaction('01000000012312503f2491a2a97fcd775f11e108a540a5528b5d4dee7a3c68ae4add01dab300000000fdfe0000483045022100f6649b0eddfdfd4ad55426663385090d51ee86c3481bdc6b0c18ea6c0ece2c0b0220561c315b07cffa6f7dd9df96dbae9200c2dee09bf93cc35ca05e6cdf613340aa0148304502207aacee820e08b0b174e248abd8d7a34ed63b5da3abedb99934df9fddd65c05c4022100dfe87896ab5ee3df476c2655f9fbe5bd089dccbef3e4ea05b5d121169fe7f5f4014c695221031d11db38972b712a9fe1fc023577c7ae3ddb4a3004187d41c45121eecfdbb5b7210207ec36911b6ad2382860d32989c7b8728e9489d7bbc94a6b5509ef0029be128821024ea9fac06f666a4adc3fc1357b7bec1fd0bdece2b9d08579226a8ebde53058e453aeffffffff0180380100000000001976a914c9b99cddf847d10685a4fabaa0baf505f7c3dfab88ac00000000') + self.assertEqual('f4b05f978689c89000f729cae187dcfbe64c9819af67a4f05c0b4d59e717d64d', tx.txid()) + + def test_txid_bitcoin_core_0020(self): + tx = transaction.Transaction('0100000001f709fa82596e4f908ee331cb5e0ed46ab331d7dcfaf697fe95891e73dac4ebcb000000008c20ca42095840735e89283fec298e62ac2ddea9b5f34a8cbb7097ad965b87568100201b1b01dc829177da4a14551d2fc96a9db00c6501edfa12f22cd9cefd335c227f483045022100a9df60536df5733dd0de6bc921fab0b3eee6426501b43a228afa2c90072eb5ca02201c78b74266fac7d1db5deff080d8a403743203f109fbcabf6d5a760bf87386d20100ffffffff01c075790000000000232103611f9a45c18f28f06f19076ad571c344c82ce8fcfe34464cf8085217a2d294a6ac00000000') + self.assertEqual('cc60b1f899ec0a69b7c3f25ddf32c4524096a9c5b01cbd84c6d0312a0c478984', tx.txid()) + + def test_txid_bitcoin_core_0021(self): + tx = transaction.Transaction('01000000012c651178faca83be0b81c8c1375c4b0ad38d53c8fe1b1c4255f5e795c25792220000000049483045022100d6044562284ac76c985018fc4a90127847708c9edb280996c507b28babdc4b2a02203d74eca3f1a4d1eea7ff77b528fde6d5dc324ec2dbfdb964ba885f643b9704cd01ffffffff010100000000000000232102c2410f8891ae918cab4ffc4bb4a3b0881be67c7a1e7faa8b5acf9ab8932ec30cac00000000') + self.assertEqual('1edc7f214659d52c731e2016d258701911bd62a0422f72f6c87a1bc8dd3f8667', tx.txid()) + + def test_txid_bitcoin_core_0022(self): + tx = transaction.Transaction('0100000001f725ea148d92096a79b1709611e06e94c63c4ef61cbae2d9b906388efd3ca99c000000000100ffffffff0101000000000000002321028a1d66975dbdf97897e3a4aef450ebeb5b5293e4a0b4a6d3a2daaa0b2b110e02ac00000000') + self.assertEqual('018adb7133fde63add9149a2161802a1bcf4bdf12c39334e880c073480eda2ff', tx.txid()) + + def test_txid_bitcoin_core_0023(self): + tx = transaction.Transaction('0100000001be599efaa4148474053c2fa031c7262398913f1dc1d9ec201fd44078ed004e44000000004900473044022022b29706cb2ed9ef0cb3c97b72677ca2dfd7b4160f7b4beb3ba806aa856c401502202d1e52582412eba2ed474f1f437a427640306fd3838725fab173ade7fe4eae4a01ffffffff010100000000000000232103ac4bba7e7ca3e873eea49e08132ad30c7f03640b6539e9b59903cf14fd016bbbac00000000') + self.assertEqual('1464caf48c708a6cc19a296944ded9bb7f719c9858986d2501cf35068b9ce5a2', tx.txid()) + + def test_txid_bitcoin_core_0024(self): + tx = transaction.Transaction('010000000112b66d5e8c7d224059e946749508efea9d66bf8d0c83630f080cf30be8bb6ae100000000490047304402206ffe3f14caf38ad5c1544428e99da76ffa5455675ec8d9780fac215ca17953520220779502985e194d84baa36b9bd40a0dbd981163fa191eb884ae83fc5bd1c86b1101ffffffff010100000000000000232103905380c7013e36e6e19d305311c1b81fce6581f5ee1c86ef0627c68c9362fc9fac00000000') + self.assertEqual('1fb73fbfc947d52f5d80ba23b67c06a232ad83fdd49d1c0a657602f03fbe8f7a', tx.txid()) + + def test_txid_bitcoin_core_0025(self): + tx = transaction.Transaction('0100000001b0ef70cc644e0d37407e387e73bfad598d852a5aa6d691d72b2913cebff4bceb000000004a00473044022068cd4851fc7f9a892ab910df7a24e616f293bcb5c5fbdfbc304a194b26b60fba022078e6da13d8cb881a22939b952c24f88b97afd06b4c47a47d7f804c9a352a6d6d0100ffffffff0101000000000000002321033bcaa0a602f0d44cc9d5637c6e515b0471db514c020883830b7cefd73af04194ac00000000') + self.assertEqual('24cecfce0fa880b09c9b4a66c5134499d1b09c01cc5728cd182638bea070e6ab', tx.txid()) + + def test_txid_bitcoin_core_0026(self): + tx = transaction.Transaction('0100000001c188aa82f268fcf08ba18950f263654a3ea6931dabc8bf3ed1d4d42aaed74cba000000004b0000483045022100940378576e069aca261a6b26fb38344e4497ca6751bb10905c76bb689f4222b002204833806b014c26fd801727b792b1260003c55710f87c5adbd7a9cb57446dbc9801ffffffff0101000000000000002321037c615d761e71d38903609bf4f46847266edc2fb37532047d747ba47eaae5ffe1ac00000000') + self.assertEqual('9eaa819e386d6a54256c9283da50c230f3d8cd5376d75c4dcc945afdeb157dd7', tx.txid()) + + def test_txid_bitcoin_core_0027(self): + tx = transaction.Transaction('01000000012432b60dc72cebc1a27ce0969c0989c895bdd9e62e8234839117f8fc32d17fbc000000004a493046022100a576b52051962c25e642c0fd3d77ee6c92487048e5d90818bcf5b51abaccd7900221008204f8fb121be4ec3b24483b1f92d89b1b0548513a134e345c5442e86e8617a501ffffffff010000000000000000016a00000000') + self.assertEqual('46224764c7870f95b58f155bce1e38d4da8e99d42dbb632d0dd7c07e092ee5aa', tx.txid()) + + def test_txid_bitcoin_core_0028(self): + tx = transaction.Transaction('01000000014710b0e7cf9f8930de259bdc4b84aa5dfb9437b665a3e3a21ff26e0bf994e183000000004a493046022100a166121a61b4eeb19d8f922b978ff6ab58ead8a5a5552bf9be73dc9c156873ea02210092ad9bc43ee647da4f6652c320800debcf08ec20a094a0aaf085f63ecb37a17201ffffffff010000000000000000016a00000000') + self.assertEqual('8d66836045db9f2d7b3a75212c5e6325f70603ee27c8333a3bce5bf670d9582e', tx.txid()) + + def test_txid_bitcoin_core_0029(self): + tx = transaction.Transaction('01000000015ebaa001d8e4ec7a88703a3bcf69d98c874bca6299cca0f191512bf2a7826832000000004948304502203bf754d1c6732fbf87c5dcd81258aefd30f2060d7bd8ac4a5696f7927091dad1022100f5bcb726c4cf5ed0ed34cc13dadeedf628ae1045b7cb34421bc60b89f4cecae701ffffffff010000000000000000016a00000000') + self.assertEqual('aab7ef280abbb9cc6fbaf524d2645c3daf4fcca2b3f53370e618d9cedf65f1f8', tx.txid()) + + def test_txid_bitcoin_core_0030(self): + tx = transaction.Transaction('010000000144490eda355be7480f2ec828dcc1b9903793a8008fad8cfe9b0c6b4d2f0355a900000000924830450221009c0a27f886a1d8cb87f6f595fbc3163d28f7a81ec3c4b252ee7f3ac77fd13ffa02203caa8dfa09713c8c4d7ef575c75ed97812072405d932bd11e6a1593a98b679370148304502201e3861ef39a526406bad1e20ecad06be7375ad40ddb582c9be42d26c3a0d7b240221009d0a3985e96522e59635d19cc4448547477396ce0ef17a58e7d74c3ef464292301ffffffff010000000000000000016a00000000') + self.assertEqual('6327783a064d4e350c454ad5cd90201aedf65b1fc524e73709c52f0163739190', tx.txid()) + + def test_txid_bitcoin_core_0031(self): + tx = transaction.Transaction('010000000144490eda355be7480f2ec828dcc1b9903793a8008fad8cfe9b0c6b4d2f0355a9000000004a48304502207a6974a77c591fa13dff60cabbb85a0de9e025c09c65a4b2285e47ce8e22f761022100f0efaac9ff8ac36b10721e0aae1fb975c90500b50c56e8a0cc52b0403f0425dd0100ffffffff010000000000000000016a00000000') + self.assertEqual('892464645599cc3c2d165adcc612e5f982a200dfaa3e11e9ce1d228027f46880', tx.txid()) + + def test_txid_bitcoin_core_0032(self): + tx = transaction.Transaction('010000000144490eda355be7480f2ec828dcc1b9903793a8008fad8cfe9b0c6b4d2f0355a9000000004a483045022100fa4a74ba9fd59c59f46c3960cf90cbe0d2b743c471d24a3d5d6db6002af5eebb02204d70ec490fd0f7055a7c45f86514336e3a7f03503dacecabb247fc23f15c83510151ffffffff010000000000000000016a00000000') + self.assertEqual('578db8c6c404fec22c4a8afeaf32df0e7b767c4dda3478e0471575846419e8fc', tx.txid()) + + def test_txid_bitcoin_core_0033(self): + tx = transaction.Transaction('0100000001e0be9e32f1f89c3d916c4f21e55cdcd096741b895cc76ac353e6023a05f4f7cc00000000d86149304602210086e5f736a2c3622ebb62bd9d93d8e5d76508b98be922b97160edc3dcca6d8c47022100b23c312ac232a4473f19d2aeb95ab7bdf2b65518911a0d72d50e38b5dd31dc820121038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f22041ac4730440220508fa761865c8abd81244a168392876ee1d94e8ed83897066b5e2df2400dad24022043f5ee7538e87e9c6aef7ef55133d3e51da7cc522830a9c4d736977a76ef755c0121038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f22041ffffffff010000000000000000016a00000000') + self.assertEqual('974f5148a0946f9985e75a240bb24c573adbbdc25d61e7b016cdbb0a5355049f', tx.txid()) + + def test_txid_bitcoin_core_0034(self): + tx = transaction.Transaction('01000000013c6f30f99a5161e75a2ce4bca488300ca0c6112bde67f0807fe983feeff0c91001000000e608646561646265656675ab61493046022100ce18d384221a731c993939015e3d1bcebafb16e8c0b5b5d14097ec8177ae6f28022100bcab227af90bab33c3fe0a9abfee03ba976ee25dc6ce542526e9b2e56e14b7f10121038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f22041ac493046022100c3b93edcc0fd6250eb32f2dd8a0bba1754b0f6c3be8ed4100ed582f3db73eba2022100bf75b5bd2eff4d6bf2bda2e34a40fcc07d4aa3cf862ceaa77b47b81eff829f9a01ab21038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f22041ffffffff010000000000000000016a00000000') + self.assertEqual('b0097ec81df231893a212657bf5fe5a13b2bff8b28c0042aca6fc4159f79661b', tx.txid()) + + def test_txid_bitcoin_core_0035(self): + tx = transaction.Transaction('01000000016f3dbe2ca96fa217e94b1017860be49f20820dea5c91bdcb103b0049d5eb566000000000fd1d0147304402203989ac8f9ad36b5d0919d97fa0a7f70c5272abee3b14477dc646288a8b976df5022027d19da84a066af9053ad3d1d7459d171b7e3a80bc6c4ef7a330677a6be548140147304402203989ac8f9ad36b5d0919d97fa0a7f70c5272abee3b14477dc646288a8b976df5022027d19da84a066af9053ad3d1d7459d171b7e3a80bc6c4ef7a330677a6be548140121038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f22041ac47304402203757e937ba807e4a5da8534c17f9d121176056406a6465054bdd260457515c1a02200f02eccf1bec0f3a0d65df37889143c2e88ab7acec61a7b6f5aa264139141a2b0121038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f22041ffffffff010000000000000000016a00000000') + self.assertEqual('feeba255656c80c14db595736c1c7955c8c0a497622ec96e3f2238fbdd43a7c9', tx.txid()) + + def test_txid_bitcoin_core_0036(self): + tx = transaction.Transaction('01000000012139c555ccb81ee5b1e87477840991ef7b386bc3ab946b6b682a04a621006b5a01000000fdb40148304502201723e692e5f409a7151db386291b63524c5eb2030df652b1f53022fd8207349f022100b90d9bbf2f3366ce176e5e780a00433da67d9e5c79312c6388312a296a5800390148304502201723e692e5f409a7151db386291b63524c5eb2030df652b1f53022fd8207349f022100b90d9bbf2f3366ce176e5e780a00433da67d9e5c79312c6388312a296a5800390121038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f2204148304502201723e692e5f409a7151db386291b63524c5eb2030df652b1f53022fd8207349f022100b90d9bbf2f3366ce176e5e780a00433da67d9e5c79312c6388312a296a5800390175ac4830450220646b72c35beeec51f4d5bc1cbae01863825750d7f490864af354e6ea4f625e9c022100f04b98432df3a9641719dbced53393022e7249fb59db993af1118539830aab870148304502201723e692e5f409a7151db386291b63524c5eb2030df652b1f53022fd8207349f022100b90d9bbf2f3366ce176e5e780a00433da67d9e5c79312c6388312a296a580039017521038479a0fa998cd35259a2ef0a7a5c68662c1474f88ccb6d08a7677bbec7f22041ffffffff010000000000000000016a00000000') + self.assertEqual('a0c984fc820e57ddba97f8098fa640c8a7eb3fe2f583923da886b7660f505e1e', tx.txid()) + + def test_txid_bitcoin_core_0037(self): + tx = transaction.Transaction('0100000002f9cbafc519425637ba4227f8d0a0b7160b4e65168193d5af39747891de98b5b5000000006b4830450221008dd619c563e527c47d9bd53534a770b102e40faa87f61433580e04e271ef2f960220029886434e18122b53d5decd25f1f4acb2480659fea20aabd856987ba3c3907e0121022b78b756e2258af13779c1a1f37ea6800259716ca4b7f0b87610e0bf3ab52a01ffffffff42e7988254800876b69f24676b3e0205b77be476512ca4d970707dd5c60598ab00000000fd260100483045022015bd0139bcccf990a6af6ec5c1c52ed8222e03a0d51c334df139968525d2fcd20221009f9efe325476eb64c3958e4713e9eefe49bf1d820ed58d2112721b134e2a1a53034930460221008431bdfa72bc67f9d41fe72e94c88fb8f359ffa30b33c72c121c5a877d922e1002210089ef5fc22dd8bfc6bf9ffdb01a9862d27687d424d1fefbab9e9c7176844a187a014c9052483045022015bd0139bcccf990a6af6ec5c1c52ed8222e03a0d51c334df139968525d2fcd20221009f9efe325476eb64c3958e4713e9eefe49bf1d820ed58d2112721b134e2a1a5303210378d430274f8c5ec1321338151e9f27f4c676a008bdf8638d07c0b6be9ab35c71210378d430274f8c5ec1321338151e9f27f4c676a008bdf8638d07c0b6be9ab35c7153aeffffffff01a08601000000000017a914d8dacdadb7462ae15cd906f1878706d0da8660e68700000000') + self.assertEqual('5df1375ffe61ac35ca178ebb0cab9ea26dedbd0e96005dfcee7e379fa513232f', tx.txid()) + + def test_txid_bitcoin_core_0038(self): + tx = transaction.Transaction('0100000002dbb33bdf185b17f758af243c5d3c6e164cc873f6bb9f40c0677d6e0f8ee5afce000000006b4830450221009627444320dc5ef8d7f68f35010b4c050a6ed0d96b67a84db99fda9c9de58b1e02203e4b4aaa019e012e65d69b487fdf8719df72f488fa91506a80c49a33929f1fd50121022b78b756e2258af13779c1a1f37ea6800259716ca4b7f0b87610e0bf3ab52a01ffffffffdbb33bdf185b17f758af243c5d3c6e164cc873f6bb9f40c0677d6e0f8ee5afce010000009300483045022015bd0139bcccf990a6af6ec5c1c52ed8222e03a0d51c334df139968525d2fcd20221009f9efe325476eb64c3958e4713e9eefe49bf1d820ed58d2112721b134e2a1a5303483045022015bd0139bcccf990a6af6ec5c1c52ed8222e03a0d51c334df139968525d2fcd20221009f9efe325476eb64c3958e4713e9eefe49bf1d820ed58d2112721b134e2a1a5303ffffffff01a0860100000000001976a9149bc0bbdd3024da4d0c38ed1aecf5c68dd1d3fa1288ac00000000') + self.assertEqual('ded7ff51d89a4e1ec48162aee5a96447214d93dfb3837946af2301a28f65dbea', tx.txid()) + + def test_txid_bitcoin_core_0039(self): + tx = transaction.Transaction('010000000100010000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000') + self.assertEqual('3444be2e216abe77b46015e481d8cc21abd4c20446aabf49cd78141c9b9db87e', tx.txid()) + + def test_txid_bitcoin_core_0040(self): + tx = transaction.Transaction('0100000001000100000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000ff64cd1d') + self.assertEqual('abd62b4627d8d9b2d95fcfd8c87e37d2790637ce47d28018e3aece63c1d62649', tx.txid()) + + def test_txid_bitcoin_core_0041(self): + tx = transaction.Transaction('01000000010001000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000065cd1d') + self.assertEqual('58b6de8413603b7f556270bf48caedcf17772e7105f5419f6a80be0df0b470da', tx.txid()) + + def test_txid_bitcoin_core_0042(self): + tx = transaction.Transaction('0100000001000100000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000ffffffff') + self.assertEqual('5f99c0abf511294d76cbe144d86b77238a03e086974bc7a8ea0bdb2c681a0324', tx.txid()) + + def test_txid_bitcoin_core_0043(self): + tx = transaction.Transaction('010000000100010000000000000000000000000000000000000000000000000000000000000000000000feffffff0100000000000000000000000000') + self.assertEqual('25d35877eaba19497710666473c50d5527d38503e3521107a3fc532b74cd7453', tx.txid()) + + def test_txid_bitcoin_core_0044(self): + tx = transaction.Transaction('0100000001000100000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000feffffff') + self.assertEqual('1b9aef851895b93c62c29fbd6ca4d45803f4007eff266e2f96ff11e9b6ef197b', tx.txid()) + + def test_txid_bitcoin_core_0045(self): + tx = transaction.Transaction('010000000100010000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000') + self.assertEqual('3444be2e216abe77b46015e481d8cc21abd4c20446aabf49cd78141c9b9db87e', tx.txid()) + + def test_txid_bitcoin_core_0046(self): + tx = transaction.Transaction('01000000010001000000000000000000000000000000000000000000000000000000000000000000000251b1000000000100000000000000000001000000') + self.assertEqual('f53761038a728b1f17272539380d96e93f999218f8dcb04a8469b523445cd0fd', tx.txid()) + + def test_txid_bitcoin_core_0047(self): + tx = transaction.Transaction('0100000001000100000000000000000000000000000000000000000000000000000000000000000000030251b1000000000100000000000000000001000000') + self.assertEqual('d193f0f32fceaf07bb25c897c8f99ca6f69a52f6274ca64efc2a2e180cb97fc1', tx.txid()) + + def test_txid_bitcoin_core_0048(self): + tx = transaction.Transaction('010000000132211bdd0d568506804eef0d8cc3db68c3d766ab9306cdfcc0a9c89616c8dbb1000000006c493045022100c7bb0faea0522e74ff220c20c022d2cb6033f8d167fb89e75a50e237a35fd6d202203064713491b1f8ad5f79e623d0219ad32510bfaa1009ab30cbee77b59317d6e30001210237af13eb2d84e4545af287b919c2282019c9691cc509e78e196a9d8274ed1be0ffffffff0100000000000000001976a914f1b3ed2eda9a2ebe5a9374f692877cdf87c0f95b88ac00000000') + self.assertEqual('50a1e0e6a134a564efa078e3bd088e7e8777c2c0aec10a752fd8706470103b89', tx.txid()) + + def test_txid_bitcoin_core_0049(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000') + self.assertEqual('e2207d1aaf6b74e5d98c2fa326d2dc803b56b30a3f90ce779fa5edb762f38755', tx.txid()) + + def test_txid_bitcoin_core_0050(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000ffff00000100000000000000000000000000') + self.assertEqual('f335864f7c12ec7946d2c123deb91eb978574b647af125a414262380c7fbd55c', tx.txid()) + + def test_txid_bitcoin_core_0051(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000ffffbf7f0100000000000000000000000000') + self.assertEqual('d1edbcde44691e98a7b7f556bd04966091302e29ad9af3c2baac38233667e0d2', tx.txid()) + + def test_txid_bitcoin_core_0052(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000000040000100000000000000000000000000') + self.assertEqual('3a13e1b6371c545147173cc4055f0ed73686a9f73f092352fb4b39ca27d360e6', tx.txid()) + + def test_txid_bitcoin_core_0053(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000ffff40000100000000000000000000000000') + self.assertEqual('bffda23e40766d292b0510a1b556453c558980c70c94ab158d8286b3413e220d', tx.txid()) + + def test_txid_bitcoin_core_0054(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000ffffff7f0100000000000000000000000000') + self.assertEqual('01a86c65460325dc6699714d26df512a62a854a669f6ed2e6f369a238e048cfd', tx.txid()) + + def test_txid_bitcoin_core_0055(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000000000800100000000000000000000000000') + self.assertEqual('f6d2359c5de2d904e10517d23e7c8210cca71076071bbf46de9fbd5f6233dbf1', tx.txid()) + + def test_txid_bitcoin_core_0056(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000feffffff0100000000000000000000000000') + self.assertEqual('19c2b7377229dae7aa3e50142a32fd37cef7171a01682f536e9ffa80c186f6c9', tx.txid()) + + def test_txid_bitcoin_core_0057(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000ffffffff0100000000000000000000000000') + self.assertEqual('c9dda3a24cc8a5acb153d1085ecd2fecf6f87083122f8cdecc515b1148d4c40d', tx.txid()) + + def test_txid_bitcoin_core_0058(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000ffffbf7f0100000000000000000000000000') + self.assertEqual('d1edbcde44691e98a7b7f556bd04966091302e29ad9af3c2baac38233667e0d2', tx.txid()) + + def test_txid_bitcoin_core_0059(self): + tx = transaction.Transaction('020000000100010000000000000000000000000000000000000000000000000000000000000000000000ffffff7f0100000000000000000000000000') + self.assertEqual('01a86c65460325dc6699714d26df512a62a854a669f6ed2e6f369a238e048cfd', tx.txid()) + + def test_txid_bitcoin_core_0060(self): + tx = transaction.Transaction('02000000010001000000000000000000000000000000000000000000000000000000000000000000000251b2010000000100000000000000000000000000') + self.assertEqual('4b5e0aae1251a9dc66b4d5f483f1879bf518ea5e1765abc5a9f2084b43ed1ea7', tx.txid()) + + def test_txid_bitcoin_core_0061(self): + tx = transaction.Transaction('0200000001000100000000000000000000000000000000000000000000000000000000000000000000030251b2010000000100000000000000000000000000') + self.assertEqual('5f16eb3ca4581e2dfb46a28140a4ee15f85e4e1c032947da8b93549b53c105f5', tx.txid()) + + def test_txid_bitcoin_core_0062(self): + tx = transaction.Transaction('0100000000010100010000000000000000000000000000000000000000000000000000000000000000000000ffffffff01e8030000000000001976a9144c9c3dfac4207d5d8cb89df5722cb3d712385e3f88ac02483045022100cfb07164b36ba64c1b1e8c7720a56ad64d96f6ef332d3d37f9cb3c96477dc44502200a464cd7a9cf94cd70f66ce4f4f0625ef650052c7afcfe29d7d7e01830ff91ed012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc7100000000') + self.assertEqual('b2ce556154e5ab22bec0a2f990b2b843f4f4085486c0d2cd82873685c0012004', tx.txid()) + + def test_txid_bitcoin_core_0063(self): + tx = transaction.Transaction('0100000000010100010000000000000000000000000000000000000000000000000000000000000000000000ffffffff01e8030000000000001976a9144c9c3dfac4207d5d8cb89df5722cb3d712385e3f88ac02483045022100aa5d8aa40a90f23ce2c3d11bc845ca4a12acd99cbea37de6b9f6d86edebba8cb022022dedc2aa0a255f74d04c0b76ece2d7c691f9dd11a64a8ac49f62a99c3a05f9d01232103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ac00000000') + self.assertEqual('b2ce556154e5ab22bec0a2f990b2b843f4f4085486c0d2cd82873685c0012004', tx.txid()) + + def test_txid_bitcoin_core_0064(self): + tx = transaction.Transaction('01000000000101000100000000000000000000000000000000000000000000000000000000000000000000171600144c9c3dfac4207d5d8cb89df5722cb3d712385e3fffffffff01e8030000000000001976a9144c9c3dfac4207d5d8cb89df5722cb3d712385e3f88ac02483045022100cfb07164b36ba64c1b1e8c7720a56ad64d96f6ef332d3d37f9cb3c96477dc44502200a464cd7a9cf94cd70f66ce4f4f0625ef650052c7afcfe29d7d7e01830ff91ed012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc7100000000') + self.assertEqual('fee125c6cd142083fabd0187b1dd1f94c66c89ec6e6ef6da1374881c0c19aece', tx.txid()) + + def test_txid_bitcoin_core_0065(self): + tx = transaction.Transaction('0100000000010100010000000000000000000000000000000000000000000000000000000000000000000023220020ff25429251b5a84f452230a3c75fd886b7fc5a7865ce4a7bb7a9d7c5be6da3dbffffffff01e8030000000000001976a9144c9c3dfac4207d5d8cb89df5722cb3d712385e3f88ac02483045022100aa5d8aa40a90f23ce2c3d11bc845ca4a12acd99cbea37de6b9f6d86edebba8cb022022dedc2aa0a255f74d04c0b76ece2d7c691f9dd11a64a8ac49f62a99c3a05f9d01232103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ac00000000') + self.assertEqual('5f32557914351fee5f89ddee6c8983d476491d29e601d854e3927299e50450da', tx.txid()) + + def test_txid_bitcoin_core_0066(self): + tx = transaction.Transaction('0100000000010400010000000000000000000000000000000000000000000000000000000000000200000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000300000000ffffffff05540b0000000000000151d0070000000000000151840300000000000001513c0f00000000000001512c010000000000000151000248304502210092f4777a0f17bf5aeb8ae768dec5f2c14feabf9d1fe2c89c78dfed0f13fdb86902206da90a86042e252bcd1e80a168c719e4a1ddcc3cebea24b9812c5453c79107e9832103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71000000000000') + self.assertEqual('07dfa2da3d67c8a2b9f7bd31862161f7b497829d5da90a88ba0f1a905e7a43f7', tx.txid()) + + def test_txid_bitcoin_core_0067(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff03e8030000000000000151d0070000000000000151b80b0000000000000151000248304502210092f4777a0f17bf5aeb8ae768dec5f2c14feabf9d1fe2c89c78dfed0f13fdb86902206da90a86042e252bcd1e80a168c719e4a1ddcc3cebea24b9812c5453c79107e9832103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('8a1bddf924d24570074b09d7967c145e54dc4cee7972a92fd975a2ad9e64b424', tx.txid()) + + def test_txid_bitcoin_core_0068(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff0484030000000000000151d0070000000000000151540b0000000000000151c800000000000000015100024730440220699e6b0cfe015b64ca3283e6551440a34f901ba62dd4c72fe1cb815afb2e6761022021cc5e84db498b1479de14efda49093219441adc6c543e5534979605e273d80b032103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('f92bb6e4f3ff89172f23ef647f74c13951b665848009abb5862cdf7a0412415a', tx.txid()) + + def test_txid_bitcoin_core_0069(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff03e8030000000000000151d0070000000000000151b80b000000000000015100024730440220699e6b0cfe015b64ca3283e6551440a34f901ba62dd4c72fe1cb815afb2e6761022021cc5e84db498b1479de14efda49093219441adc6c543e5534979605e273d80b032103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('8a1bddf924d24570074b09d7967c145e54dc4cee7972a92fd975a2ad9e64b424', tx.txid()) + + def test_txid_bitcoin_core_0070(self): + tx = transaction.Transaction('0100000000010400010000000000000000000000000000000000000000000000000000000000000200000000ffffffff00010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000300000000ffffffff04b60300000000000001519e070000000000000151860b00000000000001009600000000000000015100000248304502210091b32274295c2a3fa02f5bce92fb2789e3fc6ea947fbe1a76e52ea3f4ef2381a022079ad72aefa3837a2e0c033a8652a59731da05fa4a813f4fc48e87c075037256b822103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('e657e25fc9f2b33842681613402759222a58cf7dd504d6cdc0b69a0b8c2e7dcb', tx.txid()) + + def test_txid_bitcoin_core_0071(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff03e8030000000000000151d0070000000000000151b80b0000000000000151000248304502210091b32274295c2a3fa02f5bce92fb2789e3fc6ea947fbe1a76e52ea3f4ef2381a022079ad72aefa3837a2e0c033a8652a59731da05fa4a813f4fc48e87c075037256b822103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('8a1bddf924d24570074b09d7967c145e54dc4cee7972a92fd975a2ad9e64b424', tx.txid()) + + def test_txid_bitcoin_core_0072(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff04b60300000000000001519e070000000000000151860b0000000000000100960000000000000001510002473044022022fceb54f62f8feea77faac7083c3b56c4676a78f93745adc8a35800bc36adfa022026927df9abcf0a8777829bcfcce3ff0a385fa54c3f9df577405e3ef24ee56479022103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('4ede5e22992d43d42ccdf6553fb46e448aa1065ba36423f979605c1e5ab496b8', tx.txid()) + + def test_txid_bitcoin_core_0073(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff03e8030000000000000151d0070000000000000151b80b00000000000001510002473044022022fceb54f62f8feea77faac7083c3b56c4676a78f93745adc8a35800bc36adfa022026927df9abcf0a8777829bcfcce3ff0a385fa54c3f9df577405e3ef24ee56479022103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('8a1bddf924d24570074b09d7967c145e54dc4cee7972a92fd975a2ad9e64b424', tx.txid()) + + def test_txid_bitcoin_core_0074(self): + tx = transaction.Transaction('01000000000103000100000000000000000000000000000000000000000000000000000000000000000000000200000000010000000000000000000000000000000000000000000000000000000000000100000000ffffffff000100000000000000000000000000000000000000000000000000000000000002000000000200000003e8030000000000000151d0070000000000000151b80b00000000000001510002473044022022fceb54f62f8feea77faac7083c3b56c4676a78f93745adc8a35800bc36adfa022026927df9abcf0a8777829bcfcce3ff0a385fa54c3f9df577405e3ef24ee56479022103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('cfe9f4b19f52b8366860aec0d2b5815e329299b2e9890d477edd7f1182be7ac8', tx.txid()) + + def test_txid_bitcoin_core_0075(self): + tx = transaction.Transaction('0100000000010400010000000000000000000000000000000000000000000000000000000000000200000000ffffffff00010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000300000000ffffffff03e8030000000000000151d0070000000000000151b80b0000000000000151000002483045022100a3cec69b52cba2d2de623eeef89e0ba1606184ea55476c0f8189fda231bc9cbb022003181ad597f7c380a7d1c740286b1d022b8b04ded028b833282e055e03b8efef812103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('aee8f4865ca40fa77ff2040c0d7de683bea048b103d42ca406dc07dd29d539cb', tx.txid()) + + def test_txid_bitcoin_core_0076(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff03e8030000000000000151d0070000000000000151b80b00000000000001510002483045022100a3cec69b52cba2d2de623eeef89e0ba1606184ea55476c0f8189fda231bc9cbb022003181ad597f7c380a7d1c740286b1d022b8b04ded028b833282e055e03b8efef812103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('8a1bddf924d24570074b09d7967c145e54dc4cee7972a92fd975a2ad9e64b424', tx.txid()) + + def test_txid_bitcoin_core_0077(self): + tx = transaction.Transaction('0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff03e8030000000000000151d0070000000000000151b80b00000000000001510002483045022100a3cec69b52cba2d2de623ffffffffff1606184ea55476c0f8189fda231bc9cbb022003181ad597f7c380a7d1c740286b1d022b8b04ded028b833282e055e03b8efef812103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('8a1bddf924d24570074b09d7967c145e54dc4cee7972a92fd975a2ad9e64b424', tx.txid()) + + def test_txid_bitcoin_core_0078(self): + tx = transaction.Transaction('0100000000010100010000000000000000000000000000000000000000000000000000000000000000000000ffffffff010000000000000000015102fd08020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002755100000000') + self.assertEqual('d93ab9e12d7c29d2adc13d5cdf619d53eec1f36eb6612f55af52be7ba0448e97', tx.txid()) + + def test_txid_bitcoin_core_0079(self): + tx = transaction.Transaction('0100000000010c00010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff0001000000000000000000000000000000000000000000000000000000000000020000006a473044022026c2e65b33fcd03b2a3b0f25030f0244bd23cc45ae4dec0f48ae62255b1998a00220463aa3982b718d593a6b9e0044513fd67a5009c2fdccc59992cffc2b167889f4012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ffffffff0001000000000000000000000000000000000000000000000000000000000000030000006a4730440220008bd8382911218dcb4c9f2e75bf5c5c3635f2f2df49b36994fde85b0be21a1a02205a539ef10fb4c778b522c1be852352ea06c67ab74200977c722b0bc68972575a012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ffffffff0001000000000000000000000000000000000000000000000000000000000000040000006b483045022100d9436c32ff065127d71e1a20e319e4fe0a103ba0272743dbd8580be4659ab5d302203fd62571ee1fe790b182d078ecfd092a509eac112bea558d122974ef9cc012c7012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ffffffff0001000000000000000000000000000000000000000000000000000000000000050000006a47304402200e2c149b114ec546015c13b2b464bbcb0cdc5872e6775787527af6cbc4830b6c02207e9396c6979fb15a9a2b96ca08a633866eaf20dc0ff3c03e512c1d5a1654f148012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ffffffff0001000000000000000000000000000000000000000000000000000000000000060000006b483045022100b20e70d897dc15420bccb5e0d3e208d27bdd676af109abbd3f88dbdb7721e6d6022005836e663173fbdfe069f54cde3c2decd3d0ea84378092a5d9d85ec8642e8a41012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ffffffff00010000000000000000000000000000000000000000000000000000000000000700000000ffffffff00010000000000000000000000000000000000000000000000000000000000000800000000ffffffff00010000000000000000000000000000000000000000000000000000000000000900000000ffffffff00010000000000000000000000000000000000000000000000000000000000000a00000000ffffffff00010000000000000000000000000000000000000000000000000000000000000b0000006a47304402206639c6e05e3b9d2675a7f3876286bdf7584fe2bbd15e0ce52dd4e02c0092cdc60220757d60b0a61fc95ada79d23746744c72bac1545a75ff6c2c7cdb6ae04e7e9592012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71ffffffff0ce8030000000000000151e9030000000000000151ea030000000000000151eb030000000000000151ec030000000000000151ed030000000000000151ee030000000000000151ef030000000000000151f0030000000000000151f1030000000000000151f2030000000000000151f30300000000000001510248304502210082219a54f61bf126bfc3fa068c6e33831222d1d7138c6faa9d33ca87fd4202d6022063f9902519624254d7c2c8ea7ba2d66ae975e4e229ae38043973ec707d5d4a83012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc7102473044022017fb58502475848c1b09f162cb1688d0920ff7f142bed0ef904da2ccc88b168f02201798afa61850c65e77889cbcd648a5703b487895517c88f85cdd18b021ee246a012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc7100000000000247304402202830b7926e488da75782c81a54cd281720890d1af064629ebf2e31bf9f5435f30220089afaa8b455bbeb7d9b9c3fe1ed37d07685ade8455c76472cda424d93e4074a012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc7102473044022026326fcdae9207b596c2b05921dbac11d81040c4d40378513670f19d9f4af893022034ecd7a282c0163b89aaa62c22ec202cef4736c58cd251649bad0d8139bcbf55012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc71024730440220214978daeb2f38cd426ee6e2f44131a33d6b191af1c216247f1dd7d74c16d84a02205fdc05529b0bc0c430b4d5987264d9d075351c4f4484c16e91662e90a72aab24012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710247304402204a6e9f199dc9672cf2ff8094aaa784363be1eb62b679f7ff2df361124f1dca3302205eeb11f70fab5355c9c8ad1a0700ea355d315e334822fa182227e9815308ee8f012103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000') + self.assertEqual('b83579db5246aa34255642768167132a0c3d2932b186cd8fb9f5490460a0bf91', tx.txid()) + + def test_txid_bitcoin_core_0080(self): + tx = transaction.Transaction('010000000100010000000000000000000000000000000000000000000000000000000000000000000000ffffffff01e803000000000000015100000000') + self.assertEqual('2b1e44fff489d09091e5e20f9a01bbc0e8d80f0662e629fd10709cdb4922a874', tx.txid()) + + def test_txid_bitcoin_core_0081(self): + tx = transaction.Transaction('0100000000010200010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff01d00700000000000001510003483045022100e078de4e96a0e05dcdc0a414124dd8475782b5f3f0ed3f607919e9a5eeeb22bf02201de309b3a3109adb3de8074b3610d4cf454c49b61247a2779a0bcbf31c889333032103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc711976a9144c9c3dfac4207d5d8cb89df5722cb3d712385e3f88ac00000000') + self.assertEqual('60ebb1dd0b598e20dd0dd462ef6723dd49f8f803b6a2492926012360119cfdd7', tx.txid()) + + def test_txid_bitcoin_core_0082(self): + tx = transaction.Transaction('0100000000010200010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff02e8030000000000000151e90300000000000001510247304402206d59682663faab5e4cb733c562e22cdae59294895929ec38d7c016621ff90da0022063ef0af5f970afe8a45ea836e3509b8847ed39463253106ac17d19c437d3d56b832103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710248304502210085001a820bfcbc9f9de0298af714493f8a37b3b354bfd21a7097c3e009f2018c022050a8b4dbc8155d4d04da2f5cdd575dcf8dd0108de8bec759bd897ea01ecb3af7832103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc7100000000') + self.assertEqual('ed0c7f4163e275f3f77064f471eac861d01fdf55d03aa6858ebd3781f70bf003', tx.txid()) + + def test_txid_bitcoin_core_0083(self): + tx = transaction.Transaction('0100000000010200010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000000000000ffffffff02e9030000000000000151e80300000000000001510248304502210085001a820bfcbc9f9de0298af714493f8a37b3b354bfd21a7097c3e009f2018c022050a8b4dbc8155d4d04da2f5cdd575dcf8dd0108de8bec759bd897ea01ecb3af7832103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710247304402206d59682663faab5e4cb733c562e22cdae59294895929ec38d7c016621ff90da0022063ef0af5f970afe8a45ea836e3509b8847ed39463253106ac17d19c437d3d56b832103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc7100000000') + self.assertEqual('f531ddf5ce141e1c8a7fdfc85cc634e5ff686f446a5cf7483e9dbe076b844862', tx.txid()) + + def test_txid_bitcoin_core_0084(self): + tx = transaction.Transaction('01000000020001000000000000000000000000000000000000000000000000000000000000000000004847304402202a0b4b1294d70540235ae033d78e64b4897ec859c7b6f1b2b1d8a02e1d46006702201445e756d2254b0f1dfda9ab8e1e1bc26df9668077403204f32d16a49a36eb6983ffffffff00010000000000000000000000000000000000000000000000000000000000000100000049483045022100acb96cfdbda6dc94b489fd06f2d720983b5f350e31ba906cdbd800773e80b21c02200d74ea5bdf114212b4bbe9ed82c36d2e369e302dff57cb60d01c428f0bd3daab83ffffffff02e8030000000000000151e903000000000000015100000000') + self.assertEqual('98229b70948f1c17851a541f1fe532bf02c408267fecf6d7e174c359ae870654', tx.txid()) + + def test_txid_bitcoin_core_0085(self): + tx = transaction.Transaction('01000000000102fe3dc9208094f3ffd12645477b3dc56f60ec4fa8e6f5d67c565d1c6b9216b36e000000004847304402200af4e47c9b9629dbecc21f73af989bdaa911f7e6f6c2e9394588a3aa68f81e9902204f3fcf6ade7e5abb1295b6774c8e0abd94ae62217367096bc02ee5e435b67da201ffffffff0815cf020f013ed6cf91d29f4202e8a58726b1ac6c79da47c23d1bee0a6925f80000000000ffffffff0100f2052a010000001976a914a30741f8145e5acadf23f751864167f32e0963f788ac000347304402200de66acf4527789bfda55fc5459e214fa6083f936b430a762c629656216805ac0220396f550692cd347171cbc1ef1f51e15282e837bb2b30860dc77c8f78bc8501e503473044022027dc95ad6b740fe5129e7e62a75dd00f291a2aeb1200b84b09d9e3789406b6c002201a9ecd315dd6a0e632ab20bbb98948bc0c6fb204f2c286963bb48517a7058e27034721026dccc749adc2a9d0d89497ac511f760f45c47dc5ed9cf352a58ac706453880aeadab210255a9626aebf5e29c0e6538428ba0d1dcf6ca98ffdf086aa8ced5e0d0215ea465ac00000000') + self.assertEqual('570e3730deeea7bd8bc92c836ccdeb4dd4556f2c33f2a1f7b889a4cb4e48d3ab', tx.txid()) + + def test_txid_bitcoin_core_0086(self): + tx = transaction.Transaction('01000000000102e9b542c5176808107ff1df906f46bb1f2583b16112b95ee5380665ba7fcfc0010000000000ffffffff80e68831516392fcd100d186b3c2c7b95c80b53c77e77c35ba03a66b429a2a1b0000000000ffffffff0280969800000000001976a914de4b231626ef508c9a74a8517e6783c0546d6b2888ac80969800000000001976a9146648a8cd4531e1ec47f35916de8e259237294d1e88ac02483045022100f6a10b8604e6dc910194b79ccfc93e1bc0ec7c03453caaa8987f7d6c3413566002206216229ede9b4d6ec2d325be245c5b508ff0339bf1794078e20bfe0babc7ffe683270063ab68210392972e2eb617b2388771abe27235fd5ac44af8e61693261550447a4c3e39da98ac024730440220032521802a76ad7bf74d0e2c218b72cf0cbc867066e2e53db905ba37f130397e02207709e2188ed7f08f4c952d9d13986da504502b8c3be59617e043552f506c46ff83275163ab68210392972e2eb617b2388771abe27235fd5ac44af8e61693261550447a4c3e39da98ac00000000') + self.assertEqual('e0b8142f587aaa322ca32abce469e90eda187f3851043cc4f2a0fff8c13fc84e', tx.txid()) + + def test_txid_bitcoin_core_0087(self): + tx = transaction.Transaction('0100000000010280e68831516392fcd100d186b3c2c7b95c80b53c77e77c35ba03a66b429a2a1b0000000000ffffffffe9b542c5176808107ff1df906f46bb1f2583b16112b95ee5380665ba7fcfc0010000000000ffffffff0280969800000000001976a9146648a8cd4531e1ec47f35916de8e259237294d1e88ac80969800000000001976a914de4b231626ef508c9a74a8517e6783c0546d6b2888ac024730440220032521802a76ad7bf74d0e2c218b72cf0cbc867066e2e53db905ba37f130397e02207709e2188ed7f08f4c952d9d13986da504502b8c3be59617e043552f506c46ff83275163ab68210392972e2eb617b2388771abe27235fd5ac44af8e61693261550447a4c3e39da98ac02483045022100f6a10b8604e6dc910194b79ccfc93e1bc0ec7c03453caaa8987f7d6c3413566002206216229ede9b4d6ec2d325be245c5b508ff0339bf1794078e20bfe0babc7ffe683270063ab68210392972e2eb617b2388771abe27235fd5ac44af8e61693261550447a4c3e39da98ac00000000') + self.assertEqual('b9ecf72df06b8f98f8b63748d1aded5ffc1a1186f8a302e63cf94f6250e29f4d', tx.txid()) + + def test_txid_bitcoin_core_0088(self): + tx = transaction.Transaction('0100000000010136641869ca081e70f394c6948e8af409e18b619df2ed74aa106c1ca29787b96e0100000023220020a16b5755f7f6f96dbd65f5f0d6ab9418b89af4b1f14a1bb8a09062c35f0dcb54ffffffff0200e9a435000000001976a914389ffce9cd9ae88dcc0631e88a821ffdbe9bfe2688acc0832f05000000001976a9147480a33f950689af511e6e84c138dbbd3c3ee41588ac080047304402206ac44d672dac41f9b00e28f4df20c52eeb087207e8d758d76d92c6fab3b73e2b0220367750dbbe19290069cba53d096f44530e4f98acaa594810388cf7409a1870ce01473044022068c7946a43232757cbdf9176f009a928e1cd9a1a8c212f15c1e11ac9f2925d9002205b75f937ff2f9f3c1246e547e54f62e027f64eefa2695578cc6432cdabce271502473044022059ebf56d98010a932cf8ecfec54c48e6139ed6adb0728c09cbe1e4fa0915302e022007cd986c8fa870ff5d2b3a89139c9fe7e499259875357e20fcbb15571c76795403483045022100fbefd94bd0a488d50b79102b5dad4ab6ced30c4069f1eaa69a4b5a763414067e02203156c6a5c9cf88f91265f5a942e96213afae16d83321c8b31bb342142a14d16381483045022100a5263ea0553ba89221984bd7f0b13613db16e7a70c549a86de0cc0444141a407022005c360ef0ae5a5d4f9f2f87a56c1546cc8268cab08c73501d6b3be2e1e1a8a08824730440220525406a1482936d5a21888260dc165497a90a15669636d8edca6b9fe490d309c022032af0c646a34a44d1f4576bf6a4a74b67940f8faa84c7df9abe12a01a11e2b4783cf56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae00000000') + self.assertEqual('27eae69aff1dd4388c0fa05cbbfe9a3983d1b0b5811ebcd4199b86f299370aac', tx.txid()) + + def test_txid_bitcoin_core_0089(self): + tx = transaction.Transaction('010000000169c12106097dc2e0526493ef67f21269fe888ef05c7a3a5dacab38e1ac8387f1581b0000b64830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e0121037a3fb04bcdb09eba90f69961ba1692a3528e45e67c85b200df820212d7594d334aad4830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e01ffffffff0101000000000000000000000000') + self.assertEqual('22d020638e3b7e1f2f9a63124ac76f5e333c74387862e3675f64b25e960d3641', tx.txid()) + + def test_txid_bitcoin_core_0090(self): + tx = transaction.Transaction('0100000000010169c12106097dc2e0526493ef67f21269fe888ef05c7a3a5dacab38e1ac8387f14c1d000000ffffffff01010000000000000000034830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e012102a9781d66b61fb5a7ef00ac5ad5bc6ffc78be7b44a566e3c87870e1079368df4c4aad4830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e0100000000') + self.assertEqual('2862bc0c69d2af55da7284d1b16a7cddc03971b77e5a97939cca7631add83bf5', tx.txid()) + + def test_txid_bitcoin_core_0091(self): + tx = transaction.Transaction('01000000019275cb8d4a485ce95741c013f7c0d28722160008021bb469a11982d47a662896581b0000fd6f01004830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e0148304502205286f726690b2e9b0207f0345711e63fa7012045b9eb0f19c2458ce1db90cf43022100e89f17f86abc5b149eba4115d4f128bcf45d77fb3ecdd34f594091340c03959601522102cd74a2809ffeeed0092bc124fd79836706e41f048db3f6ae9df8708cefb83a1c2102e615999372426e46fd107b76eaf007156a507584aa2cc21de9eee3bdbd26d36c4c9552af4830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e0148304502205286f726690b2e9b0207f0345711e63fa7012045b9eb0f19c2458ce1db90cf43022100e89f17f86abc5b149eba4115d4f128bcf45d77fb3ecdd34f594091340c0395960175ffffffff0101000000000000000000000000') + self.assertEqual('1aebf0c98f01381765a8c33d688f8903e4d01120589ac92b78f1185dc1f4119c', tx.txid()) + + def test_txid_bitcoin_core_0092(self): + tx = transaction.Transaction('010000000001019275cb8d4a485ce95741c013f7c0d28722160008021bb469a11982d47a6628964c1d000000ffffffff0101000000000000000007004830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e0148304502205286f726690b2e9b0207f0345711e63fa7012045b9eb0f19c2458ce1db90cf43022100e89f17f86abc5b149eba4115d4f128bcf45d77fb3ecdd34f594091340c0395960101022102966f109c54e85d3aee8321301136cedeb9fc710fdef58a9de8a73942f8e567c021034ffc99dd9a79dd3cb31e2ab3e0b09e0e67db41ac068c625cd1f491576016c84e9552af4830450220487fb382c4974de3f7d834c1b617fe15860828c7f96454490edd6d891556dcc9022100baf95feb48f845d5bfc9882eb6aeefa1bc3790e39f59eaa46ff7f15ae626c53e0148304502205286f726690b2e9b0207f0345711e63fa7012045b9eb0f19c2458ce1db90cf43022100e89f17f86abc5b149eba4115d4f128bcf45d77fb3ecdd34f594091340c039596017500000000') + self.assertEqual('45d17fb7db86162b2b6ca29fa4e163acf0ef0b54110e49b819bda1f948d423a3', tx.txid()) + +# txns from Bitcoin Core ends <--- + class NetworkMock(object): diff --git a/lib/tests/test_wallet_vertical.py b/lib/tests/test_wallet_vertical.py index 4a95cb11..a29690ea 100644 --- a/lib/tests/test_wallet_vertical.py +++ b/lib/tests/test_wallet_vertical.py @@ -128,7 +128,7 @@ class TestWalletKeystoreAddressIntegrity(unittest.TestCase): long_user_id, short_id = trustedcoin.get_user_id( {'x1/': {'xpub': xpub1}, 'x2/': {'xpub': xpub2}}) - xpub3 = trustedcoin.make_xpub(trustedcoin.signing_xpub, long_user_id) + xpub3 = trustedcoin.make_xpub(trustedcoin.get_signing_xpub(), long_user_id) ks3 = keystore.from_xpub(xpub3) self._check_xpub_keystore_sanity(ks3) self.assertTrue(isinstance(ks3, keystore.BIP32_KeyStore)) diff --git a/lib/transaction.py b/lib/transaction.py index b18d1842..4ee57d4e 100644 --- a/lib/transaction.py +++ b/lib/transaction.py @@ -32,6 +32,8 @@ from .util import print_error, profiler from . import bitcoin from .bitcoin import * import struct +import traceback +import sys # # Workalike python implementation of Bitcoin's CDataStream class. @@ -45,6 +47,14 @@ class SerializationError(Exception): """ Thrown when there's a problem deserializing or serializing """ +class UnknownTxinType(Exception): + pass + + +class NotRecognizedRedeemScript(Exception): + pass + + class BCDataStream(object): def __init__(self): self.input = None @@ -295,17 +305,30 @@ def parse_scriptSig(d, _bytes): decoded = [ x for x in script_GetOp(_bytes) ] except Exception as e: # coinbase transactions raise an exception - print_error("cannot find address in input script", bh2u(_bytes)) + print_error("parse_scriptSig: cannot find address in input script (coinbase?)", + bh2u(_bytes)) return match = [ opcodes.OP_PUSHDATA4 ] if match_decoded(decoded, match): item = decoded[0][1] if item[0] == 0: + # segwit embedded into p2sh + # witness version 0 d['address'] = bitcoin.hash160_to_p2sh(bitcoin.hash_160(item)) - d['type'] = 'p2wpkh-p2sh' if len(item) == 22 else 'p2wsh-p2sh' + if len(item) == 22: + d['type'] = 'p2wpkh-p2sh' + elif len(item) == 34: + d['type'] = 'p2wsh-p2sh' + else: + print_error("unrecognized txin type", bh2u(item)) + elif opcodes.OP_1 <= item[0] <= opcodes.OP_16: + # segwit embedded into p2sh + # witness version 1-16 + pass else: - # payto_pubkey + # assert item[0] == 0x30 + # pay-to-pubkey d['type'] = 'p2pk' d['address'] = "(pubkey)" d['signatures'] = [bh2u(item)] @@ -314,9 +337,9 @@ def parse_scriptSig(d, _bytes): d['pubkeys'] = ["(pubkey)"] return - # non-generated TxIn transactions push a signature - # (seventy-something bytes) and then their public key - # (65 bytes) onto the stack: + # p2pkh TxIn transactions push a signature + # (71-73 bytes) and then their public key + # (33 or 65 bytes) onto the stack: match = [ opcodes.OP_PUSHDATA4, opcodes.OP_PUSHDATA4 ] if match_decoded(decoded, match): sig = bh2u(decoded[0][1]) @@ -325,7 +348,8 @@ def parse_scriptSig(d, _bytes): signatures = parse_sig([sig]) pubkey, address = xpubkey_to_address(x_pubkey) except: - print_error("cannot find address in input script", bh2u(_bytes)) + print_error("parse_scriptSig: cannot find address in input script (p2pkh?)", + bh2u(_bytes)) return d['type'] = 'p2pkh' d['signatures'] = signatures @@ -337,31 +361,42 @@ def parse_scriptSig(d, _bytes): # p2sh transaction, m of n match = [ opcodes.OP_0 ] + [ opcodes.OP_PUSHDATA4 ] * (len(decoded) - 1) - if not match_decoded(decoded, match): - print_error("cannot find address in input script", bh2u(_bytes)) + if match_decoded(decoded, match): + x_sig = [bh2u(x[1]) for x in decoded[1:-1]] + try: + m, n, x_pubkeys, pubkeys, redeemScript = parse_redeemScript(decoded[-1][1]) + except NotRecognizedRedeemScript: + print_error("parse_scriptSig: cannot find address in input script (p2sh?)", + bh2u(_bytes)) + # we could still guess: + # d['address'] = hash160_to_p2sh(hash_160(decoded[-1][1])) + return + # write result in d + d['type'] = 'p2sh' + d['num_sig'] = m + d['signatures'] = parse_sig(x_sig) + d['x_pubkeys'] = x_pubkeys + d['pubkeys'] = pubkeys + d['redeemScript'] = redeemScript + d['address'] = hash160_to_p2sh(hash_160(bfh(redeemScript))) return - x_sig = [bh2u(x[1]) for x in decoded[1:-1]] - m, n, x_pubkeys, pubkeys, redeemScript = parse_redeemScript(decoded[-1][1]) - # write result in d - d['type'] = 'p2sh' - d['num_sig'] = m - d['signatures'] = parse_sig(x_sig) - d['x_pubkeys'] = x_pubkeys - d['pubkeys'] = pubkeys - d['redeemScript'] = redeemScript - d['address'] = hash160_to_p2sh(hash_160(bfh(redeemScript))) + + print_error("parse_scriptSig: cannot find address in input script (unknown)", + bh2u(_bytes)) def parse_redeemScript(s): dec2 = [ x for x in script_GetOp(s) ] - m = dec2[0][0] - opcodes.OP_1 + 1 - n = dec2[-2][0] - opcodes.OP_1 + 1 + try: + m = dec2[0][0] - opcodes.OP_1 + 1 + n = dec2[-2][0] - opcodes.OP_1 + 1 + except IndexError: + raise NotRecognizedRedeemScript() op_m = opcodes.OP_1 + m - 1 op_n = opcodes.OP_1 + n - 1 match_multisig = [ op_m ] + [opcodes.OP_PUSHDATA4]*n + [ op_n, opcodes.OP_CHECKMULTISIG ] if not match_decoded(dec2, match_multisig): - print_error("cannot find address in input script", bh2u(s)) - return + raise NotRecognizedRedeemScript() x_pubkeys = [bh2u(x[1]) for x in dec2[1:-2]] pubkeys = [safe_parse_pubkey(x) for x in x_pubkeys] redeemScript = multisig_script(pubkeys, m) @@ -416,7 +451,11 @@ def parse_input(vds): d['num_sig'] = 0 if scriptSig: d['scriptSig'] = bh2u(scriptSig) - parse_scriptSig(d, scriptSig) + try: + parse_scriptSig(d, scriptSig) + except BaseException: + traceback.print_exc(file=sys.stderr) + print_error('failed to parse scriptSig', bh2u(scriptSig)) else: d['scriptSig'] = '' @@ -430,21 +469,55 @@ def parse_witness(vds, txin): if n == 0xffffffff: txin['value'] = vds.read_uint64() n = vds.read_compact_size() + # now 'n' is the number of items in the witness w = list(bh2u(vds.read_bytes(vds.read_compact_size())) for i in range(n)) - if txin['type'] == 'coinbase': - pass - elif n > 2: - txin['signatures'] = parse_sig(w[1:-1]) - m, n, x_pubkeys, pubkeys, witnessScript = parse_redeemScript(bfh(w[-1])) - txin['num_sig'] = m - txin['x_pubkeys'] = x_pubkeys - txin['pubkeys'] = pubkeys - txin['witnessScript'] = witnessScript - else: - txin['num_sig'] = 1 - txin['x_pubkeys'] = [w[1]] - txin['pubkeys'] = [safe_parse_pubkey(w[1])] - txin['signatures'] = parse_sig([w[0]]) + + add_w = lambda x: var_int(len(x) // 2) + x + txin['witness'] = var_int(n) + ''.join(add_w(i) for i in w) + + # FIXME: witness version > 0 will probably fail here. + # For native segwit, we would need the scriptPubKey of the parent txn + # to determine witness program version, and properly parse the witness. + # In case of p2sh-segwit, we can tell based on the scriptSig in this txn. + # The code below assumes witness version 0. + # p2sh-segwit should work in that case; for native segwit we need to tell + # between p2wpkh and p2wsh; we do this based on number of witness items, + # hence (FIXME) p2wsh with n==2 (maybe n==1 ?) will probably fail. + # If v==0 and n==2, we need parent scriptPubKey to distinguish between p2wpkh and p2wsh. + try: + if txin['type'] == 'coinbase': + pass + elif txin['type'] == 'p2wsh-p2sh' or n > 2: + try: + m, n, x_pubkeys, pubkeys, witnessScript = parse_redeemScript(bfh(w[-1])) + except NotRecognizedRedeemScript: + raise UnknownTxinType() + txin['signatures'] = parse_sig(w[1:-1]) + txin['num_sig'] = m + txin['x_pubkeys'] = x_pubkeys + txin['pubkeys'] = pubkeys + txin['witnessScript'] = witnessScript + if not txin.get('scriptSig'): # native segwit script + txin['type'] = 'p2wsh' + txin['address'] = bitcoin.script_to_p2wsh(txin['witnessScript']) + elif txin['type'] == 'p2wpkh-p2sh' or n == 2: + txin['num_sig'] = 1 + txin['x_pubkeys'] = [w[1]] + txin['pubkeys'] = [safe_parse_pubkey(w[1])] + txin['signatures'] = parse_sig([w[0]]) + if not txin.get('scriptSig'): # native segwit script + txin['type'] = 'p2wpkh' + txin['address'] = bitcoin.public_key_to_p2wpkh(bfh(txin['pubkeys'][0])) + else: + raise UnknownTxinType() + except UnknownTxinType: + txin['type'] = 'unknown' + # FIXME: GUI might show 'unknown' address (e.g. for a non-multisig p2wsh) + except BaseException: + txin['type'] = 'unknown' + traceback.print_exc(file=sys.stderr) + print_error('failed to parse witness', txin.get('witness')) + def parse_output(vds, i): d = {} @@ -463,9 +536,18 @@ def deserialize(raw): start = vds.read_cursor d['version'] = vds.read_int32() n_vin = vds.read_compact_size() + is_segwit = (n_vin == 0) + if is_segwit: + marker = vds.read_bytes(1) + assert marker == b'\x01' + n_vin = vds.read_compact_size() d['inputs'] = [parse_input(vds) for i in range(n_vin)] n_vout = vds.read_compact_size() d['outputs'] = [parse_output(vds, i) for i in range(n_vout)] + if is_segwit: + for i in range(n_vin): + txin = d['inputs'][i] + parse_witness(vds, txin) d['lockTime'] = vds.read_uint32() return d @@ -657,7 +739,9 @@ class Transaction: witness_script = multisig_script(pubkeys, txin['num_sig']) witness = var_int(n) + '00' + ''.join(add_w(x) for x in sig_list) + add_w(witness_script) else: - raise BaseException('wrong txin type') + witness = txin.get('witness', None) + if not witness: + raise BaseException('wrong txin type:', txin['type']) if self.is_txin_complete(txin) or estimate_size: value_field = '' else: @@ -666,7 +750,8 @@ class Transaction: @classmethod def is_segwit_input(cls, txin): - return cls.is_segwit_inputtype(txin['type']) + has_nonzero_witness = txin.get('witness', '00') != '00' + return cls.is_segwit_inputtype(txin['type']) or has_nonzero_witness @classmethod def is_segwit_inputtype(cls, txin_type): @@ -733,6 +818,14 @@ class Transaction: def serialize_outpoint(self, txin): return bh2u(bfh(txin['prevout_hash'])[::-1]) + int_to_hex(txin['prevout_n'], 4) + @classmethod + def get_outpoint_from_txin(cls, txin): + if txin['type'] == 'coinbase': + return None + prevout_hash = txin['prevout_hash'] + prevout_n = txin['prevout_n'] + return prevout_hash + ':%d' % prevout_n + @classmethod def serialize_input(self, txin, script): # Prev hash and index diff --git a/lib/util.py b/lib/util.py index bcc097ab..00277ab7 100644 --- a/lib/util.py +++ b/lib/util.py @@ -29,7 +29,6 @@ import traceback import urllib import threading import hmac -import requests from .i18n import _ @@ -41,11 +40,7 @@ def inv_dict(d): return {v: k for k, v in d.items()} -is_bundle = getattr(sys, 'frozen', False) -is_macOS = sys.platform == 'darwin' - base_units = {'ZCL':8, 'mZCL':5, 'uZCL':2} -fee_levels = [_('Within 25 blocks'), _('Within 10 blocks'), _('Within 5 blocks'), _('Within 2 blocks'), _('In the next block')] def normalize_version(v): return [int(x) for x in re.sub(r'(\.0+)*$','', v).split(".")] @@ -62,17 +57,80 @@ class InvalidPassword(Exception): def __str__(self): return _("Incorrect password") + +class FileImportFailed(Exception): + def __init__(self, message=''): + self.message = str(message) + + def __str__(self): + return _("Failed to import from file.") + "\n" + self.message + + +class FileExportFailed(Exception): + def __init__(self, message=''): + self.message = str(message) + + def __str__(self): + return _("Failed to export to file.") + "\n" + self.message + + +class TimeoutException(Exception): + def __init__(self, message=''): + self.message = str(message) + + def __str__(self): + if not self.message: + return _("Operation timed out.") + return self.message + + # Throw this exception to unwind the stack like when an error occurs. # However unlike other exceptions the user won't be informed. class UserCancelled(Exception): '''An exception that is suppressed from the user''' pass +class Satoshis(object): + def __new__(cls, value): + self = super(Satoshis, cls).__new__(cls) + self.value = value + return self + + def __repr__(self): + return 'Satoshis(%d)'%self.value + + def __str__(self): + return format_satoshis(self.value) + " BTC" + +class Fiat(object): + def __new__(cls, value, ccy): + self = super(Fiat, cls).__new__(cls) + self.ccy = ccy + self.value = value + return self + + def __repr__(self): + return 'Fiat(%s)'% self.__str__() + + def __str__(self): + if self.value.is_nan(): + return _('No Data') + else: + return "{:.2f}".format(self.value) + ' ' + self.ccy + class MyEncoder(json.JSONEncoder): def default(self, obj): from .transaction import Transaction if isinstance(obj, Transaction): return obj.as_dict() + if isinstance(obj, Satoshis): + return str(obj) + if isinstance(obj, Fiat): + return str(obj) + if isinstance(obj, Decimal): + return str(obj) + if isinstance(obj, datetime): + return obj.isoformat(' ')[:-3] return super(MyEncoder, self).default(obj) class PrintError(object): @@ -81,8 +139,12 @@ class PrintError(object): return self.__class__.__name__ def print_error(self, *msg): + # only prints with --verbose flag print_error("[%s]" % self.diagnostic_name(), *msg) + def print_stderr(self, *msg): + print_stderr("[%s]" % self.diagnostic_name(), *msg) + def print_msg(self, *msg): print_msg("[%s]" % self.diagnostic_name(), *msg) @@ -347,7 +409,7 @@ def format_satoshis_plain(x, decimal_point = 8): def format_satoshis(x, is_diff=False, num_zeros = 0, decimal_point = 8, whitespaces=False): from locale import localeconv if x is None: - return 'Unknown' + return 'unknown' x = int(x) # Some callers pass Decimal scale_factor = pow (10, decimal_point) integer_part = "{:n}".format(int(abs(x) / scale_factor)) @@ -367,10 +429,9 @@ def format_satoshis(x, is_diff=False, num_zeros = 0, decimal_point = 8, whitespa return result def timestamp_to_datetime(timestamp): - try: - return datetime.fromtimestamp(timestamp) - except: + if timestamp is None: return None + return datetime.fromtimestamp(timestamp) def format_time(timestamp): date = timestamp_to_datetime(timestamp) @@ -453,8 +514,8 @@ testnet_block_explorers = { } def block_explorer_info(): - from . import bitcoin - return testnet_block_explorers if bitcoin.NetworkConstants.TESTNET else mainnet_block_explorers + from . import constants + return testnet_block_explorers if constants.net.TESTNET else mainnet_block_explorers def block_explorer(config): return config.get('block_explorer', 'zclassic-ce.io') @@ -470,7 +531,7 @@ def block_explorer_URL(config, kind, item): if not kind_str: return url_parts = [be_tuple[0], kind_str, item] - return "/".join(url_parts) + return ''.join(url_parts) # URL decode #_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE) @@ -693,8 +754,56 @@ class QueuePipe: self.send(request) -def get_cert_path(): - if is_bundle and is_macOS: - # set in ./electrum - return requests.utils.DEFAULT_CA_BUNDLE_PATH - return requests.certs.where() + + +def setup_thread_excepthook(): + """ + Workaround for `sys.excepthook` thread bug from: + http://bugs.python.org/issue1230540 + + Call once from the main thread before creating any threads. + """ + + init_original = threading.Thread.__init__ + + def init(self, *args, **kwargs): + + init_original(self, *args, **kwargs) + run_original = self.run + + def run_with_except_hook(*args2, **kwargs2): + try: + run_original(*args2, **kwargs2) + except Exception: + sys.excepthook(*sys.exc_info()) + + self.run = run_with_except_hook + + threading.Thread.__init__ = init + + +def versiontuple(v): + return tuple(map(int, (v.split(".")))) + + +def import_meta(path, validater, load_meta): + try: + with open(path, 'r') as f: + d = validater(json.loads(f.read())) + load_meta(d) + #backwards compatibility for JSONDecodeError + except ValueError: + traceback.print_exc(file=sys.stderr) + raise FileImportFailed(_("Invalid JSON code.")) + except BaseException as e: + traceback.print_exc(file=sys.stdout) + raise FileImportFailed(e) + + +def export_meta(meta, fileName): + try: + with open(fileName, 'w+') as f: + json.dump(meta, f, indent=4, sort_keys=True) + except (IOError, os.error) as e: + traceback.print_exc(file=sys.stderr) + raise FileExportFailed(e) diff --git a/lib/verifier.py b/lib/verifier.py index d1baec93..d76b719b 100644 --- a/lib/verifier.py +++ b/lib/verifier.py @@ -36,15 +36,22 @@ class SPV(ThreadJob): self.merkle_roots = {} def run(self): + interface = self.network.interface + if not interface: + return + blockchain = interface.blockchain + if not blockchain: + return lh = self.network.get_local_height() unverified = self.wallet.get_unverified_txs() for tx_hash, tx_height in unverified.items(): # do not request merkle branch before headers are available if (tx_height > 0) and (tx_height <= lh): - header = self.network.blockchain().read_header(tx_height) - if header is None and self.network.interface: - index = tx_height // NetworkConstants.CHUNK_SIZE - self.network.request_chunk(self.network.interface, index) + header = blockchain.read_header(tx_height) + if header is None: + index = tx_height // constants.net.CHUNK_SIZE + if index < len(blockchain.checkpoints): + self.network.request_chunk(interface, index) else: if tx_hash not in self.merkle_roots: request = ('blockchain.transaction.get_merkle', @@ -58,6 +65,8 @@ class SPV(ThreadJob): self.undo_verifications() def verify_merkle(self, r): + if self.wallet.verifier is None: + return # we have been killed, this was just an orphan callback if r.get('error'): self.print_error('received an error:', r) return @@ -70,17 +79,26 @@ class SPV(ThreadJob): pos = merkle.get('pos') merkle_root = self.hash_merkle_root(merkle['merkle'], tx_hash, pos) header = self.network.blockchain().read_header(tx_height) - if not header or header.get('merkle_root') != merkle_root: - # FIXME: we should make a fresh connection to a server to - # recover from this, as this TX will now never verify - self.print_error("merkle verification failed for", tx_hash) + # FIXME: if verification fails below, + # we should make a fresh connection to a server to + # recover from this, as this TX will now never verify + if not header: + self.print_error( + "merkle verification failed for {} (missing header {})" + .format(tx_hash, tx_height)) + return + if header.get('merkle_root') != merkle_root: + self.print_error( + "merkle verification failed for {} (merkle root mismatch {} != {})" + .format(tx_hash, header.get('merkle_root'), merkle_root)) return # we passed all the tests self.merkle_roots[tx_hash] = merkle_root self.print_error("verified %s" % tx_hash) self.wallet.add_verified_tx(tx_hash, (tx_height, header.get('timestamp'), pos)) - def hash_merkle_root(self, merkle_s, target_hash, pos): + @classmethod + def hash_merkle_root(cls, merkle_s, target_hash, pos): h = hash_decode(target_hash) for i in range(len(merkle_s)): item = merkle_s[i] diff --git a/lib/version.py b/lib/version.py index 27a5d524..764f2945 100644 --- a/lib/version.py +++ b/lib/version.py @@ -1,7 +1,5 @@ - # version of the client package -ELECTRUM_VERSION = '1.0.6' -# protocol version requested -PROTOCOL_VERSION = '1.1' +ELECTRUM_VERSION = '1.0.7' # '3.1' = version of the client package +PROTOCOL_VERSION = '1.1' # protocol version requested # The hash of the mnemonic seed must begin with this SEED_PREFIX = '01' # Standard wallet diff --git a/lib/wallet.py b/lib/wallet.py index cda0cf92..9678eb05 100644 --- a/lib/wallet.py +++ b/lib/wallet.py @@ -38,17 +38,18 @@ import traceback from functools import partial from collections import defaultdict from numbers import Number +from decimal import Decimal import sys from .i18n import _ from .util import (NotEnoughFunds, PrintError, UserCancelled, profiler, - format_satoshis, NoDynamicFeeEstimates) + format_satoshis, NoDynamicFeeEstimates, TimeoutException) from .bitcoin import * from .version import * from .keystore import load_keystore, Hardware_KeyStore -from .storage import multisig_type +from .storage import multisig_type, STO_EV_PLAINTEXT, STO_EV_USER_PW, STO_EV_XPUB_PW from . import transaction from .transaction import Transaction @@ -64,19 +65,21 @@ from .paymentrequest import InvoiceStore from .contacts import Contacts TX_STATUS = [ - _('Replaceable'), - _('Unconfirmed parent'), - _('Low fee'), _('Unconfirmed'), + _('Unconfirmed parent'), _('Not Verified'), + _('Local'), ] +TX_HEIGHT_LOCAL = -2 +TX_HEIGHT_UNCONF_PARENT = -1 +TX_HEIGHT_UNCONFIRMED = 0 def relayfee(network): - RELAY_FEE = 1000 + from .simple_config import FEERATE_DEFAULT_RELAY MAX_RELAY_FEE = 50000 - f = network.relay_fee if network and network.relay_fee else RELAY_FEE + f = network.relay_fee if network and network.relay_fee else FEERATE_DEFAULT_RELAY return min(f, MAX_RELAY_FEE) def dust_threshold(network): @@ -153,6 +156,20 @@ def sweep(privkeys, network, config, recipient, fee=None, imax=100): return tx +class AddTransactionException(Exception): + pass + + +class UnrelatedTransactionException(AddTransactionException): + def __str__(self): + return _("Transaction is unrelated to this wallet.") + + +class NotIsMineTransactionException(AddTransactionException): + def __str__(self): + return _("Only transactions with inputs owned by the wallet can be added.") + + class Abstract_Wallet(PrintError): """ Wallet classes are created to handle various address generation methods. @@ -176,11 +193,12 @@ class Abstract_Wallet(PrintError): self.labels = storage.get('labels', {}) self.frozen_addresses = set(storage.get('frozen_addresses',[])) self.history = storage.get('addr_history',{}) # address -> list(txid, height) + self.fiat_value = storage.get('fiat_value', {}) self.load_keystore() self.load_addresses() self.load_transactions() - self.build_reverse_history() + self.build_spent_outpoints() # load requests self.receive_requests = self.storage.get('payment_requests', {}) @@ -196,8 +214,10 @@ class Abstract_Wallet(PrintError): # interface.is_up_to_date() returns true when all requests have been answered and processed # wallet.up_to_date is true when the wallet is synchronized (stronger requirement) self.up_to_date = False - self.lock = threading.Lock() - self.transaction_lock = threading.Lock() + + # locks: if you need to take multiple ones, acquire them in the order they are defined here! + self.lock = threading.RLock() + self.transaction_lock = threading.RLock() self.check_history() @@ -230,7 +250,8 @@ class Abstract_Wallet(PrintError): for tx_hash, raw in tx_list.items(): tx = Transaction(raw) self.transactions[tx_hash] = tx - if self.txi.get(tx_hash) is None and self.txo.get(tx_hash) is None and (tx_hash not in self.pruned_txo.values()): + if self.txi.get(tx_hash) is None and self.txo.get(tx_hash) is None \ + and (tx_hash not in self.pruned_txo.values()): self.print_error("removing unreferenced tx", tx_hash) self.transactions.pop(tx_hash) @@ -250,32 +271,38 @@ class Abstract_Wallet(PrintError): self.storage.write() def clear_history(self): - with self.transaction_lock: - self.txi = {} - self.txo = {} - self.tx_fees = {} - self.pruned_txo = {} - self.save_transactions() with self.lock: - self.history = {} - self.tx_addr_hist = {} + with self.transaction_lock: + self.txi = {} + self.txo = {} + self.tx_fees = {} + self.pruned_txo = {} + self.spent_outpoints = {} + self.history = {} + self.verified_tx = {} + self.transactions = {} + self.save_transactions() @profiler - def build_reverse_history(self): - self.tx_addr_hist = {} - for addr, hist in self.history.items(): - for tx_hash, h in hist: - s = self.tx_addr_hist.get(tx_hash, set()) - s.add(addr) - self.tx_addr_hist[tx_hash] = s + def build_spent_outpoints(self): + self.spent_outpoints = {} + for txid, items in self.txi.items(): + for addr, l in items.items(): + for ser, v in l: + self.spent_outpoints[ser] = txid @profiler def check_history(self): save = False - mine_addrs = list(filter(lambda k: self.is_mine(self.history[k]), self.history.keys())) - if len(mine_addrs) != len(self.history.keys()): + + hist_addrs_mine = list(filter(lambda k: self.is_mine(k), self.history.keys())) + hist_addrs_not_mine = list(filter(lambda k: not self.is_mine(k), self.history.keys())) + + for addr in hist_addrs_not_mine: + self.history.pop(addr) save = True - for addr in mine_addrs: + + for addr in hist_addrs_mine: hist = self.history[addr] for tx_hash, tx_height in hist: @@ -303,6 +330,9 @@ class Abstract_Wallet(PrintError): def synchronize(self): pass + def is_deterministic(self): + return self.keystore.is_deterministic() + def set_up_to_date(self, up_to_date): with self.lock: self.up_to_date = up_to_date @@ -324,50 +354,70 @@ class Abstract_Wallet(PrintError): if old_text: self.labels.pop(name) changed = True - if changed: run_hook('set_label', self, name, text) self.storage.put('labels', self.labels) - return changed + def set_fiat_value(self, txid, ccy, text): + if txid not in self.transactions: + return + if not text: + d = self.fiat_value.get(ccy, {}) + if d and txid in d: + d.pop(txid) + else: + return + else: + try: + Decimal(text) + except: + return + if ccy not in self.fiat_value: + self.fiat_value[ccy] = {} + self.fiat_value[ccy][txid] = text + self.storage.put('fiat_value', self.fiat_value) + + def get_fiat_value(self, txid, ccy): + fiat_value = self.fiat_value.get(ccy, {}).get(txid) + try: + return Decimal(fiat_value) + except: + return + def is_mine(self, address): return address in self.get_addresses() def is_change(self, address): if not self.is_mine(address): return False - return address in self.change_addresses + return self.get_address_index(address)[0] def get_address_index(self, address): - if address in self.receiving_addresses: - return False, self.receiving_addresses.index(address) - if address in self.change_addresses: - return True, self.change_addresses.index(address) - raise Exception("Address not found", address) + raise NotImplementedError() + + def get_redeem_script(self, address): + return None def export_private_key(self, address, password): - """ extended WIF format """ if self.is_watching_only(): return [] index = self.get_address_index(address) pk, compressed = self.keystore.get_private_key(index, password) - if self.txin_type in ['p2sh', 'p2wsh', 'p2wsh-p2sh']: - pubkeys = self.get_public_keys(address) - redeem_script = self.pubkeys_to_redeem_script(pubkeys) - else: - redeem_script = None - return bitcoin.serialize_privkey(pk, compressed, self.txin_type), redeem_script - + txin_type = self.get_txin_type(address) + redeem_script = self.get_redeem_script(address) + serialized_privkey = bitcoin.serialize_privkey(pk, compressed, txin_type) + return serialized_privkey, redeem_script def get_public_keys(self, address): - sequence = self.get_address_index(address) - return self.get_pubkeys(*sequence) + return [self.get_public_key(address)] def add_unverified_tx(self, tx_hash, tx_height): - if tx_height == 0 and tx_hash in self.verified_tx: + if tx_height in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT) \ + and tx_hash in self.verified_tx: self.verified_tx.pop(tx_hash) - self.verifier.merkle_roots.pop(tx_hash, None) + if self.verifier: + self.verifier.merkle_roots.pop(tx_hash, None) # tx will be verified only if height > 0 if tx_hash not in self.verified_tx: @@ -404,28 +454,30 @@ class Abstract_Wallet(PrintError): return self.network.get_local_height() if self.network else self.storage.get('stored_height', 0) def get_tx_height(self, tx_hash): - """ return the height and timestamp of a verified transaction. """ + """ Given a transaction, returns (height, conf, timestamp) """ with self.lock: if tx_hash in self.verified_tx: height, timestamp, pos = self.verified_tx[tx_hash] conf = max(self.get_local_height() - height + 1, 0) return height, conf, timestamp - else: + elif tx_hash in self.unverified_tx: height = self.unverified_tx[tx_hash] - return height, 0, False + return height, 0, None + else: + # local transaction + return TX_HEIGHT_LOCAL, 0, None def get_txpos(self, tx_hash): "return position, even if the tx is unverified" with self.lock: - x = self.verified_tx.get(tx_hash) - y = self.unverified_tx.get(tx_hash) - if x: - height, timestamp, pos = x + if tx_hash in self.verified_tx: + height, timestamp, pos = self.verified_tx[tx_hash] return height, pos - elif y > 0: - return y, 0 + elif tx_hash in self.unverified_tx: + height = self.unverified_tx[tx_hash] + return (height, 0) if height > 0 else ((1e9 - height), 0) else: - return 1e12 - y, 0 + return (1e9+1, 0) def is_found(self): return self.history.values() != [[]] * len(self.history) @@ -450,6 +502,17 @@ class Abstract_Wallet(PrintError): delta += v return delta + def get_tx_value(self, txid): + " effect of tx on the entire domain" + delta = 0 + for addr, d in self.txi.get(txid, {}).items(): + for n, v in d: + delta -= v + for addr, d in self.txo.get(txid, {}).items(): + for n, v, cb in d: + delta += v + return delta + def get_wallet_delta(self, tx): """ effect of tx on wallet """ addresses = self.get_addresses() @@ -517,18 +580,21 @@ class Abstract_Wallet(PrintError): height, conf, timestamp = self.get_tx_height(tx_hash) if height > 0: if conf: - status = _("%d confirmations") % conf + status = _("{} confirmations").format(conf) else: - status = _('Not Verified') - else: + status = _('Not verified') + elif height in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED): status = _('Unconfirmed') if fee is None: fee = self.tx_fees.get(tx_hash) - if fee and self.network.config.has_fee_estimates(): + if fee and self.network.config.has_fee_mempool(): size = tx.estimated_size() - fee_per_kb = fee * 1000 / size - exp_n = self.network.config.reverse_dynfee(fee_per_kb) + fee_per_byte = fee / size + exp_n = self.network.config.fee_to_depth(fee_per_byte) can_bump = is_mine and not tx.is_final() + else: + status = _('Local') + can_broadcast = self.network is not None else: status = _("Signed") can_broadcast = self.network is not None @@ -550,7 +616,7 @@ class Abstract_Wallet(PrintError): return tx_hash, status, label, can_broadcast, can_bump, amount, fee, height, conf, timestamp, exp_n def get_addr_io(self, address): - h = self.history.get(address, []) + h = self.get_address_history(address) received = {} sent = {} for tx_hash, height in h: @@ -649,11 +715,23 @@ class Abstract_Wallet(PrintError): xx += x return cc, uu, xx - def get_address_history(self, address): - with self.lock: - return self.history.get(address, []) + def get_address_history(self, addr): + h = [] + # we need self.transaction_lock but get_tx_height will take self.lock + # so we need to take that too here, to enforce order of locks + with self.lock, self.transaction_lock: + for tx_hash in self.transactions: + if addr in self.txi.get(tx_hash, []) or addr in self.txo.get(tx_hash, []): + tx_height = self.get_tx_height(tx_hash)[0] + h.append((tx_hash, tx_height)) + return h - def find_pay_to_pubkey_address(self, prevout_hash, prevout_n): + def get_txin_address(self, txi): + addr = txi.get('address') + if addr != "(pubkey)": + return addr + prevout_hash = txi.get('prevout_hash') + prevout_n = txi.get('prevout_n') dd = self.txo.get(prevout_hash, {}) for addr, l in dd.items(): for n, v, is_cb in l: @@ -661,23 +739,103 @@ class Abstract_Wallet(PrintError): self.print_error("found pay-to-pubkey address:", addr) return addr - def add_transaction(self, tx_hash, tx): - is_shielded_input = len(tx.inputs()) == 0 + def get_txout_address(self, txo): + _type, x, v = txo + if _type == TYPE_ADDRESS: + addr = x + elif _type == TYPE_PUBKEY: + addr = bitcoin.public_key_to_p2pkh(bfh(x)) + else: + addr = None + return addr - is_coinbase = not is_shielded_input and tx.inputs()[0]['type'] == 'coinbase' + def get_conflicting_transactions(self, tx): + """Returns a set of transaction hashes from the wallet history that are + directly conflicting with tx, i.e. they have common outpoints being + spent with tx. If the tx is already in wallet history, that will not be + reported as a conflict. + """ + conflicting_txns = set() with self.transaction_lock: + for txi in tx.inputs(): + ser = Transaction.get_outpoint_from_txin(txi) + if ser is None: + continue + spending_tx_hash = self.spent_outpoints.get(ser, None) + if spending_tx_hash is None: + continue + # this outpoint (ser) has already been spent, by spending_tx + assert spending_tx_hash in self.transactions + conflicting_txns |= {spending_tx_hash} + txid = tx.txid() + if txid in conflicting_txns: + # this tx is already in history, so it conflicts with itself + if len(conflicting_txns) > 1: + raise Exception('Found conflicting transactions already in wallet history.') + conflicting_txns -= {txid} + return conflicting_txns + + def add_transaction(self, tx_hash, tx): + # we need self.transaction_lock but get_tx_height will take self.lock + # so we need to take that too here, to enforce order of locks + with self.lock, self.transaction_lock: + # NOTE: returning if tx in self.transactions might seem like a good idea + # BUT we track is_mine inputs in a txn, and during subsequent calls + # of add_transaction tx, we might learn of more-and-more inputs of + # being is_mine, as we roll the gap_limit forward + is_shielded_input = len(tx.inputs()) == 0 + + is_coinbase = not is_shielded_input and tx.inputs()[0]['type'] == 'coinbase' + tx_height = self.get_tx_height(tx_hash)[0] + is_mine = any([self.is_mine(txin['address']) for txin in tx.inputs()]) + # do not save if tx is local and not mine + if tx_height == TX_HEIGHT_LOCAL and not is_mine: + # FIXME the test here should be for "not all is_mine"; cannot detect conflict in some cases + raise NotIsMineTransactionException() + # raise exception if unrelated to wallet + is_for_me = any([self.is_mine(self.get_txout_address(txo)) for txo in tx.outputs()]) + if not is_mine and not is_for_me: + raise UnrelatedTransactionException() + # Find all conflicting transactions. + # In case of a conflict, + # 1. confirmed > mempool > local + # 2. this new txn has priority over existing ones + # When this method exits, there must NOT be any conflict, so + # either keep this txn and remove all conflicting (along with dependencies) + # or drop this txn + conflicting_txns = self.get_conflicting_transactions(tx) + if conflicting_txns: + existing_mempool_txn = any( + self.get_tx_height(tx_hash2)[0] in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT) + for tx_hash2 in conflicting_txns) + existing_confirmed_txn = any( + self.get_tx_height(tx_hash2)[0] > 0 + for tx_hash2 in conflicting_txns) + if existing_confirmed_txn and tx_height <= 0: + # this is a non-confirmed tx that conflicts with confirmed txns; drop. + return False + if existing_mempool_txn and tx_height == TX_HEIGHT_LOCAL: + # this is a local tx that conflicts with non-local txns; drop. + return False + # keep this txn and remove all conflicting + to_remove = set() + to_remove |= conflicting_txns + for conflicting_tx_hash in conflicting_txns: + to_remove |= self.get_depending_transactions(conflicting_tx_hash) + for tx_hash2 in to_remove: + self.remove_transaction(tx_hash2) # add inputs self.txi[tx_hash] = d = {} for txi in tx.inputs(): - addr = txi.get('address') + addr = self.get_txin_address(txi) if txi['type'] != 'coinbase': prevout_hash = txi['prevout_hash'] prevout_n = txi['prevout_n'] ser = prevout_hash + ':%d'%prevout_n - if addr == "(pubkey)": - addr = self.find_pay_to_pubkey_address(prevout_hash, prevout_n) - # find value from prev output if addr and self.is_mine(addr): + # we only track is_mine spends + self.spent_outpoints[ser] = tx_hash + # find value from prev output dd = self.txo.get(prevout_hash, {}) for n, v, is_cb in dd.get(addr, []): if n == prevout_n: @@ -687,18 +845,12 @@ class Abstract_Wallet(PrintError): break else: self.pruned_txo[ser] = tx_hash - # add outputs self.txo[tx_hash] = d = {} for n, txo in enumerate(tx.outputs()): + v = txo[2] ser = tx_hash + ':%d'%n - _type, x, v = txo - if _type == TYPE_ADDRESS: - addr = x - elif _type == TYPE_PUBKEY: - addr = bitcoin.public_key_to_p2pkh(bfh(x)) - else: - addr = None + addr = self.get_txout_address(txo) if addr and self.is_mine(addr): if d.get(addr) is None: d[addr] = [] @@ -713,14 +865,23 @@ class Abstract_Wallet(PrintError): dd[addr].append((ser, v)) # save self.transactions[tx_hash] = tx + return True def remove_transaction(self, tx_hash): + with self.transaction_lock: self.print_error("removing tx from history", tx_hash) - #tx = self.transactions.pop(tx_hash) + self.transactions.pop(tx_hash, None) + # undo spent_outpoints that are in txi + for addr, l in self.txi[tx_hash].items(): + for ser, v in l: + self.spent_outpoints.pop(ser, None) + # undo spent_outpoints that are in pruned_txo for ser, hh in list(self.pruned_txo.items()): if hh == tx_hash: + self.spent_outpoints.pop(ser) self.pruned_txo.pop(ser) + # add tx to pruned_txo, and undo the txi addition for next_tx, dd in self.txi.items(): for addr, l in list(dd.items()): @@ -742,27 +903,28 @@ class Abstract_Wallet(PrintError): self.print_error("tx was not in history", tx_hash) def receive_tx_callback(self, tx_hash, tx, tx_height): - self.add_transaction(tx_hash, tx) self.add_unverified_tx(tx_hash, tx_height) + self.add_transaction(tx_hash, tx) def receive_history_callback(self, addr, hist, tx_fees): with self.lock: - old_hist = self.history.get(addr, []) + old_hist = self.get_address_history(addr) for tx_hash, height in old_hist: if (tx_hash, height) not in hist: - # remove tx if it's not referenced in histories - self.tx_addr_hist[tx_hash].remove(addr) - if not self.tx_addr_hist[tx_hash]: + # make tx local + self.unverified_tx.pop(tx_hash, None) + self.verified_tx.pop(tx_hash, None) + if self.verifier: + self.verifier.merkle_roots.pop(tx_hash, None) + # but remove completely if not is_mine + if self.txi[tx_hash] == {}: + # FIXME the test here should be for "not all is_mine"; cannot detect conflict in some cases self.remove_transaction(tx_hash) self.history[addr] = hist for tx_hash, tx_height in hist: # add it in case it was previously unconfirmed self.add_unverified_tx(tx_hash, tx_height) - # add reference in tx_addr_hist - s = self.tx_addr_hist.get(tx_hash, set()) - s.add(addr) - self.tx_addr_hist[tx_hash] = s # if addr is new, we have to recompute txi and txo tx = self.transactions.get(tx_hash) if tx is not None and self.txi.get(tx_hash, {}).get(addr) is None and self.txo.get(tx_hash, {}).get(addr) is None: @@ -815,6 +977,115 @@ class Abstract_Wallet(PrintError): return h2 + def balance_at_timestamp(self, domain, target_timestamp): + h = self.get_history(domain) + for tx_hash, height, conf, timestamp, value, balance in h: + if timestamp > target_timestamp: + return balance - value + # return last balance + return balance + + @profiler + def get_full_history(self, domain=None, from_timestamp=None, to_timestamp=None, fx=None, show_addresses=False): + from .util import timestamp_to_datetime, Satoshis, Fiat + out = [] + capital_gains = 0 + income = 0 + expenditures = 0 + fiat_income = 0 + fiat_expenditures = 0 + h = self.get_history(domain) + for tx_hash, height, conf, timestamp, value, balance in h: + if from_timestamp and (timestamp or time.time()) < from_timestamp: + continue + if to_timestamp and (timestamp or time.time()) >= to_timestamp: + continue + item = { + 'txid':tx_hash, + 'height':height, + 'confirmations':conf, + 'timestamp':timestamp, + 'value': Satoshis(value), + 'balance': Satoshis(balance) + } + item['date'] = timestamp_to_datetime(timestamp) + item['label'] = self.get_label(tx_hash) + if show_addresses: + tx = self.transactions.get(tx_hash) + tx.deserialize() + input_addresses = [] + output_addresses = [] + for x in tx.inputs(): + if x['type'] == 'coinbase': continue + addr = self.get_txin_address(x) + if addr is None: + continue + input_addresses.append(addr) + for addr, v in tx.get_outputs(): + output_addresses.append(addr) + item['input_addresses'] = input_addresses + item['output_addresses'] = output_addresses + # value may be None if wallet is not fully synchronized + if value is None: + continue + # fixme: use in and out values + if value < 0: + expenditures += -value + else: + income += value + # fiat computations + if fx is not None: + date = timestamp_to_datetime(timestamp) + fiat_value = self.get_fiat_value(tx_hash, fx.ccy) + fiat_default = fiat_value is None + fiat_value = fiat_value if fiat_value is not None else value / Decimal(COIN) * self.price_at_timestamp(tx_hash, fx.timestamp_rate) + item['fiat_value'] = Fiat(fiat_value, fx.ccy) + item['fiat_default'] = fiat_default + if value < 0: + acquisition_price = - value / Decimal(COIN) * self.average_price(tx_hash, fx.timestamp_rate, fx.ccy) + liquidation_price = - fiat_value + item['acquisition_price'] = Fiat(acquisition_price, fx.ccy) + cg = liquidation_price - acquisition_price + item['capital_gain'] = Fiat(cg, fx.ccy) + capital_gains += cg + fiat_expenditures += -fiat_value + else: + fiat_income += fiat_value + out.append(item) + # add summary + if out: + b, v = out[0]['balance'].value, out[0]['value'].value + start_balance = None if b is None or v is None else b - v + end_balance = out[-1]['balance'].value + if from_timestamp is not None and to_timestamp is not None: + start_date = timestamp_to_datetime(from_timestamp) + end_date = timestamp_to_datetime(to_timestamp) + else: + start_date = None + end_date = None + summary = { + 'start_date': start_date, + 'end_date': end_date, + 'start_balance': Satoshis(start_balance), + 'end_balance': Satoshis(end_balance), + 'income': Satoshis(income), + 'expenditures': Satoshis(expenditures) + } + if fx: + unrealized = self.unrealized_gains(domain, fx.timestamp_rate, fx.ccy) + summary['capital_gains'] = Fiat(capital_gains, fx.ccy) + summary['fiat_income'] = Fiat(fiat_income, fx.ccy) + summary['fiat_expenditures'] = Fiat(fiat_expenditures, fx.ccy) + summary['unrealized_gains'] = Fiat(unrealized, fx.ccy) + summary['start_fiat_balance'] = Fiat(fx.historical_value(start_balance, start_date), fx.ccy) + summary['end_fiat_balance'] = Fiat(fx.historical_value(end_balance, end_date), fx.ccy) + else: + summary = {} + return { + 'transactions': out, + 'summary': summary + } + def get_label(self, tx_hash): label = self.labels.get(tx_hash, '') if label is '': @@ -834,32 +1105,40 @@ class Abstract_Wallet(PrintError): def get_tx_status(self, tx_hash, height, conf, timestamp): from .util import format_time + extra = [] if conf == 0: tx = self.transactions.get(tx_hash) if not tx: - return 3, 'Unknown' + return 2, 'unknown' is_final = tx and tx.is_final() - fee = self.tx_fees.get(tx_hash) - if fee and self.network and self.network.config.has_fee_estimates(): - size = len(tx.raw)/2 - low_fee = int(self.network.config.dynfee(0)*size/1000) - is_lowfee = fee < low_fee * 0.5 - else: - is_lowfee = False - if height==0 and not is_final: - status = 0 - elif height < 0: - status = 1 - elif height == 0 and is_lowfee: - status = 2 - elif height == 0: + if not is_final: + extra.append('rbf') + fee = self.get_wallet_delta(tx)[3] + if fee is None: + fee = self.tx_fees.get(tx_hash) + if fee is not None: + size = tx.estimated_size() + fee_per_byte = fee / size + extra.append('%.1f sat/b'%(fee_per_byte)) + if fee is not None and height in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED) \ + and self.network and self.network.config.has_fee_mempool(): + exp_n = self.network.config.fee_to_depth(fee_per_byte) + if exp_n: + extra.append('%.2f MB'%(exp_n/1000000)) + if height == TX_HEIGHT_LOCAL: status = 3 + elif height == TX_HEIGHT_UNCONF_PARENT: + status = 1 + elif height == TX_HEIGHT_UNCONFIRMED: + status = 0 else: - status = 4 + status = 2 else: - status = 4 + min(conf, 6) - time_str = format_time(timestamp) if timestamp else _("Unknown") - status_str = TX_STATUS[status] if status < 5 else time_str + status = 3 + min(conf, 6) + time_str = format_time(timestamp) if timestamp else _("unknown") + status_str = TX_STATUS[status] if status < 4 else time_str + if extra: + status_str += ' [%s]'%(', '.join(extra)) return status, status_str def relayfee(self): @@ -889,8 +1168,9 @@ class Abstract_Wallet(PrintError): if fixed_fee is None and config.fee_per_kb() is None: raise NoDynamicFeeEstimates() - for item in inputs: - self.add_input_info(item) + if not is_sweep: + for item in inputs: + self.add_input_info(item) # change address if change_addr: @@ -906,7 +1186,8 @@ class Abstract_Wallet(PrintError): if not change_addrs: change_addrs = [random.choice(addrs)] else: - change_addrs = [inputs[0]['address']] + # coin_chooser will set change address + change_addrs = [] # Fee estimator if fixed_fee is None: @@ -925,6 +1206,7 @@ class Abstract_Wallet(PrintError): tx = coin_chooser.make_tx(inputs, outputs, change_addrs[:max_change], fee_estimator, self.dust_threshold()) else: + # FIXME?? this might spend inputs with negative effective value... sendable = sum(map(lambda x:x['value'], inputs)) _type, data, value = outputs[i_max] outputs[i_max] = (_type, data, 0) @@ -968,18 +1250,11 @@ class Abstract_Wallet(PrintError): # add it in case it was previously unconfirmed self.add_unverified_tx(tx_hash, tx_height) - # if we are on a pruning server, remove unverified transactions - with self.lock: - vr = list(self.verified_tx.keys()) + list(self.unverified_tx.keys()) - for tx_hash in list(self.transactions): - if tx_hash not in vr: - self.print_error("removing transaction", tx_hash) - self.transactions.pop(tx_hash) - def start_threads(self, network): self.network = network + # prepare self.unverified_tx regardless of network + self.prepare_for_verifier() if self.network is not None: - self.prepare_for_verifier() self.verifier = SPV(self.network, self) self.synchronizer = Synchronizer(self, network) network.add_jobs([self.verifier, self.synchronizer]) @@ -1030,8 +1305,10 @@ class Abstract_Wallet(PrintError): def is_used(self, address): h = self.history.get(address,[]) + if len(h) == 0: + return False c, u, x = self.get_addr_balance(address) - return len(h) > 0 and c + u + x == 0 + return c + u + x == 0 def is_empty(self, address): c, u, x = self.get_addr_balance(address) @@ -1041,7 +1318,7 @@ class Abstract_Wallet(PrintError): age = -1 h = self.history.get(address, []) for tx_hash, tx_height in h: - if tx_height == 0: + if tx_height <= 0: tx_age = 0 else: tx_age = self.get_local_height() - tx_height + 1 @@ -1127,21 +1404,28 @@ class Abstract_Wallet(PrintError): return True return False - def get_input_tx(self, tx_hash): + def get_input_tx(self, tx_hash, ignore_timeout=False): # First look up an input transaction in the wallet where it # will likely be. If co-signing a transaction it may not have # all the input txs, in which case we ask the network. - tx = self.transactions.get(tx_hash) + tx = self.transactions.get(tx_hash, None) if not tx and self.network: request = ('blockchain.transaction.get', [tx_hash]) - tx = Transaction(self.network.synchronous_get(request)) + try: + tx = Transaction(self.network.synchronous_get(request)) + except TimeoutException as e: + self.print_error('getting input txn from network timed out for {}'.format(tx_hash)) + if not ignore_timeout: + raise e return tx def add_hw_info(self, tx): # add previous tx for hw wallets for txin in tx.inputs(): tx_hash = txin['prevout_hash'] - txin['prev_tx'] = self.get_input_tx(tx_hash) + # segwit inputs might not be needed for some hw wallets + ignore_timeout = Transaction.is_segwit_input(txin) + txin['prev_tx'] = self.get_input_tx(tx_hash, ignore_timeout) # add output info for hw wallets info = {} xpubs = self.get_master_public_keys() @@ -1360,10 +1644,65 @@ class Abstract_Wallet(PrintError): self.synchronizer.add(address) def has_password(self): - return self.storage.get('use_encryption', False) + return self.has_keystore_encryption() or self.has_storage_encryption() + + def can_have_keystore_encryption(self): + return self.keystore and self.keystore.may_have_password() + + def get_available_storage_encryption_version(self): + """Returns the type of storage encryption offered to the user. + + A wallet file (storage) is either encrypted with this version + or is stored in plaintext. + """ + if isinstance(self.keystore, Hardware_KeyStore): + return STO_EV_XPUB_PW + else: + return STO_EV_USER_PW + + def has_keystore_encryption(self): + """Returns whether encryption is enabled for the keystore. + + If True, e.g. signing a transaction will require a password. + """ + if self.can_have_keystore_encryption(): + return self.storage.get('use_encryption', False) + return False + + def has_storage_encryption(self): + """Returns whether encryption is enabled for the wallet file on disk.""" + return self.storage.is_encrypted() + + @classmethod + def may_have_password(cls): + return True def check_password(self, password): - self.keystore.check_password(password) + if self.has_keystore_encryption(): + self.keystore.check_password(password) + self.storage.check_password(password) + + def update_password(self, old_pw, new_pw, encrypt_storage=False): + if old_pw is None and self.has_password(): + raise InvalidPassword() + self.check_password(old_pw) + + if encrypt_storage: + enc_version = self.get_available_storage_encryption_version() + else: + enc_version = STO_EV_PLAINTEXT + self.storage.set_password(new_pw, enc_version) + + # note: Encrypting storage with a hw device is currently only + # allowed for non-multisig wallets. Further, + # Hardware_KeyStore.may_have_password() == False. + # If these were not the case, + # extra care would need to be taken when encrypting keystores. + self._update_password_for_keystore(old_pw, new_pw) + encrypt_keystore = self.can_have_keystore_encryption() + self.storage.set_keystore_encryption(bool(new_pw) and encrypt_keystore) + + self.storage.write() def sign_message(self, address, message, password): index = self.get_address_index(address) @@ -1374,6 +1713,61 @@ class Abstract_Wallet(PrintError): index = self.get_address_index(addr) return self.keystore.decrypt_message(index, message, password) + def get_depending_transactions(self, tx_hash): + """Returns all (grand-)children of tx_hash in this wallet.""" + children = set() + for other_hash, tx in self.transactions.items(): + for input in (tx.inputs()): + if input["prevout_hash"] == tx_hash: + children.add(other_hash) + children |= self.get_depending_transactions(other_hash) + return children + + def txin_value(self, txin): + txid = txin['prevout_hash'] + prev_n = txin['prevout_n'] + for address, d in self.txo[txid].items(): + for n, v, cb in d: + if n == prev_n: + return v + raise BaseException('unknown txin value') + + def price_at_timestamp(self, txid, price_func): + height, conf, timestamp = self.get_tx_height(txid) + return price_func(timestamp if timestamp else time.time()) + + def unrealized_gains(self, domain, price_func, ccy): + coins = self.get_utxos(domain) + now = time.time() + p = price_func(now) + ap = sum(self.coin_price(coin['prevout_hash'], price_func, ccy, self.txin_value(coin)) for coin in coins) + lp = sum([coin['value'] for coin in coins]) * p / Decimal(COIN) + return lp - ap + + def average_price(self, txid, price_func, ccy): + """ Average acquisition price of the inputs of a transaction """ + input_value = 0 + total_price = 0 + for addr, d in self.txi.get(txid, {}).items(): + for ser, v in d: + input_value += v + total_price += self.coin_price(ser.split(':')[0], price_func, ccy, v) + return total_price / (input_value/Decimal(COIN)) + + def coin_price(self, txid, price_func, ccy, txin_value): + """ + Acquisition price of a coin. + This assumes that either all inputs are mine, or no input is mine. + """ + if self.txi.get(txid, {}) != {}: + return self.average_price(txid, price_func, ccy) * txin_value/Decimal(COIN) + else: + fiat_value = self.get_fiat_value(txid, ccy) + if fiat_value is not None: + return fiat_value + else: + p = self.price_at_timestamp(txid, price_func) + return p * txin_value/Decimal(COIN) class Simple_Wallet(Abstract_Wallet): # wallet with a single keystore @@ -1387,16 +1781,10 @@ class Simple_Wallet(Abstract_Wallet): def is_watching_only(self): return self.keystore.is_watching_only() - def can_change_password(self): - return self.keystore.can_change_password() - - def update_password(self, old_pw, new_pw, encrypt=False): - if old_pw is None and self.has_password(): - raise InvalidPassword() - self.keystore.update_password(old_pw, new_pw) - self.save_keystore() - self.storage.set_password(new_pw, encrypt) - self.storage.write() + def _update_password_for_keystore(self, old_pw, new_pw): + if self.keystore and self.keystore.may_have_password(): + self.keystore.update_password(old_pw, new_pw) + self.save_keystore() def save_keystore(self): self.storage.put('keystore', self.keystore.dump()) @@ -1435,9 +1823,6 @@ class Imported_Wallet(Simple_Wallet): def save_addresses(self): self.storage.put('addresses', self.addresses) - def can_change_password(self): - return not self.is_watching_only() - def can_import_address(self): return self.is_watching_only() @@ -1456,9 +1841,12 @@ class Imported_Wallet(Simple_Wallet): def get_master_public_keys(self): return [] - def is_beyond_limit(self, address, is_change): + def is_beyond_limit(self, address): return False + def is_mine(self, address): + return address in self.addresses + def get_fingerprint(self): return '' @@ -1551,12 +1939,10 @@ class Imported_Wallet(Simple_Wallet): self.add_address(addr) return addr - def export_private_key(self, address, password): + def get_redeem_script(self, address): d = self.addresses[address] - pubkey = d['pubkey'] redeem_script = d['redeem_script'] - sec = pw_decode(self.keystore.keypairs[pubkey], password) - return sec, redeem_script + return redeem_script def get_txin_type(self, address): return self.addresses[address].get('type', 'address') @@ -1594,9 +1980,6 @@ class Deterministic_Wallet(Abstract_Wallet): def has_seed(self): return self.keystore.has_seed() - def is_deterministic(self): - return self.keystore.is_deterministic() - def get_receiving_addresses(self): return self.receiving_addresses @@ -1648,16 +2031,26 @@ class Deterministic_Wallet(Abstract_Wallet): if n > nmax: nmax = n return nmax + 1 + def load_addresses(self): + super().load_addresses() + self._addr_to_addr_index = {} # key: address, value: (is_change, index) + for i, addr in enumerate(self.receiving_addresses): + self._addr_to_addr_index[addr] = (False, i) + for i, addr in enumerate(self.change_addresses): + self._addr_to_addr_index[addr] = (True, i) + def create_new_address(self, for_change=False): assert type(for_change) is bool - addr_list = self.change_addresses if for_change else self.receiving_addresses - n = len(addr_list) - x = self.derive_pubkeys(for_change, n) - address = self.pubkeys_to_address(x) - addr_list.append(address) - self.save_addresses() - self.add_address(address) - return address + with self.lock: + addr_list = self.change_addresses if for_change else self.receiving_addresses + n = len(addr_list) + x = self.derive_pubkeys(for_change, n) + address = self.pubkeys_to_address(x) + addr_list.append(address) + self._addr_to_addr_index[address] = (for_change, n) + self.save_addresses() + self.add_address(address) + return address def synchronize_sequence(self, for_change): limit = self.gap_limit_for_change if for_change else self.gap_limit @@ -1673,30 +2066,27 @@ class Deterministic_Wallet(Abstract_Wallet): def synchronize(self): with self.lock: - if self.is_deterministic(): - self.synchronize_sequence(False) - self.synchronize_sequence(True) - else: - if len(self.receiving_addresses) != len(self.keystore.keypairs): - pubkeys = self.keystore.keypairs.keys() - self.receiving_addresses = [self.pubkeys_to_address(i) for i in pubkeys] - self.save_addresses() - for addr in self.receiving_addresses: - self.add_address(addr) + self.synchronize_sequence(False) + self.synchronize_sequence(True) - def is_beyond_limit(self, address, is_change): + def is_beyond_limit(self, address): + is_change, i = self.get_address_index(address) addr_list = self.get_change_addresses() if is_change else self.get_receiving_addresses() - i = addr_list.index(address) - prev_addresses = addr_list[:max(0, i)] limit = self.gap_limit_for_change if is_change else self.gap_limit - if len(prev_addresses) < limit: + if i < limit: return False - prev_addresses = prev_addresses[max(0, i - limit):] + prev_addresses = addr_list[max(0, i - limit):max(0, i)] for addr in prev_addresses: if self.history.get(addr): return False return True + def is_mine(self, address): + return address in self._addr_to_addr_index + + def get_address_index(self, address): + return self._addr_to_addr_index[address] + def get_master_public_keys(self): return [self.get_master_public_key()] @@ -1730,9 +2120,6 @@ class Simple_Deterministic_Wallet(Simple_Wallet, Deterministic_Wallet): def get_pubkey(self, c, i): return self.derive_pubkeys(c, i) - def get_public_keys(self, address): - return [self.get_public_key(address)] - def add_input_sig_info(self, txin, address): derivation = self.get_address_index(address) x_pubkey = self.keystore.get_xpubkey(*derivation) @@ -1770,6 +2157,10 @@ class Multisig_Wallet(Deterministic_Wallet): def get_pubkeys(self, c, i): return self.derive_pubkeys(c, i) + def get_public_keys(self, address): + sequence = self.get_address_index(address) + return self.get_pubkeys(*sequence) + def pubkeys_to_address(self, pubkeys): redeem_script = self.pubkeys_to_redeem_script(pubkeys) return bitcoin.redeem_script_to_address(self.txin_type, redeem_script) @@ -1777,6 +2168,11 @@ class Multisig_Wallet(Deterministic_Wallet): def pubkeys_to_redeem_script(self, pubkeys): return transaction.multisig_script(sorted(pubkeys), self.m) + def get_redeem_script(self, address): + pubkeys = self.get_public_keys(address) + redeem_script = self.pubkeys_to_redeem_script(pubkeys) + return redeem_script + def derive_pubkeys(self, c, i): return [k.derive_pubkey(c, i) for k in self.get_keystores()] @@ -1799,22 +2195,28 @@ class Multisig_Wallet(Deterministic_Wallet): def get_keystores(self): return [self.keystores[i] for i in sorted(self.keystores.keys())] - def update_password(self, old_pw, new_pw, encrypt=False): - if old_pw is None and self.has_password(): - raise InvalidPassword() + def can_have_keystore_encryption(self): + return any([k.may_have_password() for k in self.get_keystores()]) + + def _update_password_for_keystore(self, old_pw, new_pw): for name, keystore in self.keystores.items(): - if keystore.can_change_password(): + if keystore.may_have_password(): keystore.update_password(old_pw, new_pw) self.storage.put(name, keystore.dump()) - self.storage.set_password(new_pw, encrypt) - self.storage.write() + + def check_password(self, password): + for name, keystore in self.keystores.items(): + if keystore.may_have_password(): + keystore.check_password(password) + self.storage.check_password(password) + + def get_available_storage_encryption_version(self): + # multisig wallets are not offered hw device encryption + return STO_EV_USER_PW def has_seed(self): return self.keystore.has_seed() - def can_change_password(self): - return self.keystore.can_change_password() - def is_watching_only(self): return not any([not k.is_watching_only() for k in self.get_keystores()]) diff --git a/lib/websockets.py b/lib/websockets.py index f2bd9149..415556b3 100644 --- a/lib/websockets.py +++ b/lib/websockets.py @@ -84,7 +84,8 @@ class WsClientThread(util.DaemonThread): l = self.subscriptions.get(addr, []) l.append((ws, amount)) self.subscriptions[addr] = l - self.network.send([('blockchain.address.subscribe', [addr])], self.response_queue.put) + h = self.network.addr_to_scripthash(addr) + self.network.send([('blockchain.scripthash.subscribe', [h])], self.response_queue.put) def run(self): @@ -100,10 +101,13 @@ class WsClientThread(util.DaemonThread): result = r.get('result') if result is None: continue - if method == 'blockchain.address.subscribe': - self.network.send([('blockchain.address.get_balance', params)], self.response_queue.put) - elif method == 'blockchain.address.get_balance': - addr = params[0] + if method == 'blockchain.scripthash.subscribe': + self.network.send([('blockchain.scripthash.get_balance', params)], self.response_queue.put) + elif method == 'blockchain.scripthash.get_balance': + h = params[0] + addr = self.network.h2addr.get(h, None) + if addr is None: + util.print_error("can't find address for scripthash: %s" % h) l = self.subscriptions.get(addr, []) for ws, amount in l: if not ws.closed: diff --git a/lib/x509.py b/lib/x509.py index 46933251..122aa730 100644 --- a/lib/x509.py +++ b/lib/x509.py @@ -23,7 +23,7 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from . import util -from .util import profiler, bh2u, get_cert_path +from .util import profiler, bh2u import ecdsa import hashlib diff --git a/plugins/cosigner_pool/qt.py b/plugins/cosigner_pool/qt.py index e9ee8ad6..52d6a8f3 100644 --- a/plugins/cosigner_pool/qt.py +++ b/plugins/cosigner_pool/qt.py @@ -194,7 +194,7 @@ class Plugin(BasePlugin): return wallet = window.wallet - if wallet.has_password(): + if wallet.has_keystore_encryption(): password = window.password_dialog('An encrypted transaction was retrieved from cosigning pool.\nPlease enter your password to decrypt it.') if not password: return diff --git a/plugins/digitalbitbox/cmdline.py b/plugins/digitalbitbox/cmdline.py index 7902c98a..82192cfd 100644 --- a/plugins/digitalbitbox/cmdline.py +++ b/plugins/digitalbitbox/cmdline.py @@ -9,3 +9,6 @@ class Plugin(DigitalBitboxPlugin): if not isinstance(keystore, self.keystore_class): return keystore.handler = self.handler + + def create_handler(self, window): + return self.handler diff --git a/plugins/digitalbitbox/digitalbitbox.py b/plugins/digitalbitbox/digitalbitbox.py index 622bba52..62329054 100644 --- a/plugins/digitalbitbox/digitalbitbox.py +++ b/plugins/digitalbitbox/digitalbitbox.py @@ -7,12 +7,13 @@ try: import electrum from electrum.bitcoin import TYPE_ADDRESS, push_script, var_int, msg_magic, Hash, verify_message, pubkey_from_signature, point_to_ser, public_key_to_p2pkh, EncodeAES, DecodeAES, MyVerifyingKey from electrum.bitcoin import serialize_xpub, deserialize_xpub + from electrum import constants from electrum.transaction import Transaction from electrum.i18n import _ from electrum.keystore import Hardware_KeyStore from ..hw_wallet import HW_PluginBase from electrum.util import print_error, to_string, UserCancelled - from electrum.base_wizard import ScriptTypeNotSupported + from electrum.base_wizard import ScriptTypeNotSupported, HWD_SETUP_NEW_WALLET import time import hid @@ -92,10 +93,10 @@ class DigitalBitbox_Client(): if reply: xpub = reply['xpub'] # Change type of xpub to the requested type. The firmware - # only ever returns the standard type, but it is agnostic + # only ever returns the mainnet standard type, but it is agnostic # to the type when signing. - if xtype != 'standard': - _, depth, fingerprint, child_number, c, cK = deserialize_xpub(xpub) + if xtype != 'standard' or constants.net.TESTNET: + _, depth, fingerprint, child_number, c, cK = deserialize_xpub(xpub, net=constants.BitcoinMainnet) xpub = serialize_xpub(xtype, c, cK, depth, fingerprint, child_number) return xpub else: @@ -421,7 +422,7 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): def decrypt_message(self, pubkey, message, password): - raise RuntimeError(_('Encryption and decryption are currently not supported for %s') % self.device) + raise RuntimeError(_('Encryption and decryption are currently not supported for {}').format(self.device)) def sign_message(self, sequence, message, password): @@ -661,7 +662,8 @@ class DigitalBitboxPlugin(HW_PluginBase): def create_client(self, device, handler): if device.interface_number == 0 or device.usage_page == 0xffff: - self.handler = handler + if handler: + self.handler = handler client = self.get_dbb_device(device) if client is not None: client = DigitalBitbox_Client(self, client) @@ -670,12 +672,13 @@ class DigitalBitboxPlugin(HW_PluginBase): return None - def setup_device(self, device_info, wizard): + def setup_device(self, device_info, wizard, purpose): devmgr = self.device_manager() device_id = device_info.device.id_ client = devmgr.client_by_id(device_id) client.handler = self.create_handler(wizard) - client.setupRunning = True + if purpose == HWD_SETUP_NEW_WALLET: + client.setupRunning = True client.get_xpub("m/44'/0'", 'standard') diff --git a/plugins/digitalbitbox/qt.py b/plugins/digitalbitbox/qt.py index 389dcdb5..8930d624 100644 --- a/plugins/digitalbitbox/qt.py +++ b/plugins/digitalbitbox/qt.py @@ -39,7 +39,7 @@ class Plugin(DigitalBitboxPlugin, QtPluginBase): } self.comserver_post_notification(verify_request_payload) - menu.addAction(_("Show on %s") % self.device, show_address) + menu.addAction(_("Show on {}").format(self.device), show_address) class DigitalBitbox_Handler(QtHandlerBase): diff --git a/plugins/email_requests/qt.py b/plugins/email_requests/qt.py index 64061841..a4facccf 100644 --- a/plugins/email_requests/qt.py +++ b/plugins/email_requests/qt.py @@ -27,6 +27,8 @@ import time import threading import base64 from functools import partial +import traceback +import sys import smtplib import imaplib @@ -37,14 +39,14 @@ from email.encoders import encode_base64 from PyQt5.QtGui import * from PyQt5.QtCore import * -import PyQt5.QtGui as QtGui -from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QLineEdit) +from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QLineEdit, + QInputDialog) from electrum.plugins import BasePlugin, hook from electrum.paymentrequest import PaymentRequest from electrum.i18n import _ -from electrum_gui.qt.util import EnterButton, Buttons, CloseButton -from electrum_gui.qt.util import OkButton, WindowModalDialog +from electrum_gui.qt.util import (EnterButton, Buttons, CloseButton, OkButton, + WindowModalDialog, get_parent_main_window) class Processor(threading.Thread): @@ -64,9 +66,9 @@ class Processor(threading.Thread): except: return typ, data = self.M.search(None, 'ALL') - for num in data[0].split(): + for num in str(data[0], 'utf8').split(): typ, msg_data = self.M.fetch(num, '(RFC822)') - msg = email.message_from_string(msg_data[0][1]) + msg = email.message_from_string(str(msg_data[0][1], 'utf8')) p = msg.get_payload() if not msg.is_multipart(): p = [p] @@ -127,19 +129,29 @@ class Plugin(BasePlugin): self.processor.start() self.obj = QEmailSignalObject() self.obj.email_new_invoice_signal.connect(self.new_invoice) + self.wallets = set() def on_receive(self, pr_str): self.print_error('received payment request') self.pr = PaymentRequest(pr_str) self.obj.email_new_invoice_signal.emit() + @hook + def load_wallet(self, wallet, main_window): + self.wallets |= {wallet} + + @hook + def close_wallet(self, wallet): + self.wallets -= {wallet} + def new_invoice(self): - self.parent.invoices.add(self.pr) - #window.update_invoices_list() + for wallet in self.wallets: + wallet.invoices.add(self.pr) + #main_window.invoice_list.update() @hook def receive_list_menu(self, menu, addr): - window = menu.parentWidget() + window = get_parent_main_window(menu) menu.addAction(_("Send via e-mail"), lambda: self.send(window, addr)) def send(self, window, addr): @@ -152,20 +164,20 @@ class Plugin(BasePlugin): pr = paymentrequest.make_request(self.config, r) if not pr: return - recipient, ok = QtGui.QInputDialog.getText(window, 'Send request', 'Email invoice to:') + recipient, ok = QInputDialog.getText(window, 'Send request', 'Email invoice to:') if not ok: return recipient = str(recipient) payload = pr.SerializeToString() self.print_error('sending mail to', recipient) try: + # FIXME this runs in the GUI thread and blocks it... self.processor.send(recipient, message, payload) except BaseException as e: + traceback.print_exc(file=sys.stderr) window.show_message(str(e)) - return - - window.show_message(_('Request sent.')) - + else: + window.show_message(_('Request sent.')) def requires_settings(self): return True @@ -204,9 +216,12 @@ class Plugin(BasePlugin): server = str(server_e.text()) self.config.set_key('email_server', server) + self.imap_server = server username = str(username_e.text()) self.config.set_key('email_username', username) + self.username = username password = str(password_e.text()) self.config.set_key('email_password', password) + self.password = password diff --git a/plugins/greenaddress_instant/qt.py b/plugins/greenaddress_instant/qt.py index 5a01091e..96cd87b2 100644 --- a/plugins/greenaddress_instant/qt.py +++ b/plugins/greenaddress_instant/qt.py @@ -65,9 +65,14 @@ class Plugin(BasePlugin): tx = d.tx wallet = d.wallet window = d.main_window + + if wallet.is_watching_only(): + d.show_critical(_('This feature is not available for watch-only wallets.')) + return + # 1. get the password and sign the verification request password = None - if wallet.has_password(): + if wallet.has_keystore_encryption(): msg = _('GreenAddress requires your signature \n' 'to verify that transaction is instant.\n' 'Please enter your password to sign a\n' @@ -91,9 +96,9 @@ class Plugin(BasePlugin): # 3. display the result if response.get('verified'): - d.show_message(_('%s is covered by GreenAddress instant confirmation') % (tx.txid()), title=_('Verification successful!')) + d.show_message(_('{} is covered by GreenAddress instant confirmation').format(tx.txid()), title=_('Verification successful!')) else: - d.show_critical(_('%s is not covered by GreenAddress instant confirmation') % (tx.txid()), title=_('Verification failed!')) + d.show_critical(_('{} is not covered by GreenAddress instant confirmation').format(tx.txid()), title=_('Verification failed!')) except BaseException as e: import traceback traceback.print_exc(file=sys.stdout) diff --git a/plugins/hw_wallet/plugin.py b/plugins/hw_wallet/plugin.py index 6ed8635f..34573cf9 100644 --- a/plugins/hw_wallet/plugin.py +++ b/plugins/hw_wallet/plugin.py @@ -51,3 +51,10 @@ class HW_PluginBase(BasePlugin): for keystore in wallet.get_keystores(): if isinstance(keystore, self.keystore_class): self.device_manager().unpair_xpub(keystore.xpub) + + def setup_device(self, device_info, wizard, purpose): + """Called when creating a new wallet or when using the device to decrypt + an existing wallet. Select the device to use. If the device is + uninitialized, go through the initialization process. + """ + raise NotImplementedError() diff --git a/plugins/hw_wallet/qt.py b/plugins/hw_wallet/qt.py index bd7cc223..d2a3bb5f 100644 --- a/plugins/hw_wallet/qt.py +++ b/plugins/hw_wallet/qt.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python3 # -*- mode: python -*- # # Electrum - lightweight Bitcoin client @@ -70,9 +70,10 @@ class QtHandlerBase(QObject, PrintError): self.status_signal.emit(paired) def _update_status(self, paired): - button = self.button - icon = button.icon_paired if paired else button.icon_unpaired - button.setIcon(QIcon(icon)) + if hasattr(self, 'button'): + button = self.button + icon = button.icon_paired if paired else button.icon_unpaired + button.setIcon(QIcon(icon)) def query_choice(self, msg, labels): self.done.clear() @@ -143,7 +144,7 @@ class QtHandlerBase(QObject, PrintError): def message_dialog(self, msg, on_cancel): # Called more than once during signing, to confirm output and fee self.clear_dialog() - title = _('Please check your %s device') % self.device + title = _('Please check your {} device').format(self.device) self.dialog = dialog = WindowModalDialog(self.top_level_window(), title) l = QLabel(msg) vbox = QVBoxLayout(dialog) @@ -183,10 +184,12 @@ class QtPluginBase(object): if not isinstance(keystore, self.keystore_class): continue if not self.libraries_available: - window.show_error( - _("Cannot find python library for") + " '%s'.\n" % self.name \ - + _("Make sure you install it with python3") - ) + if hasattr(self, 'libraries_available_message'): + message = self.libraries_available_message + '\n' + else: + message = _("Cannot find python library for") + " '%s'.\n" % self.name + message += _("Make sure you install it with python3") + window.show_error(message) return tooltip = self.device + '\n' + (keystore.label or 'unnamed') cb = partial(self.show_settings_dialog, window, keystore) diff --git a/plugins/keepkey/clientbase.py b/plugins/keepkey/clientbase.py index de7d47c1..6b33c9d4 100644 --- a/plugins/keepkey/clientbase.py +++ b/plugins/keepkey/clientbase.py @@ -11,15 +11,15 @@ class GuiMixin(object): # Requires: self.proto, self.device messages = { - 3: _("Confirm the transaction output on your %s device"), - 4: _("Confirm internal entropy on your %s device to begin"), - 5: _("Write down the seed word shown on your %s"), - 6: _("Confirm on your %s that you want to wipe it clean"), - 7: _("Confirm on your %s device the message to sign"), + 3: _("Confirm the transaction output on your {} device"), + 4: _("Confirm internal entropy on your {} device to begin"), + 5: _("Write down the seed word shown on your {}"), + 6: _("Confirm on your {} that you want to wipe it clean"), + 7: _("Confirm on your {} device the message to sign"), 8: _("Confirm the total amount spent and the transaction fee on your " - "%s device"), - 10: _("Confirm wallet address on your %s device"), - 'default': _("Check your %s device to continue"), + "{} device"), + 10: _("Confirm wallet address on your {} device"), + 'default': _("Check your {} device to continue"), } def callback_Failure(self, msg): @@ -38,18 +38,18 @@ class GuiMixin(object): message = self.msg if not message: message = self.messages.get(msg.code, self.messages['default']) - self.handler.show_message(message % self.device, self.cancel) + self.handler.show_message(message.format(self.device), self.cancel) return self.proto.ButtonAck() def callback_PinMatrixRequest(self, msg): if msg.type == 2: - msg = _("Enter a new PIN for your %s:") + msg = _("Enter a new PIN for your {}:") elif msg.type == 3: - msg = (_("Re-enter the new PIN for your %s.\n\n" + msg = (_("Re-enter the new PIN for your {}.\n\n" "NOTE: the positions of the numbers have changed!")) else: - msg = _("Enter your current %s PIN:") - pin = self.handler.get_pin(msg % self.device) + msg = _("Enter your current {} PIN:") + pin = self.handler.get_pin(msg.format(self.device)) if not pin: return self.proto.Cancel() return self.proto.PinMatrixAck(pin=pin) @@ -57,9 +57,9 @@ class GuiMixin(object): def callback_PassphraseRequest(self, req): if self.creating_wallet: msg = _("Enter a passphrase to generate this wallet. Each time " - "you use this wallet your %s will prompt you for the " + "you use this wallet your {} will prompt you for the " "passphrase. If you forget the passphrase you cannot " - "access the bitcoins in the wallet.") % self.device + "access the bitcoins in the wallet.").format(self.device) else: msg = _("Enter the passphrase to unlock this wallet:") passphrase = self.handler.get_passphrase(msg, self.creating_wallet) @@ -70,8 +70,8 @@ class GuiMixin(object): def callback_WordRequest(self, msg): self.step += 1 - msg = _("Step %d/24. Enter seed word as explained on " - "your %s:") % (self.step, self.device) + msg = _("Step {}/24. Enter seed word as explained on " + "your {}:").format(self.step, self.device) word = self.handler.get_word(msg) # Unfortunately the device can't handle self.proto.Cancel() return self.proto.WordAck(word=word) @@ -155,27 +155,27 @@ class KeepKeyClientBase(GuiMixin, PrintError): def toggle_passphrase(self): if self.features.passphrase_protection: - self.msg = _("Confirm on your %s device to disable passphrases") + self.msg = _("Confirm on your {} device to disable passphrases") else: - self.msg = _("Confirm on your %s device to enable passphrases") + self.msg = _("Confirm on your {} device to enable passphrases") enabled = not self.features.passphrase_protection self.apply_settings(use_passphrase=enabled) def change_label(self, label): - self.msg = _("Confirm the new label on your %s device") + self.msg = _("Confirm the new label on your {} device") self.apply_settings(label=label) def change_homescreen(self, homescreen): - self.msg = _("Confirm on your %s device to change your home screen") + self.msg = _("Confirm on your {} device to change your home screen") self.apply_settings(homescreen=homescreen) def set_pin(self, remove): if remove: - self.msg = _("Confirm on your %s device to disable PIN protection") + self.msg = _("Confirm on your {} device to disable PIN protection") elif self.features.pin_protection: - self.msg = _("Confirm on your %s device to change your PIN") + self.msg = _("Confirm on your {} device to change your PIN") else: - self.msg = _("Confirm on your %s device to set a PIN") + self.msg = _("Confirm on your {} device to set a PIN") self.change_pin(remove) def clear_session(self): @@ -188,7 +188,6 @@ class KeepKeyClientBase(GuiMixin, PrintError): except BaseException as e: # If the device was removed it has the same effect... self.print_error("clear_session: ignoring error", str(e)) - pass def get_public_node(self, address_n, creating): self.creating_wallet = creating diff --git a/plugins/keepkey/cmdline.py b/plugins/keepkey/cmdline.py index cd30bc0c..4262b701 100644 --- a/plugins/keepkey/cmdline.py +++ b/plugins/keepkey/cmdline.py @@ -9,3 +9,6 @@ class Plugin(KeepKeyPlugin): if not isinstance(keystore, self.keystore_class): return keystore.handler = self.handler + + def create_handler(self, window): + return self.handler diff --git a/plugins/keepkey/plugin.py b/plugins/keepkey/plugin.py index 057c44e1..a81a328b 100644 --- a/plugins/keepkey/plugin.py +++ b/plugins/keepkey/plugin.py @@ -4,8 +4,9 @@ from binascii import hexlify, unhexlify from electrum.util import bfh, bh2u from electrum.bitcoin import (b58_address_to_hash160, xpub_from_pubkey, - TYPE_ADDRESS, TYPE_SCRIPT, NetworkConstants, + TYPE_ADDRESS, TYPE_SCRIPT, is_segwit_address) +from electrum import constants from electrum.i18n import _ from electrum.plugins import BasePlugin from electrum.transaction import deserialize @@ -30,7 +31,7 @@ class KeepKeyCompatibleKeyStore(Hardware_KeyStore): return self.plugin.get_client(self, force_pair) def decrypt_message(self, sequence, message, password): - raise RuntimeError(_('Encryption and decryption are not implemented by %s') % self.device) + raise RuntimeError(_('Encryption and decryption are not implemented by {}').format(self.device)) def sign_message(self, sequence, message, password): client = self.get_client() @@ -119,9 +120,9 @@ class KeepKeyCompatiblePlugin(HW_PluginBase): return None if not client.atleast_version(*self.minimum_firmware): - msg = (_('Outdated %s firmware for device labelled %s. Please ' - 'download the updated firmware from %s') % - (self.device, client.label(), self.firmware_URL)) + msg = (_('Outdated {} firmware for device labelled {}. Please ' + 'download the updated firmware from {}') + .format(self.device, client.label(), self.firmware_URL)) self.print_error(msg) handler.show_error(msg) return None @@ -139,18 +140,18 @@ class KeepKeyCompatiblePlugin(HW_PluginBase): return client def get_coin_name(self): - return "Testnet" if NetworkConstants.TESTNET else "Bitcoin" + return "Testnet" if constants.net.TESTNET else "Bitcoin" def initialize_device(self, device_id, wizard, handler): # Initialization method - msg = _("Choose how you want to initialize your %s.\n\n" + msg = _("Choose how you want to initialize your {}.\n\n" "The first two methods are secure as no secret information " "is entered into your computer.\n\n" "For the last two methods you input secrets on your keyboard " - "and upload them to your %s, and so you should " + "and upload them to your {}, and so you should " "only do those on a computer you know to be trustworthy " "and free of malware." - ) % (self.device, self.device) + ).format(self.device, self.device) choices = [ # Must be short as QT doesn't word-wrap radio button text (TIM_NEW, _("Let the device generate a completely new seed randomly")), @@ -194,10 +195,7 @@ class KeepKeyCompatiblePlugin(HW_PluginBase): label, language) wizard.loop.exit(0) - def setup_device(self, device_info, wizard): - '''Called when creating a new wallet. Select the device to use. If - the device is uninitialized, go through the intialization - process.''' + def setup_device(self, device_info, wizard, purpose): devmgr = self.device_manager() device_id = device_info.device.id_ client = devmgr.client_by_id(device_id) @@ -347,9 +345,9 @@ class KeepKeyCompatiblePlugin(HW_PluginBase): txoutputtype.script_type = self.types.PAYTOWITNESS else: addrtype, hash_160 = b58_address_to_hash160(address) - if addrtype == NetworkConstants.ADDRTYPE_P2PKH: + if addrtype == constants.net.ADDRTYPE_P2PKH: txoutputtype.script_type = self.types.PAYTOADDRESS - elif addrtype == NetworkConstants.ADDRTYPE_P2SH: + elif addrtype == constants.net.ADDRTYPE_P2SH: txoutputtype.script_type = self.types.PAYTOSCRIPTHASH else: raise BaseException('addrtype: ' + str(addrtype)) diff --git a/plugins/keepkey/qt_generic.py b/plugins/keepkey/qt_generic.py index d564be6d..a66e8f3d 100644 --- a/plugins/keepkey/qt_generic.py +++ b/plugins/keepkey/qt_generic.py @@ -194,7 +194,7 @@ class QtPlugin(QtPluginBase): if type(keystore) == self.keystore_class and len(addrs) == 1: def show_address(): keystore.thread.add(partial(self.show_address, wallet, addrs[0])) - menu.addAction(_("Show on %s") % self.device, show_address) + menu.addAction(_("Show on {}").format(self.device), show_address) def show_settings_dialog(self, window, keystore): device_id = self.choose_device(window, keystore) @@ -227,7 +227,7 @@ class QtPlugin(QtPluginBase): bg = QButtonGroup() for i, count in enumerate([12, 18, 24]): rb = QRadioButton(gb) - rb.setText(_("%d words") % count) + rb.setText(_("{} words").format(count)) bg.addButton(rb) bg.setId(rb, i) hbox1.addWidget(rb) @@ -292,7 +292,7 @@ class SettingsDialog(WindowModalDialog): their PIN.''' def __init__(self, window, plugin, keystore, device_id): - title = _("%s Settings") % plugin.device + title = _("{} Settings").format(plugin.device) super(SettingsDialog, self).__init__(window, title) self.setMaximumWidth(540) @@ -457,9 +457,9 @@ class SettingsDialog(WindowModalDialog): settings_glayout = QGridLayout() # Settings tab - Label - label_msg = QLabel(_("Name this %s. If you have mutiple devices " + label_msg = QLabel(_("Name this {}. If you have mutiple devices " "their labels help distinguish them.") - % plugin.device) + .format(plugin.device)) label_msg.setWordWrap(True) label_label = QLabel(_("Device Label")) label_edit = QLineEdit() @@ -482,7 +482,7 @@ class SettingsDialog(WindowModalDialog): pin_msg = QLabel(_("PIN protection is strongly recommended. " "A PIN is your only protection against someone " "stealing your bitcoins if they obtain physical " - "access to your %s.") % plugin.device) + "access to your {}.").format(plugin.device)) pin_msg.setWordWrap(True) pin_msg.setStyleSheet("color: red") settings_glayout.addWidget(pin_msg, 3, 1, 1, -1) @@ -497,8 +497,8 @@ class SettingsDialog(WindowModalDialog): homescreen_clear_button.clicked.connect(clear_homescreen) homescreen_msg = QLabel(_("You can set the homescreen on your " "device to personalize it. You must " - "choose a %d x %d monochrome black and " - "white image.") % (hs_rows, hs_cols)) + "choose a {} x {} monochrome black and " + "white image.").format(hs_rows, hs_cols)) homescreen_msg.setWordWrap(True) settings_glayout.addWidget(homescreen_label, 4, 0) settings_glayout.addWidget(homescreen_change_button, 4, 1) @@ -541,7 +541,7 @@ class SettingsDialog(WindowModalDialog): clear_pin_button.clicked.connect(clear_pin) clear_pin_warning = QLabel( _("If you disable your PIN, anyone with physical access to your " - "%s device can spend your bitcoins.") % plugin.device) + "{} device can spend your bitcoins.").format(plugin.device)) clear_pin_warning.setWordWrap(True) clear_pin_warning.setStyleSheet("color: red") advanced_glayout.addWidget(clear_pin_button, 0, 2) diff --git a/plugins/ledger/auth2fa.py b/plugins/ledger/auth2fa.py index c5f50a0e..add619a8 100644 --- a/plugins/ledger/auth2fa.py +++ b/plugins/ledger/auth2fa.py @@ -164,7 +164,7 @@ class LedgerAuthDialog(QDialog): if not self.cfg['pair']: self.modes.addItem(_("Mobile - Not paired")) else: - self.modes.addItem(_("Mobile - %s") % self.cfg['pair'][1]) + self.modes.addItem(_("Mobile - {}").format(self.cfg['pair'][1])) self.modes.blockSignals(False) def update_dlg(self): diff --git a/plugins/ledger/cmdline.py b/plugins/ledger/cmdline.py index b0b252ac..5d8c9f46 100644 --- a/plugins/ledger/cmdline.py +++ b/plugins/ledger/cmdline.py @@ -9,3 +9,6 @@ class Plugin(LedgerPlugin): if not isinstance(keystore, self.keystore_class): return keystore.handler = self.handler + + def create_handler(self, window): + return self.handler diff --git a/plugins/ledger/ledger.py b/plugins/ledger/ledger.py index e89747aa..f062426e 100644 --- a/plugins/ledger/ledger.py +++ b/plugins/ledger/ledger.py @@ -10,7 +10,7 @@ from electrum.plugins import BasePlugin from electrum.keystore import Hardware_KeyStore from electrum.transaction import Transaction from ..hw_wallet import HW_PluginBase -from electrum.util import print_error, is_verbose, bfh, bh2u +from electrum.util import print_error, is_verbose, bfh, bh2u, versiontuple try: import hid @@ -57,9 +57,6 @@ class Ledger_Client(): def i4b(self, x): return pack('>I', x) - def versiontuple(self, v): - return tuple(map(int, (v.split(".")))) - def test_pin_unlocked(func): """Function decorator to test the Ledger for being unlocked, and if not, raise a human-readable exception. @@ -140,9 +137,9 @@ class Ledger_Client(): try: firmwareInfo = self.dongleObject.getFirmwareVersion() firmware = firmwareInfo['version'] - self.multiOutputSupported = self.versiontuple(firmware) >= self.versiontuple(MULTI_OUTPUT_SUPPORT) - self.nativeSegwitSupported = self.versiontuple(firmware) >= self.versiontuple(SEGWIT_SUPPORT) - self.segwitSupported = self.nativeSegwitSupported or (firmwareInfo['specialVersion'] == 0x20 and self.versiontuple(firmware) >= self.versiontuple(SEGWIT_SUPPORT_SPECIAL)) + self.multiOutputSupported = versiontuple(firmware) >= versiontuple(MULTI_OUTPUT_SUPPORT) + self.nativeSegwitSupported = versiontuple(firmware) >= versiontuple(SEGWIT_SUPPORT) + self.segwitSupported = self.nativeSegwitSupported or (firmwareInfo['specialVersion'] == 0x20 and versiontuple(firmware) >= versiontuple(SEGWIT_SUPPORT_SPECIAL)) if not checkFirmware(firmwareInfo): self.dongleObject.dongle.close() @@ -172,6 +169,10 @@ class Ledger_Client(): raise Exception("Dongle is temporarily locked - please unplug it and replug it again") if ((e.sw & 0xFFF0) == 0x63c0): raise Exception("Invalid PIN - please unplug the dongle and plug it again before retrying") + if e.sw == 0x6f00 and e.message == 'Invalid channel': + # based on docs 0x6f00 might be a more general error, hence we also compare message to be sure + raise Exception("Invalid channel.\n" + "Please make sure that 'Browser support' is disabled on your device.") raise e def checkDevice(self): @@ -236,7 +237,7 @@ class Ledger_KeyStore(Hardware_KeyStore): return address_path[2:] def decrypt_message(self, pubkey, message, password): - raise RuntimeError(_('Encryption and decryption are currently not supported for %s') % self.device) + raise RuntimeError(_('Encryption and decryption are currently not supported for {}').format(self.device)) def sign_message(self, sequence, message, password): self.signing = True @@ -463,8 +464,9 @@ class Ledger_KeyStore(Hardware_KeyStore): address_path = self.get_derivation()[2:] + "/%d/%d"%sequence self.handler.show_message(_("Showing address ...")) segwit = Transaction.is_segwit_inputtype(txin_type) + segwitNative = txin_type == 'p2wpkh' try: - client.getWalletPublicKey(address_path, showOnScreen=True, segwit=segwit) + client.getWalletPublicKey(address_path, showOnScreen=True, segwit=segwit, segwitNative=segwitNative) except BTChipException as e: if e.sw == 0x6985: # cancelled by user pass @@ -514,14 +516,15 @@ class LedgerPlugin(HW_PluginBase): return HIDDongleHIDAPI(dev, ledger, BTCHIP_DEBUG) def create_client(self, device, handler): - self.handler = handler + if handler: + self.handler = handler client = self.get_btchip_device(device) if client is not None: client = Ledger_Client(client) return client - def setup_device(self, device_info, wizard): + def setup_device(self, device_info, wizard, purpose): devmgr = self.device_manager() device_id = device_info.device.id_ client = devmgr.client_by_id(device_id) diff --git a/plugins/trezor/clientbase.py b/plugins/trezor/clientbase.py index 31a97ca1..6e10d4c4 100644 --- a/plugins/trezor/clientbase.py +++ b/plugins/trezor/clientbase.py @@ -11,15 +11,15 @@ class GuiMixin(object): # Requires: self.proto, self.device messages = { - 3: _("Confirm the transaction output on your %s device"), - 4: _("Confirm internal entropy on your %s device to begin"), - 5: _("Write down the seed word shown on your %s"), - 6: _("Confirm on your %s that you want to wipe it clean"), - 7: _("Confirm on your %s device the message to sign"), + 3: _("Confirm the transaction output on your {} device"), + 4: _("Confirm internal entropy on your {} device to begin"), + 5: _("Write down the seed word shown on your {}"), + 6: _("Confirm on your {} that you want to wipe it clean"), + 7: _("Confirm on your {} device the message to sign"), 8: _("Confirm the total amount spent and the transaction fee on your " - "%s device"), - 10: _("Confirm wallet address on your %s device"), - 'default': _("Check your %s device to continue"), + "{} device"), + 10: _("Confirm wallet address on your {} device"), + 'default': _("Check your {} device to continue"), } def callback_Failure(self, msg): @@ -38,28 +38,31 @@ class GuiMixin(object): message = self.msg if not message: message = self.messages.get(msg.code, self.messages['default']) - self.handler.show_message(message % self.device, self.cancel) + self.handler.show_message(message.format(self.device), self.cancel) return self.proto.ButtonAck() def callback_PinMatrixRequest(self, msg): if msg.type == 2: - msg = _("Enter a new PIN for your %s:") + msg = _("Enter a new PIN for your {}:") elif msg.type == 3: - msg = (_("Re-enter the new PIN for your %s.\n\n" + msg = (_("Re-enter the new PIN for your {}.\n\n" "NOTE: the positions of the numbers have changed!")) else: - msg = _("Enter your current %s PIN:") - pin = self.handler.get_pin(msg % self.device) + msg = _("Enter your current {} PIN:") + pin = self.handler.get_pin(msg.format(self.device)) if not pin: return self.proto.Cancel() return self.proto.PinMatrixAck(pin=pin) def callback_PassphraseRequest(self, req): + if req and hasattr(req, 'on_device') and req.on_device is True: + return self.proto.PassphraseAck() + if self.creating_wallet: msg = _("Enter a passphrase to generate this wallet. Each time " - "you use this wallet your %s will prompt you for the " + "you use this wallet your {} will prompt you for the " "passphrase. If you forget the passphrase you cannot " - "access the bitcoins in the wallet.") % self.device + "access the bitcoins in the wallet.").format(self.device) else: msg = _("Enter the passphrase to unlock this wallet:") passphrase = self.handler.get_passphrase(msg, self.creating_wallet) @@ -68,10 +71,13 @@ class GuiMixin(object): passphrase = bip39_normalize_passphrase(passphrase) return self.proto.PassphraseAck(passphrase=passphrase) + def callback_PassphraseStateRequest(self, msg): + return self.proto.PassphraseStateAck() + def callback_WordRequest(self, msg): self.step += 1 - msg = _("Step %d/24. Enter seed word as explained on " - "your %s:") % (self.step, self.device) + msg = _("Step {}/24. Enter seed word as explained on " + "your {}:").format(self.step, self.device) word = self.handler.get_word(msg) # Unfortunately the device can't handle self.proto.Cancel() return self.proto.WordAck(word=word) @@ -155,27 +161,27 @@ class TrezorClientBase(GuiMixin, PrintError): def toggle_passphrase(self): if self.features.passphrase_protection: - self.msg = _("Confirm on your %s device to disable passphrases") + self.msg = _("Confirm on your {} device to disable passphrases") else: - self.msg = _("Confirm on your %s device to enable passphrases") + self.msg = _("Confirm on your {} device to enable passphrases") enabled = not self.features.passphrase_protection self.apply_settings(use_passphrase=enabled) def change_label(self, label): - self.msg = _("Confirm the new label on your %s device") + self.msg = _("Confirm the new label on your {} device") self.apply_settings(label=label) def change_homescreen(self, homescreen): - self.msg = _("Confirm on your %s device to change your home screen") + self.msg = _("Confirm on your {} device to change your home screen") self.apply_settings(homescreen=homescreen) def set_pin(self, remove): if remove: - self.msg = _("Confirm on your %s device to disable PIN protection") + self.msg = _("Confirm on your {} device to disable PIN protection") elif self.features.pin_protection: - self.msg = _("Confirm on your %s device to change your PIN") + self.msg = _("Confirm on your {} device to change your PIN") else: - self.msg = _("Confirm on your %s device to set a PIN") + self.msg = _("Confirm on your {} device to set a PIN") self.change_pin(remove) def clear_session(self): @@ -188,7 +194,6 @@ class TrezorClientBase(GuiMixin, PrintError): except BaseException as e: # If the device was removed it has the same effect... self.print_error("clear_session: ignoring error", str(e)) - pass def get_public_node(self, address_n, creating): self.creating_wallet = creating diff --git a/plugins/trezor/cmdline.py b/plugins/trezor/cmdline.py index 9149eeee..630578ac 100644 --- a/plugins/trezor/cmdline.py +++ b/plugins/trezor/cmdline.py @@ -9,3 +9,6 @@ class Plugin(TrezorPlugin): if not isinstance(keystore, self.keystore_class): return keystore.handler = self.handler + + def create_handler(self, window): + return self.handler diff --git a/plugins/trezor/plugin.py b/plugins/trezor/plugin.py deleted file mode 100644 index c1dde481..00000000 --- a/plugins/trezor/plugin.py +++ /dev/null @@ -1,411 +0,0 @@ -import threading - -from binascii import hexlify, unhexlify - -from electrum.util import bfh, bh2u -from electrum.bitcoin import (b58_address_to_hash160, xpub_from_pubkey, - TYPE_ADDRESS, TYPE_SCRIPT, NetworkConstants) -from electrum.i18n import _ -from electrum.plugins import BasePlugin -from electrum.transaction import deserialize -from electrum.keystore import Hardware_KeyStore, is_xpubkey, parse_xpubkey - -from ..hw_wallet import HW_PluginBase - - -# TREZOR initialization methods -TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY = range(0, 4) - -# script "generation" -SCRIPT_GEN_LEGACY, SCRIPT_GEN_P2SH_SEGWIT, SCRIPT_GEN_NATIVE_SEGWIT = range(0, 3) - -class TrezorCompatibleKeyStore(Hardware_KeyStore): - - def get_derivation(self): - return self.derivation - - def get_script_gen(self): - def is_p2sh_segwit(): - return self.derivation.startswith("m/49'/") - - def is_native_segwit(): - return self.derivation.startswith("m/84'/") - - if is_native_segwit(): - return SCRIPT_GEN_NATIVE_SEGWIT - elif is_p2sh_segwit(): - return SCRIPT_GEN_P2SH_SEGWIT - else: - return SCRIPT_GEN_LEGACY - - def get_client(self, force_pair=True): - return self.plugin.get_client(self, force_pair) - - def decrypt_message(self, sequence, message, password): - raise RuntimeError(_('Encryption and decryption are not implemented by %s') % self.device) - - def sign_message(self, sequence, message, password): - client = self.get_client() - address_path = self.get_derivation() + "/%d/%d"%sequence - address_n = client.expand_path(address_path) - msg_sig = client.sign_message(self.plugin.get_coin_name(), address_n, message) - return msg_sig.signature - - def sign_transaction(self, tx, password): - if tx.is_complete(): - return - # previous transactions used as inputs - prev_tx = {} - # path of the xpubs that are involved - xpub_path = {} - for txin in tx.inputs(): - pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) - tx_hash = txin['prevout_hash'] - prev_tx[tx_hash] = txin['prev_tx'] - for x_pubkey in x_pubkeys: - if not is_xpubkey(x_pubkey): - continue - xpub, s = parse_xpubkey(x_pubkey) - if xpub == self.get_master_public_key(): - xpub_path[xpub] = self.get_derivation() - - self.plugin.sign_transaction(self, tx, prev_tx, xpub_path) - - -class TrezorCompatiblePlugin(HW_PluginBase): - # Derived classes provide: - # - # class-static variables: client_class, firmware_URL, handler_class, - # libraries_available, libraries_URL, minimum_firmware, - # wallet_class, ckd_public, types, HidTransport - - MAX_LABEL_LEN = 32 - - def __init__(self, parent, config, name): - HW_PluginBase.__init__(self, parent, config, name) - self.main_thread = threading.current_thread() - # FIXME: move to base class when Ledger is fixed - if self.libraries_available: - self.device_manager().register_devices(self.DEVICE_IDS) - - def _try_hid(self, device): - self.print_error("Trying to connect over USB...") - try: - return self.hid_transport(device) - except BaseException as e: - # see fdb810ba622dc7dbe1259cbafb5b28e19d2ab114 - # raise - self.print_error("cannot connect at", device.path, str(e)) - return None - - def _try_bridge(self, device): - self.print_error("Trying to connect over Trezor Bridge...") - try: - return self.bridge_transport({'path': hexlify(device.path)}) - except BaseException as e: - self.print_error("cannot connect to bridge", str(e)) - return None - - def create_client(self, device, handler): - # disable bridge because it seems to never returns if keepkey is plugged - #transport = self._try_bridge(device) or self._try_hid(device) - transport = self._try_hid(device) - if not transport: - self.print_error("cannot connect to device") - return - - self.print_error("connected to device at", device.path) - - client = self.client_class(transport, handler, self) - - # Try a ping for device sanity - try: - client.ping('t') - except BaseException as e: - self.print_error("ping failed", str(e)) - return None - - if not client.atleast_version(*self.minimum_firmware): - msg = (_('Outdated %s firmware for device labelled %s. Please ' - 'download the updated firmware from %s') % - (self.device, client.label(), self.firmware_URL)) - self.print_error(msg) - handler.show_error(msg) - return None - - return client - - def get_client(self, keystore, force_pair=True): - devmgr = self.device_manager() - handler = keystore.handler - with devmgr.hid_lock: - client = devmgr.client_for_keystore(self, handler, keystore, force_pair) - # returns the client for a given keystore. can use xpub - if client: - client.used() - return client - - def get_coin_name(self): - return "Testnet" if NetworkConstants.TESTNET else "Bitcoin" - - def initialize_device(self, device_id, wizard, handler): - # Initialization method - msg = _("Choose how you want to initialize your %s.\n\n" - "The first two methods are secure as no secret information " - "is entered into your computer.\n\n" - "For the last two methods you input secrets on your keyboard " - "and upload them to your %s, and so you should " - "only do those on a computer you know to be trustworthy " - "and free of malware." - ) % (self.device, self.device) - choices = [ - # Must be short as QT doesn't word-wrap radio button text - (TIM_NEW, _("Let the device generate a completely new seed randomly")), - (TIM_RECOVER, _("Recover from a seed you have previously written down")), - (TIM_MNEMONIC, _("Upload a BIP39 mnemonic to generate the seed")), - (TIM_PRIVKEY, _("Upload a master private key")) - ] - def f(method): - import threading - settings = self.request_trezor_init_settings(wizard, method, self.device) - t = threading.Thread(target = self._initialize_device, args=(settings, method, device_id, wizard, handler)) - t.setDaemon(True) - t.start() - wizard.loop.exec_() - wizard.choice_dialog(title=_('Initialize Device'), message=msg, choices=choices, run_next=f) - - def _initialize_device(self, settings, method, device_id, wizard, handler): - item, label, pin_protection, passphrase_protection = settings - - if method == TIM_RECOVER: - # FIXME the PIN prompt will appear over this message - # which makes this unreadable - handler.show_error(_( - "You will be asked to enter 24 words regardless of your " - "seed's actual length. If you enter a word incorrectly or " - "misspell it, you cannot change it or go back - you will need " - "to start again from the beginning.\n\nSo please enter " - "the words carefully!")) - - language = 'english' - devmgr = self.device_manager() - client = devmgr.client_by_id(device_id) - - if method == TIM_NEW: - strength = 64 * (item + 2) # 128, 192 or 256 - u2f_counter = 0 - skip_backup = False - client.reset_device(True, strength, passphrase_protection, - pin_protection, label, language, - u2f_counter, skip_backup) - elif method == TIM_RECOVER: - word_count = 6 * (item + 2) # 12, 18 or 24 - client.step = 0 - client.recovery_device(word_count, passphrase_protection, - pin_protection, label, language) - elif method == TIM_MNEMONIC: - pin = pin_protection # It's the pin, not a boolean - client.load_device_by_mnemonic(str(item), pin, - passphrase_protection, - label, language) - else: - pin = pin_protection # It's the pin, not a boolean - client.load_device_by_xprv(item, pin, passphrase_protection, - label, language) - wizard.loop.exit(0) - - def setup_device(self, device_info, wizard): - '''Called when creating a new wallet. Select the device to use. If - the device is uninitialized, go through the intialization - process.''' - devmgr = self.device_manager() - device_id = device_info.device.id_ - client = devmgr.client_by_id(device_id) - # fixme: we should use: client.handler = wizard - client.handler = self.create_handler(wizard) - if not device_info.initialized: - self.initialize_device(device_id, wizard, client.handler) - client.get_xpub('m', 'standard') - client.used() - - def get_xpub(self, device_id, derivation, xtype, wizard): - devmgr = self.device_manager() - client = devmgr.client_by_id(device_id) - client.handler = wizard - xpub = client.get_xpub(derivation, xtype) - client.used() - return xpub - - def sign_transaction(self, keystore, tx, prev_tx, xpub_path): - self.prev_tx = prev_tx - self.xpub_path = xpub_path - client = self.get_client(keystore) - inputs = self.tx_inputs(tx, True, keystore.get_script_gen()) - outputs = self.tx_outputs(keystore.get_derivation(), tx, keystore.get_script_gen()) - signed_tx = client.sign_tx(self.get_coin_name(), inputs, outputs, lock_time=tx.locktime)[1] - raw = bh2u(signed_tx) - tx.update_signatures(raw) - - def show_address(self, wallet, address): - client = self.get_client(wallet.keystore) - if not client.atleast_version(1, 3): - wallet.keystore.handler.show_error(_("Your device firmware is too old")) - return - change, index = wallet.get_address_index(address) - derivation = wallet.keystore.derivation - address_path = "%s/%d/%d"%(derivation, change, index) - address_n = client.expand_path(address_path) - script_gen = wallet.keystore.get_script_gen() - if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: - script_type = self.types.InputScriptType.SPENDWITNESS - elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: - script_type = self.types.InputScriptType.SPENDP2SHWITNESS - else: - script_type = self.types.InputScriptType.SPENDADDRESS - client.get_address(self.get_coin_name(), address_n, True, script_type=script_type) - - def tx_inputs(self, tx, for_sig=False, script_gen=SCRIPT_GEN_LEGACY): - inputs = [] - for txin in tx.inputs(): - txinputtype = self.types.TxInputType() - if txin['type'] == 'coinbase': - prev_hash = "\0"*32 - prev_index = 0xffffffff # signed int -1 - else: - if for_sig: - x_pubkeys = txin['x_pubkeys'] - if len(x_pubkeys) == 1: - x_pubkey = x_pubkeys[0] - xpub, s = parse_xpubkey(x_pubkey) - xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) - txinputtype._extend_address_n(xpub_n + s) - if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: - txinputtype.script_type = self.types.InputScriptType.SPENDWITNESS - elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: - txinputtype.script_type = self.types.InputScriptType.SPENDP2SHWITNESS - else: - txinputtype.script_type = self.types.InputScriptType.SPENDADDRESS - else: - def f(x_pubkey): - if is_xpubkey(x_pubkey): - xpub, s = parse_xpubkey(x_pubkey) - else: - xpub = xpub_from_pubkey(0, bfh(x_pubkey)) - s = [] - node = self.ckd_public.deserialize(xpub) - return self.types.HDNodePathType(node=node, address_n=s) - pubkeys = list(map(f, x_pubkeys)) - multisig = self.types.MultisigRedeemScriptType( - pubkeys=pubkeys, - signatures=list(map(lambda x: bfh(x)[:-1] if x else b'', txin.get('signatures'))), - m=txin.get('num_sig'), - ) - if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: - script_type = self.types.InputScriptType.SPENDWITNESS - elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: - script_type = self.types.InputScriptType.SPENDP2SHWITNESS - else: - script_type = self.types.InputScriptType.SPENDMULTISIG - txinputtype = self.types.TxInputType( - script_type=script_type, - multisig=multisig - ) - # find which key is mine - for x_pubkey in x_pubkeys: - if is_xpubkey(x_pubkey): - xpub, s = parse_xpubkey(x_pubkey) - if xpub in self.xpub_path: - xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) - txinputtype._extend_address_n(xpub_n + s) - break - - prev_hash = unhexlify(txin['prevout_hash']) - prev_index = txin['prevout_n'] - - if 'value' in txin: - txinputtype.amount = txin['value'] - txinputtype.prev_hash = prev_hash - txinputtype.prev_index = prev_index - - if 'scriptSig' in txin: - script_sig = bfh(txin['scriptSig']) - txinputtype.script_sig = script_sig - - txinputtype.sequence = txin.get('sequence', 0xffffffff - 1) - - inputs.append(txinputtype) - - return inputs - - def tx_outputs(self, derivation, tx, script_gen=SCRIPT_GEN_LEGACY): - outputs = [] - has_change = False - - for _type, address, amount in tx.outputs(): - info = tx.output_info.get(address) - if info is not None and not has_change: - has_change = True # no more than one change address - index, xpubs, m = info - if len(xpubs) == 1: - if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: - script_type = self.types.OutputScriptType.PAYTOWITNESS - elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: - script_type = self.types.OutputScriptType.PAYTOP2SHWITNESS - else: - script_type = self.types.OutputScriptType.PAYTOADDRESS - address_n = self.client_class.expand_path(derivation + "/%d/%d"%index) - txoutputtype = self.types.TxOutputType( - amount = amount, - script_type = script_type, - address_n = address_n, - ) - else: - if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: - script_type = self.types.OutputScriptType.PAYTOWITNESS - elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: - script_type = self.types.OutputScriptType.PAYTOP2SHWITNESS - else: - script_type = self.types.OutputScriptType.PAYTOMULTISIG - address_n = self.client_class.expand_path("/%d/%d"%index) - nodes = map(self.ckd_public.deserialize, xpubs) - pubkeys = [ self.types.HDNodePathType(node=node, address_n=address_n) for node in nodes] - multisig = self.types.MultisigRedeemScriptType( - pubkeys = pubkeys, - signatures = [b''] * len(pubkeys), - m = m) - txoutputtype = self.types.TxOutputType( - multisig = multisig, - amount = amount, - address_n = self.client_class.expand_path(derivation + "/%d/%d"%index), - script_type = script_type) - else: - txoutputtype = self.types.TxOutputType() - txoutputtype.amount = amount - if _type == TYPE_SCRIPT: - txoutputtype.script_type = self.types.OutputScriptType.PAYTOOPRETURN - txoutputtype.op_return_data = address[2:] - elif _type == TYPE_ADDRESS: - txoutputtype.script_type = self.types.OutputScriptType.PAYTOADDRESS - txoutputtype.address = address - - outputs.append(txoutputtype) - - return outputs - - def electrum_tx_to_txtype(self, tx): - t = self.types.TransactionType() - d = deserialize(tx.raw) - t.version = d['version'] - t.lock_time = d['lockTime'] - inputs = self.tx_inputs(tx) - t._extend_inputs(inputs) - for vout in d['outputs']: - o = t._add_bin_outputs() - o.amount = vout['value'] - o.script_pubkey = bfh(vout['scriptPubKey']) - return t - - # This function is called from the trezor libraries (via tx_api) - def get_tx(self, tx_hash): - tx = self.prev_tx[tx_hash] - return self.electrum_tx_to_txtype(tx) diff --git a/plugins/trezor/qt_generic.py b/plugins/trezor/qt_generic.py index f0510e13..808f83a6 100644 --- a/plugins/trezor/qt_generic.py +++ b/plugins/trezor/qt_generic.py @@ -5,7 +5,7 @@ from PyQt5.Qt import Qt from PyQt5.Qt import QGridLayout, QInputDialog, QPushButton from PyQt5.Qt import QVBoxLayout, QLabel from electrum_gui.qt.util import * -from .plugin import TIM_NEW, TIM_RECOVER, TIM_MNEMONIC +from .trezor import TIM_NEW, TIM_RECOVER, TIM_MNEMONIC from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from electrum.i18n import _ @@ -188,13 +188,14 @@ class QtPlugin(QtPluginBase): @hook def receive_menu(self, menu, addrs, wallet): - if type(wallet) is not Standard_Wallet: + if len(addrs) != 1: return - keystore = wallet.get_keystore() - if type(keystore) == self.keystore_class and len(addrs) == 1: - def show_address(): - keystore.thread.add(partial(self.show_address, wallet, addrs[0])) - menu.addAction(_("Show on %s") % self.device, show_address) + for keystore in wallet.get_keystores(): + if type(keystore) == self.keystore_class: + def show_address(): + keystore.thread.add(partial(self.show_address, wallet, keystore, addrs[0])) + menu.addAction(_("Show on {}").format(self.device), show_address) + break def show_settings_dialog(self, window, keystore): device_id = self.choose_device(window, keystore) @@ -292,7 +293,7 @@ class SettingsDialog(WindowModalDialog): their PIN.''' def __init__(self, window, plugin, keystore, device_id): - title = _("%s Settings") % plugin.device + title = _("{} Settings").format(plugin.device) super(SettingsDialog, self).__init__(window, title) self.setMaximumWidth(540) @@ -320,8 +321,11 @@ class SettingsDialog(WindowModalDialog): def update(features): self.features = features set_label_enabled() - bl_hash = bh2u(features.bootloader_hash) - bl_hash = "\n".join([bl_hash[:32], bl_hash[32:]]) + if features.bootloader_hash: + bl_hash = bh2u(features.bootloader_hash) + bl_hash = "\n".join([bl_hash[:32], bl_hash[32:]]) + else: + bl_hash = "N/A" noyes = [_("No"), _("Yes")] endis = [_("Enable Passphrases"), _("Disable Passphrases")] disen = [_("Disabled"), _("Enabled")] @@ -463,9 +467,9 @@ class SettingsDialog(WindowModalDialog): settings_glayout = QGridLayout() # Settings tab - Label - label_msg = QLabel(_("Name this %s. If you have mutiple devices " + label_msg = QLabel(_("Name this {}. If you have mutiple devices " "their labels help distinguish them.") - % plugin.device) + .format(plugin.device)) label_msg.setWordWrap(True) label_label = QLabel(_("Device Label")) label_edit = QLineEdit() @@ -488,7 +492,7 @@ class SettingsDialog(WindowModalDialog): pin_msg = QLabel(_("PIN protection is strongly recommended. " "A PIN is your only protection against someone " "stealing your bitcoins if they obtain physical " - "access to your %s.") % plugin.device) + "access to your {}.").format(plugin.device)) pin_msg.setWordWrap(True) pin_msg.setStyleSheet("color: red") settings_glayout.addWidget(pin_msg, 3, 1, 1, -1) @@ -503,8 +507,8 @@ class SettingsDialog(WindowModalDialog): homescreen_clear_button.clicked.connect(clear_homescreen) homescreen_msg = QLabel(_("You can set the homescreen on your " "device to personalize it. You must " - "choose a %d x %d monochrome black and " - "white image.") % (hs_rows, hs_cols)) + "choose a {} x {} monochrome black and " + "white image.").format(hs_rows, hs_cols)) homescreen_msg.setWordWrap(True) settings_glayout.addWidget(homescreen_label, 4, 0) settings_glayout.addWidget(homescreen_change_button, 4, 1) @@ -547,7 +551,7 @@ class SettingsDialog(WindowModalDialog): clear_pin_button.clicked.connect(clear_pin) clear_pin_warning = QLabel( _("If you disable your PIN, anyone with physical access to your " - "%s device can spend your bitcoins.") % plugin.device) + "{} device can spend your bitcoins.").format(plugin.device)) clear_pin_warning.setWordWrap(True) clear_pin_warning.setStyleSheet("color: red") advanced_glayout.addWidget(clear_pin_button, 0, 2) diff --git a/plugins/trezor/trezor.py b/plugins/trezor/trezor.py index 5ee31d39..322c2da3 100644 --- a/plugins/trezor/trezor.py +++ b/plugins/trezor/trezor.py @@ -1,36 +1,455 @@ -from .plugin import TrezorCompatiblePlugin, TrezorCompatibleKeyStore +import threading + +from binascii import hexlify, unhexlify + +from electrum.util import bfh, bh2u, versiontuple +from electrum.bitcoin import (b58_address_to_hash160, xpub_from_pubkey, + TYPE_ADDRESS, TYPE_SCRIPT) +from electrum import constants +from electrum.i18n import _ +from electrum.plugins import BasePlugin, Device +from electrum.transaction import deserialize +from electrum.keystore import Hardware_KeyStore, is_xpubkey, parse_xpubkey + +from ..hw_wallet import HW_PluginBase -class TrezorKeyStore(TrezorCompatibleKeyStore): +# TREZOR initialization methods +TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY = range(0, 4) + +# script "generation" +SCRIPT_GEN_LEGACY, SCRIPT_GEN_P2SH_SEGWIT, SCRIPT_GEN_NATIVE_SEGWIT = range(0, 3) + + +class TrezorKeyStore(Hardware_KeyStore): hw_type = 'trezor' device = 'TREZOR' -class TrezorPlugin(TrezorCompatiblePlugin): + def get_derivation(self): + return self.derivation + + def get_script_gen(self): + def is_p2sh_segwit(): + return self.derivation.startswith("m/49'/") + + def is_native_segwit(): + return self.derivation.startswith("m/84'/") + + if is_native_segwit(): + return SCRIPT_GEN_NATIVE_SEGWIT + elif is_p2sh_segwit(): + return SCRIPT_GEN_P2SH_SEGWIT + else: + return SCRIPT_GEN_LEGACY + + def get_client(self, force_pair=True): + return self.plugin.get_client(self, force_pair) + + def decrypt_message(self, sequence, message, password): + raise RuntimeError(_('Encryption and decryption are not implemented by {}').format(self.device)) + + def sign_message(self, sequence, message, password): + client = self.get_client() + address_path = self.get_derivation() + "/%d/%d"%sequence + address_n = client.expand_path(address_path) + msg_sig = client.sign_message(self.plugin.get_coin_name(), address_n, message) + return msg_sig.signature + + def sign_transaction(self, tx, password): + if tx.is_complete(): + return + # previous transactions used as inputs + prev_tx = {} + # path of the xpubs that are involved + xpub_path = {} + for txin in tx.inputs(): + pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) + tx_hash = txin['prevout_hash'] + prev_tx[tx_hash] = txin['prev_tx'] + for x_pubkey in x_pubkeys: + if not is_xpubkey(x_pubkey): + continue + xpub, s = parse_xpubkey(x_pubkey) + if xpub == self.get_master_public_key(): + xpub_path[xpub] = self.get_derivation() + + self.plugin.sign_transaction(self, tx, prev_tx, xpub_path) + + +class TrezorPlugin(HW_PluginBase): + # Derived classes provide: + # + # class-static variables: client_class, firmware_URL, handler_class, + # libraries_available, libraries_URL, minimum_firmware, + # wallet_class, ckd_public, types + firmware_URL = 'https://wallet.trezor.io' libraries_URL = 'https://github.com/trezor/python-trezor' minimum_firmware = (1, 5, 2) keystore_class = TrezorKeyStore + minimum_library = (0, 9, 0) + + MAX_LABEL_LEN = 32 + + def __init__(self, parent, config, name): + HW_PluginBase.__init__(self, parent, config, name) + self.main_thread = threading.current_thread() - def __init__(self, *args): try: - from . import client + # Minimal test if python-trezor is installed import trezorlib - import trezorlib.ckd_public - import trezorlib.transport_hid - import trezorlib.messages - self.client_class = client.TrezorClient - self.ckd_public = trezorlib.ckd_public - self.types = trezorlib.messages - self.DEVICE_IDS = (trezorlib.transport_hid.DEV_TREZOR1, trezorlib.transport_hid.DEV_TREZOR2) + try: + library_version = trezorlib.__version__ + except AttributeError: + # python-trezor only introduced __version__ in 0.9.0 + library_version = 'unknown' + if library_version == 'unknown' or \ + versiontuple(library_version) < self.minimum_library: + self.libraries_available_message = ( + _("Library version for '{}' is too old.").format(name) + + '\nInstalled: {}, Needed: {}' + .format(library_version, self.minimum_library)) + self.print_stderr(self.libraries_available_message) + raise ImportError() self.libraries_available = True except ImportError: self.libraries_available = False - TrezorCompatiblePlugin.__init__(self, *args) + return - def hid_transport(self, device): - from trezorlib.transport_hid import HidTransport - return HidTransport.find_by_path(device.path) + from . import client + import trezorlib.ckd_public + import trezorlib.messages + self.client_class = client.TrezorClient + self.ckd_public = trezorlib.ckd_public + self.types = trezorlib.messages + self.DEVICE_IDS = ('TREZOR',) - def bridge_transport(self, d): - from trezorlib.transport_bridge import BridgeTransport - return BridgeTransport(d) + self.device_manager().register_enumerate_func(self.enumerate) + + def enumerate(self): + from trezorlib.device import TrezorDevice + return [Device(d.get_path(), -1, d.get_path(), 'TREZOR', 0) for d in TrezorDevice.enumerate()] + + def create_client(self, device, handler): + from trezorlib.device import TrezorDevice + try: + self.print_error("connecting to device at", device.path) + transport = TrezorDevice.find_by_path(device.path) + except BaseException as e: + self.print_error("cannot connect at", device.path, str(e)) + return None + + if not transport: + self.print_error("cannot connect at", device.path) + return + + self.print_error("connected to device at", device.path) + client = self.client_class(transport, handler, self) + + # Try a ping for device sanity + try: + client.ping('t') + except BaseException as e: + self.print_error("ping failed", str(e)) + return None + + if not client.atleast_version(*self.minimum_firmware): + msg = (_('Outdated {} firmware for device labelled {}. Please ' + 'download the updated firmware from {}') + .format(self.device, client.label(), self.firmware_URL)) + self.print_error(msg) + handler.show_error(msg) + return None + + return client + + def get_client(self, keystore, force_pair=True): + devmgr = self.device_manager() + handler = keystore.handler + with devmgr.hid_lock: + client = devmgr.client_for_keystore(self, handler, keystore, force_pair) + # returns the client for a given keystore. can use xpub + if client: + client.used() + return client + + def get_coin_name(self): + return "Testnet" if constants.net.TESTNET else "Bitcoin" + + def initialize_device(self, device_id, wizard, handler): + # Initialization method + msg = _("Choose how you want to initialize your {}.\n\n" + "The first two methods are secure as no secret information " + "is entered into your computer.\n\n" + "For the last two methods you input secrets on your keyboard " + "and upload them to your {}, and so you should " + "only do those on a computer you know to be trustworthy " + "and free of malware." + ).format(self.device, self.device) + choices = [ + # Must be short as QT doesn't word-wrap radio button text + (TIM_NEW, _("Let the device generate a completely new seed randomly")), + (TIM_RECOVER, _("Recover from a seed you have previously written down")), + (TIM_MNEMONIC, _("Upload a BIP39 mnemonic to generate the seed")), + (TIM_PRIVKEY, _("Upload a master private key")) + ] + def f(method): + import threading + settings = self.request_trezor_init_settings(wizard, method, self.device) + t = threading.Thread(target = self._initialize_device, args=(settings, method, device_id, wizard, handler)) + t.setDaemon(True) + t.start() + wizard.loop.exec_() + wizard.choice_dialog(title=_('Initialize Device'), message=msg, choices=choices, run_next=f) + + def _initialize_device(self, settings, method, device_id, wizard, handler): + item, label, pin_protection, passphrase_protection = settings + + if method == TIM_RECOVER: + # FIXME the PIN prompt will appear over this message + # which makes this unreadable + handler.show_error(_( + "You will be asked to enter 24 words regardless of your " + "seed's actual length. If you enter a word incorrectly or " + "misspell it, you cannot change it or go back - you will need " + "to start again from the beginning.\n\nSo please enter " + "the words carefully!")) + + language = 'english' + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + + if method == TIM_NEW: + strength = 64 * (item + 2) # 128, 192 or 256 + u2f_counter = 0 + skip_backup = False + client.reset_device(True, strength, passphrase_protection, + pin_protection, label, language, + u2f_counter, skip_backup) + elif method == TIM_RECOVER: + word_count = 6 * (item + 2) # 12, 18 or 24 + client.step = 0 + client.recovery_device(word_count, passphrase_protection, + pin_protection, label, language) + elif method == TIM_MNEMONIC: + pin = pin_protection # It's the pin, not a boolean + client.load_device_by_mnemonic(str(item), pin, + passphrase_protection, + label, language) + else: + pin = pin_protection # It's the pin, not a boolean + client.load_device_by_xprv(item, pin, passphrase_protection, + label, language) + wizard.loop.exit(0) + + def setup_device(self, device_info, wizard, purpose): + devmgr = self.device_manager() + device_id = device_info.device.id_ + client = devmgr.client_by_id(device_id) + # fixme: we should use: client.handler = wizard + client.handler = self.create_handler(wizard) + if not device_info.initialized: + self.initialize_device(device_id, wizard, client.handler) + client.get_xpub('m', 'standard') + client.used() + + def get_xpub(self, device_id, derivation, xtype, wizard): + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + client.handler = wizard + xpub = client.get_xpub(derivation, xtype) + client.used() + return xpub + + def sign_transaction(self, keystore, tx, prev_tx, xpub_path): + self.prev_tx = prev_tx + self.xpub_path = xpub_path + client = self.get_client(keystore) + inputs = self.tx_inputs(tx, True, keystore.get_script_gen()) + outputs = self.tx_outputs(keystore.get_derivation(), tx, keystore.get_script_gen()) + signed_tx = client.sign_tx(self.get_coin_name(), inputs, outputs, lock_time=tx.locktime)[1] + raw = bh2u(signed_tx) + tx.update_signatures(raw) + + def show_address(self, wallet, keystore, address): + client = self.get_client(keystore) + if not client.atleast_version(1, 3): + keystore.handler.show_error(_("Your device firmware is too old")) + return + change, index = wallet.get_address_index(address) + derivation = keystore.derivation + address_path = "%s/%d/%d"%(derivation, change, index) + address_n = client.expand_path(address_path) + xpubs = wallet.get_master_public_keys() + if len(xpubs) == 1: + script_gen = keystore.get_script_gen() + if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: + script_type = self.types.InputScriptType.SPENDWITNESS + elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: + script_type = self.types.InputScriptType.SPENDP2SHWITNESS + else: + script_type = self.types.InputScriptType.SPENDADDRESS + client.get_address(self.get_coin_name(), address_n, True, script_type=script_type) + else: + def f(xpub): + node = self.ckd_public.deserialize(xpub) + return self.types.HDNodePathType(node=node, address_n=[change, index]) + pubkeys = wallet.get_public_keys(address) + # sort xpubs using the order of pubkeys + sorted_pubkeys, sorted_xpubs = zip(*sorted(zip(pubkeys, xpubs))) + pubkeys = list(map(f, sorted_xpubs)) + multisig = self.types.MultisigRedeemScriptType( + pubkeys=pubkeys, + signatures=[b''] * wallet.n, + m=wallet.m, + ) + client.get_address(self.get_coin_name(), address_n, True, multisig=multisig) + + def tx_inputs(self, tx, for_sig=False, script_gen=SCRIPT_GEN_LEGACY): + inputs = [] + for txin in tx.inputs(): + txinputtype = self.types.TxInputType() + if txin['type'] == 'coinbase': + prev_hash = "\0"*32 + prev_index = 0xffffffff # signed int -1 + else: + if for_sig: + x_pubkeys = txin['x_pubkeys'] + if len(x_pubkeys) == 1: + x_pubkey = x_pubkeys[0] + xpub, s = parse_xpubkey(x_pubkey) + xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) + txinputtype._extend_address_n(xpub_n + s) + if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: + txinputtype.script_type = self.types.InputScriptType.SPENDWITNESS + elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: + txinputtype.script_type = self.types.InputScriptType.SPENDP2SHWITNESS + else: + txinputtype.script_type = self.types.InputScriptType.SPENDADDRESS + else: + def f(x_pubkey): + if is_xpubkey(x_pubkey): + xpub, s = parse_xpubkey(x_pubkey) + else: + xpub = xpub_from_pubkey(0, bfh(x_pubkey)) + s = [] + node = self.ckd_public.deserialize(xpub) + return self.types.HDNodePathType(node=node, address_n=s) + pubkeys = list(map(f, x_pubkeys)) + multisig = self.types.MultisigRedeemScriptType( + pubkeys=pubkeys, + signatures=list(map(lambda x: bfh(x)[:-1] if x else b'', txin.get('signatures'))), + m=txin.get('num_sig'), + ) + if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: + script_type = self.types.InputScriptType.SPENDWITNESS + elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: + script_type = self.types.InputScriptType.SPENDP2SHWITNESS + else: + script_type = self.types.InputScriptType.SPENDMULTISIG + txinputtype = self.types.TxInputType( + script_type=script_type, + multisig=multisig + ) + # find which key is mine + for x_pubkey in x_pubkeys: + if is_xpubkey(x_pubkey): + xpub, s = parse_xpubkey(x_pubkey) + if xpub in self.xpub_path: + xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) + txinputtype._extend_address_n(xpub_n + s) + break + + prev_hash = unhexlify(txin['prevout_hash']) + prev_index = txin['prevout_n'] + + if 'value' in txin: + txinputtype.amount = txin['value'] + txinputtype.prev_hash = prev_hash + txinputtype.prev_index = prev_index + + if 'scriptSig' in txin: + script_sig = bfh(txin['scriptSig']) + txinputtype.script_sig = script_sig + + txinputtype.sequence = txin.get('sequence', 0xffffffff - 1) + + inputs.append(txinputtype) + + return inputs + + def tx_outputs(self, derivation, tx, script_gen=SCRIPT_GEN_LEGACY): + outputs = [] + has_change = False + + for _type, address, amount in tx.outputs(): + info = tx.output_info.get(address) + if info is not None and not has_change: + has_change = True # no more than one change address + index, xpubs, m = info + if len(xpubs) == 1: + if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: + script_type = self.types.OutputScriptType.PAYTOWITNESS + elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: + script_type = self.types.OutputScriptType.PAYTOP2SHWITNESS + else: + script_type = self.types.OutputScriptType.PAYTOADDRESS + address_n = self.client_class.expand_path(derivation + "/%d/%d"%index) + txoutputtype = self.types.TxOutputType( + amount = amount, + script_type = script_type, + address_n = address_n, + ) + else: + if script_gen == SCRIPT_GEN_NATIVE_SEGWIT: + script_type = self.types.OutputScriptType.PAYTOWITNESS + elif script_gen == SCRIPT_GEN_P2SH_SEGWIT: + script_type = self.types.OutputScriptType.PAYTOP2SHWITNESS + else: + script_type = self.types.OutputScriptType.PAYTOMULTISIG + address_n = self.client_class.expand_path("/%d/%d"%index) + nodes = map(self.ckd_public.deserialize, xpubs) + pubkeys = [ self.types.HDNodePathType(node=node, address_n=address_n) for node in nodes] + multisig = self.types.MultisigRedeemScriptType( + pubkeys = pubkeys, + signatures = [b''] * len(pubkeys), + m = m) + txoutputtype = self.types.TxOutputType( + multisig = multisig, + amount = amount, + address_n = self.client_class.expand_path(derivation + "/%d/%d"%index), + script_type = script_type) + else: + txoutputtype = self.types.TxOutputType() + txoutputtype.amount = amount + if _type == TYPE_SCRIPT: + txoutputtype.script_type = self.types.OutputScriptType.PAYTOOPRETURN + txoutputtype.op_return_data = address[2:] + elif _type == TYPE_ADDRESS: + txoutputtype.script_type = self.types.OutputScriptType.PAYTOADDRESS + txoutputtype.address = address + + outputs.append(txoutputtype) + + return outputs + + def electrum_tx_to_txtype(self, tx): + t = self.types.TransactionType() + if tx is None: + # probably for segwit input and we don't need this prev txn + return t + d = deserialize(tx.raw) + t.version = d['version'] + t.lock_time = d['lockTime'] + inputs = self.tx_inputs(tx) + t._extend_inputs(inputs) + for vout in d['outputs']: + o = t._add_bin_outputs() + o.amount = vout['value'] + o.script_pubkey = bfh(vout['scriptPubKey']) + return t + + # This function is called from the trezor libraries (via tx_api) + def get_tx(self, tx_hash): + tx = self.prev_tx[tx_hash] + return self.electrum_tx_to_txtype(tx) diff --git a/plugins/trustedcoin/qt.py b/plugins/trustedcoin/qt.py index cc0280cf..38d4fa1c 100644 --- a/plugins/trustedcoin/qt.py +++ b/plugins/trustedcoin/qt.py @@ -173,7 +173,7 @@ class Plugin(TrustedCoinPlugin): i += 1 n = wallet.billing_info.get('tx_remaining', 0) - grid.addWidget(QLabel(_("Your wallet has %d prepaid transactions.")%n), i, 0) + grid.addWidget(QLabel(_("Your wallet has {} prepaid transactions.").format(n)), i, 0) vbox.addLayout(Buttons(CloseButton(d))) d.exec_() diff --git a/plugins/trustedcoin/trustedcoin.py b/plugins/trustedcoin/trustedcoin.py index a8f80822..b8e832e6 100644 --- a/plugins/trustedcoin/trustedcoin.py +++ b/plugins/trustedcoin/trustedcoin.py @@ -32,6 +32,7 @@ from urllib.parse import quote import electrum from electrum import bitcoin +from electrum import constants from electrum import keystore from electrum.bitcoin import * from electrum.mnemonic import Mnemonic @@ -40,10 +41,20 @@ from electrum.wallet import Multisig_Wallet, Deterministic_Wallet from electrum.i18n import _ from electrum.plugins import BasePlugin, hook from electrum.util import NotEnoughFunds +from electrum.storage import STO_EV_USER_PW # signing_xpub is hardcoded so that the wallet can be restored from seed, without TrustedCoin's server -signing_xpub = "xpub661MyMwAqRbcGnMkaTx2594P9EDuiEqMq25PM2aeG6UmwzaohgA6uDmNsvSUV8ubqwA3Wpste1hg69XHgjUuCD5HLcEp2QPzyV1HMrPppsL" -billing_xpub = "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU" +def get_signing_xpub(): + if constants.net.TESTNET: + return "tpubD6NzVbkrYhZ4XdmyJQcCPjQfg6RXVUzGFhPjZ7uvRC8JLcS7Hw1i7UTpyhp9grHpak4TyK2hzBJrujDVLXQ6qB5tNpVx9rC6ixijUXadnmY" + else: + return "xpub661MyMwAqRbcGnMkaTx2594P9EDuiEqMq25PM2aeG6UmwzaohgA6uDmNsvSUV8ubqwA3Wpste1hg69XHgjUuCD5HLcEp2QPzyV1HMrPppsL" + +def get_billing_xpub(): + if constants.net.TESTNET: + return "tpubD6NzVbkrYhZ4X11EJFTJujsYbUmVASAYY7gXsEt4sL97AMBdypiH1E9ZVTpdXXEy3Kj9Eqd1UkxdGtvDt5z23DKsh6211CfNJo8bLLyem5r" + else: + return "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU" SEED_PREFIX = version.SEED_PREFIX_2FA @@ -306,7 +317,7 @@ def make_xpub(xpub, s): def make_billing_address(wallet, num): long_id, short_id = wallet.get_user_id() - xpub = make_xpub(billing_xpub, long_id) + xpub = make_xpub(get_billing_xpub(), long_id) version, _, _, _, c, cK = deserialize_xpub(xpub) cK, c = bitcoin.CKD_pub(cK, c, num) return bitcoin.public_key_to_p2pkh(cK) @@ -420,20 +431,22 @@ class TrustedCoinPlugin(BasePlugin): k2 = keystore.from_xpub(xpub2) wizard.request_password(run_next=lambda pw, encrypt: self.on_password(wizard, pw, encrypt, k1, k2)) - def on_password(self, wizard, password, encrypt, k1, k2): + def on_password(self, wizard, password, encrypt_storage, k1, k2): k1.update_password(None, password) - wizard.storage.set_password(password, encrypt) + wizard.storage.set_keystore_encryption(bool(password)) + if encrypt_storage: + wizard.storage.set_password(password, enc_version=STO_EV_USER_PW) wizard.storage.put('x1/', k1.dump()) wizard.storage.put('x2/', k2.dump()) wizard.storage.write() msg = [ - _("Your wallet file is: %s.")%os.path.abspath(wizard.storage.path), + _("Your wallet file is: {}.").format(os.path.abspath(wizard.storage.path)), _("You need to be online in order to complete the creation of " "your wallet. If you generated your seed on an offline " - 'computer, click on "%s" to close this window, move your ' + 'computer, click on "{}" to close this window, move your ' "wallet file to an online computer, and reopen it with " - "Electrum.") % _('Cancel'), - _('If you are online, click on "%s" to continue.') % _('Next') + "Electrum.").format(_('Cancel')), + _('If you are online, click on "{}" to continue.').format(_('Next')) ] msg = '\n\n'.join(msg) wizard.stack = [] @@ -470,7 +483,7 @@ class TrustedCoinPlugin(BasePlugin): else: self.create_keystore(wizard, seed, passphrase) - def on_restore_pw(self, wizard, seed, passphrase, password, encrypt): + def on_restore_pw(self, wizard, seed, passphrase, password, encrypt_storage): storage = wizard.storage xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase) k1 = keystore.from_xprv(xprv1) @@ -481,10 +494,14 @@ class TrustedCoinPlugin(BasePlugin): storage.put('x1/', k1.dump()) storage.put('x2/', k2.dump()) long_user_id, short_id = get_user_id(storage) - xpub3 = make_xpub(signing_xpub, long_user_id) + xpub3 = make_xpub(get_signing_xpub(), long_user_id) k3 = keystore.from_xpub(xpub3) storage.put('x3/', k3.dump()) - storage.set_password(password, encrypt) + + storage.set_keystore_encryption(bool(password)) + if encrypt_storage: + storage.set_password(password, enc_version=STO_EV_USER_PW) + wizard.wallet = Wallet_2fa(storage) wizard.create_addresses() @@ -494,7 +511,7 @@ class TrustedCoinPlugin(BasePlugin): xpub2 = wizard.storage.get('x2/')['xpub'] # Generate third key deterministically. long_user_id, short_id = get_user_id(wizard.storage) - xpub3 = make_xpub(signing_xpub, long_user_id) + xpub3 = make_xpub(get_signing_xpub(), long_user_id) # secret must be sent by the server try: r = server.create(xpub1, xpub2, email) diff --git a/pubkeys/bauerj.asc b/pubkeys/bauerj.asc new file mode 100644 index 00000000..b50bed1b --- /dev/null +++ b/pubkeys/bauerj.asc @@ -0,0 +1,166 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFRL5aABCACgnvbQOPgPeyBolejlFaY279tVUWaBeFEYQ17xfI3xo87Ywb7E +DOq1xsQx6RNGOiriKFWyM41S8lcIu7fOAtfkilWiqUCoapn7bQlDyTl7LPKOQgNA +txIKibKyfmDJ1xyMAcyF8kV+Gav3JgucpBlYjmTdNC3MvI/6MGd4GdxG1l/4aGLc +1xV9a38RvjZnDD0HOfyUGbqE1dY5nEVla0sgMp1h7mSyBebjLkOareidXJxK5N7v +o+/yFidN2BiyKSQLzpftx4OIJx2hWfaTRbn+l1WF35Bu6iYhBtsvrZFZBK1bjc/A +xHTu15kJsS+GuP3v8qH/QB5fcGah44QjM7FdABEBAAG0IEpvaGFubiBCYXVlciA8 +YmF1ZXJqQHR2NHVzZXIuZGU+iQE/BBMBAgApBQJUS/v2AhsDBQkB4MKABwsJCAcD +AgEGFQgCCQoLBBYCAwECHgECF4AACgkQhPG/klsfSE2JAQf7BE7GHWifVHMjiciN +bvS0SQ/hx33hn42Yd/jwYsXsIBuJcJ/81s0sq+O/JRXrhZxSrOx4ekKQ+8tQURvw +42MAXN8QTp9lXno3jPvyTHPLlmW3Ig1wQ31Kh5daKv/dmRTrsgP2aBH0YRLQ28Qr +gRiCEK8Ea1ujoUq6PzmmcRB3waKJm1eIUwEj1iP2rFB5MV+ESDfKXTyUiDpRRma1 +bgj4mKv6vDO0839Ho3tLyGnRYksCcS3XUqYU1nhsROzW+91YWQiD8zfTmnQ+q/t6 +VxXW9aRgq9EY8KZUy7I94f5ETRokhszOxxdv5zZRTKpWyKUt1e8zeLss2krUtJzl +T3GWtokBPwQTAQIAKQIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheABQJWlU5z +BQkEKpxPAAoJEITxv5JbH0hNycIH+wYbhniOrfrmWhgyjWKFqvdhNA9Z1t6DPAqJ +Di4Ow4GBEp6N4RmRrv6WateG/Mva+Fy1x/Rj6PgrJti+9CZUuvrlhCJ3SPQN6Ajr +cwih0QyiFAPRXZ8FVOds93GUKyMy4SzLU/d/OOJ/0MxPCjbWnz6J+0snwzYAykuL +WeB3PIeq3n97MM2XRSDMY3a5/6XpKBK+JPb95MwMbSeh6czqp1Xa96S2iW14Wa/v +4shHXwBgC32Sk6CUu4qidi+w2eGK/tVWRKAffONULFB7cT5sFgm1l4gScxH4GrBH +SsZWilFckkUXxxogh/FY5i60FJ58rLdGntZ8x7sO5lcdHTy5Uo6JAT8EEwECACkC +GwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIXgAUCWKW/OgUJCBxAjAAKCRCE8b+S +Wx9ITZGmB/9CPtyBSOv9hMhf3NouFrrIZfVHW3RDvr0zPtF7Z1JQdQzccXMdboyc +m9kAP4OzkG2uRhJtaTvGuiCd/B9X7xsbI2JkQo67rgQiesByZIuBHwugg/nmGerM +vpApTqljTqd3yVxy68377mFRd2DU9byCyghPGyFMS8RAo5lMEEpk4kicfjSL75la +9W4MAcHM1HZ1h0roqN3Nxwhn4RsD6ssOiGEO4LQUhzsaU4LSYk1OjHb2zvd7UHsV +RNRLlSsj66y7nLuQFcJX0/YyqHWwhyUTKDRN24ifpCO3/HlD4PmO84FdF35b21DG +SE5ZOywtpPSqP6R3gF1qxvSXFLxI7nePiQE/BBMBAgApAhsDBwsJCAcDAgEGFQgC +CQoLBBYCAwECHgECF4AFAlilwkUFCQgcQ5gACgkQhPG/klsfSE0b8Qf+KBY+HW+z +lvZbEzsZ9s/4Er/0InGSHWD8o9K1V2M2woThXlbiZZjvnJQaEzXXjvgdqd2BhAp4 +fPwcd28ww7mVBycDMqffGq4M1xKzwXSXC8oSC+zqP5po7cFppYZi0QnwATtJDdS1 +qBOCx4r6+TXndMP8wlXOAIYVPFPgvsAICOhBfFz/BPx7V/gEWj03TC6P4+chbPfW +B9bFKUUlsW7IqM5nps9GHs/jkCArb29f2UiKEbMSlPzB30uHxqw1cma9CPvYjpXu +5Rnw+nIThBdOhuTcAqBwgBRwI4StMAd2mBEeCUJ8OrR/tQ7BDHXWdgNrQJdybeS1 +tuEwSDm6f52vHbQgSm9oYW5uIEJhdWVyIDxqaG5uLmJyQGdtYWlsLmNvbT6JAT8E +EwECACkFAlRL8mcCGwMFCQHgwoAHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIXgAAK +CRCE8b+SWx9ITYN9B/sHBt/PZ26zsHYu+b8mLGENm7lw2jYgYsde03NWf+dT7a8p +W5c1rt2ENmG2N68a8+aAgMxcn8ZJsXOF/APMmRbHfpHdshGTUMBs2wYaizlAwjYv +nerBfSOvWSZpk7VqI2/+0Q+sYn5w1MjRu60upyEGQVM+ZIftwwrp0FolJdkDgihM +zXcJuwxCSscqF6NsVukSxo1A5gKjJ1V9jvcXi4yUaYhfSw/hUSAjHo4hXeXbJNuA +aBjLiTq+QMQ7d9dAflZCAvd+KsG3BBXuG8IQIz+OxTtdDnFvQQxTPzlcIq5KHI7O +6IdXC+T7Fmf9x0h6QkhFuVS6OB81E0I000d2TMcViQE/BBMBAgApAhsDBwsJCAcD +AgEGFQgCCQoLBBYCAwECHgECF4AFAlaVTnMFCQQqnE8ACgkQhPG/klsfSE177AgA +hUXVzFWHpUXJbsMsdzuZ9d9ts72+NUY/0ilNaL3t6X1GFvKfTDxuc72ivP2W6Eo4 +aYWAHBYQb5a7SphvrknQetIwCM7ll5LZFlvkff0xb8DjLSLfVj4BBiT7N4pBJRsl +2VQoqhdcul+EilXb7bYcPQGIU0ZK2epBbm8VfO0hetQtb4DxT6viuSOmkntMcgHG +7zSgvhOkyZHjlw3sMqAr999xyV0hZRE3vUEHeO3f9L/nZ0msLpLrfKvczKrlHkNI +IHzG80Tm5JzmVtmnc3nVGbskqZgTLgR8sIdNdTBN9j6I03wwvve8BqNaeh3W6I3P +xgVgWxwF7ULLutld6z4mGokBPwQTAQIAKQIbAwcLCQgHAwIBBhUIAgkKCwQWAgMB +Ah4BAheABQJYpb86BQkIHECMAAoJEITxv5JbH0hNBxIH/jCr/qpjflwuWAIojmLQ +i/HOZTssUym36zseOW0BN0pMdbqrinrzSXxrn7C+Yzf/1EZTy1bgE3tI0fmcPOJS +dOCIIqeuMbF3uZ82imYg3aX1t4eaGF2/hnJWn8W054FCmR8iRO0/Ge8bPT8ZO79Z +pvZzY1w31qnOVIflFNJla0+fXhi+2Bys6WpvEdAo6PfUh775RE2bRGO7i08nyJUP +3fLuuWiF7rIrO14lCTBkwBYQUEfN2JbIFfckFJBieZPyirB+EHdHJG3qMZCeefee +o8vkSIX4NfLkHB5qXkdYYwBKlXuVTXwZpD2FyIAuKRcbWJgJ8Uw0sLSRyYDXdlKz +lgSJAT8EEwECACkCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIXgAUCWKXCRQUJ +CBxDmAAKCRCE8b+SWx9ITQtfB/4gzmhMaFp3RE1Swel2G5dMbgfnU+RkutdHWUtN +QPZFzRE7aKDY5dNXU/NyjNgiD9EIrJwgalXo7m9TCBR4jwLqdFwLSQ1IgPNGoyRj +x6IVudLX2apzR2ZDnJCFaJKNxxLH9pIouORk30XsBVPRSyVYJJaksdR8nyae3jNl +LNgHTb9P+mMuMBErrFf9tEWOb4hqO52zTnKCeMdMneL7r1ZZthJhk4nKV7FUWjwZ ++8HEIhiJo2HgTUqdQlgJ+NKQw/FnO4XIJp+97eKD38W3rFjYKLH+gx+a6Ftxn2Hz +rcwKvn59/P3BbkaS+m48nROy2lOIzolNGel8L60OkIAkX8EHtB9Kb2hhbm4gQmF1 +ZXIgPGJhdWVyakBiYXVlcmouZXU+iQE/BBMBAgApAhsDBwsJCAcDAgEGFQgCCQoL +BBYCAwECHgECF4AFAlRL5gMFCQHgwoAACgkQhPG/klsfSE0DBggAkVZPbh84VxGs +lLqhj6FLOJFEP52TPbmNWhKe3C5KT+tWawuBQDcnlmyly9A+fVcW8BE5JnAn/Q+q +bwBZUZCF2tqgR0SHL3f1GOrpwWJ3VbCCodoeG/UFa3XSW9C1klre0m9vISl/NB4L +ga/ILmXy9Y7M4igHGgzxEGdn0jo9X9o0tp3iPwLlO5nAZwL74YlH5ay1e7RsZQ/0 +RJDvrATd9Fuqog5vXFq4xJay9p8/KsMMMeJwh11BsN48DDW/JytB1juTGoTAG4UT +0N8KFOsfKdEuEFJddyQAtS6ZtHKmmDDubYoAHPW0zXzkUXTFNM53xkjJOl0LwVPt +Z/7u7TU7sIkBPwQTAQIAKQIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheABQJW +lU5zBQkEKpxPAAoJEITxv5JbH0hNPcEIAJwDysT3uBCsaoVQvxJB4HOussnvz8hA +xuvB/GoUMF5lg9WUpImNM0iUEoCFWtYUBspPhP6XdVOHOwAUINqJTi+tEQZgRJvv +PD3Y+oXhIV9SzXhVRzPvkRhcU6VVQKd7DqDyZceGGn6CRahRMdDhDWZuEBjb/Std +Ov04GDwNYWSwpz+iU3pP5Ab2dT6oDrxKCLogu3LV2TuhTXypvOhTeFpspfGRacyf +bcVezL+kHT/EbWVp/qZnh5v4AdqxYQulzW3JWzWt2LTdPDO4AsE+2UAse2vyPgGP +//69RXfvrVoW9gilmP5sLuozP1AZ4KnFwOvTrv8BP/sSzUJumUdChR+JAT8EEwEC +ACkCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIXgAUCWKW/OgUJCBxAjAAKCRCE +8b+SWx9ITdmHB/44opEJEEEboquNtYHiyjcvU6PI1jJPIRocE93klBDHfo91UbE3 +NwDp0TfeS6ooje+8Q+nWcTb19EdL+kDLRIj2i8O6amQ4p42ypd/6A04C0MJHM4Mw +9zamihy25+ORtl25BG+qhF57jWn+r828TGgx3PWQbdenacjXm4bkyb7f67HkaEAD +aiB1D0U2lrBaKoVYc4qTSC8mgcdh7hSB6iBMPsuqtriGqTeFsRs3Kl/P3IfWtbdN +VAE9Le5dcllAX0OORolXgvQBBVRWz0LcqdRitRIevcZ902P4Jl4trMq4bel0Spqy +PqNcn+Cswq95nSyLTEwlb+shK1vDs5icNiFriQE/BBMBAgApAhsDBwsJCAcDAgEG +FQgCCQoLBBYCAwECHgECF4AFAlilwkUFCQgcQ5gACgkQhPG/klsfSE10Sgf+JPsZ +5/dW/sDx+W3G0rfRU8PKiKgxvkmm7U4R9UuF1FoPv1iMrBMh/sdOeEwD6A6kZFmB +rXgb+gPToc8Vavmo9QumbNVW6msj403H0oReGxxbbQ++XimTGrGQjLsjGIdmDWJm +o1sZC1bVHMlRUEyaCRtBc5wJUGdo+m6zE6308XiSg9EcFw6ZQo15imevmiSdGSQ3 +ovlA9aJe878bJRy7MbilsDabXeasvUtCZ02zu46VfkbdlH5oDP/tKY2FdinVOED2 +94r2JJUid0chDb2FQW6cZ1WzidBfmJmwUKyMDx/Igmu4pNcYxt5q9KLuvoRMBbRg +ylmG9Uyo0r8dXZCgObQqSm9oYW5uIEJhdWVyIDxqb2hhbm4uYmF1ZXJAdW5pLXJv +c3RvY2suZGU+iQE/BBMBAgApBQJUS/JAAhsDBQkB4MKABwsJCAcDAgEGFQgCCQoL +BBYCAwECHgECF4AACgkQhPG/klsfSE2+dQf9GmR7T30orDcptqjVA+63hiNR8RKi +jJXRi8VsvX0gKacJ3E9o6MBMGWMuJAQ/oR2YYzS8T3vUbtLuvEOq3lkedyu032XO +vDwCuEzs751Y/6YR2mitats3ze7Ey280hqYbq+NjZthFe1Ezr//ZsDYeOBhRGB/Z +SBt7uhVmwc/17AbdrS5xJb+a2VmC5DdYTeR0bdE4A0TRKNQ/9kt9SIQ4aJ8b0ueh +8tXO8PgFUlsvO07N/k9UkAkwWC8kd3FTVNZt5zabRUoy98ygOIiL3YlfIjaBK2xp +n3DF5KRsmKmDtBXKs929KCgAolV8QjMJuZLe+UdynXA35E0gyUDT1j+hhYkBPwQT +AQIAKQIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheABQJWlU5zBQkEKpxPAAoJ +EITxv5JbH0hN2LQH/0nJgXlfI1YAf+mD5JmY4FThzcnud2PpYuIUAZ5bzgMp9KGC +idiuHa0m6HCGvZiQPJ+MEfVfZN0zvysrJhoo5uk6slf9hIaKgWQxaCSkw1pGj+2F +8Qbg9Lx49Be04DKnk8C9KCqzA2vpaD3p6aiXYJ05FB7b19GxT4v0FAQNmI3tR9fu +wrxMK/kl3lQok+I8fwVeWIvwia+DLJJa+Pf2bOrQginXPOrSr/Ysw0ZOJoDvrtXm +I/RVGQnR3kJW29wJXIeQzwFFgjHI3qC9jiQqij6SCgaunGKrfdZ56qe7SwfXcXlp +4C+FmA4tvPHwMHnrb9jXJutY1ECL3darU9QX5iGJAT8EEwECACkCGwMHCwkIBwMC +AQYVCAIJCgsEFgIDAQIeAQIXgAUCWKW/OgUJCBxAjAAKCRCE8b+SWx9ITcAxB/9/ +Zc52sOSeyoyITBJlz2uCXcpvBuQBN7GoVEDmQEP7EBYBy3o5xs7TFbep6dVamzIF +bp0V1TcW8aKk37Jac2WVbpdfBTu44AdLAYuOnLVuSu6sTGGct3tK41Op72RXXVYN +1l8JAFXpHtP32z4t6tq3Tc6Rgr4G2aozYQjOzbgmBcPeZRSz5ubMTIsTDaVZILku +YT8fwvBbRiiOoYfVThWlJxWtz7Xs23TFKwVdBYDyKQWQyvBnpIPKusd+GIjIAR4a ++P1Wujsxu88Mruhxp1iSB1gnbN7hum0MOu/ncEg4r2locX3133LU6t7fbAmleZ66 +uyYofllRyxY3FJrdBtsziQE/BBMBAgApAhsDBwsJCAcDAgEGFQgCCQoLBBYCAwEC +HgECF4AFAlilwkUFCQgcQ5gACgkQhPG/klsfSE34hQf/SPzpAjbpghUnPvYgUsRI +AuQbGZANBgSBNj6K65RNNCz78M/eUdNSqyx/n/wMPLewNW1aJzZDV533ADzckvd5 +l1qfsE6iJlQlTwjlfirmVJ3eKYAS/7gn6Yrked7KjKMzL7E0Ytz6idzSXkDPyPWb +Nl06Q70sU+kEKSEP5Q1W0u3BUOU3t0v4GsMeWK/OlIMUOxoEpj1sVnUFT8RtZBKp +Q9VKZTdOX3TBeEx9O9NjbjTt62SSB1WCH34d0o2GAYLJOEhFNKt92lzaygytfOAt +FY/TBJl/gnqY7CzMFtKgUHttrz98XdI2ze+GqZ2KRMCTfhWStAnwkxgMK0X++jIF +EbQfSm9oYW5uIEJhdWVyIDxiYXVlcmpAYmF1ZXJqLmRlPokBPwQTAQIAKQUCVEwi +OwIbAwUJAeDCgAcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheAAAoJEITxv5JbH0hN +5OoH/0kWbdL7R4sznsrstkU+Z1Gi795M6tzk/1/oSkR8j9tf4B8RX2bSs6tVmHQP +ByTVdKV48b16//k4MmznziJuQmjs8rJvMsxKleD6UTncH0DNzYUxpxhsAGj9ekf9 +UB7uRtQ00DuK+6z+aqfbBh2FgnxtpQrpsLbHvW9WI2DX0zvKmec3WlrhU4lsVwBp +RWUyvAv++PB5ivkm4TBea10nVAy1RvLeBqPolniAW3nE+pTljQeMOMK0L5sDuMvA +fiIiBAjMq1WUGirRmZDWRbgzD86BaVnY3+IB8pCjnG/uxX3lrpz5n+hYYeNt6q5h +P3zixFFrA3W1+h/hBGBZtDV4iAiJAT8EEwECACkCGwMHCwkIBwMCAQYVCAIJCgsE +FgIDAQIeAQIXgAUCVpVObwUJBCqcTwAKCRCE8b+SWx9ITXIqB/oD66hPC7m2g7NA +cAe4sEp0qplr723lhn7fcJ3mBvCHUxUl01lQoKCSGIQX1ilVgd+xjPytPRhUy1Rr +O1z0pldDyJfVazYP7VSq8qwbYNcAeU/efVuE57hlQJ1mlhJ+h3j1qkYL0k9pf23m +Js1amiGb2FO7d0MSClERno4gJJ/BWSa47ZTtM/YJfvp2CV5mOD+LseEsCP4U+Uzd +ONP0mTV4WgX0jdH5kAl7PvXb3g2n72kWuRV3QrTF1PV+3Et1BJinhGU3+YJb4/OB +LnF0cufGiL8DR6A13pbskaFRBxqZs7x90E0lpAqGIz2Z/hy5KnqATUTF/TeDG0zg +goqxX9fxiQE/BBMBAgApAhsDBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AFAlil +vzoFCQgcQIwACgkQhPG/klsfSE2ViAf/a+Ayp4MdDT6zfRIt7RbAx4bdpYe3pWU6 +0jH3b4UJ36LtmqukPvoQzhfQBazwPPmOxnvo4Ias0XTgCx8lbNmLl9tlRbxYvgNx +Nk6/Wtz6h/y9i2TPtzDe9xmeH9/nK0HvaDxWfFTp94LfJqlpYLwpalK6uC7uczh0 +kEl6Y/3pYuEtXb/hk6XjiZWj73gKkrienktHj9lQBsfph8Jjuweym7zRacZaycd0 +CiDOWBStHvq1gDqy1lggne7OPRhWN2Ttp+gEmkSboL1dV+7BDvBhzZ1efhE/DSfk ++2BR8MROCgaAGA8FoZvxlwfKJBCLygCmXUG1pcCvxbmcgN7OK+iw6okBPwQTAQIA +KQIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheABQJYpcJFBQkIHEOYAAoJEITx +v5JbH0hNer0H/j1XV3GiMAzbEQje2oGWss381CJyVnqJFVklpssUgNjRikfbnj4w +06H4BCg3c5rzxVTd4aK4hyWWGH8tHwVhN7tfxLzr3OxnZOI8ftujvdwyOwHSXGJ7 +oad1Nsov7glzHFhkzBCjY1U9UERQ3T+9u+SJOZkhyTsipUIK7JPI0r4r2/A07jsJ +Aj09yREC+Jz+sdtXrEWo+dz1ewamHPkha3HHfkgnw4yWRQ3BxRoxb5xaotlzOuVD +z47oB8Y33FxdpXYZikajTZBPeX28zHjI5FPmkQBQ97sbyZTw5rg59Wg1A1gXV/jQ +N//Q34fhExbcLyeVv4drUkFL5mDXYzFCB/u5AQ0EVEvloAEIAK/PFf19cxUVxu6a +F5GXTqZXvhEszCWfurhPEiloSpoaH0aH31oFgi58KmivH2tworyUG8PeIBOcoUGm +QUFJrXPsnNu3hdFIEkI2eeT1FBezF+newY0S3oOQG5aISgzLu7r3vvbY4JW3AUFA +gVVwJmatBplNPrnoLwG+Nn8oBtOdMMvkOOaHnW3z62I4JLwCnFRG2eDDFYCWsxh5 +Ekh0DgJEdYGXSKIsHPm+UD/18WNG78C8zC9GyUmbsZ3zibc6GmdW3Sh08lNdraAR +S3V6Ty2aKXq6jdi682ehKzAeSvqtr0LEUPsmD5s6g2PhfXCX0Dc/9czmaGPVs05Z +X/3Y/skAEQEAAYkBHwQYAQIACQUCVEvloAIbDAAKCRCE8b+SWx9ITffQB/9Q5AMw +ElZu2g0cE5tfhh0dydN5D9Z3T892lYG3R2EQ/puCrLV8xg9R1/Oe3LYvpxavAeKQ +afmj8BIHYzuGYwMmNRRQEOGTlkisQlFmuAVgPniOf2AEgjwly0Me4eib7CHVIEP+ +tHTU7FzcVw4PPl3PbHKyPNi7MF/LL68xaJthIgzKCQkl7vGkChHJFRwphFinNHAZ +57und85/CMrDMK6/BHAkI+ShwxVGgZIwzOq9pKbaBUVeNWhvAQWl1JBRh+e/CCJT +9hnJJGKUTdUMjIDNfH9mEFEYkAYMH+SATTwTDumdS8ixmMVaSX3E1zblogE3NO2P +T2vtGNK2jhXLDcGeiQElBBgBAgAPAhsMBQJYpcJTBQkIHEOwAAoJEITxv5JbH0hN +mUMH/2roD8oBNjQrhzkT2N0amWa8Wlg0Kyc1qbkEdi57b9PVEAuTmR6AGzIlLcJG +7s8qZHMdyY/Rg62aJkJ+ma1YNF7cK4ALVW0LUjXNiyfTnUSBgwx/QobtMUcE3K+z +4DRLa4QYE28qaweNAA7VKeHSzC9G86BnxGIKvZolRASPW6hwDiUZfHLLdt6jLVwf +b/b7f/2fLQDzQmxm/nwMN+qLAkv/4+vhcKDcMNfAhz5DmuAAg3OrkZEghX54troN +tpb9QxdWdhrgTZ6OocAloqc5aFOsTY5CFqmc5lQupMsVzpXhqLiYA2OXRbh7eQIA +402TZWn+BlhGAFxa+Wzl46MVavI= +=bDjo +-----END PGP PUBLIC KEY BLOCK----- diff --git a/setup.py b/setup.py index f70f3bf3..b5f8dd0a 100755 --- a/setup.py +++ b/setup.py @@ -9,28 +9,18 @@ import platform import imp import argparse +with open('contrib/requirements/requirements.txt') as f: + requirements = f.read().splitlines() + +with open('contrib/requirements/requirements-hw.txt') as f: + requirements_hw = f.read().splitlines() + version = imp.load_source('version', 'lib/version.py') - -def readhere(path): - here = os.path.abspath(os.path.dirname(__file__)) - with open(os.path.join(here, path), 'r') as fd: - return fd.read() - - -def readreqs(path): - return [req for req in - [line.strip() for line in readhere(path).split('\n')] - if req and not req.startswith(('#', '-r'))] - - -install_requires = readreqs('requirements.txt') -tests_requires = install_requires + readreqs('requirements_travis.txt') - if sys.version_info[:3] < (3, 4, 0): sys.exit("Error: Electrum requires Python version >= 3.4.0...") -data_files = [] +data_files = ['contrib/requirements/' + r for r in ['requirements.txt', 'requirements-hw.txt']] if platform.system() in ['Linux', 'FreeBSD', 'DragonFly']: parser = argparse.ArgumentParser() @@ -51,8 +41,10 @@ if platform.system() in ['Linux', 'FreeBSD', 'DragonFly']: setup( name="Electrum-ZCL", version=version.ELECTRUM_VERSION, - install_requires=install_requires, - tests_require=tests_requires, + install_requires=requirements, + extras_require={ + 'hardware': requirements_hw, + }, packages=[ 'electrum', 'electrum_gui',