diff --git a/.travis.yml b/.travis.yml index 1553f479b..cce16258f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -dist: xenial +dist: bionic language: python python: - 3.6 @@ -6,15 +6,10 @@ python: - 3.8 git: depth: false -addons: - apt: - sources: - - sourceline: 'ppa:tah83/secp256k1' - packages: - - libsecp256k1-0 before_install: - git tag install: + - sudo apt-get -y install libsecp256k1-0 - pip install -r contrib/requirements/requirements-travis.txt cache: - pip: true @@ -31,11 +26,12 @@ jobs: language: python python: 3.7 before_install: - - sudo add-apt-repository -y ppa:bitcoin/bitcoin + - sudo add-apt-repository -y ppa:luke-jr/bitcoincore - sudo apt-get -qq update - sudo apt-get install -yq bitcoind install: - - pip install -r contrib/requirements/requirements.txt + - sudo apt-get -y install libsecp256k1-0 + - pip install .[tests] - pip install electrumx before_script: - electrum/tests/regtest/start_bitcoind.sh @@ -48,7 +44,7 @@ jobs: install: pip install flake8 script: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - stage: binary builds - if: branch = master + if: (branch = master) OR (tag IS present) name: "Windows build" language: c python: false @@ -61,7 +57,7 @@ jobs: script: - sudo docker run --name electrum-wine-builder-cont -v $PWD:/opt/wine64/drive_c/electrum --rm --workdir /opt/wine64/drive_c/electrum/contrib/build-wine electrum-wine-builder-img ./build.sh after_success: true - - if: branch = master + - if: (branch = master) OR (tag IS present) name: "Android build" language: python python: 3.7 @@ -70,18 +66,18 @@ jobs: install: - pip install requests && ./contrib/pull_locale - ./contrib/make_packages - - sudo docker build --no-cache -t electrum-android-builder-img electrum/gui/kivy/tools + - sudo docker build --no-cache -t electrum-android-builder-img contrib/android script: - sudo chown -R 1000:1000 . # Output something every minute or Travis kills the job - while sleep 60; do echo "=====[ $SECONDS seconds still running ]====="; done & - - sudo docker run -it -u 1000:1000 --rm --name electrum-android-builder-cont --env CI=true -v $PWD:/home/user/wspace/electrum --workdir /home/user/wspace/electrum electrum-android-builder-img ./contrib/make_apk + - sudo docker run -it -u 1000:1000 --rm --name electrum-android-builder-cont --env CI=true -v $PWD:/home/user/wspace/electrum --workdir /home/user/wspace/electrum electrum-android-builder-img ./contrib/android/make_apk # kill background sleep loop - kill %1 - ls -la bin - if [ $(ls bin | grep -c Electrum-*) -eq 0 ]; then exit 1; fi after_success: true - - if: branch = master + - if: (branch = master) OR (tag IS present) name: "MacOS build" os: osx language: c @@ -93,7 +89,7 @@ jobs: script: ./contrib/osx/make_osx after_script: ls -lah dist && md5 dist/* after_success: true - - if: branch = master + - if: (branch = master) OR (tag IS present) name: "AppImage build" language: c python: false @@ -104,6 +100,26 @@ jobs: script: - sudo docker run --name electrum-appimage-builder-cont -v $PWD:/opt/electrum --rm --workdir /opt/electrum/contrib/build-linux/appimage electrum-appimage-builder-img ./build.sh after_success: true + - if: (branch = master) OR (tag IS present) + name: "tarball build" + language: c + python: false + services: + - docker + before_install: + # hack: travis already cloned the repo, but we re-clone now, as we need to have umask set BEFORE cloning + - umask 0022 + - mkdir fresh_clone && cd fresh_clone + - git clone https://github.com/$TRAVIS_REPO_SLUG.git && cd electrum + - if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then git fetch origin pull/$TRAVIS_PULL_REQUEST/merge; fi + - git checkout $TRAVIS_COMMIT + - echo "Second git clone ready at $PWD" + install: + - sudo docker build --no-cache -t electrum-sdist-builder-img ./contrib/build-linux/sdist/ + script: + - echo "Building sdist at $PWD" + - sudo docker run --name electrum-sdist-builder-cont -v $PWD:/opt/electrum --rm --workdir /opt/electrum/contrib/build-linux/sdist electrum-sdist-builder-img ./build.sh + after_success: true - stage: release check install: - git fetch --all --tags diff --git a/README.rst b/README.rst index 72b08ba84..8abd4ba66 100644 --- a/README.rst +++ b/README.rst @@ -2,31 +2,51 @@ LBRY Vault - Lightweight LBRY Credit client ===================================== Guides =============== -Guide for Ledger devices - +Guide for Ledger devices - https://kodxana.github.io/LBRY-Vault-website/ Getting started =============== -LBRY Vault itself is pure Python, and so are most of the required dependencies. +LBRY Vault itself is pure Python, and so are most of the required dependencies, +but not everything. The following sections describe how to run from source, but here +is a TL;DR:: -Non-python dependencies ------------------------ + sudo apt-get install libsecp256k1-0 + python3 -m pip install --user .[gui,crypto] + + +Not pure-python dependencies +---------------------------- If you want to use the Qt interface, install the Qt dependencies:: sudo apt-get install python3-pyqt5 -For elliptic curve operations, libsecp256k1 is a required dependency:: +For elliptic curve operations, `libsecp256k1`_ is a required dependency:: sudo apt-get install libsecp256k1-0 Alternatively, when running from a cloned repository, a script is provided to build libsecp256k1 yourself:: - + sudo apt-get install automake libtool ./contrib/make_libsecp256k1.sh + Due to the need for fast symmetric ciphers, either one of `pycryptodomex`_ + or `cryptography`_ is required. Install from your package manager + (or from pip):: + + sudo apt-get install python3-cryptography + + + If you would like hardware wallet support, see `this`_. + + .. _libsecp256k1: https://github.com/bitcoin-core/secp256k1 + .. _pycryptodomex: https://github.com/Legrandin/pycryptodome + .. _cryptography: https://github.com/pyca/cryptography + .. _this: https://github.com/spesmilo/electrum-docs/blob/master/hardware-linux.rst + Running from tar.gz ------------------- @@ -45,9 +65,8 @@ You can also install Electrum on your system, by running this command:: This will download and install the Python dependencies used by LBRY Vault instead of using the 'packages' directory. -If you cloned the git repository, you need to compile extra files -before you can run LBRY Vault. Read the next section, "Development -version". +It will also place an executable named :code:`electrum` in :code:`~/.local/bin`, +so make sure that is on your :code:`PATH` variable. Development version @@ -61,7 +80,7 @@ Check out the code from GitHub:: Run install (this should install dependencies):: - python3 -m pip install --user . + python3 -m pip install --user -e . Compile the protobuf description file:: @@ -74,6 +93,9 @@ Create translations (optional):: sudo apt-get install python-requests gettext ./contrib/pull_locale + Finally, to start Electrum:: + + ./run_electrum @@ -83,7 +105,7 @@ Creating Binaries Linux (tarball) --------------- -See :code:`contrib/build-linux/README.md`. +See :code:`contrib/build-linux/sdist/README.md`. Linux (AppImage) @@ -107,4 +129,4 @@ See :code:`contrib/build-wine/README.md`. Android ------- -See :code:`electrum/gui/kivy/Readme.md`. +See :code:`contrib/android/Readme.md`. diff --git a/contrib/build-linux/appimage/Dockerfile b/contrib/build-linux/appimage/Dockerfile index f76258b23..e66ea6827 100644 --- a/contrib/build-linux/appimage/Dockerfile +++ b/contrib/build-linux/appimage/Dockerfile @@ -1,29 +1,30 @@ -FROM ubuntu:16.04@sha256:97b54e5692c27072234ff958a7442dde4266af21e7b688e7fca5dc5acc8ed7d9 +ROM ubuntu:16.04@sha256:a4fc0c40360ff2224db3a483e5d80e9164fe3fdce2a8439d2686270643974632 ENV LC_ALL=C.UTF-8 LANG=C.UTF-8 RUN apt-get update -q && \ apt-get install -qy \ - git=1:2.7.4-0ubuntu1.7 \ + git=1:2.7.4-0ubuntu1.9 \ wget=1.17.1-1ubuntu1.5 \ make=4.1-6 \ autotools-dev=20150820.1 \ autoconf=2.69-9 \ libtool=2.4.6-0.1 \ xz-utils=5.1.1alpha+20120614-2ubuntu2 \ - libssl-dev=1.0.2g-1ubuntu4.15 \ - libssl1.0.0=1.0.2g-1ubuntu4.15 \ - openssl=1.0.2g-1ubuntu4.15 \ + libssl-dev=1.0.2g-1ubuntu4.16 \ + libssl1.0.0=1.0.2g-1ubuntu4.16 \ + openssl=1.0.2g-1ubuntu4.16 \ zlib1g-dev=1:1.2.8.dfsg-2ubuntu4.3 \ libffi-dev=3.2.1-4 \ libncurses5-dev=6.0+20160213-1ubuntu1 \ - libsqlite3-dev=3.11.0-1ubuntu1.3 \ + libsqlite3-dev=3.11.0-1ubuntu1.5 \ libusb-1.0-0-dev=2:1.0.20-1 \ - libudev-dev=229-4ubuntu21.27 \ + libudev-dev=229-4ubuntu21.28 \ gettext=0.19.7-2ubuntu3.1 \ libzbar0=0.10+doc-10ubuntu1 \ - libdbus-1-3=1.10.6-1ubuntu3.4 \ + libdbus-1-3=1.10.6-1ubuntu3.6 \ libxkbcommon-x11-0=0.5.0-1ubuntu2.1 \ + libc6-dev=2.23-0ubuntu11.2 \ && \ rm -rf /var/lib/apt/lists/* && \ apt-get autoremove -y && \ diff --git a/contrib/build-linux/appimage/README.md b/contrib/build-linux/appimage/README.md index cccc884da..774ccf312 100644 --- a/contrib/build-linux/appimage/README.md +++ b/contrib/build-linux/appimage/README.md @@ -61,6 +61,13 @@ diff sha256sum1 sha256sum2 > d cat d ``` +For file metadata, e.g. timestamps: +``` +rsync -n -a -i --delete squashfs-root1/ squashfs-root2/ +``` + + + Useful binary comparison tools: - vbindiff - diffoscope diff --git a/contrib/build-linux/appimage/build.sh b/contrib/build-linux/appimage/build.sh index f69512d69..b4071f794 100644 --- a/contrib/build-linux/appimage/build.sh +++ b/contrib/build-linux/appimage/build.sh @@ -13,7 +13,7 @@ CACHEDIR="$CONTRIB_APPIMAGE/.cache/appimage" export GCC_STRIP_BINARIES="1" # pinned versions -PYTHON_VERSION=3.7.6 +PYTHON_VERSION=3.7.7 PKG2APPIMAGE_COMMIT="eb8f3acdd9f11ab19b78f5cb15daa772367daf15" SQUASHFSKIT_COMMIT="ae0d656efa2d0df2fcac795b6823b44462f19386" @@ -38,7 +38,7 @@ download_if_not_exist "$CACHEDIR/appimagetool" "https://github.com/AppImage/AppI verify_hash "$CACHEDIR/appimagetool" "d918b4df547b388ef253f3c9e7f6529ca81a885395c31f619d9aaf7030499a13" download_if_not_exist "$CACHEDIR/Python-$PYTHON_VERSION.tar.xz" "https://www.python.org/ftp/python/$PYTHON_VERSION/Python-$PYTHON_VERSION.tar.xz" -verify_hash "$CACHEDIR/Python-$PYTHON_VERSION.tar.xz" "55a2cce72049f0794e9a11a84862e9039af9183603b78bc60d89539f82cf533f" +verify_hash "$CACHEDIR/Python-$PYTHON_VERSION.tar.xz" "06a0a9f1bf0d8cd1e4121194d666c4e28ddae4dd54346de6c343206599f02136" @@ -71,7 +71,7 @@ info "Building squashfskit" git clone "https://github.com/squashfskit/squashfskit.git" "$BUILDDIR/squashfskit" ( cd "$BUILDDIR/squashfskit" - git checkout "$SQUASHFSKIT_COMMIT" + git checkout "${SQUASHFSKIT_COMMIT}^{commit}" make -C squashfs-tools mksquashfs || fail "Could not build squashfskit" ) MKSQUASHFS="$BUILDDIR/squashfskit/squashfs-tools/mksquashfs" @@ -206,13 +206,11 @@ rm -rf "$PYDIR"/site-packages/PyQt5/Qt.so # these are deleted as they were not deterministic; and are not needed anyway find "$APPDIR" -path '*/__pycache__*' -delete -# note that jsonschema-*.dist-info is needed by that package as it uses 'pkg_resources.get_distribution' -# also, see https://gitlab.com/python-devs/importlib_metadata/issues/71 -for f in "$PYDIR"/site-packages/jsonschema-*.dist-info; do mv "$f" "$(echo "$f" | sed s/\.dist-info/\.dist-info2/)"; done +# note that *.dist-info is needed by certain packages. +# e.g. see https://gitlab.com/python-devs/importlib_metadata/issues/71 for f in "$PYDIR"/site-packages/importlib_metadata-*.dist-info; do mv "$f" "$(echo "$f" | sed s/\.dist-info/\.dist-info2/)"; done rm -rf "$PYDIR"/site-packages/*.dist-info/ rm -rf "$PYDIR"/site-packages/*.egg-info/ -for f in "$PYDIR"/site-packages/jsonschema-*.dist-info2; do mv "$f" "$(echo "$f" | sed s/\.dist-info2/\.dist-info/)"; done for f in "$PYDIR"/site-packages/importlib_metadata-*.dist-info2; do mv "$f" "$(echo "$f" | sed s/\.dist-info2/\.dist-info/)"; done diff --git a/contrib/build-linux/appimage/sdist/Dockerfile b/contrib/build-linux/appimage/sdist/Dockerfile new file mode 100644 index 000000000..b7391f379 --- /dev/null +++ b/contrib/build-linux/appimage/sdist/Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:20.04@sha256:5747316366b8cc9e3021cd7286f42b2d6d81e3d743e2ab571f55bcd5df788cc8 + +ENV LC_ALL=C.UTF-8 LANG=C.UTF-8 +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -q && \ + apt-get install -qy \ + git \ + gettext \ + python3 \ + python3-pip \ + python3-setuptools \ + faketime \ + && \ + rm -rf /var/lib/apt/lists/* && \ + apt-get autoremove -y && \ + apt-get clean diff --git a/contrib/build-linux/appimage/sdist/README.md b/contrib/build-linux/appimage/sdist/README.md new file mode 100644 index 000000000..20aef2b56 --- /dev/null +++ b/contrib/build-linux/appimage/sdist/README.md @@ -0,0 +1,52 @@ +Source tarballs +=============== + +✓ _This file should be reproducible, meaning you should be able to generate + distributables that match the official releases._ + +This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another +similar system. The docker commands should be executed in the project's root +folder. + +1. Install Docker + + ``` + $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + $ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" + $ sudo apt-get update + $ sudo apt-get install -y docker-ce + ``` + +2. Build image + + ``` + $ sudo docker build -t electrum-sdist-builder-img contrib/build-linux/sdist + ``` + +3. Build source tarballs + + It's recommended to build from a fresh clone + (but you can skip this if reproducibility is not necessary). + + ``` + $ FRESH_CLONE=contrib/build-linux/sdist/fresh_clone && \ + sudo rm -rf $FRESH_CLONE && \ + umask 0022 && \ + mkdir -p $FRESH_CLONE && \ + cd $FRESH_CLONE && \ + git clone https://github.com/spesmilo/electrum.git && \ + cd electrum + ``` + + And then build from this directory: + ``` + $ git checkout $REV + $ sudo docker run -it \ + --name electrum-sdist-builder-cont \ + -v $PWD:/opt/electrum \ + --rm \ + --workdir /opt/electrum/contrib/build-linux/sdist \ + electrum-sdist-builder-img \ + ./build.sh + ``` +4. The generated distributables are in `./dist`. diff --git a/contrib/build-linux/appimage/sdist/build.sh b/contrib/build-linux/appimage/sdist/build.sh new file mode 100644 index 000000000..7e7109192 --- /dev/null +++ b/contrib/build-linux/appimage/sdist/build.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.." +CONTRIB="$PROJECT_ROOT/contrib" +CONTRIB_SDIST="$CONTRIB/build-linux/sdist" +DISTDIR="$PROJECT_ROOT/dist" + +. "$CONTRIB"/build_tools_util.sh + +# note that at least py3.7 is needed, to have https://bugs.python.org/issue30693 +python3 --version || fail "python interpreter not found" + +# upgrade to modern pip so that it knows the flags we need. +# we will then install a pinned version of pip as part of requirements-sdist-build +python3 -m pip install --upgrade pip + +info "Installing pinned requirements." +python3 -m pip install --no-dependencies --no-warn-script-location -r "$CONTRIB"/deterministic-build/requirements-sdist-build.txt + + +"$CONTRIB"/make_packages || fail "make_packages failed" + +"$CONTRIB_SDIST"/make_tgz || fail "make_tgz failed" + + +info "done." +ls -la "$DISTDIR" +sha256sum "$DISTDIR"/* diff --git a/contrib/build-linux/appimage/sdist/make_tgz b/contrib/build-linux/appimage/sdist/make_tgz new file mode 100644 index 000000000..8b14f3903 --- /dev/null +++ b/contrib/build-linux/appimage/sdist/make_tgz @@ -0,0 +1,47 @@ +#!/bin/bash + +set -e + +ONTRIB_SDIST="$(dirname "$(readlink -e "$0")")" +CONTRIB="$CONTRIB_SDIST"/../.. +ROOT_FOLDER="$CONTRIB"/.. +PACKAGES="$ROOT_FOLDER"/packages/ +LOCALE="$ROOT_FOLDER"/electrum/locale/ + +if [ ! -d "$PACKAGES" ]; then + echo "Run make_packages first!" + exit 1 +fi + +git submodule update --init + +( + rm -rf "$LOCALE" + cd "$CONTRIB/deterministic-build/electrum-locale/" + if ! which msgfmt > /dev/null 2>&1; then + echo "Please install gettext" + exit 1 + fi + for i in ./locale/*; do + dir="$ROOT_FOLDER"/electrum/$i/LC_MESSAGES + mkdir -p $dir + msgfmt --output-file=$dir/electrum.mo $i/electrum.po || true + cp $i/electrum.po "$ROOT_FOLDER"/electrum/$i/electrum.po + done +) + +( + cd "$ROOT_FOLDER" + + echo "'git clean -fd' would delete the following files: >>>" + git clean -fd --dry-run + echo "<<<" + + # we could build the kivy atlas potentially? + #(cd electrum/gui/kivy/; make theming) || echo "building kivy atlas failed! skipping." + + find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} + + + # note: .zip sdists would not be reproducible due to https://bugs.python.org/issue40963 + TZ=UTC faketime -f '2000-11-11 11:11:11' python3 setup.py --quiet sdist --format=gztar +) diff --git a/contrib/build-wine/Dockerfile b/contrib/build-wine/Dockerfile index 20c0efe9d..c1a204107 100644 --- a/contrib/build-wine/Dockerfile +++ b/contrib/build-wine/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:18.04@sha256:5f4bdc3467537cbbe563e80db2c3ec95d548a9145d64453b06939c4592d67b6d +FROM ubuntu:18.04@sha256:b58746c8a89938b8c9f5b77de3b8cf1fe78210c696ab03a1442e235eea65d84f ENV LC_ALL=C.UTF-8 LANG=C.UTF-8 @@ -13,7 +13,7 @@ RUN dpkg --add-architecture i386 && \ RUN apt-get update -q && \ apt-get install -qy \ - git=1:2.17.1-1ubuntu0.5 \ + git=1:2.17.1-1ubuntu0.7 \ p7zip-full=16.02+dfsg-6 \ make=4.1-9.1ubuntu1 \ mingw-w64=5.0.3-1 \ diff --git a/contrib/build-wine/build-electrum-git.sh b/contrib/build-wine/build-electrum-git.sh index d30df64c8..cf5e1797a 100644 --- a/contrib/build-wine/build-electrum-git.sh +++ b/contrib/build-wine/build-electrum-git.sh @@ -52,11 +52,6 @@ info "Pip installing Electrum. This might take a long time if the project folder $PYTHON -m pip install --no-dependencies --no-warn-script-location . popd - -# these are deleted as they were not deterministic; and are not needed anyway -rm "$WINEPREFIX"/drive_c/python3/Lib/site-packages/jsonschema-*.dist-info/RECORD - - rm -rf dist/ # build standalone and portable versions diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec index a5c225678..41b9f8cac 100644 --- a/contrib/build-wine/deterministic.spec +++ b/contrib/build-wine/deterministic.spec @@ -16,19 +16,16 @@ home = 'C:\\electrum\\' # see https://github.com/pyinstaller/pyinstaller/issues/2005 hiddenimports = [] +hiddenimports += collect_submodules('pkg_resources') # workaround for https://github.com/pypa/setuptools/issues/1963 hiddenimports += collect_submodules('trezorlib') hiddenimports += collect_submodules('safetlib') hiddenimports += collect_submodules('btchip') hiddenimports += collect_submodules('keepkeylib') hiddenimports += collect_submodules('websocket') hiddenimports += collect_submodules('ckcc') +hiddenimports += collect_submodules('bitbox02') hiddenimports += ['PyQt5.QtPrintSupport'] # needed by Revealer -# safetlib imports PyQt5.Qt. We use a local updated copy of pinmatrix.py until they -# release a new version that includes https://github.com/archos-safe-t/python-safet/commit/b1eab3dba4c04fdfc1fcf17b66662c28c5f2380e -hiddenimports.remove('safetlib.qt.pinmatrix') - - binaries = [] # Workaround for "Retro Look": @@ -39,6 +36,7 @@ binaries += [('C:/tmp/libusb-1.0.dll', '.')] datas = [ (home+'electrum/*.json', 'electrum'), + (home+'electrum/lnwire/*.csv', 'electrum/lnwire'), (home+'electrum/wordlist/english.txt', 'electrum/wordlist'), (home+'electrum/locale', 'electrum/locale'), (home+'electrum/plugins', 'electrum/plugins'), @@ -50,8 +48,7 @@ datas += collect_data_files('safetlib') datas += collect_data_files('btchip') datas += collect_data_files('keepkeylib') datas += collect_data_files('ckcc') -datas += collect_data_files('jsonrpcserver') -datas += collect_data_files('jsonrpcclient') +datas += collect_data_files('bitbox02') # 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+'run_electrum', diff --git a/contrib/build-wine/prepare-wine.sh b/contrib/build-wine/prepare-wine.sh index 555c657e9..7d31ddbd9 100644 --- a/contrib/build-wine/prepare-wine.sh +++ b/contrib/build-wine/prepare-wine.sh @@ -10,14 +10,14 @@ ZBAR_URL=https://sourceforge.net/projects/zbarw/files/$ZBAR_FILENAME/download ZBAR_SHA256=177e32b272fa76528a3af486b74e9cb356707be1c5ace4ed3fcee9723e2c2c02 LIBUSB_REPO="https://github.com/libusb/libusb.git" -LIBUSB_COMMIT=e782eeb2514266f6738e242cdcb18e3ae1ed06fa +LIBUSB_COMMIT="e782eeb2514266f6738e242cdcb18e3ae1ed06fa" # ^ tag v1.0.23 PYINSTALLER_REPO="https://github.com/SomberNight/pyinstaller.git" -PYINSTALLER_COMMIT=e934539374e30d1500fcdbe8e4eb0860413935b2 +PYINSTALLER_COMMIT="e934539374e30d1500fcdbe8e4eb0860413935b2" # ^ tag 3.6, plus a custom commit that fixes cross-compilation with MinGW -PYTHON_VERSION=3.6.8 +PYTHON_VERSION=3.7.7 ## These settings probably don't need change export WINEPREFIX=/opt/wine64 @@ -88,7 +88,7 @@ info "Compiling libusb..." git init git remote add origin $LIBUSB_REPO git fetch --depth 1 origin $LIBUSB_COMMIT - git checkout -b pinned FETCH_HEAD + git checkout -b pinned "${LIBUSB_COMMIT}^{commit}" echo "libusb_1_0_la_LDFLAGS += -Wc,-static" >> libusb/Makefile.am ./bootstrap.sh || fail "Could not bootstrap libusb" host="i686-w64-mingw32" @@ -119,7 +119,7 @@ info "Building PyInstaller." git init git remote add origin $PYINSTALLER_REPO git fetch --depth 1 origin $PYINSTALLER_COMMIT - git checkout -b pinned FETCH_HEAD + git checkout -b pinned "${PYINSTALLER_COMMIT}^{commit}" rm -fv PyInstaller/bootloader/Windows-*/run*.exe || true # add reproducible randomness. this ensures we build a different bootloader for each commit. # if we built the same one for all releases, that might also get anti-virus false positives diff --git a/contrib/build-wine/sign.sh b/contrib/build-wine/sign.sh index 724b13dd1..83cb7196a 100644 --- a/contrib/build-wine/sign.sh +++ b/contrib/build-wine/sign.sh @@ -23,6 +23,7 @@ echo "Found $(ls *.exe | wc -w) files to sign." for f in $(ls *.exe); do echo "Signing $f..." osslsigncode sign \ + -h sha256 \ -certs "$CERT_FILE" \ -key "$KEY_FILE" \ -n "Electrum" \ diff --git a/contrib/deterministic-build/requirements-binaries.txt b/contrib/deterministic-build/requirements-binaries.txt index bf47f44b4..b01ae2b6e 100644 --- a/contrib/deterministic-build/requirements-binaries.txt +++ b/contrib/deterministic-build/requirements-binaries.txt @@ -1,27 +1,64 @@ -pip==19.3.1 \ - --hash=sha256:21207d76c1031e517668898a6b46a9fb1501c7a4710ef5dfd6a40ad9e6757ea7 \ - --hash=sha256:6917c65fc3769ecdc61405d3dfd97afdedd75808d200b2838d7d961cebc0c2c7 -PyQt5==5.11.3 \ - --hash=sha256:517e4339135c4874b799af0d484bc2e8c27b54850113a68eec40a0b56534f450 \ - --hash=sha256:ac1eb5a114b6e7788e8be378be41c5e54b17d5158994504e85e43b5fca006a39 \ - --hash=sha256:d2309296a5a79d0a1c0e6c387c30f0398b65523a6dcc8a19cc172e46b949e00d \ - --hash=sha256:e85936bae1581bcb908847d2038e5b34237a5e6acc03130099a78930770e7ead -PyQt5-sip==4.19.13 \ - --hash=sha256:125f77c087572c9272219cda030a63c2f996b8507592b2a54d7ef9b75f9f054d \ - --hash=sha256:14c37b06e3fb7c2234cb208fa461ec4e62b4ba6d8b32ca3753c0b2cfd61b00e3 \ - --hash=sha256:1cb2cf52979f9085fc0eab7e0b2438eb4430d4aea8edec89762527e17317175b \ - --hash=sha256:4babef08bccbf223ec34464e1ed0a23caeaeea390ca9a3529227d9a57f0d6ee4 \ - --hash=sha256:53cb9c1208511cda0b9ed11cffee992a5a2f5d96eb88722569b2ce65ecf6b960 \ - --hash=sha256:549449d9461d6c665cbe8af4a3808805c5e6e037cd2ce4fd93308d44a049bfac \ - --hash=sha256:5f5b3089b200ff33de3f636b398e7199b57a6b5c1bb724bdb884580a072a14b5 \ - --hash=sha256:a4d9bf6e1fa2dd6e73f1873f1a47cee11a6ba0cf9ba8cf7002b28c76823600d0 \ - --hash=sha256:a4ee6026216f1fbe25c8847f9e0fbce907df5b908f84816e21af16ec7666e6fe \ - --hash=sha256:a91a308a5e0cc99de1e97afd8f09f46dd7ca20cfaa5890ef254113eebaa1adff \ - --hash=sha256:b0342540da479d2713edc68fb21f307473f68da896ad5c04215dae97630e0069 \ - --hash=sha256:f997e21b4e26a3397cb7b255b8d1db5b9772c8e0c94b6d870a5a0ab5c27eacaa -setuptools==42.0.2 \ - --hash=sha256:c5b372090d7c8709ce79a6a66872a91e518f7d65af97fca78135e1cb10d4b940 \ - --hash=sha256:c8abd0f3574bc23afd2f6fd2c415ba7d9e097c8a99b845473b0d957ba1e2dac6 -wheel==0.33.6 \ - --hash=sha256:10c9da68765315ed98850f8e048347c3eb06dd81822dc2ab1d4fde9dc9702646 \ - --hash=sha256:f4da1763d3becf2e2cd92a14a7c920f0f00eca30fdde9ea992c836685b9faf28 +pip==20.1.1 \ + --hash=sha256:27f8dc29387dd83249e06e681ce087e6061826582198a425085e0bf4c1cf3a55 \ + --hash=sha256:b27c4dedae8c41aa59108f2fa38bf78e0890e590545bc8ece7cdceb4ba60f6e4 +pycryptodomex==3.9.7 \ + --hash=sha256:1537d2d15b604b303aef56e7f440895a1c81adbee786b91f1f06eddc34da5314 \ + --hash=sha256:1d20ab8369b7558168fc014a0745c678613f9f486dae468cca2d68145196b8a4 \ + --hash=sha256:1ecc9db7409db67765eb008e558879d298406642d33ade43a6488224d23e8081 \ + --hash=sha256:37033976f72af829fe15f7fe5fe1dbed308cc43a98d9dd9d2a0a76de8ca5ee78 \ + --hash=sha256:3c3dd9d4c9c1e279d3945ae422895c901f98987333acc132dc094faf52afec35 \ + --hash=sha256:3c9b3fba037ea52c626060c5a87ee6de7e86c99e8a7c6ee07302539985d2bd64 \ + --hash=sha256:45ee555fc5e28c119a46d44ce373f5237e54a35c61b750fb3a94446b09855dbc \ + --hash=sha256:4c93038ac011b36512cb0bf2ee3e2aec774e8bc81021d015917c89fe02bb0ee5 \ + --hash=sha256:50163324834edd0c9ce3e4512ded3e221c969086e10fdd5d3fdcaadac5e24a78 \ + --hash=sha256:59b0ea9cda5490f924771456912a225d8d9e678891f9f986661af718534719b2 \ + --hash=sha256:5cf306a17cccc327a33cdc3845629fa13f4573a4ec620ed607c79cf6785f2e27 \ + --hash=sha256:5fff8da399af16a1855f58771223acbbdac720b9969cd03fc5013d2e9a7bd9a4 \ + --hash=sha256:68650ce5b9f7152b8283302a4617269f821695a612692640dd247bd12ab21c0b \ + --hash=sha256:6b3a9a562688996f760b5077714c3ab8b62ca56061b6e9ab7906841e43e19f91 \ + --hash=sha256:7e938ed51a59e29431ea86fab60423ada2757728db0f78952329fa02a789bd31 \ + --hash=sha256:87aa70daad6f039e814790a06422a3189311198b674b62f13933a2bdcb6b1bcc \ + --hash=sha256:99be3a1df2b2b9f731ebe1c264a2c07c465e71cee68e35e1640b645b5213a755 \ + --hash=sha256:a3f2908666e6f74b8c4893f86dd02e16170f50e4a78ae7f3468b6208d54bc205 \ + --hash=sha256:ae3d44a639fd11dbdeca47e35e94febb1ee8bc15daf26673331add37146e0b85 \ + --hash=sha256:afb4c2fa3c6f492fd9a8b38d76e13f32d429b8e5e1e00238309391b5591cde0d \ + --hash=sha256:b1515ce3a8a2c3fa537d137c5ca5f8b7a902044d04e07d7c3aa26c3e026120fb \ + --hash=sha256:bf391b377413a197000b43ef2b74359974d8927d329a897c9f5ba7b63dca7b9c \ + --hash=sha256:c436919117c23355740c669f89720673578b9aa4569bbfe105f6c10101fc1966 \ + --hash=sha256:d2c3c280975638e2a2c2fd9cb36ab111980219757fa163a2755594b9448e4138 \ + --hash=sha256:e585d530764c459cbd5d460aed0288807bb881f376ca9a20e653645217895961 \ + --hash=sha256:e76e6638ead4a7d93262a24218f0ff3ff74de6b6c823b7e19dccb31b6a481978 \ + --hash=sha256:ebfc2f885cafda076c31ae30fa0dd81e7e919ec34059a88d3018ed66e83fcce3 \ + --hash=sha256:f5797a39933a3d41526da60856735e6684b2b71a8ca99d5f79555ca121be2f4b \ + --hash=sha256:f7e5fc5e124200b19a14be33fb0099e956e6ebb5e25d287b0829ef0a78ed76c7 \ + --hash=sha256:fb350e31e55211fec8ddc89fc0256f3b9bc3b44b68a8bde1cf44b3b4e80c0e42 +PyQt5==5.14.2 \ + --hash=sha256:3b91dd1d0cbfaea85ad057247ba621187e511434b0c9d6d40de69fd5e833b109 \ + --hash=sha256:a9bdc46ab1f6397770e6b8dca84ac07a0250d26b1a31587f25619cf31a075532 \ + --hash=sha256:bd230c6fd699eabf1ceb51e13a8b79b74c00a80272c622427b80141a22269eb0 \ + --hash=sha256:ee168a486c9a758511568147815e2959652cd0aabea832fa5e87cf6b241d2180 \ + --hash=sha256:f61ddc78547d6ca763323ccd4a9e374c71b29feda1f5ce2d3e91e4f8d2cf1942 +PyQt5-sip==12.8.0 \ + --hash=sha256:0a34b6596bdd28d52da3a51fa8d9bb0b287bcb605c2512aa3251b9028cc71f4d \ + --hash=sha256:1d65ce08a56282fb0273dd06585b8927b88d4fba71c01a54f8e2ac87ac1ed387 \ + --hash=sha256:224e2fbb7088595940c348d168a317caa2110cbb7a5b957a8c3fc0d9296ee069 \ + --hash=sha256:2a1153cda63f2632d3d5698f0cf29f6b1f1d5162305dc6f5b23336ad8f1039ed \ + --hash=sha256:2a2239d16a49ce6eaf10166a84424543111f8ebe49d3c124d02af91b01a58425 \ + --hash=sha256:58eae636e0b1926cddec98a703319a47f671cef07d73aaa525ba421cd4adfeb5 \ + --hash=sha256:5c19c4ad67af087e8f4411da7422391b236b941f5f0697f615c5816455d1355d \ + --hash=sha256:61aa60fb848d740581646603a12c2dcb8d7c4cbd2a9c476a1c891ec360ff0b87 \ + --hash=sha256:8d9f4dc7dbae9783c5dafd66801875a2ebf9302c3addd5739f772285c1c1e91c \ + --hash=sha256:94c80677b1e8c92fa080e24045d54ace5e4343c4ee6d0216675cd91d6f8e122a \ + --hash=sha256:9b69db29571dde679908fb237784a8e7af4a2cbf1b7bb25bdb86e487210e04d2 \ + --hash=sha256:9ef12754021bcc1246f97e00ea62b5594dd5c61192830639ab4a1640bd4b7940 \ + --hash=sha256:b1bbe763d431d26f9565cba3e99866768761366ab6d609d2506d194882156fa7 \ + --hash=sha256:d7b8a8f89385ad9e3da38e0123c22c0efc18005e0e2731b6b95e4c21db2049d2 \ + --hash=sha256:e6254647fa35e1260282aeb9c32a3dd363287b9a1ffcc4f22bd27e54178e92e4 \ + --hash=sha256:f4c294bfaf2be8004583266d4621bfd3a387e12946f548f966a7fbec91845f1b \ + --hash=sha256:fa3d70f370604efc67085849d3d1d3d2109faa716c520faf601d15845df64de6 +setuptools==46.4.0 \ + --hash=sha256:4334fc63121aafb1cc98fd5ae5dd47ea8ad4a38ad638b47af03a686deb14ef5b \ + --hash=sha256:d05c2c47bbef97fd58632b63dd2b83426db38af18f65c180b2423fea4b67e6b8 +wheel==0.34.2 \ + --hash=sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96 \ + --hash=sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e diff --git a/contrib/deterministic-build/requirements-hw.txt b/contrib/deterministic-build/requirements-hw.txt index fbafd06eb..d016822d5 100644 --- a/contrib/deterministic-build/requirements-hw.txt +++ b/contrib/deterministic-build/requirements-hw.txt @@ -1,130 +1,187 @@ -btchip-python==0.1.28 \ - --hash=sha256:da09d0d7a6180d428833795ea9a233c3b317ddfcccea8cc6f0eba59435e5dd83 -certifi==2019.11.28 \ - --hash=sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3 \ - --hash=sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f +base58==2.0.1 \ + --hash=sha256:365c9561d9babac1b5f18ee797508cd54937a724b6e419a130abad69cec5ca79 \ + --hash=sha256:447adc750d6b642987ffc6d397ecd15a799852d5f6a1d308d384500243825058 +bitbox02==4.0.0 \ + --hash=sha256:b893223a021407145bc45830c1ef11c479044e32c1ae284712919baaa03dda9a \ + --hash=sha256:fce1b438a4837fd164ce208929f0f5c452a4fdcc858d629872ce7a3984d5e9fc +btchip-python==0.1.30 \ + --hash=sha256:6869c67a712969ae86af23617f6418049076626f8a8c34d1000b1c58a9702ad7 +certifi==2020.4.5.2 \ + --hash=sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1 \ + --hash=sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc +cffi==1.14.0 \ + --hash=sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff \ + --hash=sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b \ + --hash=sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac \ + --hash=sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0 \ + --hash=sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384 \ + --hash=sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26 \ + --hash=sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6 \ + --hash=sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b \ + --hash=sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e \ + --hash=sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd \ + --hash=sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2 \ + --hash=sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66 \ + --hash=sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc \ + --hash=sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8 \ + --hash=sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55 \ + --hash=sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4 \ + --hash=sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5 \ + --hash=sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d \ + --hash=sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78 \ + --hash=sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa \ + --hash=sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793 \ + --hash=sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f \ + --hash=sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a \ + --hash=sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f \ + --hash=sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30 \ + --hash=sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f \ + --hash=sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3 \ + --hash=sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c chardet==3.0.4 \ --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \ --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 -ckcc-protocol==0.8.0 \ - --hash=sha256:bad1d1448423472df95ba67621fdd0ad919e625fbe0a4d3ba93648f34ea286e0 \ - --hash=sha256:f0851c98b91825d19567d0d3bac1b28044d40a3d5f194c8b04c5338f114d7ad5 -click==7.0 \ - --hash=sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13 \ - --hash=sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7 -construct==2.9.45 \ - --hash=sha256:2271a0efd0798679dea825ff47e22a4c550456a5db0ba8baa82f7eae0af0118c -Cython==0.29.10 \ - --hash=sha256:0afa0b121b89de619e71587e25702e2b7068d7da2164c47e6eee80c17823a62f \ - --hash=sha256:1c608ba76f7a20cc9f0c021b7fe5cb04bc1a70327ae93a9298b1bc3e0edddebe \ - --hash=sha256:26229570d6787ff3caa932fe9d802960f51a89239b990d275ae845405ce43857 \ - --hash=sha256:2a9deafa437b6154cac2f25bb88e0bfd075a897c8dc847669d6f478d7e3ee6b1 \ - --hash=sha256:2f28396fbce6d9d68a40edbf49a6729cf9d92a4d39ff0f501947a89188e9099f \ - --hash=sha256:3983dd7b67297db299b403b29b328d9e03e14c4c590ea90aa1ad1d7b35fb178b \ - --hash=sha256:4100a3f8e8bbe47d499cdac00e56d5fe750f739701ea52dc049b6c56f5421d97 \ - --hash=sha256:51abfaa7b6c66f3f18028876713c8804e73d4c2b6ceddbcbcfa8ec62429377f0 \ - --hash=sha256:61c24f4554efdb8fb1ac6c8e75dab301bcdf2b7b739ed0c2b267493bb43163c5 \ - --hash=sha256:700ccf921b2fdc9b23910e95b5caae4b35767685e0812343fa7172409f1b5830 \ - --hash=sha256:7b41eb2e792822a790cb2a171df49d1a9e0baaa8e81f58077b7380a273b93d5f \ - --hash=sha256:803987d3b16d55faa997bfc12e8b97f1091f145930dee229b020487aed8a1f44 \ - --hash=sha256:99af5cfcd208c81998dcf44b3ca466dee7e17453cfb50e98b87947c3a86f8753 \ - --hash=sha256:9faea1cca34501c7e139bc7ef8e504d532b77865c58592493e2c154a003b450f \ - --hash=sha256:a7ba4c9a174db841cfee9a0b92563862a0301d7ca543334666c7266b541f141a \ - --hash=sha256:b26071c2313d1880599c69fd831a07b32a8c961ba69d7ccbe5db1cd8d319a4ca \ - --hash=sha256:b49dc8e1116abde13a3e6a9eb8da6ab292c5a3325155fb872e39011b110b37e6 \ - --hash=sha256:bd40def0fd013569887008baa6da9ca428e3d7247adeeaeada153006227bb2e7 \ - --hash=sha256:bfd0db770e8bd4e044e20298dcae6dfc42561f85d17ee546dcd978c8b23066ae \ - --hash=sha256:c2fad1efae5889925c8fd7867fdd61f59480e4e0b510f9db096c912e884704f1 \ - --hash=sha256:c81aea93d526ccf6bc0b842c91216ee9867cd8792f6725a00f19c8b5837e1715 \ - --hash=sha256:da786e039b4ad2bce3d53d4799438cf1f5e01a0108f1b8d78ac08e6627281b1a \ - --hash=sha256:deab85a069397540987082d251e9c89e0e5b2e3e044014344ff81f60e211fc4b \ - --hash=sha256:e3f1e6224c3407beb1849bdc5ae3150929e593e4cffff6ca41c6ec2b10942c80 \ - --hash=sha256:e74eb224e53aae3943d66e2d29fe42322d5753fd4c0641329bccb7efb3a46552 \ - --hash=sha256:ee697c7ea65cb14915a64f36874da8ffc2123df43cf8bc952172e04a26656cd6 \ - --hash=sha256:f37792b16d11606c28e428460bd6a3d14b8917b109e77cdbe4ca78b0b9a52c87 \ - --hash=sha256:fd2906b54cbf879c09d875ad4e4687c58d87f5ed03496063fec1c9065569fd5d -ecdsa==0.14.1 \ - --hash=sha256:64c613005f13efec6541bb0a33290d0d03c27abab5f15fbab20fb0ee162bdd8e \ - --hash=sha256:e108a5fe92c67639abae3260e43561af914e7fd0d27bae6d2ec1312ae7934dfe -hidapi==0.7.99.post21 \ - --hash=sha256:1ac170f4d601c340f2cd52fd06e85c5e77bad7ceac811a7bb54b529f7dc28c24 \ - --hash=sha256:6424ad75da0021ce8c1bcd78056a04adada303eff3c561f8d132b85d0a914cb3 \ - --hash=sha256:8d3be666f464347022e2b47caf9132287885d9eacc7895314fc8fefcb4e42946 \ - --hash=sha256:92878bad7324dee619b7832fbfc60b5360d378aa7c5addbfef0a410d8fd342c7 \ - --hash=sha256:b4b1f6aff0192e9be153fe07c1b7576cb7a1ff52e78e3f76d867be95301a8e87 \ - --hash=sha256:bf03f06f586ce7d8aeb697a94b7dba12dc9271aae92d7a8d4486360ff711a660 \ - --hash=sha256:c76de162937326fcd57aa399f94939ce726242323e65c15c67e183da1f6c26f7 \ - --hash=sha256:d4ad1e46aef98783a9e6274d523b8b1e766acfc3d72828cd44a337564d984cfa \ - --hash=sha256:d4b5787a04613503357606bb10e59c3e2c1114fa00ee328b838dd257f41cbd7b \ - --hash=sha256:e0be1aa6566979266a8fc845ab0e18613f4918cf2c977fe67050f5dc7e2a9a97 \ - --hash=sha256:edfb16b16a298717cf05b8c8a9ad1828b6ff3de5e93048ceccd74e6ae4ff0922 -idna==2.8 \ - --hash=sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407 \ - --hash=sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c + ckcc-protocol==1.0.2 \ + --hash=sha256:2a34e1b2db2dc4f3e5503fac598e010370250dbb07224090eb475b3361f87ab3 \ + --hash=sha256:31c01e4e460b949d6a570501996c54ee17f5ea25c1ec70b4e1535fe5631df67e + click==7.1.2 \ + --hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a \ + --hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc + construct==2.10.56 \ + --hash=sha256:97ba13edcd98546f10f7555af41c8ce7ae9d8221525ec4062c03f9adbf940661 + cryptography==2.9.2 \ + --hash=sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6 \ + --hash=sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b \ + --hash=sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5 \ + --hash=sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf \ + --hash=sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e \ + --hash=sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b \ + --hash=sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae \ + --hash=sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b \ + --hash=sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0 \ + --hash=sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b \ + --hash=sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d \ + --hash=sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229 \ + --hash=sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3 \ + --hash=sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365 \ + --hash=sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55 \ + --hash=sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270 \ + --hash=sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e \ + --hash=sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785 \ + --hash=sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0 + Cython==0.29.20 \ + --hash=sha256:0754ec9d45518d0dbb5da72db2c8b063d40c4c51779618c68431054de179387f \ + --hash=sha256:0bb201124f67b8d5e6a3e7c02257ca56a90204611971ecca76c02897096f097d \ + --hash=sha256:0f3488bf2a9e049d1907d35ad8834f542f8c03d858d1bca6d0cbc06b719163e0 \ + --hash=sha256:1024714b0f7829b0f712db9cebec92c2782b1f42409b8575cacc340aa438d4ba \ + --hash=sha256:10b6d2e2125169158128b7f11dad8bb0d8f5fba031d5d4f8492f3afbd06491d7 \ + --hash=sha256:16ed0260d031d90dda43997e9b0f0eebc3cf18e6ece91cad7b0fb17cd4bfb29b \ + --hash=sha256:22d91af5fc2253f717a1b80b8bb45acb655f643611983fd6f782b9423f8171c7 \ + --hash=sha256:2d84e8d2a0c698c1bce7c2a4677f9f03b076e9f0af7095947ecd2a900ffceea5 \ + --hash=sha256:34dd57f5ac5a0e3d53da964994fc1b7e7ee3f86172d7a1f0bde8a1f90739e04d \ + --hash=sha256:384582b5024007dfdabc9753e3e0f85d61837b0103b0ee3f8acf04a4bcfad175 \ + --hash=sha256:4473f169d6dd02174eb76396cb38ce469f377c08b21965ddf4f88bbbebd5816e \ + --hash=sha256:57f32d1095ad7fad1e7f2ff6e8c6a7197fa532c8e6f4d044ff69212e0bf05461 \ + --hash=sha256:5dfe519e400a1672a3ac5bdfb5e957a9c14c52caafb01f4a923998ec9ae77736 \ + --hash=sha256:60def282839ed81a2ffae29d2df0a6777fd74478c6e82c6c3f4b54e698b9d11c \ + --hash=sha256:7089fb2be9a9869b9aa277bc6de401928954ce70e139c3cf9b244ae5f490b8f2 \ + --hash=sha256:714b8926a84e3e39c5278e43fb8823598db82a4b015cff263b786dc609a5e7d6 \ + --hash=sha256:7352b88f2213325c1e111561496a7d53b0326e7f07e6f81f9b8b21420e40851c \ + --hash=sha256:809f0a3f647052c4bcbc34a15f53a5dab90de1a83ebd77add37ed5d3e6ee5d97 \ + --hash=sha256:8598b09f7973ccb15c03b21d3185dc129ae7c60d0a6caf8176b7099a4b83483e \ + --hash=sha256:8dc68f93b257718ea0e2bc9be8e3c61d70b6e49ab82391125ba0112a30a21025 \ + --hash=sha256:9bfd42c1d40aa26bf76186cba0d89be66ba47e36fa7ea56d71f377585a53f7c4 \ + --hash=sha256:a21cb3423acd6dbf383c9e41e8e60c93741987950434c85145864458d30099f3 \ + --hash=sha256:a49d0f5c55ad0f4aacad32f058a71d0701cb8936d6883803e50698fa04cac8d2 \ + --hash=sha256:a985a7e3c7f1663af398938029659a4381cfe9d1bd982cf19c46b01453e81775 \ + --hash=sha256:b3233341c3fe352b1090168bd087686880b582b635d707b2c8f5d4f1cc1fa533 \ + --hash=sha256:b32965445b8dbdc36c69fba47e024060f9b39b1b4ceb816da5028eea01924505 \ + --hash=sha256:b553473c31297e4ca77fbaea2eb2329889d898c03941d90941679247c17e38fb \ + --hash=sha256:b56c02f14f1708411d95679962b742a1235d33a23535ce4a7f75425447701245 \ + --hash=sha256:b7bb0d54ff453c7516d323c3c78b211719f39a506652b79b7e85ba447d5fa9e7 \ + --hash=sha256:c5df2c42d4066cda175cd4d075225501e1842cfdbdaeeb388eb7685c367cc3ce \ + --hash=sha256:c5e29333c9e20df384645902bed7a67a287b979da1886c8f10f88e57b69e0f4b \ + --hash=sha256:d0b445def03b4cd33bd2d1ae6fbbe252b6d1ef7077b3b5ba3f2c698a190d26e5 \ + --hash=sha256:d490a54814b69d814b157ac86ada98c15fd77fabafc23732818ed9b9f1f0af80 + ecdsa==0.15 \ + --hash=sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061 \ + --hash=sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277 + hidapi==0.9.0.post2 \ + --hash=sha256:03b9118749f6102a96af175b2b77832c0d6f8957acb46ced5aa7afcf358052bc \ + --hash=sha256:3b31b396b6e95b635db4db8e9649cdb0aa2c205dd4cd8aaf3ee9807dddb1ebb8 \ + --hash=sha256:448c2ba9f713a5ee754830b222c9bc54a4e0dca4ecd0d84e3bf14314949ec594 \ + --hash=sha256:4c712309e2534a249721feb2abe7baedb9bfe7b3cc0e06cf4b78329684480932 \ + --hash=sha256:9c4369499a322d91d9f697c6b84b78f78c42695743641cb8bf3b5fa8c3c9b09c \ + --hash=sha256:a71dd3c153cb6bb2b73d2612b5ab262830d78c6428f33f0c06818749e64c9320 \ + --hash=sha256:d8dd636b7da9dfeb4aa08da64aceb91fb311465faae347b885cb8b695b141364 \ + --hash=sha256:da40dcf99ea15d440f3f3667f4166addd5676c485acf331c6e7c6c7879e11633 \ + --hash=sha256:dc633b34e318ce4638b73beb531136ab02ab005bfb383c260a41b5dfd5d85f16 + idna==2.9 \ + --hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb \ + --hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa keepkey==6.3.1 \ --hash=sha256:88e2b5291c85c8e8567732f675697b88241082884aa1aba32257f35ee722fc09 \ --hash=sha256:cef1e862e195ece3e42640a0f57d15a63086fd1dedc8b5ddfcbc9c2657f0bb1e \ --hash=sha256:f369d640c65fec7fd8e72546304cdc768c04224a6b9b00a19dc2cd06fa9d2a6b -libusb1==1.7.1 \ - --hash=sha256:adf64a4f3f5c94643a1286f8153bcf4bc787c348b38934aacd7fe17fbeebc571 +libusb1==1.8 \ + --hash=sha256:240f65ac70ba3fab77749ec84a412e4e89624804cb80d6c9d394eef5af8878d6 mnemonic==0.19 \ --hash=sha256:4e37eb02b2cbd56a0079cabe58a6da93e60e3e4d6e757a586d9f23d96abea931 \ --hash=sha256:a8d78c5100acfa7df9bab6b9db7390831b0e54490934b718ff9efd68f0d731a6 -pip==19.3.1 \ - --hash=sha256:21207d76c1031e517668898a6b46a9fb1501c7a4710ef5dfd6a40ad9e6757ea7 \ - --hash=sha256:6917c65fc3769ecdc61405d3dfd97afdedd75808d200b2838d7d961cebc0c2c7 -protobuf==3.11.1 \ - --hash=sha256:0265379852b9e1f76af6d3d3fe4b3c383a595cc937594bda8565cf69a96baabd \ - --hash=sha256:200b77e51f17fbc1d3049045f5835f60405dec3a00fe876b9b986592e46d908c \ - --hash=sha256:29bd1ed46b2536ad8959401a2f02d2d7b5a309f8e97518e4f92ca6c5ba74dbed \ - --hash=sha256:3175d45698edb9a07c1a78a1a4850e674ce8988f20596580158b1d0921d0f057 \ - --hash=sha256:34a7270940f86da7a28be466ac541c89b6dbf144a6348b9cf7ac6f56b71006ce \ - --hash=sha256:38cbc830a4a5ba9956763b0f37090bfd14dd74e72762be6225de2ceac55f4d03 \ - --hash=sha256:665194f5ad386511ac8d8a0bd57b9ab37b8dd2cd71969458777318e774b9cd46 \ - --hash=sha256:839bad7d115c77cdff29b488fae6a3ab503ce9a4192bd4c42302a6ea8e5d0f33 \ - --hash=sha256:934a9869a7f3b0d84eca460e386fba1f7ba2a0c1a120a2648bc41fadf50efd1c \ - --hash=sha256:aecdf12ef6dc7fd91713a6da93a86c2f2a8fe54840a3b1670853a2b7402e77c9 \ - --hash=sha256:c4e90bc27c0691c76e09b5dc506133451e52caee1472b8b3c741b7c912ce43ef \ - --hash=sha256:c65d135ea2d85d40309e268106dab02d3bea723db2db21c23ecad4163ced210b \ - --hash=sha256:c98dea04a1ff41a70aff2489610f280004831798cb36a068013eed04c698903d \ - --hash=sha256:d9049aa194378a426f0b2c784e2054565bf6f754d20fcafdee7102a6250556e8 \ - --hash=sha256:e028fee51c96de4e81924484c77111dfdea14010ecfc906ea5b252209b0c4de6 \ - --hash=sha256:e84ad26fb50091b1ea676403c0dd2bd47663099454aa6d88000b1dafecab0941 \ - --hash=sha256:e88a924b591b06d0191620e9c8aa75297b3111066bb09d49a24bae1054a10c13 +noiseprotocol==0.3.1 \ + --hash=sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111 +pip==20.1.1 \ + --hash=sha256:27f8dc29387dd83249e06e681ce087e6061826582198a425085e0bf4c1cf3a55 \ + --hash=sha256:b27c4dedae8c41aa59108f2fa38bf78e0890e590545bc8ece7cdceb4ba60f6e4 +protobuf==3.12.2 \ + --hash=sha256:304e08440c4a41a0f3592d2a38934aad6919d692bb0edfb355548786728f9a5e \ + --hash=sha256:49ef8ab4c27812a89a76fa894fe7a08f42f2147078392c0dee51d4a444ef6df5 \ + --hash=sha256:50b5fee674878b14baea73b4568dc478c46a31dd50157a5b5d2f71138243b1a9 \ + --hash=sha256:5524c7020eb1fb7319472cb75c4c3206ef18b34d6034d2ee420a60f99cddeb07 \ + --hash=sha256:612bc97e42b22af10ba25e4140963fbaa4c5181487d163f4eb55b0b15b3dfcd2 \ + --hash=sha256:6f349adabf1c004aba53f7b4633459f8ca8a09654bf7e69b509c95a454755776 \ + --hash=sha256:85b94d2653b0fdf6d879e39d51018bf5ccd86c81c04e18a98e9888694b98226f \ + --hash=sha256:87535dc2d2ef007b9d44e309d2b8ea27a03d2fa09556a72364d706fcb7090828 \ + --hash=sha256:a7ab28a8f1f043c58d157bceb64f80e4d2f7f1b934bc7ff5e7f7a55a337ea8b0 \ + --hash=sha256:a96f8fc625e9ff568838e556f6f6ae8eca8b4837cdfb3f90efcb7c00e342a2eb \ + --hash=sha256:b5a114ea9b7fc90c2cc4867a866512672a47f66b154c6d7ee7e48ddb68b68122 \ + --hash=sha256:be04fe14ceed7f8641e30f36077c1a654ff6f17d0c7a5283b699d057d150d82a \ + --hash=sha256:bff02030bab8b969f4de597543e55bd05e968567acb25c0a87495a31eb09e925 \ + --hash=sha256:c9ca9f76805e5a637605f171f6c4772fc4a81eced4e2f708f79c75166a2c99ea \ + --hash=sha256:e1464a4a2cf12f58f662c8e6421772c07947266293fb701cb39cd9c1e183f63c \ + --hash=sha256:e72736dd822748b0721f41f9aaaf6a5b6d5cfc78f6c8690263aef8bba4457f0e \ + --hash=sha256:eafe9fa19fcefef424ee089fb01ac7177ff3691af7cc2ae8791ae523eb6ca907 \ + --hash=sha256:f4b73736108a416c76c17a8a09bc73af3d91edaa26c682aaa460ef91a47168d3 pyaes==1.6.1 \ --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f -pyblake2==1.1.2 \ - --hash=sha256:3757f7ad709b0e1b2a6b3919fa79fe3261f166fc375cd521f2be480f8319dde9 \ - --hash=sha256:407e02c7f8f36fcec1b7aa114ddca0c1060c598142ea6f6759d03710b946a7e3 \ - --hash=sha256:4d47b4a2c1d292b1e460bde1dda4d13aa792ed2ed70fcc263b6bc24632c8e902 \ - --hash=sha256:5ccc7eb02edb82fafb8adbb90746af71460fbc29aa0f822526fc976dff83e93f \ - --hash=sha256:8043267fbc0b2f3748c6920591cd0b8b5609dcce60c504c32858aa36206386f2 \ - --hash=sha256:982295a87907d50f4723db6bc724660da76b6547826d52160171d54f95b919ac \ - --hash=sha256:baa2190bfe549e36163aa44664d4ee3a9080b236fc5d42f50dc6fd36bbdc749e \ - --hash=sha256:c53417ee0bbe77db852d5fd1036749f03696ebc2265de359fe17418d800196c4 \ - --hash=sha256:fbc9fcde75713930bc2a91b149e97be2401f7c9c56d735b46a109210f58d7358 -requests==2.22.0 \ - --hash=sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4 \ - --hash=sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31 -safet==0.1.4 \ - --hash=sha256:522c257910f9472e9c77c487425ed286f6721c314653e232bc41c6cedece1bb1 \ - --hash=sha256:b152874acdc89ff0c8b2d680bfbf020b3e53527c2ad3404489dd61a548aa56a1 -setuptools==42.0.2 \ - --hash=sha256:c5b372090d7c8709ce79a6a66872a91e518f7d65af97fca78135e1cb10d4b940 \ - --hash=sha256:c8abd0f3574bc23afd2f6fd2c415ba7d9e097c8a99b845473b0d957ba1e2dac6 -six==1.13.0 \ - --hash=sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd \ - --hash=sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66 -trezor==0.11.5 \ - --hash=sha256:711137bb83e7e0aef4009745e0da1b7d258146f246b43e3f7f5b849405088ef1 \ - --hash=sha256:cd8aafd70a281daa644c4a3fb021ffac20b7a88e86226ecc8bb3e78e1734a184 -typing-extensions==3.7.4.1 \ - --hash=sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2 \ - --hash=sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d \ - --hash=sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575 -urllib3==1.25.7 \ - --hash=sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293 \ - --hash=sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745 -wheel==0.33.6 \ - --hash=sha256:10c9da68765315ed98850f8e048347c3eb06dd81822dc2ab1d4fde9dc9702646 \ - --hash=sha256:f4da1763d3becf2e2cd92a14a7c920f0f00eca30fdde9ea992c836685b9faf28 + pycparser==2.20 \ + --hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 \ + --hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705 + requests==2.23.0 \ + --hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \ + --hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 + safet==0.1.5 \ + --hash=sha256:a7fd4b68bb1bc6185298af665c8e8e00e2bb2bcbddbb22844ead929b845c635e \ + --hash=sha256:f966a23243312f64d14c7dfe02e8f13f6eeba4c3f51341f2c11ae57831f07de3 + semver==2.10.1 \ + --hash=sha256:21eb9deafc627dfd122e294f96acd0deadf1b5b7758ab3bbdf3698155dca4705 \ + --hash=sha256:b08a84f604ef579e474ce448672a05c8d50d1ee0b24cee9fb58a12b260e4d0dc + setuptools==46.4.0 \ + --hash=sha256:4334fc63121aafb1cc98fd5ae5dd47ea8ad4a38ad638b47af03a686deb14ef5b \ + --hash=sha256:d05c2c47bbef97fd58632b63dd2b83426db38af18f65c180b2423fea4b67e6b8 + six==1.15.0 \ + --hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 \ + --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced + trezor==0.12.0 \ + --hash=sha256:da5b750ada03830fd1f0b9010f7d5d30e77ec3e1458230e3d08fe4588a0741b2 \ + --hash=sha256:f6bc821bddec06e67a1abd0be1d9fbc61c59b08272c736522ae2f6b225bf9579 + typing-extensions==3.7.4.2 \ + --hash=sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5 \ + --hash=sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae \ + --hash=sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392 + urllib3==1.25.9 \ + --hash=sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527 \ + --hash=sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115 + wheel==0.34.2 \ + --hash=sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96 \ + --hash=sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e diff --git a/contrib/deterministic-build/requirements-mac-build.txt b/contrib/deterministic-build/requirements-mac-build.txt new file mode 100644 index 000000000..85177c4be --- /dev/null +++ b/contrib/deterministic-build/requirements-mac-build.txt @@ -0,0 +1,17 @@ +altgraph==0.17 \ + --hash=sha256:1f05a47122542f97028caf78775a095fbe6a2699b5089de8477eb583167d69aa \ + --hash=sha256:c623e5f3408ca61d4016f23a681b9adb100802ca3e3da5e718915a9e4052cebe +macholib==1.14 \ + --hash=sha256:0c436bc847e7b1d9bda0560351bf76d7caf930fb585a828d13608839ef42c432 \ + --hash=sha256:c500f02867515e6c60a27875b408920d18332ddf96b4035ef03beddd782d4281 +pip==20.1.1 \ + --hash=sha256:27f8dc29387dd83249e06e681ce087e6061826582198a425085e0bf4c1cf3a55 \ + --hash=sha256:b27c4dedae8c41aa59108f2fa38bf78e0890e590545bc8ece7cdceb4ba60f6e4 +PyInstaller==3.6 \ + --hash=sha256:3730fa80d088f8bb7084d32480eb87cbb4ddb64123363763cf8f2a1378c1c4b7 +setuptools==46.4.0 \ + --hash=sha256:4334fc63121aafb1cc98fd5ae5dd47ea8ad4a38ad638b47af03a686deb14ef5b \ + --hash=sha256:d05c2c47bbef97fd58632b63dd2b83426db38af18f65c180b2423fea4b67e6b8 +wheel==0.34.2 \ + --hash=sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96 \ + --hash=sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e diff --git a/contrib/deterministic-build/requirements-sdist-build.txt b/contrib/deterministic-build/requirements-sdist-build.txt new file mode 100644 index 000000000..6fa809f9b --- /dev/null +++ b/contrib/deterministic-build/requirements-sdist-build.txt @@ -0,0 +1,9 @@ +pip==20.1.1 \ + --hash=sha256:27f8dc29387dd83249e06e681ce087e6061826582198a425085e0bf4c1cf3a55 \ + --hash=sha256:b27c4dedae8c41aa59108f2fa38bf78e0890e590545bc8ece7cdceb4ba60f6e4 +setuptools==46.4.0 \ + --hash=sha256:4334fc63121aafb1cc98fd5ae5dd47ea8ad4a38ad638b47af03a686deb14ef5b \ + --hash=sha256:d05c2c47bbef97fd58632b63dd2b83426db38af18f65c180b2423fea4b67e6b8 +wheel==0.34.2 \ + --hash=sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96 \ + --hash=sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e diff --git a/contrib/deterministic-build/requirements-wine-build.txt b/contrib/deterministic-build/requirements-wine-build.txt index 50df23c93..a87333e3d 100644 --- a/contrib/deterministic-build/requirements-wine-build.txt +++ b/contrib/deterministic-build/requirements-wine-build.txt @@ -1,19 +1,19 @@ -altgraph==0.16.1 \ - --hash=sha256:d6814989f242b2b43025cba7161fc1b8fb487a62cd49c49245d6fd01c18ac997 \ - --hash=sha256:ddf5320017147ba7b810198e0b6619bd7b5563aa034da388cea8546b877f9b0c +altgraph==0.17 \ + --hash=sha256:1f05a47122542f97028caf78775a095fbe6a2699b5089de8477eb583167d69aa \ + --hash=sha256:c623e5f3408ca61d4016f23a681b9adb100802ca3e3da5e718915a9e4052cebe future==0.18.2 \ --hash=sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d pefile==2019.4.18 \ --hash=sha256:a5d6e8305c6b210849b47a6174ddf9c452b2888340b8177874b862ba6c207645 -pip==19.3.1 \ - --hash=sha256:21207d76c1031e517668898a6b46a9fb1501c7a4710ef5dfd6a40ad9e6757ea7 \ - --hash=sha256:6917c65fc3769ecdc61405d3dfd97afdedd75808d200b2838d7d961cebc0c2c7 +pip==20.1.1 \ + --hash=sha256:27f8dc29387dd83249e06e681ce087e6061826582198a425085e0bf4c1cf3a55 \ + --hash=sha256:b27c4dedae8c41aa59108f2fa38bf78e0890e590545bc8ece7cdceb4ba60f6e4 pywin32-ctypes==0.2.0 \ --hash=sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942 \ --hash=sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98 -setuptools==42.0.2 \ - --hash=sha256:c5b372090d7c8709ce79a6a66872a91e518f7d65af97fca78135e1cb10d4b940 \ - --hash=sha256:c8abd0f3574bc23afd2f6fd2c415ba7d9e097c8a99b845473b0d957ba1e2dac6 -wheel==0.33.6 \ - --hash=sha256:10c9da68765315ed98850f8e048347c3eb06dd81822dc2ab1d4fde9dc9702646 \ - --hash=sha256:f4da1763d3becf2e2cd92a14a7c920f0f00eca30fdde9ea992c836685b9faf28 +setuptools==46.4.0 \ + --hash=sha256:4334fc63121aafb1cc98fd5ae5dd47ea8ad4a38ad638b47af03a686deb14ef5b \ + --hash=sha256:d05c2c47bbef97fd58632b63dd2b83426db38af18f65c180b2423fea4b67e6b8 +wheel==0.34.2 \ + --hash=sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96 \ + --hash=sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e diff --git a/contrib/deterministic-build/requirements.txt b/contrib/deterministic-build/requirements.txt index c3553caf3..7982e2083 100644 --- a/contrib/deterministic-build/requirements.txt +++ b/contrib/deterministic-build/requirements.txt @@ -11,173 +11,128 @@ aiohttp==3.6.2 \ --hash=sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48 \ --hash=sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59 \ --hash=sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965 -aiohttp-socks==0.2.2 \ - --hash=sha256:e473ee222b001fe33798957b9ce3352b32c187cf41684f8e2259427925914993 \ - --hash=sha256:eebd8939a7c3c1e3e7e1b2552c60039b4c65ef6b8b2351efcbdd98290538e310 +aiohttp-socks==0.3.9 \ + --hash=sha256:5e5638d0e472baa441eab7990cf19e034960cc803f259748cc359464ccb3c2d6 \ + --hash=sha256:ccd483d7677d7ba80b7ccb738a9be27a3ad6dce4b2756509bc71c9d679d96105 aiorpcX==0.18.4 \ --hash=sha256:bec9c0feb328d62ba80b79931b07f7372c98f2891ad51300be0b7163d5ccfb4a \ --hash=sha256:d424a55bcf52ebf1b3610a7809c0748fac91ce926854ad33ce952463bc6017e8 -apply-defaults==0.1.4 \ - --hash=sha256:1ce26326a61d8773d38a9726a345c6525a91a6120d7333af79ad792dacb6246c async-timeout==3.0.1 \ --hash=sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f \ --hash=sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3 attrs==19.3.0 \ --hash=sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c \ --hash=sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72 -bitstring==3.1.6 \ - --hash=sha256:7b60b0c300d0d3d0a24ec84abfda4b0eaed3dc56dc90f6cbfe497166c9ad8443 \ - --hash=sha256:c97a8e2a136e99b523b27da420736ae5cb68f83519d633794a6a11192f69f8bf \ - --hash=sha256:e392819965e7e0246e3cf6a51d5a54e731890ae03ebbfa3cd0e4f74909072096 -certifi==2019.11.28 \ - --hash=sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3 \ - --hash=sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f +bitstring==3.1.7 \ + --hash=sha256:fdf3eb72b229d2864fb507f8f42b1b2c57af7ce5fec035972f9566de440a864a +certifi==2020.4.5.2 \ + --hash=sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1 \ + --hash=sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc chardet==3.0.4 \ --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \ --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 -click==6.7 \ - --hash=sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d \ - --hash=sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b dnspython==1.16.0 \ --hash=sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01 \ --hash=sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d -ecdsa==0.14.1 \ - --hash=sha256:64c613005f13efec6541bb0a33290d0d03c27abab5f15fbab20fb0ee162bdd8e \ - --hash=sha256:e108a5fe92c67639abae3260e43561af914e7fd0d27bae6d2ec1312ae7934dfe -idna==2.8 \ - --hash=sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407 \ - --hash=sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c +ecdsa==0.15 \ + --hash=sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061 \ + --hash=sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277 +helpdev==0.7.1 \ + --hash=sha256:779a761b18c2d96fb181aa699609f802347806125f2fee2f60dad875a625e38e \ + --hash=sha256:bb62a79acbac141dadf42cadeb92bb7450dd18b9824a62043b6a0b149190db3d +idna==2.9 \ + --hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb \ + --hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa idna_ssl==1.1.0 \ --hash=sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c -importlib-metadata==1.1.0 \ - --hash=sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21 \ - --hash=sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742 -jsonrpcclient==3.3.4 \ - --hash=sha256:c50860409b73af9f94b648439caae3b4af80d5ac937f2a8ac7783de3d1050ba9 -jsonrpcserver==4.0.5 \ - --hash=sha256:240c517f49b0fdd3bfa428c9a7cc581126a0c43eca60d29762da124017d9d9f4 -jsonschema==3.2.0 \ - --hash=sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163 \ - --hash=sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a -more-itertools==8.0.0 \ - --hash=sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2 \ - --hash=sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45 -multidict==4.6.1 \ - --hash=sha256:07f9a6bf75ad675d53956b2c6a2d4ef2fa63132f33ecc99e9c24cf93beb0d10b \ - --hash=sha256:0ffe4d4d28cbe9801952bfb52a8095dd9ffecebd93f84bdf973c76300de783c5 \ - --hash=sha256:1b605272c558e4c659dbaf0fb32a53bfede44121bcf77b356e6e906867b958b7 \ - --hash=sha256:205a011e636d885af6dd0029e41e3514a46e05bb2a43251a619a6e8348b96fc0 \ - --hash=sha256:250632316295f2311e1ed43e6b26a63b0216b866b45c11441886ac1543ca96e1 \ - --hash=sha256:2bc9c2579312c68a3552ee816311c8da76412e6f6a9cf33b15152e385a572d2a \ - --hash=sha256:318aadf1cfb6741c555c7dd83d94f746dc95989f4f106b25b8a83dfb547f2756 \ - --hash=sha256:42cdd649741a14b0602bf15985cad0dd4696a380081a3319cd1ead46fd0f0fab \ - --hash=sha256:5159c4975931a1a78bf6602bbebaa366747fce0a56cb2111f44789d2c45e379f \ - --hash=sha256:87e26d8b89127c25659e962c61a4c655ec7445d19150daea0759516884ecb8b4 \ - --hash=sha256:891b7e142885e17a894d9d22b0349b92bb2da4769b4e675665d0331c08719be5 \ - --hash=sha256:8d919034420378132d074bf89df148d0193e9780c9fe7c0e495e895b8af4d8a2 \ - --hash=sha256:9c890978e2b37dd0dc1bd952da9a5d9f245d4807bee33e3517e4119c48d66f8c \ - --hash=sha256:a37433ce8cdb35fc9e6e47e1606fa1bfd6d70440879038dca7d8dd023197eaa9 \ - --hash=sha256:c626029841ada34c030b94a00c573a0c7575fe66489cde148785b6535397d675 \ - --hash=sha256:cfec9d001a83dc73580143f3c77e898cf7ad78b27bb5e64dbe9652668fcafec7 \ - --hash=sha256:efaf1b18ea6c1f577b1371c0159edbe4749558bfe983e13aa24d0a0c01e1ad7b -pip==19.3.1 \ - --hash=sha256:21207d76c1031e517668898a6b46a9fb1501c7a4710ef5dfd6a40ad9e6757ea7 \ - --hash=sha256:6917c65fc3769ecdc61405d3dfd97afdedd75808d200b2838d7d961cebc0c2c7 -protobuf==3.11.1 \ - --hash=sha256:0265379852b9e1f76af6d3d3fe4b3c383a595cc937594bda8565cf69a96baabd \ - --hash=sha256:200b77e51f17fbc1d3049045f5835f60405dec3a00fe876b9b986592e46d908c \ - --hash=sha256:29bd1ed46b2536ad8959401a2f02d2d7b5a309f8e97518e4f92ca6c5ba74dbed \ - --hash=sha256:3175d45698edb9a07c1a78a1a4850e674ce8988f20596580158b1d0921d0f057 \ - --hash=sha256:34a7270940f86da7a28be466ac541c89b6dbf144a6348b9cf7ac6f56b71006ce \ - --hash=sha256:38cbc830a4a5ba9956763b0f37090bfd14dd74e72762be6225de2ceac55f4d03 \ - --hash=sha256:665194f5ad386511ac8d8a0bd57b9ab37b8dd2cd71969458777318e774b9cd46 \ - --hash=sha256:839bad7d115c77cdff29b488fae6a3ab503ce9a4192bd4c42302a6ea8e5d0f33 \ - --hash=sha256:934a9869a7f3b0d84eca460e386fba1f7ba2a0c1a120a2648bc41fadf50efd1c \ - --hash=sha256:aecdf12ef6dc7fd91713a6da93a86c2f2a8fe54840a3b1670853a2b7402e77c9 \ - --hash=sha256:c4e90bc27c0691c76e09b5dc506133451e52caee1472b8b3c741b7c912ce43ef \ - --hash=sha256:c65d135ea2d85d40309e268106dab02d3bea723db2db21c23ecad4163ced210b \ - --hash=sha256:c98dea04a1ff41a70aff2489610f280004831798cb36a068013eed04c698903d \ - --hash=sha256:d9049aa194378a426f0b2c784e2054565bf6f754d20fcafdee7102a6250556e8 \ - --hash=sha256:e028fee51c96de4e81924484c77111dfdea14010ecfc906ea5b252209b0c4de6 \ - --hash=sha256:e84ad26fb50091b1ea676403c0dd2bd47663099454aa6d88000b1dafecab0941 \ - --hash=sha256:e88a924b591b06d0191620e9c8aa75297b3111066bb09d49a24bae1054a10c13 +importlib-metadata==1.6.1 \ + --hash=sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545 \ + --hash=sha256:15ec6c0fd909e893e3a08b3a7c76ecb149122fb14b7efe1199ddd4c7c57ea958 +multidict==4.7.6 \ + --hash=sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a \ + --hash=sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000 \ + --hash=sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2 \ + --hash=sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507 \ + --hash=sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5 \ + --hash=sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7 \ + --hash=sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d \ + --hash=sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463 \ + --hash=sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19 \ + --hash=sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3 \ + --hash=sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b \ + --hash=sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c \ + --hash=sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87 \ + --hash=sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7 \ + --hash=sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430 \ + --hash=sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255 \ + --hash=sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d +pip==20.1.1 \ + --hash=sha256:27f8dc29387dd83249e06e681ce087e6061826582198a425085e0bf4c1cf3a55 \ + --hash=sha256:b27c4dedae8c41aa59108f2fa38bf78e0890e590545bc8ece7cdceb4ba60f6e4 +protobuf==3.12.2 \ + --hash=sha256:304e08440c4a41a0f3592d2a38934aad6919d692bb0edfb355548786728f9a5e \ + --hash=sha256:49ef8ab4c27812a89a76fa894fe7a08f42f2147078392c0dee51d4a444ef6df5 \ + --hash=sha256:50b5fee674878b14baea73b4568dc478c46a31dd50157a5b5d2f71138243b1a9 \ + --hash=sha256:5524c7020eb1fb7319472cb75c4c3206ef18b34d6034d2ee420a60f99cddeb07 \ + --hash=sha256:612bc97e42b22af10ba25e4140963fbaa4c5181487d163f4eb55b0b15b3dfcd2 \ + --hash=sha256:6f349adabf1c004aba53f7b4633459f8ca8a09654bf7e69b509c95a454755776 \ + --hash=sha256:85b94d2653b0fdf6d879e39d51018bf5ccd86c81c04e18a98e9888694b98226f \ + --hash=sha256:87535dc2d2ef007b9d44e309d2b8ea27a03d2fa09556a72364d706fcb7090828 \ + --hash=sha256:a7ab28a8f1f043c58d157bceb64f80e4d2f7f1b934bc7ff5e7f7a55a337ea8b0 \ + --hash=sha256:a96f8fc625e9ff568838e556f6f6ae8eca8b4837cdfb3f90efcb7c00e342a2eb \ + --hash=sha256:b5a114ea9b7fc90c2cc4867a866512672a47f66b154c6d7ee7e48ddb68b68122 \ + --hash=sha256:be04fe14ceed7f8641e30f36077c1a654ff6f17d0c7a5283b699d057d150d82a \ + --hash=sha256:bff02030bab8b969f4de597543e55bd05e968567acb25c0a87495a31eb09e925 \ + --hash=sha256:c9ca9f76805e5a637605f171f6c4772fc4a81eced4e2f708f79c75166a2c99ea \ + --hash=sha256:e1464a4a2cf12f58f662c8e6421772c07947266293fb701cb39cd9c1e183f63c \ + --hash=sha256:e72736dd822748b0721f41f9aaaf6a5b6d5cfc78f6c8690263aef8bba4457f0e \ + --hash=sha256:eafe9fa19fcefef424ee089fb01ac7177ff3691af7cc2ae8791ae523eb6ca907 \ + --hash=sha256:f4b73736108a416c76c17a8a09bc73af3d91edaa26c682aaa460ef91a47168d3 pyaes==1.6.1 \ --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f -pycryptodomex==3.9.4 \ - --hash=sha256:0943b65fb41b7403a9def6214061fdd9ab9afd0bbc581e553c72eebe60bded36 \ - --hash=sha256:0a1dbb5c4d975a4ea568fb7686550aa225d94023191fb0cca8747dc5b5d77857 \ - --hash=sha256:0f43f1608518347fdcb9c8f443fa5cabedd33f94188b13e4196a3a7ba90d169c \ - --hash=sha256:11ce5fec5990e34e3981ed14897ba601c83957b577d77d395f1f8f878a179f98 \ - --hash=sha256:17a09e38fdc91e4857cf5a7ce82f3c0b229c3977490f2146513e366923fc256b \ - --hash=sha256:22d970cee5c096b9123415e183ae03702b2cd4d3ba3f0ced25c4e1aba3967167 \ - --hash=sha256:2a1793efcbae3a2264c5e0e492a2629eb10d895d6e5f17dbbd00eb8b489c6bda \ - --hash=sha256:30a8a148a0fe482cec1aaf942bbd0ade56ec197c14fe058b2a94318c57e1f991 \ - --hash=sha256:32fbbaf964c5184d3f3e349085b0536dd28184b02e2b014fc900f58bbc126339 \ - --hash=sha256:347d67faee36d449dc9632da411cc318df52959079062627f1243001b10dc227 \ - --hash=sha256:45f4b4e5461a041518baabc52340c249b60833aa84cea6377dc8016a2b33c666 \ - --hash=sha256:4717daec0035034b002d31c42e55431c970e3e38a78211f43990e1b7eaf19e28 \ - --hash=sha256:51a1ac9e7dda81da444fed8be558a60ec88dfc73b2aa4b0efa310e87acb75838 \ - --hash=sha256:53e9dcc8f14783f6300b70da325a50ac1b0a3dbaee323bd9dc3f71d409c197a1 \ - --hash=sha256:5519a2ed776e193688b7ddb61ab709303f6eb7d1237081e298283c72acc44271 \ - --hash=sha256:583450e8e80a0885c453211ed2bd69ceea634d8c904f23ff8687f677fe810e95 \ - --hash=sha256:60f862bd2a07133585a4fc2ce2b1a8ec24746b07ac44307d22ef2b767cb03435 \ - --hash=sha256:612091f1d3c84e723bec7cb855cf77576e646045744794c9a3f75ba80737762f \ - --hash=sha256:629a87b87c8203b8789ccefc7f2f2faecd2daaeb56bdd0b4e44cd89565f2db07 \ - --hash=sha256:6e56ec4c8938fb388b6f250ddd5e21c15e8f25a76e0ad0e2abae9afee09e67b4 \ - --hash=sha256:8e8092651844a11ec7fa534395f3dfe99256ce4edca06f128efc9d770d6e1dc1 \ - --hash=sha256:8f5f260629876603e08f3ce95c8ccd9b6b83bf9a921c41409046796267f7adc5 \ - --hash=sha256:9a6b74f38613f54c56bd759b411a352258f47489bbefd1d57c930a291498b35b \ - --hash=sha256:a5a13ebb52c4cd065fb673d8c94f39f30823428a4de19e1f3f828b63a8882d1e \ - --hash=sha256:a77ca778a476829876a3a70ae880073379160e4a465d057e3c4e1c79acdf1b8a \ - --hash=sha256:a9f7be3d19f79429c2118fd61bc2ec4fa095e93b56fb3a5f3009822402c4380f \ - --hash=sha256:dc15a467c4f9e4b43748ba2f97aea66f67812bfd581818284c47cadc81d4caec \ - --hash=sha256:e13cdeea23059f7577c230fd580d2c8178e67ebe10e360041abe86c33c316f1c \ - --hash=sha256:e45b85c8521bca6bdfaf57e4987743ade53e9f03529dd3adbc9524094c6d55c4 \ - --hash=sha256:e87f17867b260f57c88487f943eb4d46c90532652bb37046e764842c3b66cbb1 \ - --hash=sha256:ee40a5b156f6c1192bc3082e9d73d0479904433cdda83110546cd67f5a15a5be \ - --hash=sha256:ef63ffde3b267043579af8830fc97fc3b9b8a526a24e3ba23af9989d4e9e689a -pyrsistent==0.15.6 \ - --hash=sha256:f3b280d030afb652f79d67c5586157c5c1355c9a58dfc7940566e28d28f3df1b -QDarkStyle==2.6.8 \ - --hash=sha256:037a54bf0aa5153f8055b65b8b36ac0d0f7648f2fd906c011a4da22eb0f582a2 \ - --hash=sha256:fd1abae37d3a0a004089178da7c0b26ec5eb29f965b3e573853b8f280b614dea +QDarkStyle==2.8.1 \ + --hash=sha256:7cead57817a8a1f38b48d76ef38986b6cc397d0315c0dd0431fcd06749556947 \ + --hash=sha256:d53b0120bddd9e3efba9801731e22ef86ed798bb5fc6a802f5f7bb32dedf0321 qrcode==6.1 \ --hash=sha256:3996ee560fc39532910603704c82980ff6d4d5d629f9c3f25f34174ce8606cf5 \ --hash=sha256:505253854f607f2abf4d16092c61d4e9d511a3b4392e60bff957a68592b04369 -setuptools==42.0.2 \ - --hash=sha256:c5b372090d7c8709ce79a6a66872a91e518f7d65af97fca78135e1cb10d4b940 \ - --hash=sha256:c8abd0f3574bc23afd2f6fd2c415ba7d9e097c8a99b845473b0d957ba1e2dac6 -six==1.13.0 \ - --hash=sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd \ - --hash=sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66 -typing-extensions==3.7.4.1 \ - --hash=sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2 \ - --hash=sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d \ - --hash=sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575 -wheel==0.33.6 \ - --hash=sha256:10c9da68765315ed98850f8e048347c3eb06dd81822dc2ab1d4fde9dc9702646 \ - --hash=sha256:f4da1763d3becf2e2cd92a14a7c920f0f00eca30fdde9ea992c836685b9faf28 -yarl==1.4.1 \ - --hash=sha256:031e8f56cf085d3b3df6b6bce756369ea7052b82d35ea07b6045f209c819e0e5 \ - --hash=sha256:074958fe4578ef3a3d0bdaf96bbc25e4c4db82b7ff523594776fcf3d3f16c531 \ - --hash=sha256:2db667ee21f620b446a54a793e467714fc5a446fcc82d93a47e8bde01d69afab \ - --hash=sha256:326f2dbaaa17b858ae86f261ae73a266fd820a561fc5142cee9d0fc58448fbd7 \ - --hash=sha256:32a3885f542f74d0f4f87057050c6b45529ebd79d0639f56582e741521575bfe \ - --hash=sha256:56126ef061b913c3eefecace3404ca88917265d0550b8e32bbbeab29e5c830bf \ - --hash=sha256:589ac1e82add13fbdedc04eb0a83400db728e5f1af2bd273392088ca90de7062 \ - --hash=sha256:6076bce2ecc6ebf6c92919d77762f80f4c9c6ecc9c1fbaa16567ec59ad7d6f1d \ - --hash=sha256:63be649c535d18ab6230efbc06a07f7779cd4336a687672defe70c025349a47b \ - --hash=sha256:6642cbc92eaffa586180f669adc772f5c34977e9e849e93f33dc142351e98c9c \ - --hash=sha256:6fa05a25f2280e78a514041d4609d39962e7d51525f2439db9ad7a2ae7aac163 \ - --hash=sha256:7ed006a220422c33ff0889288be24db56ff0a3008ffe9eaead58a690715ad09b \ - --hash=sha256:80c9c213803b50899460cc355f47e66778c3c868f448b7b7de5b1f1858c82c2a \ - --hash=sha256:8bae18e2129850e76969b57869dacc72a66cccdbeebce1a28d7f3d439c21a7a3 \ - --hash=sha256:ab112fba996a8f48f427e26969f2066d50080df0c24007a8cc6d7ae865e19013 \ - --hash=sha256:b1c178ef813940c9a5cbad42ab7b8b76ac08b594b0a6bad91063c968e0466efc \ - --hash=sha256:d6eff151c3b23a56a5e4f496805619bc3bdf4f749f63a7a95ad50e8267c17475 -zipp==0.6.0 \ - --hash=sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e \ - --hash=sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335 -colorama==0.4.1 \ - --hash=sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d \ - --hash=sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48 +QtPy==1.9.0 \ + --hash=sha256:2db72c44b55d0fe1407be8fba35c838ad0d6d3bb81f23007886dc1fc0f459c8d \ + --hash=sha256:fa0b8363b363e89b2a6f49eddc162a04c0699ae95e109a6be3bb145a913190ea +setuptools==46.4.0 \ + --hash=sha256:4334fc63121aafb1cc98fd5ae5dd47ea8ad4a38ad638b47af03a686deb14ef5b \ + --hash=sha256:d05c2c47bbef97fd58632b63dd2b83426db38af18f65c180b2423fea4b67e6b8 +six==1.15.0 \ + --hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 \ + --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced +typing-extensions==3.7.4.2 \ + --hash=sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5 \ + --hash=sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae \ + --hash=sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392 +wheel==0.34.2 \ + --hash=sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96 \ + --hash=sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e +yarl==1.4.2 \ + --hash=sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce \ + --hash=sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6 \ + --hash=sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce \ + --hash=sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae \ + --hash=sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d \ + --hash=sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f \ + --hash=sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b \ + --hash=sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b \ + --hash=sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb \ + --hash=sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462 \ + --hash=sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea \ + --hash=sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70 \ + --hash=sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1 \ + --hash=sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a \ + --hash=sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b \ + --hash=sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080 \ + --hash=sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2 +zipp==3.1.0 \ + --hash=sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b \ + --hash=sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96 +colorama==0.4.3 \ + --hash=sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff \ + --hash=sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1 diff --git a/contrib/freeze_packages.sh b/contrib/freeze_packages.sh index 6dc149091..87c5d14ae 100644 --- a/contrib/freeze_packages.sh +++ b/contrib/freeze_packages.sh @@ -6,13 +6,24 @@ set -e venv_dir=~/.electrum-venv contrib=$(dirname "$0") -which virtualenv > /dev/null 2>&1 || { echo "Please install virtualenv" && exit 1; } -python3 -m hashin -h > /dev/null 2>&1 || { python3 -m pip install hashin; } -other_python=$(which python3) +# note: we should not use a higher version of python than what the binaries bundle +if [[ ! "$SYSTEM_PYTHON" ]] ; then + SYSTEM_PYTHON=$(which python3.6) || printf "" +else + SYSTEM_PYTHON=$(which $SYSTEM_PYTHON) || printf "" +fi +if [[ ! "$SYSTEM_PYTHON" ]] ; then + echo "Please specify which python to use in \$SYSTEM_PYTHON" && exit 1; +fi -for i in '' '-hw' '-binaries' '-wine-build'; do +which virtualenv > /dev/null 2>&1 || { echo "Please install virtualenv" && exit 1; } + + +${SYSTEM_PYTHON} -m hashin -h > /dev/null 2>&1 || { ${SYSTEM_PYTHON} -m pip install hashin; } + +for i in '' '-hw' '-binaries' '-wine-build' '-mac-build' '-sdist-build'; do rm -rf "$venv_dir" - virtualenv -p $(which python3) $venv_dir + virtualenv -p ${SYSTEM_PYTHON} $venv_dir source $venv_dir/bin/activate @@ -23,7 +34,7 @@ for i in '' '-hw' '-binaries' '-wine-build'; do echo "OK." requirements=$(pip freeze --all) - restricted=$(echo $requirements | $other_python $contrib/deterministic-build/find_restricted_dependencies.py) + restricted=$(echo $requirements | ${SYSTEM_PYTHON} $contrib/deterministic-build/find_restricted_dependencies.py) requirements="$requirements $restricted" echo "Generating package hashes..." @@ -32,7 +43,7 @@ for i in '' '-hw' '-binaries' '-wine-build'; do for requirement in $requirements; do echo -e "\r Hashing $requirement..." - $other_python -m hashin -r $contrib/deterministic-build/requirements${i}.txt ${requirement} + ${SYSTEM_PYTHON} -m hashin -r $contrib/deterministic-build/requirements${i}.txt ${requirement} done echo "OK." diff --git a/contrib/make_libsecp256k1.sh b/contrib/make_libsecp256k1.sh index 8b602f34f..aafeaa6c9 100644 --- a/contrib/make_libsecp256k1.sh +++ b/contrib/make_libsecp256k1.sh @@ -1,6 +1,16 @@ #!/bin/bash -LIBSECP_VERSION="b408c6a8b287003d1ade5709e6f7bc3c7f1d5be7" +# This script was tested on Linux and MacOS hosts, where it can be used +# to build native libsecp256k1 binaries. +# +# It can also be used to cross-compile to Windows: +# $ sudo apt-get install mingw-w64 +# For a Windows x86 (32-bit) target, run: +# $ GCC_TRIPLET_HOST="i686-w64-mingw32" ./contrib/make_libsecp256k1.sh +# Or for a Windows x86_64 (64-bit) target, run: +# $ GCC_TRIPLET_HOST="x86_64-w64-mingw32" ./contrib/make_libsecp256k1.sh + +LIBSECP_VERSION="dbd41db16a0e91b2566820898a3ab2d7dad4fe00" set -e @@ -19,9 +29,13 @@ info "Building $pkgname..." git clone https://github.com/bitcoin-core/secp256k1.git fi cd secp256k1 + if ! $(git cat-file -e ${LIBSECP_VERSION}) ; then + info "Could not find requested version $LIBSECP_VERSION in local clone; fetching..." + git fetch --all + fi git reset --hard git clean -f -x -q - git checkout $LIBSECP_VERSION + git checkout "${LIBSECP_VERSION}^{commit}" if ! [ -x configure ] ; then echo "libsecp256k1_la_LDFLAGS = -no-undefined" >> Makefile.am @@ -35,8 +49,9 @@ info "Building $pkgname..." --enable-module-recovery \ --enable-experimental \ --enable-module-ecdh \ - --disable-jni \ + --disable-benchmark \ --disable-tests \ + --disable-exhaustive-tests \ --disable-static \ --enable-shared || fail "Could not configure $pkgname. Please make sure you have a C compiler installed and try again." fi diff --git a/contrib/make_packages b/contrib/make_packages index 56098d337..669d0147d 100644 --- a/contrib/make_packages +++ b/contrib/make_packages @@ -6,5 +6,5 @@ test -n "$CONTRIB" -a -d "$CONTRIB" || exit rm "$CONTRIB"/../packages/ -r #Install pure python modules in electrum directory -python3 -m pip install -r "$CONTRIB"/deterministic-build/requirements.txt -t "$CONTRIB"/../packages - +python3 -m pip install --no-dependencies --no-binary :all: \ + -r "$CONTRIB"/deterministic-build/requirements.txt -t "$CONTRIB"/../packages diff --git a/contrib/osx/README.md b/contrib/osx/README.md index 41ad97c13..423e15fc7 100644 --- a/contrib/osx/README.md +++ b/contrib/osx/README.md @@ -1,5 +1,5 @@ -Building Mac OS binaries -======================== +Building macOS binaries +======================= ✗ _This script does not produce reproducible output (yet!). Please help us remedy this._ @@ -7,36 +7,48 @@ Building Mac OS binaries This guide explains how to build Electrum binaries for macOS systems. -## 1. Building the binary +## Building the binary -This needs to be done on a system running macOS or OS X. We use El Capitan (10.11.6) as building it -on High Sierra (or later) -makes the binaries [incompatible with older versions](https://github.com/pyinstaller/pyinstaller/issues/1191). +This needs to be done on a system running macOS or OS X. -Another factor for the minimum supported macOS version is the -[bundled Qt version](https://github.com/spesmilo/electrum/issues/3685). +Notes about compatibility with different macOS versions: +- In general the binary is not guaranteed to run on an older version of macOS + than what the build machine has. This is due to bundling the compiled Python into + the [PyInstaller binary](https://github.com/pyinstaller/pyinstaller/issues/1191). +- The [bundled version of Qt](https://github.com/spesmilo/electrum/issues/3685) also + imposes a minimum supported macOS version. +- If you want to build binaries that conform to the macOS "Gatekeeper", so as to + minimise the warnings users get, the binaries need to be codesigned with a + certificate issued by Apple, and starting with macOS 10.15 the binaries also + need to be notarized by Apple's central server. The catch is that to be able to build + binaries that Apple will notarise (due to the requirements on the binaries themselves, + e.g. hardened runtime) the build machine needs at least macOS 10.14. + See [#6128](https://github.com/spesmilo/electrum/issues/6128). + +We currently build the release binaries on macOS 10.14.6, and these seem to run on +10.13 or newer. Before starting, make sure that the Xcode command line tools are installed (e.g. you have `git`). -#### 1.1a Get Xcode +#### 1.a Get Xcode Building the QR scanner (CalinsQRReader) requires full Xcode (not just command line tools). -The last Xcode version compatible with El Capitan is Xcode 8.2.1 - Get it from [here](https://developer.apple.com/download/more/). Unfortunately, you need an "Apple ID" account. +(note: the last Xcode that runs on macOS 10.14.6 is Xcode 11.3.1) + After downloading, uncompress it. Make sure it is the "selected" xcode (e.g.): sudo xcode-select -s $HOME/Downloads/Xcode.app/Contents/Developer/ -#### 1.1b Build QR scanner separately on newer Mac +#### 1.b Build QR scanner separately on another Mac -Alternatively, you can try building just the QR scanner on newer macOS. +Alternatively, you can try building just the QR scanner on another Mac. On newer Mac, run: @@ -46,27 +58,17 @@ On newer Mac, run: Move `prebuilt_qr` to El Capitan: `contrib/osx/CalinsQRReader/prebuilt_qr`. -#### 1.2 Build Electrum +#### 2. Build Electrum cd electrum ./contrib/osx/make_osx - + This creates both a folder named Electrum.app and the .dmg file. +If you want the binaries codesigned for MacOS and notarised by Apple's central server, +provide these env vars to the `make_osx` script: -## 2. Building the image deterministically (WIP) -The usual way to distribute macOS applications is to use image files containing the -application. Although these images can be created on a Mac with the built-in `hdiutil`, -they are not deterministic. - -Instead, we use the toolchain that Bitcoin uses: genisoimage and libdmg-hfsplus. -These tools do not work on macOS, so you need a separate Linux machine (or VM). - -Copy the Electrum.app directory over and install the dependencies, e.g.: - - apt install libcap-dev cmake make gcc faketime - -Then you can just invoke `package.sh` with the path to the app: - - cd electrum - ./contrib/osx/package.sh ~/Electrum.app/ + CODESIGN_CERT="Developer ID Application: Electrum Technologies GmbH (L6P37P7P56)" \ + APPLE_ID_USER="me@email.com" \ + APPLE_ID_PASSWORD="1234" \ + ./contrib/osx/make_osx diff --git a/contrib/osx/base.sh b/contrib/osx/base.sh deleted file mode 100644 index c2e3527c0..000000000 --- a/contrib/osx/base.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -. $(dirname "$0")/../build_tools_util.sh - - -function DoCodeSignMaybe { # ARGS: infoName fileOrDirName codesignIdentity - infoName="$1" - file="$2" - identity="$3" - deep="" - if [ -z "$identity" ]; then - # we are ok with them not passing anything; master script calls us unconditionally even if no identity is specified - return - fi - if [ -d "$file" ]; then - deep="--deep" - fi - if [ -z "$infoName" ] || [ -z "$file" ] || [ -z "$identity" ] || [ ! -e "$file" ]; then - fail "Argument error to internal function DoCodeSignMaybe()" - fi - info "Code signing ${infoName}..." - codesign -f -v $deep -s "$identity" "$file" || fail "Could not code sign ${infoName}" -} diff --git a/contrib/osx/entitlements.plist b/contrib/osx/entitlements.plist new file mode 100644 index 000000000..7c93d5b37 --- /dev/null +++ b/contrib/osx/entitlements.plist @@ -0,0 +1,23 @@ + + + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.allow-jit + + + + com.apple.security.device.camera + + + diff --git a/contrib/osx/make_osx b/contrib/osx/make_osx index a5f5a5802..cc72dd9a8 100644 --- a/contrib/osx/make_osx +++ b/contrib/osx/make_osx @@ -1,15 +1,15 @@ #!/usr/bin/env bash # Parameterize -PYTHON_VERSION=3.7.6 +PYTHON_VERSION=3.7.7 BUILDDIR=/tmp/electrum-build PACKAGE=Electrum GIT_REPO=https://github.com/spesmilo/electrum -LIBSECP_VERSION="b408c6a8b287003d1ade5709e6f7bc3c7f1d5be7" +PYTHON_VERSION=3.7.7 export GCC_STRIP_BINARIES="1" -. $(dirname "$0")/base.sh +. $(dirname "$0")/../build_tools_util.sh CONTRIB_OSX="$(dirname "$(realpath "$0")")" CONTRIB="$CONTRIB_OSX/.." @@ -24,26 +24,44 @@ which brew > /dev/null 2>&1 || fail "Please install brew from https://brew.sh/ t which xcodebuild > /dev/null 2>&1 || fail "Please install Xcode and xcode command line tools to continue" # Code Signing: See https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html -APP_SIGN="" -if [ -n "$1" ]; then +if [ -n "$CODESIGN_CERT" ]; then # Test the identity is valid for signing by doing this hack. There is no other way to do this. cp -f /bin/ls ./CODESIGN_TEST - codesign -s "$1" --dryrun -f ./CODESIGN_TEST > /dev/null 2>&1 + codesign -s "$CODESIGN_CERT" --dryrun -f ./CODESIGN_TEST > /dev/null 2>&1 res=$? rm -f ./CODESIGN_TEST if ((res)); then - fail "Code signing identity \"$1\" appears to be invalid." + fail "Code signing identity \"$CODESIGN_CERT\" appears to be invalid." fi unset res - APP_SIGN="$1" - info "Code signing enabled using identity \"$APP_SIGN\"" + info "Code signing enabled using identity \"$CODESIGN_CERT\"" else - warn "Code signing DISABLED. Specify a valid macOS Developer identity installed on the system as the first argument to this script to enable signing." + warn "Code signing DISABLED. Specify a valid macOS Developer identity installed on the system to enable signing." fi +function DoCodeSignMaybe { # ARGS: infoName fileOrDirName + infoName="$1" + file="$2" + deep="" + if [ -z "$CODESIGN_CERT" ]; then + # no cert -> we won't codesign + return + fi + if [ -d "$file" ]; then + deep="--deep" + fi + if [ -z "$infoName" ] || [ -z "$file" ] || [ ! -e "$file" ]; then + fail "Argument error to internal function DoCodeSignMaybe()" + fi + hardened_arg="--entitlements=${CONTRIB_OSX}/entitlements.plist -o runtime" + + info "Code signing ${infoName}..." + codesign -f -v $deep -s "$CODESIGN_CERT" $hardened_arg "$file" || fail "Could not code sign ${infoName}" +} + info "Installing Python $PYTHON_VERSION" export PATH="~/.pyenv/bin:~/.pyenv/shims:~/Library/Python/3.7/bin:$PATH" -if [ -d "~/.pyenv" ]; then +if [ -d "${HOME}/.pyenv" ]; then pyenv update else curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash > /dev/null 2>&1 @@ -53,14 +71,9 @@ pyenv global $PYTHON_VERSION || \ fail "Unable to use Python $PYTHON_VERSION" -info "install dependencies specific to binaries" -# note that this also installs pinned versions of both pip and setuptools -python3 -m pip install --no-dependencies -Ir ./contrib/deterministic-build/requirements-binaries.txt --user \ - || fail "Could not install pyinstaller" - - -info "Installing pyinstaller" -python3 -m pip install -I --user pyinstaller==3.6 || fail "Could not install pyinstaller" +info "Installing build dependencies" +python3 -m pip install --no-dependencies -Ir ./contrib/deterministic-build/requirements-mac-build.txt --user \ + || fail "Could not install build dependencies" info "Using these versions for building $PACKAGE:" sw_vers @@ -91,10 +104,10 @@ info "generating locale" info "Downloading libusb..." -curl https://homebrew.bintray.com/bottles/libusb-1.0.22.el_capitan.bottle.tar.gz | \ +curl https://homebrew.bintray.com/bottles/libusb-1.0.23.high_sierra.bottle.tar.gz | \ tar xz --directory $BUILDDIR -cp $BUILDDIR/libusb/1.0.22/lib/libusb-1.0.dylib contrib/osx -echo "82c368dfd4da017ceb32b12ca885576f325503428a4966cc09302cbd62702493 contrib/osx/libusb-1.0.dylib" | \ +cp $BUILDDIR/libusb/1.0.23/lib/libusb-1.0.dylib contrib/osx +echo "caea266f3fc3982adc55d6cb8d9bad10f6e61f0c24ce5901aa1804618e08e14d contrib/osx/libusb-1.0.dylib" | \ shasum -a 256 -c || fail "libusb checksum mismatched" info "Preparing for building libsecp256k1" @@ -109,16 +122,20 @@ rm -fr build # prefer building using xcode ourselves. otherwise fallback to prebuilt binary xcodebuild || cp -r prebuilt_qr build || fail "Could not build CalinsQRReader" popd -DoCodeSignMaybe "CalinsQRReader.app" "${d}/build/Release/CalinsQRReader.app" "$APP_SIGN" # If APP_SIGN is empty will be a noop +DoCodeSignMaybe "CalinsQRReader.app" "${d}/build/Release/CalinsQRReader.app" info "Installing requirements..." -python3 -m pip install --no-dependencies -Ir ./contrib/deterministic-build/requirements.txt --user || \ -fail "Could not install requirements" +python3 -m pip install --no-dependencies -Ir ./contrib/deterministic-build/requirements.txt --user \ + || fail "Could not install requirements" info "Installing hardware wallet requirements..." -python3 -m pip install --no-dependencies -Ir ./contrib/deterministic-build/requirements-hw.txt --user || \ -fail "Could not install hardware wallet requirements" +python3 -m pip install --no-dependencies -Ir ./contrib/deterministic-build/requirements-hw.txt --user \ + || fail "Could not install hardware wallet requirements" + +info "Installing dependencies specific to binaries..." +python3 -m pip install --no-dependencies -Ir ./contrib/deterministic-build/requirements-binaries.txt --user \ + || fail "Could not install dependencies specific to binaries" info "Building $PACKAGE..." python3 -m pip install --no-dependencies --user . > /dev/null || fail "Could not build $PACKAGE" @@ -131,7 +148,7 @@ for d in ~/Library/Python/ ~/.pyenv .; do done info "Building binary" -APP_SIGN="$APP_SIGN" pyinstaller --noconfirm --ascii --clean --name $VERSION contrib/osx/osx.spec || fail "Could not build binary" +APP_SIGN="$CODESIGN_CERT" pyinstaller --noconfirm --ascii --clean --name $VERSION contrib/osx/osx.spec || fail "Could not build binary" info "Adding bitcoin URI types to Info.plist" plutil -insert 'CFBundleURLTypes' \ @@ -139,14 +156,23 @@ plutil -insert 'CFBundleURLTypes' \ -- dist/$PACKAGE.app/Contents/Info.plist \ || fail "Could not add keys to Info.plist. Make sure the program 'plutil' exists and is installed." -DoCodeSignMaybe "app bundle" "dist/${PACKAGE}.app" "$APP_SIGN" # If APP_SIGN is empty will be a noop +DoCodeSignMaybe "app bundle" "dist/${PACKAGE}.app" + +if [ ! -z "$CODESIGN_CERT" ]; then + if [ ! -z "$APPLE_ID_USER" ]; then + info "Notarizing .app with Apple's central server..." + "${CONTRIB_OSX}/notarize_app.sh" "dist/${PACKAGE}.app" || fail "Could not notarize binary." + else + warn "AppleID details not set! Skipping Apple notarization." + fi +fi info "Creating .DMG" hdiutil create -fs HFS+ -volname $PACKAGE -srcfolder dist/$PACKAGE.app dist/electrum-$VERSION.dmg || fail "Could not create .DMG" -DoCodeSignMaybe ".DMG" "dist/electrum-${VERSION}.dmg" "$APP_SIGN" # If APP_SIGN is empty will be a noop +DoCodeSignMaybe ".DMG" "dist/electrum-${VERSION}.dmg" -if [ -z "$APP_SIGN" ]; then +if [ -z "$CODESIGN_CERT" ]; then warn "App was built successfully but was not code signed. Users may get security warnings from macOS." - warn "Specify a valid code signing identity as the first argument to this script to enable code signing." + warn "Specify a valid code signing identity to enable code signing." fi diff --git a/contrib/osx/notarize_app.sh b/contrib/osx/notarize_app.sh new file mode 100644 index 000000000..1017a4965 --- /dev/null +++ b/contrib/osx/notarize_app.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# from https://github.com/metabrainz/picard/blob/e1354632d2db305b7a7624282701d34d73afa225/scripts/package/macos-notarize-app.sh + + +if [ -z "$1" ]; then + echo "Specify app bundle as first parameter" + exit 1 +fi + +if [ -z "$APPLE_ID_USER" ] || [ -z "$APPLE_ID_PASSWORD" ]; then + echo "You need to set your Apple ID credentials with \$APPLE_ID_USER and \$APPLE_ID_PASSWORD." + exit 1 +fi + +APP_BUNDLE=$(basename "$1") +APP_BUNDLE_DIR=$(dirname "$1") + +cd "$APP_BUNDLE_DIR" || exit 1 + +# Package app for submission +echo "Generating ZIP archive ${APP_BUNDLE}.zip..." +ditto -c -k --rsrc --keepParent "$APP_BUNDLE" "${APP_BUNDLE}.zip" + +# Submit for notarization +echo "Submitting $APP_BUNDLE for notarization..." +RESULT=$(xcrun altool --notarize-app --type osx \ + --file "${APP_BUNDLE}.zip" \ + --primary-bundle-id org.electrum.electrum \ + --username $APPLE_ID_USER \ + --password @env:APPLE_ID_PASSWORD \ + --output-format xml) + +if [ $? -ne 0 ]; then + echo "Submitting $APP_BUNDLE failed:" + echo "$RESULT" + exit 1 +fi + +REQUEST_UUID=$(echo "$RESULT" | xpath \ + "//key[normalize-space(text()) = 'RequestUUID']/following-sibling::string[1]/text()" 2> /dev/null) + +if [ -z "$REQUEST_UUID" ]; then + echo "Submitting $APP_BUNDLE failed:" + echo "$RESULT" + exit 1 +fi + +echo "$(echo "$RESULT" | xpath \ + "//key[normalize-space(text()) = 'success-message']/following-sibling::string[1]/text()" 2> /dev/null)" + +# Poll for notarization status +echo "Submitted notarization request $REQUEST_UUID, waiting for response..." +sleep 60 +while : +do + RESULT=$(xcrun altool --notarization-info "$REQUEST_UUID" \ + --username "$APPLE_ID_USER" \ + --password @env:APPLE_ID_PASSWORD \ + --output-format xml) + STATUS=$(echo "$RESULT" | xpath \ + "//key[normalize-space(text()) = 'Status']/following-sibling::string[1]/text()" 2> /dev/null) + + if [ "$STATUS" = "success" ]; then + echo "Notarization of $APP_BUNDLE succeeded!" + break + elif [ "$STATUS" = "in progress" ]; then + echo "Notarization in progress..." + sleep 20 + else + echo "Notarization of $APP_BUNDLE failed:" + echo "$RESULT" + exit 1 + fi +done + +# Staple the notary ticket +xcrun stapler staple "$APP_BUNDLE" diff --git a/contrib/osx/osx.spec b/contrib/osx/osx.spec index 99bb79c44..950a576d5 100644 --- a/contrib/osx/osx.spec +++ b/contrib/osx/osx.spec @@ -59,21 +59,19 @@ block_cipher = None # see https://github.com/pyinstaller/pyinstaller/issues/2005 hiddenimports = [] +hiddenimports += collect_submodules('pkg_resources') # workaround for https://github.com/pypa/setuptools/issues/1963 hiddenimports += collect_submodules('trezorlib') hiddenimports += collect_submodules('safetlib') hiddenimports += collect_submodules('btchip') hiddenimports += collect_submodules('keepkeylib') hiddenimports += collect_submodules('websocket') hiddenimports += collect_submodules('ckcc') +hiddenimports += collect_submodules('bitbox02') hiddenimports += ['PyQt5.QtPrintSupport'] # needed by Revealer -# safetlib imports PyQt5.Qt. We use a local updated copy of pinmatrix.py until they -# release a new version that includes https://github.com/archos-safe-t/python-safet/commit/b1eab3dba4c04fdfc1fcf17b66662c28c5f2380e -hiddenimports.remove('safetlib.qt.pinmatrix') - - datas = [ (electrum + PYPKG + '/*.json', PYPKG), + (electrum + PYPKG + '/lnwire/*.csv', PYPKG + '/lnwire'), (electrum + PYPKG + '/wordlist/english.txt', PYPKG + '/wordlist'), (electrum + PYPKG + '/locale', PYPKG + '/locale'), (electrum + PYPKG + '/plugins', PYPKG + '/plugins'), @@ -84,8 +82,7 @@ datas += collect_data_files('safetlib') datas += collect_data_files('btchip') datas += collect_data_files('keepkeylib') datas += collect_data_files('ckcc') -datas += collect_data_files('jsonrpcserver') -datas += collect_data_files('jsonrpcclient') +datas += collect_data_files('bitbox02') # Add the QR Scanner helper app datas += [(electrum + "contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app", "./contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app")] @@ -142,24 +139,29 @@ if APP_SIGN: 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', - 'NSSupportsAutomaticGraphicsSwitching': 'True' - } +exe = EXE( + pyz, + a.scripts, + exclude_binaries=True, + name=MAIN_SCRIPT, + debug=False, + strip=False, + upx=True, + icon=electrum+ICONS_FILE, + console=False, +) + +app = BUNDLE( + exe, + a.binaries, + a.zipfiles, + a.datas, + version = VERSION, + name=PACKAGE + '.app', + icon=electrum+ICONS_FILE, + bundle_identifier=None, + info_plist={ + 'NSHighResolutionCapable': 'True', + 'NSSupportsAutomaticGraphicsSwitching': 'True' + }, ) diff --git a/contrib/requirements/requirements-binaries.txt b/contrib/requirements/requirements-binaries.txt index 0e8a4dc15..803cc99bf 100644 --- a/contrib/requirements/requirements-binaries.txt +++ b/contrib/requirements/requirements-binaries.txt @@ -1,2 +1,2 @@ -PyQt5<5.12 -PyQt5-sip<=4.19.13 +PyQt5<5.15 +pycryptodomex>=3.7 diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt index 1d50f72fb..aa5ed6c83 100644 --- a/contrib/requirements/requirements-hw.txt +++ b/contrib/requirements/requirements-hw.txt @@ -8,9 +8,10 @@ # see https://github.com/spesmilo/electrum/issues/5859 Cython>=0.27 -trezor[hidapi]>=0.11.5 -safet[hidapi]>=0.1.0 +trezor[hidapi]>=0.12.0 +safet>=0.1.5 keepkey>=6.3.1 -btchip-python>=0.1.26 +btchip-python>=0.1.30 ckcc-protocol>=0.7.7 +bitbox02>=4.0.0 hidapi diff --git a/contrib/requirements/requirements-mac-build.txt b/contrib/requirements/requirements-mac-build.txt new file mode 100644 index 000000000..d268ae4ee --- /dev/null +++ b/contrib/requirements/requirements-mac-build.txt @@ -0,0 +1,6 @@ +pip +setuptools +pyinstaller>=3.6 + +# needed by pyinstaller: +macholib diff --git a/contrib/requirements/requirements-sdist-build.txt b/contrib/requirements/requirements-sdist-build.txt new file mode 100644 index 000000000..c367eb47a --- /dev/null +++ b/contrib/requirements/requirements-sdist-build.txt @@ -0,0 +1,3 @@ +# need modern versions of pip (and maybe other build tools), the one in apt had issues +pip +setuptools diff --git a/contrib/requirements/requirements-travis.txt b/contrib/requirements/requirements-travis.txt index b0aaeff51..d2c85106d 100644 --- a/contrib/requirements/requirements-travis.txt +++ b/contrib/requirements/requirements-travis.txt @@ -1,3 +1,3 @@ tox -python-coveralls +coveralls tox-travis diff --git a/contrib/requirements/requirements.txt b/contrib/requirements/requirements.txt index 962ba4b0b..a419b0bfd 100644 --- a/contrib/requirements/requirements.txt +++ b/contrib/requirements/requirements.txt @@ -1,15 +1,12 @@ pyaes>=0.1a1 ecdsa>=0.14 qrcode -protobuf -dnspython -qdarkstyle<2.7 +protobuf>=3.12 +dnspython<2.0 +qdarkstyle<2.9 aiorpcx>=0.18,<0.19 aiohttp>=3.3.0,<4.0.0 -aiohttp_socks +aiohttp_socks>=0.3 certifi bitstring -pycryptodomex>=3.7 -jsonrpcserver -jsonrpcclient -attrs +attrs>=19.2.0 diff --git a/contrib/udev/53-hid-bitbox02.rules b/contrib/udev/53-hid-bitbox02.rules new file mode 100644 index 000000000..2daffc03b --- /dev/null +++ b/contrib/udev/53-hid-bitbox02.rules @@ -0,0 +1 @@ +SUBSYSTEM=="usb", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02_%n", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403" diff --git a/contrib/udev/54-hid-bitbox02.rules b/contrib/udev/54-hid-bitbox02.rules new file mode 100644 index 000000000..1b74e4774 --- /dev/null +++ b/contrib/udev/54-hid-bitbox02.rules @@ -0,0 +1 @@ +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02-%n" diff --git a/contrib/udev/README.md b/contrib/udev/README.md index 6ff403a4f..451ef2b2f 100644 --- a/contrib/udev/README.md +++ b/contrib/udev/README.md @@ -6,7 +6,8 @@ These are necessary for the devices to be usable on Linux environments. - `20-hw1.rules` (Ledger): https://github.com/LedgerHQ/udev-rules/blob/master/20-hw1.rules - `51-coinkite.rules` (Coldcard): https://github.com/Coldcard/ckcc-protocol/blob/master/51-coinkite.rules - - `51-hid-digitalbitbox.rules`, `52-hid-digitalbitbox.rules` (Digital Bitbox): https://shiftcrypto.ch/start_linux + - `51-hid-digitalbitbox.rules`, `52-hid-digitalbitbox.rules` (Digital Bitbox): https://github.com/digitalbitbox/bitbox-wallet-app/blob/master/frontends/qt/resources/deb-afterinstall.sh + - `53-hid-bitbox02.rules`, `54-hid-bitbox02.rules` (BitBox02): https://github.com/digitalbitbox/bitbox-wallet-app/blob/master/frontends/qt/resources/deb-afterinstall.sh - `51-trezor.rules` (Trezor): https://github.com/trezor/trezor-common/blob/master/udev/51-trezor.rules - `51-usb-keepkey.rules` (Keepkey): https://github.com/keepkey/udev-rules/blob/master/51-usb-keepkey.rules - `51-safe-t.rules` (Archos): https://github.com/archos-safe-t/safe-t-common/blob/master/udev/51-safe-t.rules diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py index e8b2a02b8..ad3c5e83c 100644 --- a/electrum/address_synchronizer.py +++ b/electrum/address_synchronizer.py @@ -28,7 +28,7 @@ import itertools from collections import defaultdict from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple, NamedTuple, Sequence, List -from . import bitcoin +from . import bitcoin, util from .bitcoin import COINBASE_MATURITY from .util import profiler, bfh, TxMinedInfo from .transaction import Transaction, TxOutput, TxInput, PartialTxInput, TxOutpoint, PartialTransaction @@ -70,13 +70,17 @@ class AddressSynchronizer(Logger): inherited by wallet """ + network: Optional['Network'] + synchronizer: Optional['Synchronizer'] + verifier: Optional['SPV'] + def __init__(self, db: 'WalletDB'): self.db = db - self.network = None # type: Network + self.network = None Logger.__init__(self) # verifier (SPV) and synchronizer are started in start_network - self.synchronizer = None # type: Synchronizer - self.verifier = None # type: SPV + self.synchronizer = None + self.verifier = None # 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() @@ -156,17 +160,17 @@ class AddressSynchronizer(Logger): # add it in case it was previously unconfirmed self.add_unverified_tx(tx_hash, tx_height) - def start_network(self, network): + def start_network(self, network: Optional['Network']) -> None: self.network = network if self.network is not None: self.synchronizer = Synchronizer(self) self.verifier = SPV(self.network, self) - self.network.register_callback(self.on_blockchain_updated, ['blockchain_updated']) + util.register_callback(self.on_blockchain_updated, ['blockchain_updated']) def on_blockchain_updated(self, event, *args): self._get_addr_balance_cache = {} # invalidate cache - def stop_threads(self): + def stop(self): if self.network: if self.synchronizer: asyncio.run_coroutine_threadsafe(self.synchronizer.stop(), self.network.asyncio_loop) @@ -174,7 +178,7 @@ class AddressSynchronizer(Logger): if self.verifier: asyncio.run_coroutine_threadsafe(self.verifier.stop(), self.network.asyncio_loop) self.verifier = None - self.network.unregister_callback(self.on_blockchain_updated) + util.unregister_callback(self.on_blockchain_updated) self.db.put('stored_height', self.get_local_height()) def add_address(self, address): @@ -345,7 +349,7 @@ class AddressSynchronizer(Logger): prevout = TxOutpoint(bfh(tx_hash), idx) self.db.remove_prevout_by_scripthash(scripthash, prevout=prevout, value=txo.value) - def get_depending_transactions(self, tx_hash): + def get_depending_transactions(self, tx_hash: str) -> Set[str]: """Returns all (grand-)children of tx_hash in this wallet.""" with self.transaction_lock: children = set() @@ -449,7 +453,7 @@ class AddressSynchronizer(Logger): domain = set(domain) # 1. Get the history of each address in the domain, maintain the # delta of a tx as the sum of its deltas on domain addresses - tx_deltas = defaultdict(int) + tx_deltas = defaultdict(int) # type: Dict[str, Optional[int]] for addr in domain: h = self.get_address_history(addr) for tx_hash, height in h: @@ -546,7 +550,7 @@ class AddressSynchronizer(Logger): self.unverified_tx.pop(tx_hash, None) self.db.add_verified_tx(tx_hash, info) tx_mined_status = self.get_tx_height(tx_hash) - self.network.trigger_callback('verified', self, tx_hash, tx_mined_status) + util.trigger_callback('verified', self, tx_hash, tx_mined_status) def get_unverified_txs(self): '''Returns a map from tx hash to transaction height''' @@ -584,13 +588,17 @@ class AddressSynchronizer(Logger): return cached_local_height return self.network.get_local_height() if self.network else self.db.get('stored_height', 0) - def add_future_tx(self, tx: Transaction, num_blocks: int) -> None: + def add_future_tx(self, tx: Transaction, num_blocks: int) -> bool: assert num_blocks > 0, num_blocks with self.lock: - self.add_transaction(tx) - self.future_tx[tx.txid()] = num_blocks + tx_was_added = self.add_transaction(tx) + if tx_was_added: + self.future_tx[tx.txid()] = num_blocks + return tx_was_added def get_tx_height(self, tx_hash: str) -> TxMinedInfo: + if tx_hash is None: # ugly backwards compat... + return TxMinedInfo(height=TX_HEIGHT_LOCAL, conf=0) with self.lock: verified_tx_mined_info = self.db.get_verified_tx(tx_hash) if verified_tx_mined_info: @@ -609,9 +617,12 @@ class AddressSynchronizer(Logger): def set_up_to_date(self, up_to_date): with self.lock: + status_changed = self.up_to_date != up_to_date self.up_to_date = up_to_date if self.network: self.network.notify('status') + if status_changed: + self.logger.info(f'set_up_to_date: {up_to_date}') def is_up_to_date(self): with self.lock: return self.up_to_date @@ -751,29 +762,34 @@ class AddressSynchronizer(Logger): sent[txi] = height return received, sent - def get_addr_utxo(self, address: str) -> Dict[TxOutpoint, PartialTxInput]: + def get_addr_outputs(self, address: str) -> Dict[TxOutpoint, PartialTxInput]: coins, spent = self.get_addr_io(address) - for txi in spent: - coins.pop(txi) out = {} for prevout_str, v in coins.items(): tx_height, value, is_cb = v prevout = TxOutpoint.from_str(prevout_str) - utxo = PartialTxInput(prevout=prevout, - is_coinbase_output=is_cb) + utxo = PartialTxInput(prevout=prevout, is_coinbase_output=is_cb) utxo._trusted_address = address utxo._trusted_value_sats = value utxo.block_height = tx_height + utxo.spent_height = spent.get(prevout_str, None) out[prevout] = utxo return out + def get_addr_utxo(self, address: str) -> Dict[TxOutpoint, PartialTxInput]: + out = self.get_addr_outputs(address) + for k, v in list(out.items()): + if v.spent_height is not None: + out.pop(k) + return out + # return the total amount ever received by an address def get_addr_received(self, address): received, sent = self.get_addr_io(address) return sum([v for height, v, is_cb in received.values()]) @with_local_height_cached - def get_addr_balance(self, address, *, excluded_coins: Set[str] = None): + def get_addr_balance(self, address, *, excluded_coins: Set[str] = None) -> Tuple[int, int, int]: """Return the balance of a bitcoin address: confirmed and matured, unconfirmed, unmatured """ diff --git a/electrum/base_crash_reporter.py b/electrum/base_crash_reporter.py index 212a71411..6c10861e5 100644 --- a/electrum/base_crash_reporter.py +++ b/electrum/base_crash_reporter.py @@ -75,7 +75,7 @@ class BaseCrashReporter(Logger): async def do_post(self, proxy, url, data): async with make_aiohttp_session(proxy) as session: - async with session.post(url, data=data) as resp: + async with session.post(url, data=data, raise_for_status=True) as resp: return await resp.text() def get_traceback_info(self): @@ -121,15 +121,18 @@ class BaseCrashReporter(Logger): ['git', 'describe', '--always', '--dirty'], cwd=dir) return str(version, "utf8").strip() + def _get_traceback_str(self) -> str: + return "".join(traceback.format_exception(*self.exc_args)) + def get_report_string(self): info = self.get_additional_info() - info["traceback"] = "".join(traceback.format_exception(*self.exc_args)) + info["traceback"] = self._get_traceback_str() return self.issue_template.format(**info) def get_user_description(self): raise NotImplementedError - def get_wallet_type(self): + def get_wallet_type(self) -> str: raise NotImplementedError diff --git a/electrum/base_wizard.py b/electrum/base_wizard.py index 93de6f91f..293714ba5 100644 --- a/electrum/base_wizard.py +++ b/electrum/base_wizard.py @@ -28,20 +28,19 @@ import sys import copy import traceback from functools import partial -from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional +from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional, Union from . import bitcoin from . import keystore from . import mnemonic from .bip32 import is_bip32_derivation, xpub_type, normalize_bip32_derivation, BIP32Node -from .keystore import bip44_derivation, purpose48_derivation, Hardware_KeyStore, KeyStore +from .keystore import bip44_derivation, purpose48_derivation, Hardware_KeyStore, KeyStore, bip39_to_seed from .wallet import (Imported_Wallet, Standard_Wallet, Multisig_Wallet, wallet_types, Wallet, Abstract_Wallet) -from .storage import (WalletStorage, StorageEncryptionVersion, - get_derivation_used_for_hw_device_encryption) +from .storage import WalletStorage, StorageEncryptionVersion from .wallet_db import WalletDB from .i18n import _ -from .util import UserCancelled, InvalidPassword, WalletFileException +from .util import UserCancelled, InvalidPassword, WalletFileException, UserFacingException from .simple_config import SimpleConfig from .plugin import Plugins, HardwarePluginLibraryUnavailable from .logging import Logger @@ -61,6 +60,12 @@ class ScriptTypeNotSupported(Exception): pass class GoBack(Exception): pass +class ReRunDialog(Exception): pass + + +class ChooseHwDeviceAgain(Exception): pass + + class WizardStackItem(NamedTuple): action: Any args: Any @@ -114,18 +119,21 @@ class BaseWizard(Logger): def can_go_back(self): return len(self._stack) > 1 - def go_back(self): + def go_back(self, *, rerun_previous: bool = True) -> None: if not self.can_go_back(): return # pop 'current' frame self._stack.pop() - # pop 'previous' frame - stack_item = self._stack.pop() + prev_frame = self._stack[-1] # try to undo side effects since we last entered 'previous' frame - # FIXME only self.storage is properly restored - self.data = copy.deepcopy(stack_item.db_data) - # rerun 'previous' frame - self.run(stack_item.action, *stack_item.args, **stack_item.kwargs) + # FIXME only self.data is properly restored + self.data = copy.deepcopy(prev_frame.db_data) + + if rerun_previous: + # pop 'previous' frame + self._stack.pop() + # rerun 'previous' frame + self.run(prev_frame.action, *prev_frame.args, **prev_frame.kwargs) def reset_stack(self): self._stack = [] @@ -145,7 +153,7 @@ class BaseWizard(Logger): self.choice_dialog(title=title, message=message, choices=choices, run_next=self.on_wallet_type) def upgrade_db(self, storage, db): - exc = None + exc = None # type: Optional[Exception] def on_finished(): if exc is None: self.terminate(storage=storage, db=db) @@ -159,6 +167,15 @@ class BaseWizard(Logger): exc = e self.waiting_dialog(do_upgrade, _('Upgrading wallet format...'), on_finished=on_finished) + def run_task_without_blocking_gui(self, task, *, msg: str = None) -> Any: + """Perform a task in a thread without blocking the GUI. + Returns the result of 'task', or raises the same exception. + This method blocks until 'task' is finished. + """ + raise NotImplementedError() + + + def load_2fa(self): self.data['wallet_type'] = '2fa' self.data['use_trustedcoin'] = True @@ -254,7 +271,16 @@ class BaseWizard(Logger): k = keystore.from_master_key(text) self.on_keystore(k) - def choose_hw_device(self, purpose=HWD_SETUP_NEW_WALLET, *, storage=None): + ef choose_hw_device(self, purpose=HWD_SETUP_NEW_WALLET, *, storage: WalletStorage = None): + while True: + try: + self._choose_hw_device(purpose=purpose, storage=storage) + except ChooseHwDeviceAgain: + pass + else: + break + + def _choose_hw_device(self, *, purpose, storage: WalletStorage = None): title = _('Hardware Keystore') # check available plugins supported_plugins = self.plugins.get_hardware_support() @@ -271,7 +297,8 @@ class BaseWizard(Logger): # scan devices try: - scanned_devices = devmgr.scan_devices() + scanned_devices = self.run_task_without_blocking_gui(task=devmgr.scan_devices, + msg=_("Scanning devices...")) except BaseException as e: self.logger.info('error scanning devices: {}'.format(repr(e))) debug_msg = ' {}:\n {}'.format(_('Error scanning devices'), e) @@ -317,8 +344,8 @@ class BaseWizard(Logger): msg += '\n\n' msg += _('Debug message') + '\n' + debug_msg self.confirm_dialog(title=title, message=msg, - run_next=lambda x: self.choose_hw_device(purpose, storage=storage)) - return + run_next=lambda x: None) +raise ChooseHwDeviceAgain() # select device self.devices = devices choices = [] @@ -327,36 +354,37 @@ class BaseWizard(Logger): label = info.label or _("An unnamed {}").format(name) try: transport_str = info.device.transport_ui_string[:20] except: transport_str = 'unknown transport' - descr = f"{label} [{name}, {state}, {transport_str}]" + descr = f"{label} [{info.model_name or name}, {state}, {transport_str}]" choices.append(((name, info), descr)) msg = _('Select a device') + ':' self.choice_dialog(title=title, message=msg, choices=choices, run_next=lambda *args: self.on_device(*args, purpose=purpose, storage=storage)) - def on_device(self, name, device_info, *, purpose, storage=None): - self.plugin = self.plugins.get_plugin(name) # type: HW_PluginBase + def on_device(self, name, device_info: 'DeviceInfo', *, purpose, storage: WalletStorage = None): + self.plugin = self.plugins.get_plugin(name) + assert isinstance(self.plugin, HW_PluginBase) + devmgr = self.plugins.device_manager try: - self.plugin.setup_device(device_info, self, purpose) + client = 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, storage=storage) - return + raise ChooseHwDeviceAgain() except OutdatedHwFirmwareException as e: if self.question(e.text_ignore_old_fw_and_continue(), title=_("Outdated device firmware")): self.plugin.set_ignore_outdated_fw() # will need to re-pair - devmgr = self.plugins.device_manager devmgr.unpair_id(device_info.device.id_) - self.choose_hw_device(purpose, storage=storage) - return + raise ChooseHwDeviceAgain() except (UserCancelled, GoBack): - self.choose_hw_device(purpose, storage=storage) - return + raise ChooseHwDeviceAgain() + except UserFacingException as e: + self.show_error(str(e)) + raise ChooseHwDeviceAgain() except BaseException as e: self.logger.exception('') self.show_error(str(e)) @@ -368,22 +396,18 @@ class BaseWizard(Logger): self.run('on_hw_derivation', name, device_info, derivation, script_type) self.derivation_and_script_type_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, ()).hex() + password = client.get_password_for_storage_encryption() try: storage.decrypt(password) except InvalidPassword: # try to clear session so that user can type another passphrase - devmgr = self.plugins.device_manager - client = devmgr.client_by_id(device_info.device.id_) if hasattr(client, 'clear_session'): # FIXME not all hw wallet plugins have this client.clear_session() raise else: raise Exception('unknown purpose: %s' % purpose) - def derivation_and_script_type_dialog(self, f): + def derivation_and_script_type_dialog(self, f, *, get_account_xpub=None): message1 = _('Choose the type of addresses in your wallet.') message2 = ' '.join([ _('You can override the suggested derivation path.'), @@ -407,29 +431,32 @@ class BaseWizard(Logger): ] while True: try: - self.choice_and_line_dialog( + self.derivation_and_script_type_gui_specific_dialog( run_next=f, title=_('Script type and Derivation path'), message1=message1, message2=message2, choices=choices, test_text=is_bip32_derivation, - default_choice_idx=default_choice_idx) + default_choice_idx=default_choice_idx, get_account_xpub=get_account_xpub) return except ScriptTypeNotSupported as e: self.show_error(e) # let the user choose again - def on_hw_derivation(self, name, device_info, derivation, xtype): + def on_hw_derivation(self, name, device_info: 'DeviceInfo', derivation, xtype): from .keystore import hardware_keystore devmgr = self.plugins.device_manager + assert isinstance(self.plugin, HW_PluginBase) try: xpub = self.plugin.get_xpub(device_info.device.id_, derivation, xtype, self) - client = devmgr.client_by_id(device_info.device.id_) + client = devmgr.client_by_id(device_info.device.id_, scan_now=False) if not client: raise Exception("failed to find client for device id") root_fingerprint = client.request_root_fingerprint_from_device() + label = client.label() # use this as device_info.label might be outdated! + soft_device_id = client.get_soft_device_id() # use this as device_info.device_id might be outdated! except ScriptTypeNotSupported: raise # this is handled in derivation_dialog except BaseException as e: self.logger.exception('') self.show_error(e) - return + raise ChooseHwDeviceAgain() d = { 'type': 'hardware', 'hw_type': name, @@ -466,7 +493,8 @@ class BaseWizard(Logger): def on_restore_seed(self, seed, is_bip39, is_ext): self.seed_type = 'bip39' if is_bip39 else mnemonic.seed_type(seed) if self.seed_type == 'bip39': - f = lambda passphrase: self.on_restore_bip39(seed, passphrase) + def f(passphrase): + self.on_restore_bip39(seed, passphrase) self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') elif self.seed_type in ['standard', 'segwit']: f = lambda passphrase: self.run('create_keystore', seed, passphrase) @@ -483,7 +511,13 @@ class BaseWizard(Logger): def f(derivation, script_type): derivation = normalize_bip32_derivation(derivation) self.run('on_bip43', seed, passphrase, derivation, script_type) - self.derivation_and_script_type_dialog(f) + def get_account_xpub(account_path): + root_seed = bip39_to_seed(seed, passphrase) + root_node = BIP32Node.from_rootseed(root_seed, xtype="standard") + account_node = root_node.subkey_at_private_derivation(account_path) + account_xpub = account_node.to_xpub() + return account_xpub + self.derivation_and_script_type_dialog(f, get_account_xpub=get_account_xpub) def create_keystore(self, seed, passphrase): k = keystore.from_seed(seed, passphrase, self.wallet_type == 'multisig') @@ -520,12 +554,12 @@ class BaseWizard(Logger): self.show_error(_('Cannot add this cosigner:') + '\n' + "Their key type is '%s', we are '%s'"%(t1, t2)) self.run('choose_keystore') return - self.keystores.append(k) - if len(self.keystores) == 1: + if len(self.keystores) == 0: xpub = k.get_master_public_key() - self.reset_stack() self.run('show_xpub_and_add_cosigners', xpub) - elif len(self.keystores) < self.n: + self.reset_stack() + self.keystores.append(k) + if len(self.keystores) < self.n: self.run('choose_keystore') else: self.run('create_wallet') @@ -537,18 +571,19 @@ class BaseWizard(Logger): if self.wallet_type == 'standard' and isinstance(self.keystores[0], Hardware_KeyStore): # offer encrypting with a pw derived from the hw device k = self.keystores[0] # type: Hardware_KeyStore + assert isinstance(self.plugin, HW_PluginBase) 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 + raise ChooseHwDeviceAgain() + except BaseException as e: self.logger.exception('') self.show_error(str(e)) - return + raise ChooseHwDeviceAgain() self.request_storage_encryption( run_next=lambda encrypt_storage: self.on_password( password, @@ -593,11 +628,10 @@ class BaseWizard(Logger): self.terminate() - def create_storage(self, path): + def create_storage(self, path) -> Tuple[WalletStorage, WalletDB]: if os.path.exists(path): raise Exception('file already exists at path') - if not self.pw_args: - return + assert self.pw_args, f"pw_args not set?!" pw_args = self.pw_args self.pw_args = None # clean-up so that it can get GC-ed storage = WalletStorage(path) @@ -611,11 +645,13 @@ class BaseWizard(Logger): db.write(storage) return storage, db - def terminate(self, *, storage: Optional[WalletStorage], db: Optional[WalletDB] = None): + def terminate(self, *, storage: WalletStorage = None, + db: WalletDB = None, + aborted: bool = False) -> None: raise NotImplementedError() # implemented by subclasses def show_xpub_and_add_cosigners(self, xpub): - self.show_xpub_dialog(xpub=xpub, run_next=lambda x: self.run('choose_keystore')) + self.show_xpub_dialog(xpub=xpub, run_next=lambda x: None) def choose_seed_type(self, message=None, choices=None): title = _('Choose Seed type') @@ -666,3 +702,6 @@ class BaseWizard(Logger): self.line_dialog(run_next=f, title=title, message=message, default='', test=lambda x: x==passphrase) else: f('') + + def show_error(self, msg: Union[str, BaseException]) -> None: + raise NotImplementedError() diff --git a/electrum/bip32.py b/electrum/bip32.py index d37386632..06c1cebca 100644 --- a/electrum/bip32.py +++ b/electrum/bip32.py @@ -401,3 +401,25 @@ def root_fp_and_der_prefix_from_xkey(xkey: str) -> Tuple[Optional[str], Optional derivation_prefix = convert_bip32_intpath_to_strpath([child_number_int]) root_fingerprint = node.fingerprint.hex() return root_fingerprint, derivation_prefix + +def is_xkey_consistent_with_key_origin_info(xkey: str, *, + derivation_prefix: str = None, + root_fingerprint: str = None) -> bool: + bip32node = BIP32Node.from_xkey(xkey) + int_path = None + if derivation_prefix is not None: + int_path = convert_bip32_path_to_list_of_uint32(derivation_prefix) + if int_path is not None and len(int_path) != bip32node.depth: + return False + if bip32node.depth == 0: + if bfh(root_fingerprint) != bip32node.calc_fingerprint_of_this_node(): + return False + if bip32node.child_number != bytes(4): + return False + if int_path is not None and bip32node.depth > 0: + if int.from_bytes(bip32node.child_number, 'big') != int_path[-1]: + return False + if bip32node.depth == 1: + if bfh(root_fingerprint) != bip32node.fingerprint: + return False + return True diff --git a/electrum/bip39_recovery.py b/electrum/bip39_recovery.py new file mode 100644 index 000000000..4dc3862e3 --- /dev/null +++ b/electrum/bip39_recovery.py @@ -0,0 +1,75 @@ + Copyright (C) 2020 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php + +from typing import TYPE_CHECKING + +from aiorpcx import TaskGroup + +from . import bitcoin +from .constants import BIP39_WALLET_FORMATS +from .bip32 import BIP32_PRIME, BIP32Node +from .bip32 import convert_bip32_path_to_list_of_uint32 as bip32_str_to_ints +from .bip32 import convert_bip32_intpath_to_strpath as bip32_ints_to_str + +if TYPE_CHECKING: + from .network import Network + + +async def account_discovery(network: 'Network', get_account_xpub): + async with TaskGroup() as group: + account_scan_tasks = [] + for wallet_format in BIP39_WALLET_FORMATS: + account_scan = scan_for_active_accounts(network, get_account_xpub, wallet_format) + account_scan_tasks.append(await group.spawn(account_scan)) + active_accounts = [] + for task in account_scan_tasks: + active_accounts.extend(task.result()) + return active_accounts + + +async def scan_for_active_accounts(network: 'Network', get_account_xpub, wallet_format): + active_accounts = [] + account_path = bip32_str_to_ints(wallet_format["derivation_path"]) + while True: + account_xpub = get_account_xpub(account_path) + account_node = BIP32Node.from_xkey(account_xpub) + has_history = await account_has_history(network, account_node, wallet_format["script_type"]) + if has_history: + account = format_account(wallet_format, account_path) + active_accounts.append(account) + if not has_history or not wallet_format["iterate_accounts"]: + break + account_path[-1] = account_path[-1] + 1 + return active_accounts + + +async def account_has_history(network: 'Network', account_node: BIP32Node, script_type: str) -> bool: + gap_limit = 20 + async with TaskGroup() as group: + get_history_tasks = [] + for address_index in range(gap_limit): + address_node = account_node.subkey_at_public_derivation("0/" + str(address_index)) + pubkey = address_node.eckey.get_public_key_hex() + address = bitcoin.pubkey_to_address(script_type, pubkey) + script = bitcoin.address_to_script(address) + scripthash = bitcoin.script_to_scripthash(script) + get_history = network.get_history_for_scripthash(scripthash) + get_history_tasks.append(await group.spawn(get_history)) + for task in get_history_tasks: + history = task.result() + if len(history) > 0: + return True + return False + + +def format_account(wallet_format, account_path): + description = wallet_format["description"] + if wallet_format["iterate_accounts"]: + account_index = account_path[-1] % BIP32_PRIME + description = f'{description} (Account {account_index})' + return { + "description": description, + "derivation_path": bip32_ints_to_str(account_path), + "script_type": wallet_format["script_type"], + } diff --git a/electrum/bip39_wallet_formats.json b/electrum/bip39_wallet_formats.json new file mode 100644 index 000000000..d6551ff98 --- /dev/null +++ b/electrum/bip39_wallet_formats.json @@ -0,0 +1,80 @@ +[ + { + "description": "Standard BIP44 legacy", + "derivation_path": "m/44'/0'/0'", + "script_type": "p2pkh", + "iterate_accounts": true + }, + { + "description": "Standard BIP49 compatibility segwit", + "derivation_path": "m/49'/0'/0'", + "script_type": "p2wpkh-p2sh", + "iterate_accounts": true + }, + { + "description": "Standard BIP84 native segwit", + "derivation_path": "m/84'/0'/0'", + "script_type": "p2wpkh", + "iterate_accounts": true + }, + { + "description": "Non-standard legacy", + "derivation_path": "m/0'", + "script_type": "p2pkh", + "iterate_accounts": true + }, + { + "description": "Non-standard compatibility segwit", + "derivation_path": "m/0'", + "script_type": "p2wpkh-p2sh", + "iterate_accounts": true + }, + { + "description": "Non-standard native segwit", + "derivation_path": "m/0'", + "script_type": "p2wpkh", + "iterate_accounts": true + }, + { + "description": "Copay native segwit", + "derivation_path": "m/44'/0'/0'", + "script_type": "p2wpkh", + "iterate_accounts": true + }, + { + "description": "Samourai Bad Bank (toxic change)", + "derivation_path": "m/84'/0'/2147483644'", + "script_type": "p2wpkh", + "iterate_accounts": false + }, + { + "description": "Samourai Whirlpool Pre Mix", + "derivation_path": "m/84'/0'/2147483645'", + "script_type": "p2wpkh", + "iterate_accounts": false + }, + { + "description": "Samourai Whirlpool Post Mix", + "derivation_path": "m/84'/0'/2147483646'", + "script_type": "p2wpkh", + "iterate_accounts": false + }, + { + "description": "Samourai Ricochet legacy", + "derivation_path": "m/44'/0'/2147483647'", + "script_type": "p2pkh", + "iterate_accounts": false + }, + { + "description": "Samourai Ricochet compatibility segwit", + "derivation_path": "m/49'/0'/2147483647'", + "script_type": "p2wpkh-p2sh", + "iterate_accounts": false + }, + { + "description": "Samourai Ricochet native segwit", + "derivation_path": "m/84'/0'/2147483647'", + "script_type": "p2wpkh", + "iterate_accounts": false + } +] diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py index 4e65e8b98..385b08084 100644 --- a/electrum/bitcoin.py +++ b/electrum/bitcoin.py @@ -25,7 +25,8 @@ import hashlib from typing import List, Tuple, TYPE_CHECKING, Optional, Union -from enum import IntEnum +import enum +from enum import IntEnum, Enum from .util import bfh, bh2u, BitcoinException, assert_bytes, to_bytes, inv_dict from . import version @@ -44,6 +45,10 @@ COINBASE_MATURITY = 100 COIN = 100000000 TOTAL_COIN_SUPPLY_LIMIT_IN_BTC = 21000000 +NLOCKTIME_MIN = 0 +NLOCKTIME_BLOCKHEIGHT_MAX = 500_000_000 - 1 +NLOCKTIME_MAX = 2 ** 32 - 1 + # supported types of transaction outputs # TODO kill these with fire TYPE_ADDRESS = 0 @@ -295,19 +300,28 @@ def add_number_to_script(i: int) -> bytes: def relayfee(network: 'Network' = None) -> int: + """Returns feerate in sat/kbyte.""" from .simple_config import FEERATE_DEFAULT_RELAY, FEERATE_MAX_RELAY if network and network.relay_fee is not None: fee = network.relay_fee else: fee = FEERATE_DEFAULT_RELAY fee = min(fee, FEERATE_MAX_RELAY) - fee = max(fee, 0) + fee = max(fee, FEERATE_DEFAULT_RELAY) return fee -def dust_threshold(network: 'Network'=None) -> int: +# see https://github.com/bitcoin/bitcoin/blob/a62f0ed64f8bbbdfe6467ac5ce92ef5b5222d1bd/src/policy/policy.cpp#L14 +DUST_LIMIT_DEFAULT_SAT_LEGACY = 546 +DUST_LIMIT_DEFAULT_SAT_SEGWIT = 294 + + +def dust_threshold(network: 'Network' = None) -> int: + """Returns the dust limit in satoshis.""" # Change <= dust threshold is added to the tx fee - return 182 * 3 * relayfee(network) // 1000 +dust_lim = 182 * 3 * relayfee(network) # in msat +# convert to sat, but round up: +return (dust_lim // 1000) + (dust_lim % 1000 > 0) def hash_encode(x: bytes) -> str: @@ -423,6 +437,39 @@ def address_to_script(addr: str, *, net=None) -> str: raise BitcoinException(f'unknown address type: {addrtype}') return script +class OnchainOutputType(Enum): + """Opaque types of scriptPubKeys. + In case of p2sh, p2wsh and similar, no knowledge of redeem script, etc. + """ + P2PKH = enum.auto() + P2SH = enum.auto() + WITVER0_P2WPKH = enum.auto() + WITVER0_P2WSH = enum.auto() + + +def address_to_hash(addr: str, *, net=None) -> Tuple[OnchainOutputType, bytes]: + """Return (type, pubkey hash / witness program) for an address.""" + if net is None: net = constants.net + if not is_address(addr, net=net): + raise BitcoinException(f"invalid lbry address: {addr}") + witver, witprog = segwit_addr.decode(net.SEGWIT_HRP, addr) + if witprog is not None: + if witver != 0: + raise BitcoinException(f"not implemented handling for witver={witver}") + if len(witprog) == 20: + return OnchainOutputType.WITVER0_P2WPKH, bytes(witprog) + elif len(witprog) == 32: + return OnchainOutputType.WITVER0_P2WSH, bytes(witprog) + else: + raise BitcoinException(f"unexpected length for segwit witver=0 witprog: len={len(witprog)}") + addrtype, hash_160_ = b58_address_to_hash160(addr) + if addrtype == net.ADDRTYPE_P2PKH: + return OnchainOutputType.P2PKH, hash_160_ + elif addrtype == net.ADDRTYPE_P2SH: + return OnchainOutputType.P2SH, hash_160_ + raise BitcoinException(f"unknown address type: {addrtype}") + + def address_to_scripthash(addr: str) -> str: script = address_to_script(addr) return script_to_scripthash(script) @@ -556,8 +603,8 @@ def is_segwit_script_type(txin_type: str) -> bool: return txin_type in ('p2wpkh', 'p2wpkh-p2sh', 'p2wsh', 'p2wsh-p2sh') -def serialize_privkey(secret: bytes, compressed: bool, txin_type: str, - internal_use: bool=False) -> str: +def serialize_privkey(secret: bytes, compressed: bool, txin_type: str, *, + internal_use: bool = False) -> str: # we only export secrets inside curve range secret = ecc.ECPrivkey.normalize_secret_bytes(secret) if internal_use: diff --git a/electrum/blockchain.py b/electrum/blockchain.py index 96e21b0db..800037a7f 100644 --- a/electrum/blockchain.py +++ b/electrum/blockchain.py @@ -22,6 +22,7 @@ # SOFTWARE. import os import threading +import time from typing import Optional, Dict, Mapping, Sequence import hashlib @@ -189,6 +190,19 @@ _CHAINWORK_CACHE = { "0000000000000000000000000000000000000000000000000000000000000000": 0, # virtual block at height -1 } # type: Dict[str, int] +def init_headers_file_for_best_chain(): + b = get_best_chain() + filename = b.path() + length = HEADER_SIZE * len(constants.net.CHECKPOINTS) * 2016 + 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') + util.ensure_sparse_file(filename) + with b.lock: + b.update_size() + class Blockchain(Logger): """ @@ -503,6 +517,20 @@ class Blockchain(Logger): height = self.height() return self.read_header(height) + def is_tip_stale(self) -> bool: + STALE_DELAY = 8 * 60 * 60 # in seconds + header = self.header_at_tip() + if not header: + return True + # note: We check the timestamp only in the latest header. + # The Bitcoin consensus has a lot of leeway here: + # - needs to be greater than the median of the timestamps of the past 11 blocks, and + # - up to at most 2 hours into the future compared to local clock + # so there is ~2 hours of leeway in either direction + if header['timestamp'] + STALE_DELAY < time.time(): + return True + return False + def get_hash(self, height: int) -> str: def is_height_checkpoint(): within_cp_range = height <= constants.net.max_checkpoint() @@ -729,6 +757,14 @@ def can_connect(header: dict) -> Optional[Blockchain]: return b return None +def get_chains_that_contain_header(height: int, header_hash: str) -> Sequence[Blockchain]: + """Returns a list of Blockchains that contain header, best chain first.""" + with blockchains_lock: chains = list(blockchains.values()) + chains = [chain for chain in chains + if chain.check_hash(height=height, header_hash=header_hash)] + chains = sorted(chains, key=lambda x: x.get_chainwork(), reverse=True) + return chains + class ArithUint256: # https://github.com/bitcoin/bitcoin/blob/master/src/arith_uint256.cpp diff --git a/electrum/channel_db.py b/electrum/channel_db.py index aae3d2c9a..48cf5c9bc 100644 --- a/electrum/channel_db.py +++ b/electrum/channel_db.py @@ -31,26 +31,22 @@ from typing import Sequence, List, Tuple, Optional, Dict, NamedTuple, TYPE_CHECK import binascii import base64 import asyncio +import threading +from enum import IntEnum from .sql_db import SqlDB, sql -from . import constants +from . import constants, util from .util import bh2u, profiler, get_headers_dir, bfh, is_ip_address, list_enabled_bits from .logging import Logger -from .lnutil import LN_GLOBAL_FEATURES_KNOWN_SET, LNPeerAddr, format_short_channel_id, ShortChannelID +from .lnutil import (LNPeerAddr, format_short_channel_id, ShortChannelID, + validate_features, IncompatibleOrInsaneFeatures) from .lnverifier import LNChannelVerifier, verify_sig_for_channel_update +from .lnmsg import decode_msg if TYPE_CHECKING: from .network import Network - - -class UnknownEvenFeatureBits(Exception): pass - -def validate_features(features : int): - enabled_features = list_enabled_bits(features) - for fbit in enabled_features: - if (1 << fbit) not in LN_GLOBAL_FEATURES_KNOWN_SET and fbit % 2 == 0: - raise UnknownEvenFeatureBits() + from .lnchannel import Channel FLAG_DISABLE = 1 << 1 @@ -63,7 +59,7 @@ class ChannelInfo(NamedTuple): capacity_sat: Optional[int] @staticmethod - def from_msg(payload): + def from_msg(payload: dict) -> 'ChannelInfo': features = int.from_bytes(payload['features'], 'big') validate_features(features) channel_id = payload['short_channel_id'] @@ -79,6 +75,11 @@ class ChannelInfo(NamedTuple): ) + @staticmethod + def from_raw_msg(raw: bytes) -> 'ChannelInfo': + payload_dict = decode_msg(raw)[1] + return ChannelInfo.from_msg(payload_dict) + class Policy(NamedTuple): key: bytes cltv_expiry_delta: int @@ -91,19 +92,25 @@ class Policy(NamedTuple): timestamp: int @staticmethod - def from_msg(payload): + def from_msg(payload: dict) -> 'Policy': return Policy( key = payload['short_channel_id'] + payload['start_node'], - cltv_expiry_delta = int.from_bytes(payload['cltv_expiry_delta'], "big"), - htlc_minimum_msat = int.from_bytes(payload['htlc_minimum_msat'], "big"), - htlc_maximum_msat = int.from_bytes(payload['htlc_maximum_msat'], "big") if 'htlc_maximum_msat' in payload else None, - fee_base_msat = int.from_bytes(payload['fee_base_msat'], "big"), - fee_proportional_millionths = int.from_bytes(payload['fee_proportional_millionths'], "big"), + cltv_expiry_delta = payload['cltv_expiry_delta'], + htlc_minimum_msat = payload['htlc_minimum_msat'], + htlc_maximum_msat = payload.get('htlc_maximum_msat', None), + fee_base_msat = payload['fee_base_msat'], + fee_proportional_millionths = payload['fee_proportional_millionths'], message_flags = int.from_bytes(payload['message_flags'], "big"), channel_flags = int.from_bytes(payload['channel_flags'], "big"), - timestamp = int.from_bytes(payload['timestamp'], "big") + timestamp = payload['timestamp'], ) + @staticmethod + def from_raw_msg(key:bytes, raw: bytes) -> 'Policy': + payload = decode_msg(raw)[1] + payload['start_node'] = key[8:] + return Policy.from_msg(payload) + def is_disabled(self): return self.channel_flags & FLAG_DISABLE @@ -112,7 +119,7 @@ class Policy(NamedTuple): return ShortChannelID.normalize(self.key[0:8]) @property - def start_node(self): + def start_node(self) -> bytes: return self.key[8:] @@ -124,15 +131,30 @@ class NodeInfo(NamedTuple): alias: str @staticmethod - def from_msg(payload): + def from_msg(payload) -> Tuple['NodeInfo', Sequence['LNPeerAddr']]: node_id = payload['node_id'] features = int.from_bytes(payload['features'], "big") validate_features(features) addresses = NodeInfo.parse_addresses_field(payload['addresses']) + peer_addrs = [] + for host, port in addresses: + try: + peer_addrs.append(LNPeerAddr(host=host, port=port, pubkey=node_id)) + except ValueError: + pass alias = payload['alias'].rstrip(b'\x00') - timestamp = int.from_bytes(payload['timestamp'], "big") - return NodeInfo(node_id=node_id, features=features, timestamp=timestamp, alias=alias), [ - Address(host=host, port=port, node_id=node_id, last_connected_date=None) for host, port in addresses] + try: + alias = alias.decode('utf8') + except: + alias = '' + timestamp = payload['timestamp'] + node_info = NodeInfo(node_id=node_id, features=features, timestamp=timestamp, alias=alias) + return node_info, peer_addrs + + @staticmethod + def from_raw_msg(raw: bytes) -> Tuple['NodeInfo', Sequence['LNPeerAddr']]: + payload_dict = decode_msg(raw)[1] + return NodeInfo.from_msg(payload_dict) @staticmethod def parse_addresses_field(addresses_field): @@ -175,50 +197,41 @@ class NodeInfo(NamedTuple): return addresses -class Address(NamedTuple): - node_id: bytes - host: str - port: int - last_connected_date: Optional[int] +class UpdateStatus(IntEnum): + ORPHANED = 0 + EXPIRED = 1 + DEPRECATED = 2 + UNCHANGED = 3 + GOOD = 4 class CategorizedChannelUpdates(NamedTuple): orphaned: List # no channel announcement for channel update expired: List # update older than two weeks deprecated: List # update older than database entry + unchanged: List # unchanged policies good: List # good updates - to_delete: List # database entries to delete -# TODO It would make more sense to store the raw gossip messages in the db. -# That is pretty much a pre-requisite of actively participating in gossip. + create_channel_info = """ CREATE TABLE IF NOT EXISTS channel_info ( -short_channel_id VARCHAR(64), -node1_id VARCHAR(66), -node2_id VARCHAR(66), -capacity_sat INTEGER, +short_channel_id BLOB(8), +msg BLOB, PRIMARY KEY(short_channel_id) )""" create_policy = """ CREATE TABLE IF NOT EXISTS policy ( -key VARCHAR(66), -cltv_expiry_delta INTEGER NOT NULL, -htlc_minimum_msat INTEGER NOT NULL, -htlc_maximum_msat INTEGER, -fee_base_msat INTEGER NOT NULL, -fee_proportional_millionths INTEGER NOT NULL, -channel_flags INTEGER NOT NULL, -message_flags INTEGER NOT NULL, -timestamp INTEGER NOT NULL, +key BLOB(41), +msg BLOB, PRIMARY KEY(key) )""" create_address = """ CREATE TABLE IF NOT EXISTS address ( -node_id VARCHAR(66), +node_id BLOB(33), host STRING(256), port INTEGER NOT NULL, timestamp INTEGER, @@ -227,10 +240,8 @@ PRIMARY KEY(node_id, host, port) create_node_info = """ CREATE TABLE IF NOT EXISTS node_info ( -node_id VARCHAR(66), -features INTEGER NOT NULL, -timestamp INTEGER NOT NULL, -alias STRING(64), +node_id BLOB(33), +msg BLOB, PRIMARY KEY(node_id) )""" @@ -240,19 +251,26 @@ class ChannelDB(SqlDB): NUM_MAX_RECENT_PEERS = 20 def __init__(self, network: 'Network'): - path = os.path.join(get_headers_dir(network.config), 'channel_db') - super().__init__(network, path, commit_interval=100) + path = os.path.join(get_headers_dir(network.config), 'gossip_db') + super().__init__(network.asyncio_loop, path, commit_interval=100) + self.lock = threading.RLock() self.num_nodes = 0 self.num_channels = 0 self._channel_updates_for_private_channels = {} # type: Dict[Tuple[bytes, bytes], dict] self.ca_verifier = LNChannelVerifier(network, self) + # initialized in load_data - self._channels = {} # type: Dict[bytes, ChannelInfo] - self._policies = {} - self._nodes = {} + # note: modify/iterate needs self.lock + self._channels = {} # type: Dict[ShortChannelID, ChannelInfo] + self._policies = {} # type: Dict[Tuple[bytes, ShortChannelID], Policy] # (node_id, scid) -> Policy + self._nodes = {} # type: Dict[bytes, NodeInfo] # node_id -> NodeInfo # node_id -> (host, port, ts) self._addresses = defaultdict(set) # type: Dict[bytes, Set[Tuple[str, int, int]]] - self._channels_for_node = defaultdict(set) + self._channels_for_node = defaultdict(set) # type: Dict[bytes, Set[ShortChannelID]] + self._recent_peers = [] # type: List[bytes] # list of node_ids + self._chans_with_0_policies = set() # type: Set[ShortChannelID] + self._chans_with_1_policies = set() # type: Set[ShortChannelID] + self._chans_with_2_policies = set() # type: Set[ShortChannelID] self.data_loaded = asyncio.Event() self.network = network # only for callback @@ -260,19 +278,28 @@ class ChannelDB(SqlDB): self.num_nodes = len(self._nodes) self.num_channels = len(self._channels) self.num_policies = len(self._policies) - self.network.trigger_callback('channel_db', self.num_nodes, self.num_channels, self.num_policies) + util.trigger_callback('channel_db', self.num_nodes, self.num_channels, self.num_policies) + util.trigger_callback('ln_gossip_sync_progress') def get_channel_ids(self): - return set(self._channels.keys()) + with self.lock: + return set(self._channels.keys()) def add_recent_peer(self, peer: LNPeerAddr): now = int(time.time()) node_id = peer.pubkey - self._addresses[node_id].add((peer.host, peer.port, now)) - self.save_node_address(node_id, peer, now) + with self.lock: + self._addresses[node_id].add((peer.host, peer.port, now)) + # list is ordered + if node_id in self._recent_peers: + self._recent_peers.remove(node_id) + self._recent_peers.insert(0, node_id) + self._recent_peers = self._recent_peers[:self.NUM_MAX_RECENT_PEERS] + self._db_save_node_address(peer, now) def get_200_randomly_sorted_nodes_not_in(self, node_ids): - unshuffled = set(self._nodes.keys()) - node_ids + with self.lock: + unshuffled = set(self._nodes.keys()) - node_ids return random.sample(unshuffled, min(200, len(unshuffled))) def get_last_good_address(self, node_id) -> Optional[LNPeerAddr]: @@ -287,16 +314,15 @@ class ChannelDB(SqlDB): return None def get_recent_peers(self): - assert self.data_loaded.is_set(), "channelDB load_data did not finish yet!" - # FIXME this does not reliably return "recent" peers... - # Also, the list() cast over the whole dict (thousands of elements), - # is really inefficient. - r = [self.get_last_good_address(node_id) - for node_id in list(self._addresses.keys())[-self.NUM_MAX_RECENT_PEERS:]] - return list(reversed(r)) + if not self.data_loaded.is_set(): + raise Exception("channelDB data not loaded yet!") + with self.lock: + ret = [self.get_last_good_address(node_id) + for node_id in self._recent_peers] + return ret # note: currently channel announcements are trusted by default (trusted=True); - # they are not verified. Verifying them would make the gossip sync + # they are not SPV-verified. Verifying them would make the gossip sync # even slower; especially as servers will start throttling us. # It would probably put significant strain on servers if all clients # verified the complete gossip. @@ -313,8 +339,8 @@ class ChannelDB(SqlDB): continue try: channel_info = ChannelInfo.from_msg(msg) - except UnknownEvenFeatureBits: - self.logger.info("unknown feature bits") + except IncompatibleOrInsaneFeatures as e: + self.logger.info(f"unknown or insane feature bits: {e!r}") continue if trusted: added += 1 @@ -328,85 +354,112 @@ class ChannelDB(SqlDB): def add_verified_channel_info(self, msg: dict, *, capacity_sat: int = None) -> None: try: channel_info = ChannelInfo.from_msg(msg) - except UnknownEvenFeatureBits: + except IncompatibleOrInsaneFeatures: return - channel_info = channel_info._replace(capacity_sat=capacity_sat) - self._channels[channel_info.short_channel_id] = channel_info - self._channels_for_node[channel_info.node1_id].add(channel_info.short_channel_id) - self._channels_for_node[channel_info.node2_id].add(channel_info.short_channel_id) - self.save_channel(channel_info) + with self.lock: + self._channels[channel_info.short_channel_id] = channel_info + self._channels_for_node[channel_info.node1_id].add(channel_info.short_channel_id) + self._channels_for_node[channel_info.node2_id].add(channel_info.short_channel_id) + self._update_num_policies_for_chan(channel_info.short_channel_id) + if 'raw' in msg: + self._db_save_channel(channel_info.short_channel_id, msg['raw']) - def print_change(self, old_policy: Policy, new_policy: Policy): - # print what changed between policies + def policy_changed(self, old_policy: Policy, new_policy: Policy, verbose: bool) -> bool: + changed = False if old_policy.cltv_expiry_delta != new_policy.cltv_expiry_delta: - self.logger.info(f'cltv_expiry_delta: {old_policy.cltv_expiry_delta} -> {new_policy.cltv_expiry_delta}') + changed |= True + if verbose: + self.logger.info(f'cltv_expiry_delta: {old_policy.cltv_expiry_delta} -> {new_policy.cltv_expiry_delta}') if old_policy.htlc_minimum_msat != new_policy.htlc_minimum_msat: - self.logger.info(f'htlc_minimum_msat: {old_policy.htlc_minimum_msat} -> {new_policy.htlc_minimum_msat}') + changed |= True + if verbose: + self.logger.info(f'htlc_minimum_msat: {old_policy.htlc_minimum_msat} -> {new_policy.htlc_minimum_msat}') if old_policy.htlc_maximum_msat != new_policy.htlc_maximum_msat: - self.logger.info(f'htlc_maximum_msat: {old_policy.htlc_maximum_msat} -> {new_policy.htlc_maximum_msat}') + changed |= True + if verbose: + self.logger.info(f'htlc_maximum_msat: {old_policy.htlc_maximum_msat} -> {new_policy.htlc_maximum_msat}') if old_policy.fee_base_msat != new_policy.fee_base_msat: - self.logger.info(f'fee_base_msat: {old_policy.fee_base_msat} -> {new_policy.fee_base_msat}') + changed |= True + if verbose: + self.logger.info(f'fee_base_msat: {old_policy.fee_base_msat} -> {new_policy.fee_base_msat}') if old_policy.fee_proportional_millionths != new_policy.fee_proportional_millionths: - self.logger.info(f'fee_proportional_millionths: {old_policy.fee_proportional_millionths} -> {new_policy.fee_proportional_millionths}') + changed |= True + if verbose: + self.logger.info(f'fee_proportional_millionths: {old_policy.fee_proportional_millionths} -> {new_policy.fee_proportional_millionths}') if old_policy.channel_flags != new_policy.channel_flags: - self.logger.info(f'channel_flags: {old_policy.channel_flags} -> {new_policy.channel_flags}') + changed |= True + if verbose: + self.logger.info(f'channel_flags: {old_policy.channel_flags} -> {new_policy.channel_flags}') if old_policy.message_flags != new_policy.message_flags: - self.logger.info(f'message_flags: {old_policy.message_flags} -> {new_policy.message_flags}') + changed |= True + if verbose: + self.logger.info(f'message_flags: {old_policy.message_flags} -> {new_policy.message_flags}') + if not changed and verbose: + self.logger.info(f'policy unchanged: {old_policy.timestamp} -> {new_policy.timestamp}') + return changed - def add_channel_updates(self, payloads, max_age=None, verify=True) -> CategorizedChannelUpdates: + def add_channel_update(self, payload, max_age=None, verify=False, verbose=True): + now = int(time.time()) + short_channel_id = ShortChannelID(payload['short_channel_id']) + timestamp = payload['timestamp'] + if max_age and now - timestamp > max_age: + return UpdateStatus.EXPIRED + if timestamp - now > 60: + return UpdateStatus.DEPRECATED + channel_info = self._channels.get(short_channel_id) + if not channel_info: + return UpdateStatus.ORPHANED + flags = int.from_bytes(payload['channel_flags'], 'big') + direction = flags & FLAG_DIRECTION + start_node = channel_info.node1_id if direction == 0 else channel_info.node2_id + payload['start_node'] = start_node + # compare updates to existing database entries + timestamp = payload['timestamp'] + start_node = payload['start_node'] + short_channel_id = ShortChannelID(payload['short_channel_id']) + key = (start_node, short_channel_id) + old_policy = self._policies.get(key) + if old_policy and timestamp <= old_policy.timestamp + 60: + return UpdateStatus.DEPRECATED + if verify: + self.verify_channel_update(payload) + policy = Policy.from_msg(payload) + with self.lock: + self._policies[key] = policy + self._update_num_policies_for_chan(short_channel_id) + if 'raw' in payload: + self._db_save_policy(policy.key, payload['raw']) + if old_policy and not self.policy_changed(old_policy, policy, verbose): + return UpdateStatus.UNCHANGED + else: + return UpdateStatus.GOOD + + def add_channel_updates(self, payloads, max_age=None) -> CategorizedChannelUpdates: orphaned = [] expired = [] deprecated = [] + unchanged = [] good = [] - to_delete = [] - # filter orphaned and expired first - known = [] - now = int(time.time()) for payload in payloads: - short_channel_id = ShortChannelID(payload['short_channel_id']) - timestamp = int.from_bytes(payload['timestamp'], "big") - if max_age and now - timestamp > max_age: - expired.append(payload) - continue - channel_info = self._channels.get(short_channel_id) - if not channel_info: + r = self.add_channel_update(payload, max_age=max_age, verbose=False) + if r == UpdateStatus.ORPHANED: orphaned.append(payload) continue - flags = int.from_bytes(payload['channel_flags'], 'big') - direction = flags & FLAG_DIRECTION - start_node = channel_info.node1_id if direction == 0 else channel_info.node2_id - payload['start_node'] = start_node - known.append(payload) - # compare updates to existing database entries - for payload in known: - timestamp = int.from_bytes(payload['timestamp'], "big") - start_node = payload['start_node'] - short_channel_id = ShortChannelID(payload['short_channel_id']) - key = (start_node, short_channel_id) - old_policy = self._policies.get(key) - if old_policy and timestamp <= old_policy.timestamp: + elif r == UpdateStatus.EXPIRED: + expired.append(payload) + elif r == UpdateStatus.DEPRECATED: deprecated.append(payload) - continue - good.append(payload) - if verify: - self.verify_channel_update(payload) - policy = Policy.from_msg(payload) - self._policies[key] = policy - self.save_policy(policy) - # + elif r == UpdateStatus.UNCHANGED: + unchanged.append(payload) + elif r == UpdateStatus.GOOD: + good.append(payload) self.update_counts() return CategorizedChannelUpdates( orphaned=orphaned, expired=expired, deprecated=deprecated, - good=good, - to_delete=to_delete, - ) - - def add_channel_update(self, payload): - # called from add_own_channel - # the update may be categorized as deprecated because of caching - categorized_chan_upds = self.add_channel_updates([payload], verify=False) + unchanged=unchanged, + good=good) def create_database(self): c = self.conn.cursor() @@ -417,44 +470,48 @@ class ChannelDB(SqlDB): self.conn.commit() @sql - def save_policy(self, policy): + def _db_save_policy(self, key: bytes, msg: bytes): + # 'msg' is a 'channel_update' message c = self.conn.cursor() - c.execute("""REPLACE INTO policy (key, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, fee_proportional_millionths, channel_flags, message_flags, timestamp) VALUES (?,?,?,?,?,?,?,?,?)""", list(policy)) + c.execute("""REPLACE INTO policy (key, msg) VALUES (?,?)""", [key, msg]) @sql - def delete_policy(self, node_id, short_channel_id): + def _db_delete_policy(self, node_id: bytes, short_channel_id: ShortChannelID): key = short_channel_id + node_id c = self.conn.cursor() c.execute("""DELETE FROM policy WHERE key=?""", (key,)) @sql - def save_channel(self, channel_info): + def _db_save_channel(self, short_channel_id: ShortChannelID, msg: bytes): + # 'msg' is a 'channel_announcement' message c = self.conn.cursor() - c.execute("REPLACE INTO channel_info (short_channel_id, node1_id, node2_id, capacity_sat) VALUES (?,?,?,?)", list(channel_info)) + c.execute("REPLACE INTO channel_info (short_channel_id, msg) VALUES (?,?)", [short_channel_id, msg]) @sql - def delete_channel(self, short_channel_id): + def _db_delete_channel(self, short_channel_id: ShortChannelID): c = self.conn.cursor() c.execute("""DELETE FROM channel_info WHERE short_channel_id=?""", (short_channel_id,)) @sql - def save_node(self, node_info): + def _db_save_node_info(self, node_id: bytes, msg: bytes): + # 'msg' is a 'node_announcement' message c = self.conn.cursor() - c.execute("REPLACE INTO node_info (node_id, features, timestamp, alias) VALUES (?,?,?,?)", list(node_info)) + c.execute("REPLACE INTO node_info (node_id, msg) VALUES (?,?)", [node_id, msg]) @sql - def save_node_address(self, node_id, peer, now): + def _db_save_node_address(self, peer: LNPeerAddr, timestamp: int): c = self.conn.cursor() - c.execute("REPLACE INTO address (node_id, host, port, timestamp) VALUES (?,?,?,?)", (node_id, peer.host, peer.port, now)) + c.execute("REPLACE INTO address (node_id, host, port, timestamp) VALUES (?,?,?,?)", + (peer.pubkey, peer.host, peer.port, timestamp)) @sql - def save_node_addresses(self, node_id, node_addresses): + def _db_save_node_addresses(self, node_addresses: Sequence[LNPeerAddr]): c = self.conn.cursor() for addr in node_addresses: - c.execute("SELECT * FROM address WHERE node_id=? AND host=? AND port=?", (addr.node_id, addr.host, addr.port)) + c.execute("SELECT * FROM address WHERE node_id=? AND host=? AND port=?", (addr.pubkey, addr.host, addr.port)) r = c.fetchall() if r == []: - c.execute("INSERT INTO address (node_id, host, port, timestamp) VALUES (?,?,?,?)", (addr.node_id, addr.host, addr.port, 0)) + c.execute("INSERT INTO address (node_id, host, port, timestamp) VALUES (?,?,?,?)", (addr.pubkey, addr.host, addr.port, 0)) def verify_channel_update(self, payload): short_channel_id = payload['short_channel_id'] @@ -463,16 +520,15 @@ class ChannelDB(SqlDB): raise Exception('wrong chain hash') if not verify_sig_for_channel_update(payload, payload['start_node']): raise Exception(f'failed verifying channel update for {short_channel_id}') - +# note: signatures have already been verified. def add_node_announcement(self, msg_payloads): if type(msg_payloads) is dict: msg_payloads = [msg_payloads] - old_addr = None new_nodes = {} for msg_payload in msg_payloads: try: node_info, node_addresses = NodeInfo.from_msg(msg_payload) - except UnknownEvenFeatureBits: + except IncompatibleOrInsaneFeatures: continue node_id = node_info.node_id # Ignore node if it has no associated channel (DoS protection) @@ -486,50 +542,44 @@ class ChannelDB(SqlDB): if node and node.timestamp >= node_info.timestamp: continue # save - self._nodes[node_id] = node_info - self.save_node(node_info) - for addr in node_addresses: - self._addresses[node_id].add((addr.host, addr.port, 0)) - self.save_node_addresses(node_id, node_addresses) + with self.lock: + self._nodes[node_id] = node_info + if 'raw' in msg_payload: + self._db_save_node_info(node_id, msg_payload['raw']) + with self.lock: + for addr in node_addresses: + self._addresses[node_id].add((addr.host, addr.port, 0)) + self._db_save_node_addresses(node_addresses) self.logger.debug("on_node_announcement: %d/%d"%(len(new_nodes), len(msg_payloads))) self.update_counts() - def get_routing_policy_for_channel(self, start_node_id: bytes, - short_channel_id: bytes) -> Optional[Policy]: - if not start_node_id or not short_channel_id: return None - channel_info = self.get_channel_info(short_channel_id) - if channel_info is not None: - return self.get_policy_for_node(short_channel_id, start_node_id) - msg = self._channel_updates_for_private_channels.get((start_node_id, short_channel_id)) - if not msg: - return None - return Policy.from_msg(msg) # won't actually be written to DB - - def get_old_policies(self, delta): + def get_old_policies(self, delta) -> Sequence[Tuple[bytes, ShortChannelID]]: + with self.lock: + _policies = self._policies.copy() now = int(time.time()) - return list(k for k, v in list(self._policies.items()) if v.timestamp <= now - delta) + return list(k for k, v in _policies.items() if v.timestamp <= now - delta) def prune_old_policies(self, delta): - l = self.get_old_policies(delta) - if l: - for k in l: - self._policies.pop(k) - self.delete_policy(*k) + old_policies = self.get_old_policies(delta) + if old_policies: + for key in old_policies: + node_id, scid = key + with self.lock: + self._policies.pop(key) + self._db_delete_policy(*key) + self._update_num_policies_for_chan(scid) self.update_counts() - self.logger.info(f'Deleting {len(l)} old policies') - - def get_orphaned_channels(self): - ids = set(x[1] for x in self._policies.keys()) - return list(x for x in self._channels.keys() if x not in ids) + self.logger.info(f'Deleting {len(old_policies)} old policies') def prune_orphaned_channels(self): - l = self.get_orphaned_channels() - if l: - for short_channel_id in l: + with self.lock: + orphaned_chans = self._chans_with_0_policies.copy() + if orphaned_chans: + for short_channel_id in orphaned_chans: self.remove_channel(short_channel_id) self.update_counts() - self.logger.info(f'Deleting {len(l)} orphaned channels') + self.logger.info(f'Deleting {len(orphaned_chans)} orphaned channels') def add_channel_update_for_private_channel(self, msg_payload: dict, start_node_id: bytes): if not verify_sig_for_channel_update(msg_payload, start_node_id): @@ -539,12 +589,15 @@ class ChannelDB(SqlDB): self._channel_updates_for_private_channels[(start_node_id, short_channel_id)] = msg_payload def remove_channel(self, short_channel_id: ShortChannelID): - channel_info = self._channels.pop(short_channel_id, None) - if channel_info: - self._channels_for_node[channel_info.node1_id].remove(channel_info.short_channel_id) - self._channels_for_node[channel_info.node2_id].remove(channel_info.short_channel_id) + # FIXME what about rm-ing policies? + with self.lock: + channel_info = self._channels.pop(short_channel_id, None) + if channel_info: + self._channels_for_node[channel_info.node1_id].remove(channel_info.short_channel_id) + self._channels_for_node[channel_info.node2_id].remove(channel_info.short_channel_id) + self._update_num_policies_for_chan(short_channel_id) # delete from database - self.delete_channel(short_channel_id) + self._db_delete_channel(short_channel_id) def get_node_addresses(self, node_id): return self._addresses.get(node_id) @@ -557,42 +610,139 @@ class ChannelDB(SqlDB): for x in c: node_id, host, port, timestamp = x self._addresses[node_id].add((str(host), int(port), int(timestamp or 0))) + def newest_ts_for_node_id(node_id): + newest_ts = 0 + for host, port, ts in self._addresses[node_id]: + newest_ts = max(newest_ts, ts) + return newest_ts + sorted_node_ids = sorted(self._addresses.keys(), key=newest_ts_for_node_id, reverse=True) + self._recent_peers = sorted_node_ids[:self.NUM_MAX_RECENT_PEERS] c.execute("""SELECT * FROM channel_info""") - for x in c: - x = (ShortChannelID.normalize(x[0]), *x[1:]) - ci = ChannelInfo(*x) - self._channels[ci.short_channel_id] = ci + for short_channel_id, msg in c: + try: + ci = ChannelInfo.from_raw_msg(msg) + except IncompatibleOrInsaneFeatures: + continue + self._channels[ShortChannelID.normalize(short_channel_id)] = ci c.execute("""SELECT * FROM node_info""") - for x in c: - ni = NodeInfo(*x) - self._nodes[ni.node_id] = ni + for node_id, msg in c: + try: + node_info, node_addresses = NodeInfo.from_raw_msg(msg) + except IncompatibleOrInsaneFeatures: + continue + # don't load node_addresses because they dont have timestamps + self._nodes[node_id] = node_info c.execute("""SELECT * FROM policy""") - for x in c: - p = Policy(*x) + for key, msg in c: + p = Policy.from_raw_msg(key, msg) self._policies[(p.start_node, p.short_channel_id)] = p for channel_info in self._channels.values(): self._channels_for_node[channel_info.node1_id].add(channel_info.short_channel_id) self._channels_for_node[channel_info.node2_id].add(channel_info.short_channel_id) + self._update_num_policies_for_chan(channel_info.short_channel_id) self.logger.info(f'load data {len(self._channels)} {len(self._policies)} {len(self._channels_for_node)}') self.update_counts() - self.count_incomplete_channels() + (nchans_with_0p, nchans_with_1p, nchans_with_2p) = self.get_num_channels_partitioned_by_policy_count() + self.logger.info(f'num_channels_partitioned_by_policy_count. ' + f'0p: {nchans_with_0p}, 1p: {nchans_with_1p}, 2p: {nchans_with_2p}') self.data_loaded.set() + util.trigger_callback('gossip_db_loaded') - def count_incomplete_channels(self): - out = set() - for short_channel_id, ci in self._channels.items(): - p1 = self.get_policy_for_node(short_channel_id, ci.node1_id) - p2 = self.get_policy_for_node(short_channel_id, ci.node2_id) - if p1 is None or p2 is not None: - out.add(short_channel_id) - self.logger.info(f'semi-orphaned: {len(out)}') + def _update_num_policies_for_chan(self, short_channel_id: ShortChannelID) -> None: + channel_info = self.get_channel_info(short_channel_id) + if channel_info is None: + with self.lock: + self._chans_with_0_policies.discard(short_channel_id) + self._chans_with_1_policies.discard(short_channel_id) + self._chans_with_2_policies.discard(short_channel_id) + return + p1 = self.get_policy_for_node(short_channel_id, channel_info.node1_id) + p2 = self.get_policy_for_node(short_channel_id, channel_info.node2_id) + with self.lock: + self._chans_with_0_policies.discard(short_channel_id) + self._chans_with_1_policies.discard(short_channel_id) + self._chans_with_2_policies.discard(short_channel_id) + if p1 is not None and p2 is not None: + self._chans_with_2_policies.add(short_channel_id) + elif p1 is None and p2 is None: + self._chans_with_0_policies.add(short_channel_id) + else: + self._chans_with_1_policies.add(short_channel_id) - def get_policy_for_node(self, short_channel_id: bytes, node_id: bytes) -> Optional['Policy']: - return self._policies.get((node_id, short_channel_id)) + def get_num_channels_partitioned_by_policy_count(self) -> Tuple[int, int, int]: + nchans_with_0p = len(self._chans_with_0_policies) + nchans_with_1p = len(self._chans_with_1_policies) + nchans_with_2p = len(self._chans_with_2_policies) + return nchans_with_0p, nchans_with_1p, nchans_with_2p - def get_channel_info(self, channel_id: bytes) -> ChannelInfo: - return self._channels.get(channel_id) + def get_policy_for_node(self, short_channel_id: bytes, node_id: bytes, *, + my_channels: Dict[ShortChannelID, 'Channel'] = None) -> Optional['Policy']: + channel_info = self.get_channel_info(short_channel_id) + if channel_info is not None: # publicly announced channel + policy = self._policies.get((node_id, short_channel_id)) + if policy: + return policy + else: # private channel + chan_upd_dict = self._channel_updates_for_private_channels.get((node_id, short_channel_id)) + if chan_upd_dict: + return Policy.from_msg(chan_upd_dict) + # check if it's one of our own channels + if not my_channels: + return + chan = my_channels.get(short_channel_id) # type: Optional[Channel] + if not chan: + return + if node_id == chan.node_id: # incoming direction (to us) + remote_update_raw = chan.get_remote_update() + if not remote_update_raw: + return + now = int(time.time()) + remote_update_decoded = decode_msg(remote_update_raw)[1] + remote_update_decoded['timestamp'] = now + remote_update_decoded['start_node'] = node_id + return Policy.from_msg(remote_update_decoded) + elif node_id == chan.get_local_pubkey(): # outgoing direction (from us) + local_update_decoded = decode_msg(chan.get_outgoing_gossip_channel_update())[1] + local_update_decoded['start_node'] = node_id + return Policy.from_msg(local_update_decoded) - def get_channels_for_node(self, node_id) -> Set[bytes]: - """Returns the set of channels that have node_id as one of the endpoints.""" - return self._channels_for_node.get(node_id) or set() + def get_channel_info(self, short_channel_id: ShortChannelID, *, + my_channels: Dict[ShortChannelID, 'Channel'] = None) -> Optional[ChannelInfo]: + ret = self._channels.get(short_channel_id) + if ret: + return ret + # check if it's one of our own channels + if not my_channels: + return + chan = my_channels.get(short_channel_id) # type: Optional[Channel] + ci = ChannelInfo.from_raw_msg(chan.construct_channel_announcement_without_sigs()) + return ci._replace(capacity_sat=chan.constraints.capacity) + + def get_channels_for_node(self, node_id: bytes, *, + my_channels: Dict[ShortChannelID, 'Channel'] = None) -> Set[bytes]: + """Returns the set of short channel IDs where node_id is one of the channel participants.""" + if not self.data_loaded.is_set(): + raise Exception("channelDB data not loaded yet!") + relevant_channels = self._channels_for_node.get(node_id) or set() + relevant_channels = set(relevant_channels) # copy + # add our own channels # TODO maybe slow? + for chan in (my_channels.values() or []): + if node_id in (chan.node_id, chan.get_local_pubkey()): + relevant_channels.add(chan.short_channel_id) + return relevant_channels + + def get_endnodes_for_chan(self, short_channel_id: ShortChannelID, *, + my_channels: Dict[ShortChannelID, 'Channel'] = None) -> Optional[Tuple[bytes, bytes]]: + channel_info = self.get_channel_info(short_channel_id) + if channel_info is not None: # publicly announced channel + return channel_info.node1_id, channel_info.node2_id + # check if it's one of our own channels + if not my_channels: + return + chan = my_channels.get(short_channel_id) # type: Optional[Channel] + if not chan: + return + return chan.get_local_pubkey(), chan.node_id + + def get_node_info_for_node_id(self, node_id: bytes) -> Optional['NodeInfo']: + return self._nodes.get(node_id) diff --git a/electrum/coinchooser.py b/electrum/coinchooser.py index d484bbd18..e4fc9e08d 100644 --- a/electrum/coinchooser.py +++ b/electrum/coinchooser.py @@ -49,7 +49,7 @@ class PRNG: self.pool.extend(self.sha) self.sha = sha256(self.sha) result, self.pool = self.pool[:n], self.pool[n:] - return result + return bytes(result) def randint(self, start, end): # Returns random integer in [start, end) @@ -103,10 +103,9 @@ def strip_unneeded(bkts: List[Bucket], sufficient_funds) -> List[Bucket]: class CoinChooserBase(Logger): - enable_output_value_rounding = False - - def __init__(self): + def __init__(self, *, enable_output_value_rounding: bool): Logger.__init__(self) + self.enable_output_value_rounding = enable_output_value_rounding def keys(self, coins: Sequence[PartialTxInput]) -> Sequence[str]: raise NotImplementedError @@ -485,6 +484,12 @@ def get_name(config): def get_coin_chooser(config): klass = COIN_CHOOSERS[get_name(config)] - coinchooser = klass() - coinchooser.enable_output_value_rounding = config.get('coin_chooser_output_rounding', False) + # note: we enable enable_output_value_rounding by default as + # - for sacrificing a few satoshis + # + it gives better privacy for the user re change output + # + it also helps the network as a whole as fees will become noisier + # (trying to counter the heuristic that "whole integer sat/byte feerates" are common) + coinchooser = klass( + enable_output_value_rounding=config.get('coin_chooser_output_rounding', True), + ) return coinchooser diff --git a/electrum/commands.py b/electrum/commands.py index 396099788..90ee43c4a 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -47,17 +47,20 @@ from .bip32 import BIP32Node from .i18n import _ from .transaction import (Transaction, multisig_script, TxOutput, PartialTransaction, PartialTxOutput, tx_from_any, PartialTxInput, TxOutpoint) -from .paymentrequest import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED +from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED from .synchronizer import Notifier -from .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text +from .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet from .address_synchronizer import TX_HEIGHT_LOCAL from .mnemonic import Mnemonic from .lnutil import SENT, RECEIVED +from .lnutil import LnFeatures from .lnutil import ln_dummy_address from .lnpeer import channel_id_from_funding_tx from .plugin import run_hook from .version import ELECTRUM_VERSION from .simple_config import SimpleConfig +from .invoices import LNInvoice +from . import submarine_swaps if TYPE_CHECKING: @@ -68,10 +71,16 @@ if TYPE_CHECKING: known_commands = {} # type: Dict[str, Command] +class NotSynchronizedException(Exception): + pass + + def satoshis(amount): # satoshi conversion must not be performed by the parser return int(COIN*Decimal(amount)) if amount not in ['!', None] else amount +def format_satoshis(x): + return str(Decimal(x)/COIN) if x is not None else None def json_normalize(x): # note: The return value of commands, when going through the JSON-RPC interface, @@ -100,6 +109,15 @@ class Command: self.options = [] self.defaults = [] +# sanity checks + if self.requires_password: + assert self.requires_wallet + for varname in ('wallet_path', 'wallet'): + if varname in varnames: + assert varname in self.options + assert not ('wallet_path' in varnames and 'wallet' in varnames) + if self.requires_wallet: + assert 'wallet' in varnames def command(s): def decorator(func): @@ -113,18 +131,20 @@ def command(s): password = kwargs.get('password') daemon = cmd_runner.daemon if daemon: - if (cmd.requires_wallet or 'wallet_path' in cmd.options) and kwargs.get('wallet_path') is None: - # using JSON-RPC, sometimes the "wallet" kwarg needs to be used to specify a wallet - kwargs['wallet_path'] = kwargs.pop('wallet', None) or daemon.config.get_wallet_path() - if cmd.requires_wallet: - wallet_path = kwargs.pop('wallet_path') - wallet = daemon.get_wallet(wallet_path) - if wallet is None: - raise Exception('wallet not loaded') - kwargs['wallet'] = wallet - else: - # we are offline. the wallet must have been passed if required - wallet = kwargs.get('wallet') + if 'wallet_path' in cmd.options and kwargs.get('wallet_path') is None: + kwargs['wallet_path'] = daemon.config.get_wallet_path() + if cmd.requires_wallet and kwargs.get('wallet') is None: + kwargs['wallet'] = daemon.config.get_wallet_path() + if 'wallet' in cmd.options: + wallet_path = kwargs.get('wallet', None) + if isinstance(wallet_path, str): + wallet = daemon.get_wallet(wallet_path) + if wallet is None: + raise Exception('wallet not loaded') + kwargs['wallet'] = wallet + wallet = kwargs.get('wallet') # type: Optional[Abstract_Wallet] + if cmd.requires_wallet and not wallet: + raise Exception('wallet not loaded') if cmd.requires_password and password is None and wallet.has_password(): raise Exception('Password required') return await func(*args, **kwargs) @@ -181,7 +201,7 @@ class Commands: net_params = self.network.get_parameters() response = { 'path': self.network.config.path, - 'server': net_params.host, + 'server': net_params.server.host, 'blockchain_height': self.network.get_local_height(), 'server_height': self.network.get_server_height(), 'spv_nodes': len(self.network.get_interfaces()), @@ -292,6 +312,12 @@ class Commands: self.config.set_key(key, value) return True + @command('') + async def get_ssl_domain(self): + """Check and return the SSL domain set in ssl_keyfile and ssl_certfile + """ + return self.config.get_ssl_domain() + @command('') async def make_seed(self, nbits=132, language=None, seed_type=None): """Create a seed""" @@ -345,6 +371,9 @@ class Commands: raise Exception("missing prevout for txin") txin = PartialTxInput(prevout=prevout) txin._trusted_value_sats = int(txin_dict['value']) + nsequence = txin_dict.get('nsequence', None) + if nsequence is not None: + txin.nsequence = nsequence sec = txin_dict.get('privkey') if sec: txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec) @@ -414,6 +443,13 @@ class Commands: domain = address return [wallet.export_private_key(address, password) for address in domain] + @command('wp') + async def getprivatekeyforpath(self, path, password=None, wallet: Abstract_Wallet = None): + """Get private key corresponding to derivation path (address index). + 'path' can be either a str such as "m/0/50", or a list of ints such as [0, 50]. + """ + return wallet.export_private_key_for_path(path, password) + @command('w') async def ismine(self, address, wallet: Abstract_Wallet = None): """Check if address is in wallet. Return true if and only address is in wallet""" @@ -467,7 +503,7 @@ class Commands: @command('n') async def getservers(self): - """Return the list of available servers""" + """Return the list of known servers (candidates for connecting).""" return self.network.get_servers() @command('') @@ -553,81 +589,57 @@ class Commands: message = util.to_bytes(message) return ecc.verify_message_with_address(address, sig, message) - def _mktx(self, wallet: Abstract_Wallet, outputs, *, fee=None, feerate=None, change_addr=None, domain_addr=None, domain_coins=None, - nocheck=False, unsigned=False, rbf=None, password=None, locktime=None): - if fee is not None and feerate is not None: - raise Exception("Cannot specify both 'fee' and 'feerate' at the same time!") - self.nocheck = nocheck - change_addr = self._resolver(change_addr, wallet) - domain_addr = None if domain_addr is None else map(self._resolver, domain_addr, repeat(wallet)) - final_outputs = [] - for address, amount in outputs: - address = self._resolver(address, wallet) - amount = satoshis(amount) - final_outputs.append(PartialTxOutput.from_address_and_value(address, amount)) - - coins = wallet.get_spendable_coins(domain_addr) - if domain_coins is not None: - coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)] - if feerate is not None: - fee_per_kb = 1000 * Decimal(feerate) - fee_estimator = partial(SimpleConfig.estimate_fee_for_feerate, fee_per_kb) - else: - fee_estimator = fee - tx = wallet.make_unsigned_transaction(coins=coins, - outputs=final_outputs, - fee=fee_estimator, - change_addr=change_addr) - if locktime is not None: - tx.locktime = locktime - if rbf is None: - rbf = self.config.get('use_rbf', True) - if rbf: - tx.set_rbf(True) - if not unsigned: - wallet.sign_transaction(tx, password) - return tx - @command('wp') async def payto(self, destination, amount, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None, nocheck=False, unsigned=False, rbf=None, password=None, locktime=None, wallet: Abstract_Wallet = None): """Create a transaction. """ + self.nocheck = nocheck tx_fee = satoshis(fee) domain_addr = from_addr.split(',') if from_addr else None domain_coins = from_coins.split(',') if from_coins else None - tx = self._mktx(wallet, - [(destination, amount)], - fee=tx_fee, - feerate=feerate, - change_addr=change_addr, - domain_addr=domain_addr, - domain_coins=domain_coins, - nocheck=nocheck, - unsigned=unsigned, - rbf=rbf, - password=password, - locktime=locktime) + change_addr = self._resolver(change_addr, wallet) + domain_addr = None if domain_addr is None else map(self._resolver, domain_addr, repeat(wallet)) + amount_sat = satoshis(amount) + outputs = [PartialTxOutput.from_address_and_value(destination, amount_sat)] + tx = wallet.create_transaction( + outputs, + fee=tx_fee, + feerate=feerate, + change_addr=change_addr, + domain_addr=domain_addr, + domain_coins=domain_coins, + unsigned=unsigned, + rbf=rbf, + password=password, + locktime=locktime) return tx.serialize() @command('wp') async def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None, nocheck=False, unsigned=False, rbf=None, password=None, locktime=None, wallet: Abstract_Wallet = None): """Create a multi-output transaction. """ + self.nocheck = nocheck tx_fee = satoshis(fee) domain_addr = from_addr.split(',') if from_addr else None domain_coins = from_coins.split(',') if from_coins else None - tx = self._mktx(wallet, - outputs, - fee=tx_fee, - feerate=feerate, - change_addr=change_addr, - domain_addr=domain_addr, - domain_coins=domain_coins, - nocheck=nocheck, - unsigned=unsigned, - rbf=rbf, - password=password, - locktime=locktime) + change_addr = self._resolver(change_addr, wallet) + domain_addr = None if domain_addr is None else map(self._resolver, domain_addr, repeat(wallet)) + final_outputs = [] + for address, amount in outputs: + address = self._resolver(address, wallet) + amount_sat = satoshis(amount) + final_outputs.append(PartialTxOutput.from_address_and_value(address, amount_sat)) + tx = wallet.create_transaction( + final_outputs, + fee=tx_fee, + feerate=feerate, + change_addr=change_addr, + domain_addr=domain_addr, + domain_coins=domain_coins, + unsigned=unsigned, + rbf=rbf, + password=password, + locktime=locktime) return tx.serialize() @command('w') @@ -754,19 +766,13 @@ class Commands: decrypted = wallet.decrypt_message(pubkey, encrypted, password) return decrypted.decode('utf-8') - def _format_request(self, out): - from .util import get_request_status - out['amount_BTC'] = format_satoshis(out.get('amount')) - out['status_str'] = get_request_status(out) - return out - @command('w') async def getrequest(self, key, wallet: Abstract_Wallet = None): """Return a payment request""" r = wallet.get_request(key) if not r: raise Exception("Request not found") - return self._format_request(r) + return wallet.export_request(r) #@command('w') #async def ackrequest(self, serialized): @@ -776,7 +782,7 @@ class Commands: @command('w') async def list_requests(self, pending=False, expired=False, paid=False, wallet: Abstract_Wallet = None): """List the payment requests you made.""" - out = wallet.get_sorted_requests() + if pending: f = PR_UNPAID elif expired: @@ -785,9 +791,34 @@ class Commands: f = PR_PAID else: f = None + out = wallet.get_sorted_requests() if f is not None: - out = list(filter(lambda x: x.get('status')==f, out)) - return list(map(self._format_request, out)) + out = list(filter(lambda x: x.status==f, out)) + return [wallet.export_request(x) for x in out] + + @command('w') + async def changegaplimit(self, new_limit, iknowwhatimdoing=False, wallet: Abstract_Wallet = None): + """Change the gap limit of the wallet.""" + if not iknowwhatimdoing: + raise Exception("WARNING: Are you SURE you want to change the gap limit?\n" + "It makes recovering your wallet from seed difficult!\n" + "Please do your research and make sure you understand the implications.\n" + "Typically only merchants and power users might want to do this.\n" + "To proceed, try again, with the --iknowwhatimdoing option.") + if not isinstance(wallet, Deterministic_Wallet): + raise Exception("This wallet is not deterministic.") + return wallet.change_gap_limit(new_limit) + + @command('wn') + async def getminacceptablegap(self, wallet: Abstract_Wallet = None): + """Returns the minimum value for gap limit that would be sufficient to discover all + known addresses in the wallet. + """ + if not isinstance(wallet, Deterministic_Wallet): + raise Exception("This wallet is not deterministic.") + if not wallet.is_up_to_date(): + raise NotSynchronizedException("Wallet not fully synchronized.") + return wallet.min_acceptable_gap() @command('w') async def createnewaddress(self, wallet: Abstract_Wallet = None): @@ -815,14 +846,13 @@ class Commands: expiration = int(expiration) if expiration else None req = wallet.make_payment_request(addr, amount, memo, expiration) wallet.add_payment_request(req) - out = wallet.get_request(addr) - return self._format_request(out) + return wallet.export_request(req) @command('wn') async def add_lightning_request(self, amount, memo='', expiration=3600, wallet: Abstract_Wallet = None): amount_sat = int(satoshis(amount)) key = await wallet.lnworker._add_request_coro(amount_sat, memo, expiration) - return wallet.get_request(key)['invoice'] + return wallet.get_formatted_request(key) @command('w') async def addtransaction(self, tx, wallet: Abstract_Wallet = None): @@ -850,8 +880,8 @@ class Commands: @command('w') async def clear_requests(self, wallet: Abstract_Wallet = None): """Remove all payment requests""" - for k in list(wallet.receive_requests.keys()): - wallet.remove_payment_request(k) + wallet.clear_requests() + return True @command('w') async def clear_invoices(self, wallet: Abstract_Wallet = None): @@ -860,11 +890,16 @@ class Commands: return True @command('n') - async def notify(self, address: str, URL: str): - """Watch an address. Every time the address changes, a http POST is sent to the URL.""" + async def notify(self, address: str, URL: Optional[str]): + """Watch an address. Every time the address changes, a http POST is sent to the URL. + Call with an empty URL to stop watching an address. + """ if not hasattr(self, "_notifier"): self._notifier = Notifier(self.network) - await self._notifier.start_watching_queue.put((address, URL)) + if URL: + await self._notifier.start_watching_addr(address, URL) + else: + await self._notifier.stop_watching_addr(address) return True @command('wn') @@ -928,10 +963,23 @@ class Commands: # lightning network commands @command('wn') - async def add_peer(self, connection_string, timeout=20, wallet: Abstract_Wallet = None): - await wallet.lnworker.add_peer(connection_string) + async def add_peer(self, connection_string, timeout=20, gossip=False, wallet: Abstract_Wallet = None): + lnworker = self.network.lngossip if gossip else wallet.lnworker + await lnworker.add_peer(connection_string) return True + @command('wn') + async def list_peers(self, gossip=False, wallet: Abstract_Wallet = None): + lnworker = self.network.lngossip if gossip else wallet.lnworker + return [{ + 'node_id':p.pubkey.hex(), + 'address':p.transport.name(), + 'initialized':p.is_initialized(), + 'features': str(LnFeatures(p.features)), + 'channels': [c.funding_outpoint.to_str() for c in p.channels.values()], + } for p in lnworker.peers.values()] + + @command('wpn') async def open_channel(self, connection_string, amount, push_amount=0, password=None, wallet: Abstract_Wallet = None): funding_sat = satoshis(amount) @@ -945,9 +993,24 @@ class Commands: password=password) return chan.funding_outpoint.to_str() + command('') + async def decode_invoice(self, invoice: str): + invoice = LNInvoice.from_bech32(invoice) + return invoice.to_debug_json() + @command('wn') - async def lnpay(self, invoice, attempts=1, timeout=10, wallet: Abstract_Wallet = None): - return await wallet.lnworker._pay(invoice, attempts=attempts) + async def lnpay(self, invoice, attempts=1, timeout=30, wallet: Abstract_Wallet = None): + lnworker = wallet.lnworker + lnaddr = lnworker._check_invoice(invoice) + payment_hash = lnaddr.paymenthash + wallet.save_invoice(LNInvoice.from_bech32(invoice)) + success, log = await lnworker._pay(invoice, attempts=attempts) + return { + 'payment_hash': payment_hash.hex(), + 'success': success, + 'preimage': lnworker.get_preimage(payment_hash).hex() if success else None, + 'log': [x.formatted_tuple() for x in log] + } @command('w') async def nodeid(self, wallet: Abstract_Wallet = None): @@ -956,7 +1019,27 @@ class Commands: @command('w') async def list_channels(self, wallet: Abstract_Wallet = None): - return list(wallet.lnworker.list_channels()) + # we output the funding_outpoint instead of the channel_id because lnd uses channel_point (funding outpoint) to identify channels + from .lnutil import LOCAL, REMOTE, format_short_channel_id + l = list(wallet.lnworker.channels.items()) + return [ + { + 'short_channel_id': format_short_channel_id(chan.short_channel_id) if chan.short_channel_id else None, + 'channel_id': bh2u(chan.channel_id), + 'channel_point': chan.funding_outpoint.to_str(), + 'state': chan.get_state().name, + 'peer_state': chan.peer_state.name, + 'remote_pubkey': bh2u(chan.node_id), + 'local_balance': chan.balance(LOCAL)//1000, + 'remote_balance': chan.balance(REMOTE)//1000, + 'local_reserve': chan.config[REMOTE].reserve_sat, # their config has our reserve + 'remote_reserve': chan.config[LOCAL].reserve_sat, + 'local_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(LOCAL, direction=SENT) // 1000, + 'remote_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(REMOTE, direction=SENT) // 1000, + } for channel_id, chan in l + ] + + @command('wn') async def dumpgraph(self, wallet: Abstract_Wallet = None): @@ -968,17 +1051,19 @@ class Commands: self.network.config.fee_estimates = ast.literal_eval(fees) self.network.notify('fee') + @command('wn') + async def enable_htlc_settle(self, b: bool, wallet: Abstract_Wallet = None): + e = wallet.lnworker.enable_htlc_settle + e.set() if b else e.clear() + @command('n') async def clear_ln_blacklist(self): self.network.path_finder.blacklist.clear() @command('w') async def list_invoices(self, wallet: Abstract_Wallet = None): - return wallet.get_invoices() - - @command('w') - async def lightning_history(self, wallet: Abstract_Wallet = None): - return wallet.lnworker.get_history() + l = wallet.get_invoices() + return [wallet.export_invoice(x) for x in l] @command('wn') async def close_channel(self, channel_point, force=False, wallet: Abstract_Wallet = None): @@ -987,9 +1072,22 @@ class Commands: coro = wallet.lnworker.force_close_channel(chan_id) if force else wallet.lnworker.close_channel(chan_id) return await coro + @command('w') + async def export_channel_backup(self, channel_point, wallet: Abstract_Wallet = None): + txid, index = channel_point.split(':') + chan_id, _ = channel_id_from_funding_tx(txid, int(index)) + return wallet.lnworker.export_channel_backup(chan_id) + + @command('w') + async def import_channel_backup(self, encrypted, wallet: Abstract_Wallet = None): + return wallet.lnbackups.import_channel_backup(encrypted) + @command('wn') - async def get_channel_ctx(self, channel_point, wallet: Abstract_Wallet = None): + async def get_channel_ctx(self, channel_point, iknowwhatimdoing=False, wallet: Abstract_Wallet = None): """ return the current commitment transaction of a channel """ + if not iknowwhatimdoing: + raise Exception("WARNING: this command is potentially unsafe.\n" + "To proceed, try again, with the --iknowwhatimdoing option.") txid, index = channel_point.split(':') chan_id, _ = channel_id_from_funding_tx(txid, int(index)) chan = wallet.lnworker.channels[chan_id] @@ -1001,6 +1099,60 @@ class Commands: """ return the local watchtower's ctn of channel. used in regtests """ return await self.network.local_watchtower.sweepstore.get_ctn(channel_point, None) + @command('wnp') + async def normal_swap(self, onchain_amount, lightning_amount, password=None, wallet: Abstract_Wallet = None): + """ + Normal submarine swap: send on-chain BTC, receive on Lightning + Note that your funds will be locked for 24h if you do not have enough incoming capacity. + """ + sm = wallet.lnworker.swap_manager + if lightning_amount == 'dryrun': + await sm.get_pairs() + onchain_amount_sat = satoshis(onchain_amount) + lightning_amount_sat = sm.get_recv_amount(onchain_amount_sat, is_reverse=False) + txid = None + elif onchain_amount == 'dryrun': + await sm.get_pairs() + lightning_amount_sat = satoshis(lightning_amount) + onchain_amount_sat = sm.get_send_amount(lightning_amount_sat, is_reverse=False) + txid = None + else: + lightning_amount_sat = satoshis(lightning_amount) + onchain_amount_sat = satoshis(onchain_amount) + txid = await wallet.lnworker.swap_manager.normal_swap(lightning_amount_sat, onchain_amount_sat, password) + return { + 'txid': txid, + 'lightning_amount': format_satoshis(lightning_amount_sat), + 'onchain_amount': format_satoshis(onchain_amount_sat), + } + + @command('wn') + async def reverse_swap(self, lightning_amount, onchain_amount, wallet: Abstract_Wallet = None): + """Reverse submarine swap: send on Lightning, receive on-chain + """ + sm = wallet.lnworker.swap_manager + if onchain_amount == 'dryrun': + await sm.get_pairs() + lightning_amount_sat = satoshis(lightning_amount) + onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True) + success = None + elif lightning_amount == 'dryrun': + await sm.get_pairs() + onchain_amount_sat = satoshis(onchain_amount) + lightning_amount_sat = sm.get_send_amount(onchain_amount_sat, is_reverse=True) + success = None + else: + lightning_amount_sat = satoshis(lightning_amount) + onchain_amount_sat = satoshis(onchain_amount) + success = await wallet.lnworker.swap_manager.reverse_swap(lightning_amount_sat, onchain_amount_sat) + return { + 'success': success, + 'lightning_amount': format_satoshis(lightning_amount_sat), + 'onchain_amount': format_satoshis(onchain_amount_sat), + } + + + def eval_bool(x: str) -> bool: if x == 'false': return False @@ -1027,6 +1179,8 @@ param_descriptions = { 'requested_amount': 'Requested amount (in LBC).', 'outputs': 'list of ["address", amount]', 'redeem_script': 'redeem script (hexadecimal)', + 'lightning_amount': "Amount sent or received in a submarine swap. Set it to 'dryrun' to receive a value", + 'onchain_amount': "Amount sent or received in a submarine swap. Set it to 'dryrun' to receive a value", } command_options = { @@ -1073,6 +1227,8 @@ command_options = { 'fee_level': (None, "Float between 0.0 and 1.0, representing fee slider position"), 'from_height': (None, "Only show transactions that confirmed after given block height"), 'to_height': (None, "Only show transactions that confirmed before given block height"), + 'iknowwhatimdoing': (None, "Acknowledge that I understand the full implications of what I am about to do"), + 'gossip': (None, "Apply command to gossip node instead of wallet"), } @@ -1099,6 +1255,7 @@ arg_types = { 'encrypt_file': eval_bool, 'rbf': eval_bool, 'timeout': float, + 'attempts': int, } config_variables = { @@ -1166,11 +1323,13 @@ argparse._SubParsersAction.__call__ = subparser_call def add_network_options(parser): + parser.add_argument("-f", "--serverfingerprint", dest="serverfingerprint", default=None, help="only allow connecting to servers with a matching SSL certificate SHA256 fingerprint." + " " + + "To calculate this yourself: '$ openssl x509 -noout -fingerprint -sha256 -inform pem -in mycertfile.crt'. Enter as 64 hex chars.") parser.add_argument("-1", "--oneserver", action="store_true", dest="oneserver", default=None, help="connect to one server only") parser.add_argument("-s", "--server", dest="server", default=None, help="set server host:port:protocol, where protocol is either t (tcp) or s (ssl)") - parser.add_argument("-p", "--proxy", dest="proxy", default=None, help="set proxy [type:]host[:port], where type is socks4,socks5 or http") + parser.add_argument("-p", "--proxy", dest="proxy", default=None, help="set proxy [type:]host[:port] (or 'none' to disable proxy), where type is socks4,socks5 or http") parser.add_argument("--noonion", action="store_true", dest="noonion", default=None, help="do not try to connect to onion servers") - parser.add_argument("--skipmerklecheck", action="store_true", dest="skipmerklecheck", default=False, help="Tolerate invalid merkle proofs from server") + parser.add_argument("--skipmerklecheck", action="store_true", dest="skipmerklecheck", default=None, help="Tolerate invalid merkle proofs from server") def add_global_options(parser): group = parser.add_argument_group('global options') @@ -1185,6 +1344,7 @@ def add_global_options(parser): def add_wallet_option(parser): parser.add_argument("-w", "--wallet", dest="wallet_path", help="wallet path") + parser.add_argument("--forgetconfig", action="store_true", dest="forget_config", default=False, help="Forget config on exit") def get_parser(): # create main parser diff --git a/electrum/constants.py b/electrum/constants.py index 77bada886..958a96d85 100644 --- a/electrum/constants.py +++ b/electrum/constants.py @@ -43,6 +43,7 @@ def read_json(filename, default): GIT_REPO_URL = "https://github.com/spesmilo/electrum" GIT_REPO_ISSUES_URL = "https://github.com/spesmilo/electrum/issues" +BIP39_WALLET_FORMATS = read_json('bip39_wallet_formats.json', []) class AbstractNet: @@ -89,9 +90,9 @@ class BitcoinMainnet(AbstractNet): XPUB_HEADERS_INV = inv_dict(XPUB_HEADERS) BIP44_COIN_TYPE = 140 LN_REALM_BYTE = 0 - LN_DNS_SEEDS = [ - 'nodes.lightning.directory.', - 'lseed.bitcoinstats.com.', + LN_DNS_SEEDS = [ # TODO investigate this again + #'test.nodes.lightning.directory.', # times out. + #'lseed.bitcoinstats.com.', # ignores REALM byte and returns mainnet peers... ] @@ -125,9 +126,9 @@ class BitcoinTestnet(AbstractNet): XPUB_HEADERS_INV = inv_dict(XPUB_HEADERS) BIP44_COIN_TYPE = 1 LN_REALM_BYTE = 1 - LN_DNS_SEEDS = [ - 'test.nodes.lightning.directory.', - 'lseed.bitcoinstats.com.', + LN_DNS_SEEDS = [ # TODO investigate this again + #'test.nodes.lightning.directory.', # times out. + #'lseed.bitcoinstats.com.', # ignores REALM byte and returns mainnet peers... ] diff --git a/electrum/contacts.py b/electrum/contacts.py index 705af3dbd..a763b65a7 100644 --- a/electrum/contacts.py +++ b/electrum/contacts.py @@ -27,7 +27,7 @@ from dns.exception import DNSException from . import bitcoin from . import dnssec -from .util import export_meta, import_meta, to_string +from .util import read_json_file, write_json_file, to_string from .logging import Logger @@ -52,14 +52,13 @@ class Contacts(dict, Logger): self.db.put('contacts', dict(self)) def import_file(self, path): - import_meta(path, self._validate, self.load_meta) - - def load_meta(self, data): + data = read_json_file(path) + data = self._validate(data) self.update(data) self.save() - def export_file(self, filename): - export_meta(self, filename) + def export_file(self, path): + write_json_file(path, self) def __setitem__(self, key, value): dict.__setitem__(self, key, value) diff --git a/electrum/crypto.py b/electrum/crypto.py index 1d345e6c1..dc204041c 100644 --- a/electrum/crypto.py +++ b/electrum/crypto.py @@ -25,6 +25,7 @@ import base64 import os +import sys import hashlib import hmac from typing import Union @@ -34,11 +35,33 @@ import pyaes from .util import assert_bytes, InvalidPassword, to_bytes, to_string, WalletFileException from .i18n import _ - +HAS_CRYPTODOME = False try: - from Cryptodome.Cipher import AES + from Cryptodome.Cipher import ChaCha20_Poly1305 as CD_ChaCha20_Poly1305 + from Cryptodome.Cipher import ChaCha20 as CD_ChaCha20 + from Cryptodome.Cipher import AES as CD_AES except: - AES = None + pass +else: + HAS_CRYPTODOME = True + +HAS_CRYPTOGRAPHY = False +try: + import cryptography + from cryptography import exceptions + from cryptography.hazmat.primitives.ciphers import Cipher as CG_Cipher + from cryptography.hazmat.primitives.ciphers import algorithms as CG_algorithms + from cryptography.hazmat.primitives.ciphers import modes as CG_modes + from cryptography.hazmat.backends import default_backend as CG_default_backend + import cryptography.hazmat.primitives.ciphers.aead as CG_aead +except: + pass +else: + HAS_CRYPTOGRAPHY = True + + +if not (HAS_CRYPTODOME or HAS_CRYPTOGRAPHY): + sys.exit(f"Error: at least one of ('pycryptodomex', 'cryptography') needs to be installed.") class InvalidPadding(Exception): @@ -67,8 +90,12 @@ def strip_PKCS7_padding(data: bytes) -> bytes: def aes_encrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes: assert_bytes(key, iv, data) data = append_PKCS7_padding(data) - if AES: - e = AES.new(key, AES.MODE_CBC, iv).encrypt(data) + if HAS_CRYPTODOME: + e = CD_AES.new(key, CD_AES.MODE_CBC, iv).encrypt(data) + elif HAS_CRYPTOGRAPHY: + cipher = CG_Cipher(CG_algorithms.AES(key), CG_modes.CBC(iv), backend=CG_default_backend()) + encryptor = cipher.encryptor() + e = encryptor.update(data) + encryptor.finalize() else: aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv) aes = pyaes.Encrypter(aes_cbc, padding=pyaes.PADDING_NONE) @@ -78,9 +105,13 @@ def aes_encrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes: def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes: assert_bytes(key, iv, data) - if AES: - cipher = AES.new(key, AES.MODE_CBC, iv) + if HAS_CRYPTODOME: + cipher = CD_AES.new(key, CD_AES.MODE_CBC, iv) data = cipher.decrypt(data) + elif HAS_CRYPTOGRAPHY: + cipher = CG_Cipher(CG_algorithms.AES(key), CG_modes.CBC(iv), backend=CG_default_backend()) + decryptor = cipher.decryptor() + data = decryptor.update(data) + decryptor.finalize() else: aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv) aes = pyaes.Decrypter(aes_cbc, padding=pyaes.PADDING_NONE) @@ -157,34 +188,88 @@ def _hash_password(password: Union[bytes, str], *, version: int) -> bytes: raise UnexpectedPasswordHashVersion(version) -def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str: - if not password: - return data +def _pw_encode_raw(data: bytes, password: Union[bytes, str], *, version: int) -> bytes: if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) # derive key from password secret = _hash_password(password, version=version) # encrypt given data - ciphertext = EncodeAES_bytes(secret, to_bytes(data, "utf8")) - ciphertext_b64 = base64.b64encode(ciphertext) - return ciphertext_b64.decode('utf8') + ciphertext = EncodeAES_bytes(secret, data) + return ciphertext -def pw_decode(data: str, password: Union[bytes, str, None], *, version: int) -> str: - if password is None: - return data +def _pw_decode_raw(data_bytes: bytes, password: Union[bytes, str], *, version: int) -> bytes: if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) - data_bytes = bytes(base64.b64decode(data)) # derive key from password secret = _hash_password(password, version=version) # decrypt given data try: - d = to_string(DecodeAES_bytes(secret, data_bytes), "utf8") + d = DecodeAES_bytes(secret, data_bytes) except Exception as e: raise InvalidPassword() from e return d +def pw_encode_bytes(data: bytes, password: Union[bytes, str], *, version: int) -> str: + """plaintext bytes -> base64 ciphertext""" + ciphertext = _pw_encode_raw(data, password, version=version) + ciphertext_b64 = base64.b64encode(ciphertext) + return ciphertext_b64.decode('utf8') + + +def pw_decode_bytes(data: str, password: Union[bytes, str], *, version:int) -> bytes: + """base64 ciphertext -> plaintext bytes""" + if version not in KNOWN_PW_HASH_VERSIONS: + raise UnexpectedPasswordHashVersion(version) + data_bytes = bytes(base64.b64decode(data)) + return _pw_decode_raw(data_bytes, password, version=version) + + +def pw_encode_with_version_and_mac(data: bytes, password: Union[bytes, str]) -> str: + """plaintext bytes -> base64 ciphertext""" + # https://crypto.stackexchange.com/questions/202/should-we-mac-then-encrypt-or-encrypt-then-mac + # Encrypt-and-MAC. The MAC will be used to detect invalid passwords + version = PW_HASH_VERSION_LATEST + mac = sha256(data)[0:4] + ciphertext = _pw_encode_raw(data, password, version=version) + ciphertext_b64 = base64.b64encode(bytes([version]) + ciphertext + mac) + return ciphertext_b64.decode('utf8') + + +def pw_decode_with_version_and_mac(data: str, password: Union[bytes, str]) -> bytes: + """base64 ciphertext -> plaintext bytes""" + data_bytes = bytes(base64.b64decode(data)) + version = int(data_bytes[0]) + encrypted = data_bytes[1:-4] + mac = data_bytes[-4:] + if version not in KNOWN_PW_HASH_VERSIONS: + raise UnexpectedPasswordHashVersion(version) + decrypted = _pw_decode_raw(encrypted, password, version=version) + if sha256(decrypted)[0:4] != mac: + raise InvalidPassword() + return decrypted + + +def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str: + """plaintext str -> base64 ciphertext""" + if not password: + return data + plaintext_bytes = to_bytes(data, "utf8") + return pw_encode_bytes(plaintext_bytes, password, version=version) + + +def pw_decode(data: str, password: Union[bytes, str, None], *, version: int) -> str: + """base64 ciphertext -> plaintext str""" + if password is None: + return data + plaintext_bytes = pw_decode_bytes(data, password, version=version) + try: + plaintext_str = to_string(plaintext_bytes, "utf8") + except UnicodeDecodeError as e: + raise InvalidPassword() from e + return plaintext_str + + def sha256(x: Union[bytes, str]) -> bytes: x = to_bytes(x, 'utf8') @@ -216,3 +301,70 @@ def hmac_oneshot(key: bytes, msg: bytes, digest) -> bytes: return hmac.digest(key, msg, digest) else: return hmac.new(key, msg, digest).digest() + + + +def chacha20_poly1305_encrypt( + *, + key: bytes, + nonce: bytes, + associated_data: bytes = None, + data: bytes +) -> bytes: + assert isinstance(key, (bytes, bytearray)) + assert isinstance(nonce, (bytes, bytearray)) + assert isinstance(associated_data, (bytes, bytearray, type(None))) + assert isinstance(data, (bytes, bytearray)) + if HAS_CRYPTODOME: + cipher = CD_ChaCha20_Poly1305.new(key=key, nonce=nonce) + if associated_data is not None: + cipher.update(associated_data) + ciphertext, mac = cipher.encrypt_and_digest(plaintext=data) + return ciphertext + mac + if HAS_CRYPTOGRAPHY: + a = CG_aead.ChaCha20Poly1305(key) + return a.encrypt(nonce, data, associated_data) + raise Exception("no chacha20 backend found") + + +def chacha20_poly1305_decrypt( + *, + key: bytes, + nonce: bytes, + associated_data: bytes = None, + data: bytes +) -> bytes: + assert isinstance(key, (bytes, bytearray)) + assert isinstance(nonce, (bytes, bytearray)) + assert isinstance(associated_data, (bytes, bytearray, type(None))) + assert isinstance(data, (bytes, bytearray)) + if HAS_CRYPTODOME: + cipher = CD_ChaCha20_Poly1305.new(key=key, nonce=nonce) + if associated_data is not None: + cipher.update(associated_data) + # raises ValueError if not valid (e.g. incorrect MAC) + return cipher.decrypt_and_verify(ciphertext=data[:-16], received_mac_tag=data[-16:]) + if HAS_CRYPTOGRAPHY: + a = CG_aead.ChaCha20Poly1305(key) + try: + return a.decrypt(nonce, data, associated_data) + except cryptography.exceptions.InvalidTag as e: + raise ValueError("invalid tag") from e + raise Exception("no chacha20 backend found") + + +def chacha20_encrypt(*, key: bytes, nonce: bytes, data: bytes) -> bytes: + assert isinstance(key, (bytes, bytearray)) + assert isinstance(nonce, (bytes, bytearray)) + assert isinstance(data, (bytes, bytearray)) + assert len(nonce) == 8, f"unexpected nonce size: {len(nonce)} (expected: 8)" + if HAS_CRYPTODOME: + cipher = CD_ChaCha20.new(key=key, nonce=nonce) + return cipher.encrypt(data) + if HAS_CRYPTOGRAPHY: + nonce = bytes(8) + nonce # cryptography wants 16 byte nonces + algo = CG_algorithms.ChaCha20(key=key, nonce=nonce) + cipher = CG_Cipher(algo, mode=None, backend=CG_default_backend()) + encryptor = cipher.encryptor() + return encryptor.update(data) + raise Exception("no chacha20 backend found") diff --git a/electrum/daemon.py b/electrum/daemon.py index 65e95e9c1..5b87a4157 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -29,21 +29,21 @@ import time import traceback import sys import threading -from typing import Dict, Optional, Tuple, Iterable +from typing import Dict, Optional, Tuple, Iterable, Callable, Union, Sequence, Mapping from base64 import b64decode, b64encode from collections import defaultdict +import concurrent +from concurrent import futures +import json import aiohttp from aiohttp import web, client_exceptions -import jsonrpcclient -import jsonrpcserver -from jsonrpcserver import response -from jsonrpcclient.clients.aiohttp_client import AiohttpClient from aiorpcx import TaskGroup +from . import util from .network import Network from .util import (json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare) -from .util import PR_PAID, PR_EXPIRED, get_request_status +from .invoices import PR_PAID, PR_EXPIRED from .util import log_exceptions, ignore_exceptions, randrange from .wallet import Wallet, Abstract_Wallet from .storage import WalletStorage @@ -104,10 +104,8 @@ def request(config: SimpleConfig, endpoint, args=(), timeout=60): loop = asyncio.get_event_loop() async def request_coroutine(): async with aiohttp.ClientSession(auth=auth) as session: - server = AiohttpClient(session, server_url) - f = getattr(server, endpoint) - response = await f(*args) - return response.data.result + c = util.JsonRPCClient(session, server_url) + return await c.request(endpoint, *args) try: fut = asyncio.run_coroutine_threadsafe(request_coroutine(), loop) return fut.result(timeout=timeout) @@ -137,127 +135,6 @@ def get_rpc_credentials(config: SimpleConfig) -> Tuple[str, str]: return rpc_user, rpc_password -class WatchTowerServer(Logger): - - def __init__(self, network): - Logger.__init__(self) - self.config = network.config - self.network = network - self.lnwatcher = network.local_watchtower - self.app = web.Application() - self.app.router.add_post("/", self.handle) - self.methods = jsonrpcserver.methods.Methods() - self.methods.add(self.get_ctn) - self.methods.add(self.add_sweep_tx) - - async def handle(self, request): - request = await request.text() - self.logger.info(f'{request}') - response = await jsonrpcserver.async_dispatch(request, methods=self.methods) - if response.wanted: - return web.json_response(response.deserialized(), status=response.http_status) - else: - return web.Response() - - async def run(self): - host = self.config.get('watchtower_host') - port = self.config.get('watchtower_port', 12345) - self.runner = web.AppRunner(self.app) - await self.runner.setup() - site = web.TCPSite(self.runner, host, port, ssl_context=self.config.get_ssl_context()) - await site.start() - - async def get_ctn(self, *args): - return await self.lnwatcher.sweepstore.get_ctn(*args) - - async def add_sweep_tx(self, *args): - return await self.lnwatcher.sweepstore.add_sweep_tx(*args) - - -class PayServer(Logger): - - def __init__(self, daemon: 'Daemon'): - Logger.__init__(self) - self.daemon = daemon - self.config = daemon.config - self.pending = defaultdict(asyncio.Event) - self.daemon.network.register_callback(self.on_payment, ['payment_received']) - - async def on_payment(self, evt, wallet, key, status): - if status == PR_PAID: - await self.pending[key].set() - - @ignore_exceptions - @log_exceptions - async def run(self): - host = self.config.get('payserver_host', 'localhost') - port = self.config.get('payserver_port') - root = self.config.get('payserver_root', '/r') - app = web.Application() - app.add_routes([web.post('/api/create_invoice', self.create_request)]) - app.add_routes([web.get('/api/get_invoice', self.get_request)]) - app.add_routes([web.get('/api/get_status', self.get_status)]) - app.add_routes([web.get('/bip70/{key}.bip70', self.get_bip70_request)]) - app.add_routes([web.static(root, 'electrum/www')]) - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, port=port, host=host, ssl_context=self.config.get_ssl_context()) - await site.start() - - async def create_request(self, request): - params = await request.post() - wallet = self.daemon.wallet - if 'amount_sat' not in params or not params['amount_sat'].isdigit(): - raise web.HTTPUnsupportedMediaType() - amount = int(params['amount_sat']) - message = params['message'] or "donation" - payment_hash = await wallet.lnworker._add_invoice_coro(amount, message, 3600) - key = payment_hash.hex() - raise web.HTTPFound(self.root + '/pay?id=' + key) - - async def get_request(self, r): - key = r.query_string - request = self.daemon.wallet.get_request(key) - return web.json_response(request) - - async def get_bip70_request(self, r): - from .paymentrequest import make_request - key = r.match_info['key'] - request = self.daemon.wallet.get_request(key) - if not request: - return web.HTTPNotFound() - pr = make_request(self.config, request) - return web.Response(body=pr.SerializeToString(), content_type='application/bitcoin-paymentrequest') - - async def get_status(self, request): - ws = web.WebSocketResponse() - await ws.prepare(request) - key = request.query_string - info = self.daemon.wallet.get_request(key) - if not info: - await ws.send_str('unknown invoice') - await ws.close() - return ws - if info.get('status') == PR_PAID: - await ws.send_str(f'paid') - await ws.close() - return ws - if info.get('status') == PR_EXPIRED: - await ws.send_str(f'expired') - await ws.close() - return ws - while True: - try: - await asyncio.wait_for(self.pending[key].wait(), 1) - break - except asyncio.TimeoutError: - # send data on the websocket, to keep it alive - await ws.send_str('waiting') - await ws.send_str('paid') - await ws.close() - return ws - - class AuthenticationError(Exception): pass @@ -267,59 +144,18 @@ class AuthenticationInvalidOrMissing(AuthenticationError): class AuthenticationCredentialsInvalid(AuthenticationError): pass -class Daemon(Logger): +class AuthenticatedServer(Logger): - @profiler - def __init__(self, config: SimpleConfig, fd=None, *, listen_jsonrpc=True): + def __init__(self, rpc_user, rpc_password): Logger.__init__(self) + self.rpc_user = rpc_user + self.rpc_password = rpc_password self.auth_lock = asyncio.Lock() - self.running = False - self.running_lock = threading.Lock() - self.config = config - if fd is None and listen_jsonrpc: - fd = get_file_descriptor(config) - if fd is None: - raise Exception('failed to lock daemon; already running?') - self.asyncio_loop = asyncio.get_event_loop() - self.network = None - if not config.get('offline'): - self.network = Network(config, daemon=self) - self.fx = FxThread(config, self.network) - self.gui_object = None - # path -> wallet; make sure path is standardized. - self._wallets = {} # type: Dict[str, Abstract_Wallet] - daemon_jobs = [] - # Setup JSONRPC server - if listen_jsonrpc: - daemon_jobs.append(self.start_jsonrpc(config, fd)) - # request server - self.pay_server = None - if not config.get('offline') and self.config.get('run_payserver'): - self.pay_server = PayServer(self) - daemon_jobs.append(self.pay_server.run()) - # server-side watchtower - self.watchtower = None - if not config.get('offline') and self.config.get('run_watchtower'): - self.watchtower = WatchTowerServer(self.network) - daemon_jobs.append(self.watchtower.run) - if self.network: - self.network.start(jobs=[self.fx.run]) + self._methods = {} # type: Dict[str, Callable] - self.taskgroup = TaskGroup() - asyncio.run_coroutine_threadsafe(self._run(jobs=daemon_jobs), self.asyncio_loop) - - @log_exceptions - async def _run(self, jobs: Iterable = None): - if jobs is None: - jobs = [] - try: - async with self.taskgroup as group: - [await group.spawn(job) for job in jobs] - await group.spawn(asyncio.Event().wait) # run forever (until cancel) - except BaseException as e: - self.logger.exception('daemon.taskgroup died.') - finally: - self.logger.info("stopping daemon.taskgroup") + def register_method(self, f): + assert f.__name__ not in self._methods, f"name collision for {f.__name__}" + self._methods[f.__name__] = f async def authenticate(self, headers): if self.rpc_password == '': @@ -348,46 +184,66 @@ class Daemon(Logger): text='Unauthorized', status=401) except AuthenticationCredentialsInvalid: return web.Response(text='Forbidden', status=403) - request = await request.text() - response = await jsonrpcserver.async_dispatch(request, methods=self.methods) - if isinstance(response, jsonrpcserver.response.ExceptionResponse): - self.logger.error(f"error handling request: {request}", exc_info=response.exc) - # this exposes the error message to the client - response.message = str(response.exc) - if response.wanted: - return web.json_response(response.deserialized(), status=response.http_status) - else: - return web.Response() + try: + request = await request.text() + request = json.loads(request) + method = request['method'] + _id = request['id'] + params = request.get('params', []) # type: Union[Sequence, Mapping] + if method not in self._methods: + raise Exception(f"attempting to use unregistered method: {method}") + f = self._methods[method] + except Exception as e: + self.logger.exception("invalid request") + return web.Response(text='Invalid Request', status=500) + response = {'id': _id} + try: + if isinstance(params, dict): + response['result'] = await f(**params) + else: + response['result'] = await f(*params) + except BaseException as e: + self.logger.exception("internal error while executing RPC") + response['error'] = str(e) + return web.json_response(response) - async def start_jsonrpc(self, config: SimpleConfig, fd): + +class CommandsServer(AuthenticatedServer): + + def __init__(self, daemon, fd): + rpc_user, rpc_password = get_rpc_credentials(daemon.config) + AuthenticatedServer.__init__(self, rpc_user, rpc_password) + self.daemon = daemon + self.fd = fd + self.config = daemon.config + self.host = self.config.get('rpchost', '127.0.0.1') + self.port = self.config.get('rpcport', 0) self.app = web.Application() self.app.router.add_post("/", self.handle) - self.rpc_user, self.rpc_password = get_rpc_credentials(config) - self.methods = jsonrpcserver.methods.Methods() - self.methods.add(self.ping) - self.methods.add(self.gui) - self.cmd_runner = Commands(config=self.config, network=self.network, daemon=self) + self.register_method(self.ping) + self.register_method(self.gui) + self.cmd_runner = Commands(config=self.config, network=self.daemon.network, daemon=self.daemon) for cmdname in known_commands: - self.methods.add(getattr(self.cmd_runner, cmdname)) - self.methods.add(self.run_cmdline) - self.host = config.get('rpchost', '127.0.0.1') - self.port = config.get('rpcport', 0) + self.register_method(getattr(self.cmd_runner, cmdname)) + self.register_method(self.run_cmdline) + + async def run(self): self.runner = web.AppRunner(self.app) await self.runner.setup() site = web.TCPSite(self.runner, self.host, self.port) await site.start() socket = site._server.sockets[0] - os.write(fd, bytes(repr((socket.getsockname(), time.time())), 'utf8')) - os.close(fd) + os.write(self.fd, bytes(repr((socket.getsockname(), time.time())), 'utf8')) + os.close(self.fd) async def ping(self): return True async def gui(self, config_options): - if self.gui_object: - if hasattr(self.gui_object, 'new_window'): + if self.daemon.gui_object: + if hasattr(self.daemon.gui_object, 'new_window'): path = self.config.get_wallet_path(use_gui_last_wallet=True) - self.gui_object.new_window(path, config_options.get('url')) + self.daemon.gui_object.new_window(path, config_options.get('url')) response = "ok" else: response = "error: current GUI does not support multiple windows" @@ -395,6 +251,213 @@ class Daemon(Logger): response = "Error: Electrum is running in daemon mode. Please stop the daemon first." return response + async def run_cmdline(self, config_options): + cmdname = config_options['cmd'] + cmd = known_commands[cmdname] + # arguments passed to function + args = [config_options.get(x) for x in cmd.params] + # decode json arguments + args = [json_decode(i) for i in args] + # options + kwargs = {} + for x in cmd.options: + kwargs[x] = config_options.get(x) + if 'wallet_path' in cmd.options: + kwargs['wallet_path'] = config_options.get('wallet_path') + elif 'wallet' in cmd.options: + kwargs['wallet'] = config_options.get('wallet_path') + func = getattr(self.cmd_runner, cmd.name) + # fixme: not sure how to retrieve message in jsonrpcclient + try: + result = await func(*args, **kwargs) + except Exception as e: + result = {'error':str(e)} + return result + + +class WatchTowerServer(AuthenticatedServer): + + def __init__(self, network, netaddress): + self.addr = netaddress + self.config = network.config + self.network = network + watchtower_user = self.config.get('watchtower_user', '') + watchtower_password = self.config.get('watchtower_password', '') + AuthenticatedServer.__init__(self, watchtower_user, watchtower_password) + self.lnwatcher = network.local_watchtower + self.app = web.Application() + self.app.router.add_post("/", self.handle) + self.register_method(self.get_ctn) + self.register_method(self.add_sweep_tx) + + async def run(self): + self.runner = web.AppRunner(self.app) + await self.runner.setup() + site = web.TCPSite(self.runner, host=str(self.addr.host), port=self.addr.port, ssl_context=self.config.get_ssl_context()) + await site.start() + + async def get_ctn(self, *args): + return await self.lnwatcher.sweepstore.get_ctn(*args) + + async def add_sweep_tx(self, *args): + return await self.lnwatcher.sweepstore.add_sweep_tx(*args) + + +class PayServer(Logger): + + def __init__(self, daemon: 'Daemon', netaddress): + Logger.__init__(self) + self.addr = netaddress + self.daemon = daemon + self.config = daemon.config + self.pending = defaultdict(asyncio.Event) + util.register_callback(self.on_payment, ['request_status']) + +@property +def wallet(self): + # FIXME specify wallet somehow? + return list(self.daemon.get_wallets().values())[0] + + async def on_payment(self, evt, key, status): + if status == PR_PAID: + self.pending[key].set() + + @ignore_exceptions + @log_exceptions + async def run(self): + root = self.config.get('payserver_root', '/r') + app = web.Application() + app.add_routes([web.get('/api/get_invoice', self.get_request)]) + app.add_routes([web.get('/api/get_status', self.get_status)]) + app.add_routes([web.get('/bip70/{key}.bip70', self.get_bip70_request)]) + app.add_routes([web.static(root, os.path.join(os.path.dirname(__file__), 'www'))]) + if self.config.get('payserver_allow_create_invoice'): + app.add_routes([web.post('/api/create_invoice', self.create_request)]) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=str(self.addr.host), port=self.addr.port, ssl_context=self.config.get_ssl_context()) + await site.start() + + async def create_request(self, request): + params = await request.post() + wallet = self.wallet + if 'amount_sat' not in params or not params['amount_sat'].isdigit(): + raise web.HTTPUnsupportedMediaType() + amount = int(params['amount_sat']) + message = params['message'] or "donation" + payment_hash = wallet.lnworker.add_request( + amount_sat=amount, + message=message, + expiry=3600) + key = payment_hash.hex() + raise web.HTTPFound(self.root + '/pay?id=' + key) + + async def get_request(self, r): + key = r.query_string + request = self.wallet.get_formatted_request(key) + return web.json_response(request) + + async def get_bip70_request(self, r): + from .paymentrequest import make_request + key = r.match_info['key'] + request = self.wallet.get_request(key) + if not request: + return web.HTTPNotFound() + pr = make_request(self.config, request) + return web.Response(body=pr.SerializeToString(), content_type='application/bitcoin-paymentrequest') + + async def get_status(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + key = request.query_string + info = self.wallet.get_formatted_request(key) + if not info: + await ws.send_str('unknown invoice') + await ws.close() + return ws + if info.get('status') == PR_PAID: + await ws.send_str(f'paid') + await ws.close() + return ws + if info.get('status') == PR_EXPIRED: + await ws.send_str(f'expired') + await ws.close() + return ws + while True: + try: + await asyncio.wait_for(self.pending[key].wait(), 1) + break + except asyncio.TimeoutError: + # send data on the websocket, to keep it alive + await ws.send_str('waiting') + await ws.send_str('paid') + await ws.close() + return ws + + +class Daemon(Logger): + + + network: Optional[Network] + + + @profiler + def __init__(self, config: SimpleConfig, fd=None, *, listen_jsonrpc=True): + Logger.__init__(self) + self.running = False + self.running_lock = threading.Lock() + self.config = config + if fd is None and listen_jsonrpc: + fd = get_file_descriptor(config) + if fd is None: + raise Exception('failed to lock daemon; already running?') + self.asyncio_loop = asyncio.get_event_loop() + self.network = None + if not config.get('offline'): + self.network = Network(config, daemon=self) + self.fx = FxThread(config, self.network) + self.gui_object = None + # path -> wallet; make sure path is standardized. + self._wallets = {} # type: Dict[str, Abstract_Wallet] + daemon_jobs = [] + # Setup commands server + self.commands_server = None + if listen_jsonrpc: + self.commands_server = CommandsServer(self, fd) + daemon_jobs.append(self.commands_server.run()) + # pay server + payserver_address = self.config.get_netaddress('payserver_address') + if not config.get('offline') and payserver_address: + self.pay_server = PayServer(self, payserver_address) + daemon_jobs.append(self.pay_server.run()) + # server-side watchtower + self.watchtower = None + watchtower_address = self.config.get_netaddress('watchtower_address') + if not config.get('offline') and watchtower_address: + self.watchtower = WatchTowerServer(self.network, watchtower_address) + daemon_jobs.append(self.watchtower.run) + if self.network: + self.network.start(jobs=[self.fx.run]) + + self.taskgroup = TaskGroup() + asyncio.run_coroutine_threadsafe(self._run(jobs=daemon_jobs), self.asyncio_loop) + + @log_exceptions + async def _run(self, jobs: Iterable = None): + if jobs is None: + jobs = [] + self.logger.info("starting taskgroup.") + try: + async with self.taskgroup as group: + [await group.spawn(job) for job in jobs] + await group.spawn(asyncio.Event().wait) # run forever (until cancel) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger.exception("taskgroup died.") + finally: + self.logger.info("taskgroup stopped.") + def load_wallet(self, path, password, *, manual_upgrades=True) -> Optional[Abstract_Wallet]: path = standardize_path(path) # wizard will be launched if we return @@ -419,7 +482,6 @@ class Daemon(Logger): wallet = Wallet(db, storage, config=self.config) wallet.start_network(self.network) self._wallets[path] = wallet - self.wallet = wallet return wallet def add_wallet(self, wallet: Abstract_Wallet) -> None: @@ -427,7 +489,7 @@ class Daemon(Logger): path = standardize_path(path) self._wallets[path] = wallet - def get_wallet(self, path: str) -> Abstract_Wallet: + def get_wallet(self, path: str) -> Optional[Abstract_Wallet]: path = standardize_path(path) return self._wallets.get(path) @@ -447,30 +509,9 @@ class Daemon(Logger): wallet = self._wallets.pop(path, None) if not wallet: return False - wallet.stop_threads() + wallet.stop() return True - async def run_cmdline(self, config_options): - cmdname = config_options['cmd'] - cmd = known_commands[cmdname] - # arguments passed to function - args = [config_options.get(x) for x in cmd.params] - # decode json arguments - args = [json_decode(i) for i in args] - # options - kwargs = {} - for x in cmd.options: - kwargs[x] = config_options.get(x) - if cmd.requires_wallet: - kwargs['wallet_path'] = config_options.get('wallet_path') - func = getattr(self.cmd_runner, cmd.name) - # fixme: not sure how to retrieve message in jsonrpcclient - try: - result = await func(*args, **kwargs) - except Exception as e: - result = {'error':str(e)} - return result - def run_daemon(self): self.running = True try: @@ -493,7 +534,7 @@ class Daemon(Logger): self.gui_object.stop() # stop network/wallets for k, wallet in self._wallets.items(): - wallet.stop_threads() + wallet.stop() if self.network: self.logger.info("shutting down network") self.network.stop() @@ -501,7 +542,7 @@ class Daemon(Logger): fut = asyncio.run_coroutine_threadsafe(self.taskgroup.cancel_remaining(), self.asyncio_loop) try: fut.result(timeout=2) - except (asyncio.TimeoutError, asyncio.CancelledError): + except (concurrent.futures.TimeoutError, concurrent.futures.CancelledError, asyncio.CancelledError): pass self.logger.info("removing lockfile") remove_lockfile(get_lockfile(self.config)) @@ -513,11 +554,13 @@ class Daemon(Logger): if gui_name in ['lite', 'classic']: gui_name = 'qt' self.logger.info(f'launching GUI: {gui_name}') - gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum']) - self.gui_object = gui.ElectrumGui(config, self, plugins) try: + gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum']) + self.gui_object = gui.ElectrumGui(config, self, plugins) self.gui_object.main() except BaseException as e: - self.logger.exception('') + self.logger.error(f'GUI raised exception: {repr(e)}. shutting down.') + raise + finally: # app will exit now - self.on_stop() + self.on_stop() diff --git a/electrum/dnssec.py b/electrum/dnssec.py index 12fe3224a..8b497e8c0 100644 --- a/electrum/dnssec.py +++ b/electrum/dnssec.py @@ -35,6 +35,7 @@ # import sys import time import struct +import hashlib import dns.name @@ -165,11 +166,23 @@ def python_validate_rrsig(rrset, rrsig, keys, origin=None, now=None): raise ValidationFailure('verify failure') +class PyCryptodomexHashAlike: + def __init__(self, hashlib_func): + self._hash = hashlib_func + def new(self): + return self._hash() + # replace validate_rrsig dns.dnssec._validate_rrsig = python_validate_rrsig dns.dnssec.validate_rrsig = python_validate_rrsig dns.dnssec.validate = dns.dnssec._validate +dns.dnssec._have_ecdsa = True +dns.dnssec.MD5 = PyCryptodomexHashAlike(hashlib.md5) +dns.dnssec.SHA1 = PyCryptodomexHashAlike(hashlib.sha1) +dns.dnssec.SHA256 = PyCryptodomexHashAlike(hashlib.sha256) +dns.dnssec.SHA384 = PyCryptodomexHashAlike(hashlib.sha384) +dns.dnssec.SHA512 = PyCryptodomexHashAlike(hashlib.sha512) diff --git a/electrum/ecc.py b/electrum/ecc.py index 7bd1c8f3b..cdddcaea4 100644 --- a/electrum/ecc.py +++ b/electrum/ecc.py @@ -115,6 +115,7 @@ def sig_string_from_r_and_s(r: int, s: int) -> bytes: def _x_and_y_from_pubkey_bytes(pubkey: bytes) -> Tuple[int, int]: + assert isinstance(pubkey, bytes), f'pubkey must be bytes, not {type(pubkey)}' pubkey_ptr = create_string_buffer(64) assert isinstance(pubkey, bytes), f'pubkey must be bytes, not {type(pubkey)}' ret = _libsecp256k1.secp256k1_ec_pubkey_parse( diff --git a/electrum/ecc_fast.py b/electrum/ecc_fast.py index 5e2411f64..3a6e706e4 100644 --- a/electrum/ecc_fast.py +++ b/electrum/ecc_fast.py @@ -49,16 +49,17 @@ def load_library(): library_paths = (os.path.join(os.path.dirname(__file__), 'libsecp256k1.so.0'), 'libsecp256k1.so.0') + exceptions = [] secp256k1 = None for libpath in library_paths: try: secp256k1 = ctypes.cdll.LoadLibrary(libpath) - except: - pass + except BaseException as e: + exceptions.append(e) else: break if not secp256k1: - _logger.error('libsecp256k1 library failed to load') + _logger.error(f'libsecp256k1 library failed to load. exceptions: {repr(exceptions)}') return None try: diff --git a/electrum/exchange_rate.py b/electrum/exchange_rate.py index 5cee7d33a..fa4d79b2e 100644 --- a/electrum/exchange_rate.py +++ b/electrum/exchange_rate.py @@ -452,12 +452,11 @@ def get_exchanges_by_ccy(history=True): class FxThread(ThreadJob): - def __init__(self, config: SimpleConfig, network: Network): + def __init__(self, config: SimpleConfig, network: Optional[Network]): ThreadJob.__init__(self) self.config = config self.network = network - if self.network: - self.network.register_callback(self.set_proxy, ['proxy_set']) + util.register_callback(self.set_proxy, ['proxy_set']) self.ccy = self.get_currency() self.history_used_spot = False self.ccy_combo = None @@ -516,8 +515,11 @@ class FxThread(ThreadJob): self.config.set_key('use_exchange_rate', bool(b)) self.trigger_update() - def get_history_config(self, *, default=False): - return bool(self.config.get('history_rates', default)) + def get_history_config(self, *, allow_none=False): + val = self.config.get('history_rates', None) + if val is None and allow_none: + return None + return bool(val) def set_history_config(self, b): self.config.set_key('history_rates', bool(b)) @@ -567,12 +569,11 @@ class FxThread(ThreadJob): self.exchange.read_historical_rates(self.ccy, self.cache_dir) def on_quotes(self): - if self.network: - self.network.trigger_callback('on_quotes') + util.trigger_callback('on_quotes') def on_history(self): if self.network: - self.network.trigger_callback('on_history') + util.trigger_callback('on_history') def exchange_rate(self) -> Decimal: """Returns the exchange rate as a Decimal""" diff --git a/electrum/gui/kivy/__init__.py b/electrum/gui/kivy/__init__.py index 22194abf8..c83abeae9 100644 --- a/electrum/gui/kivy/__init__.py +++ b/electrum/gui/kivy/__init__.py @@ -34,8 +34,8 @@ try: import kivy except ImportError: # This error ideally shouldn't be raised with pre-built packages - sys.exit("Error: Could not import kivy. Please install it using the" + \ - "instructions mentioned here `http://kivy.org/#download` .") + sys.exit("Error: Could not import kivy. Please install it using the " + "instructions mentioned here `https://kivy.org/#download` .") # minimum required version for kivy kivy.require('1.8.0') diff --git a/electrum/gui/kivy/tools/Dockerfile b/electrum/gui/kivy/tools/Dockerfile index a31cc280a..f73dfca81 100644 --- a/electrum/gui/kivy/tools/Dockerfile +++ b/electrum/gui/kivy/tools/Dockerfile @@ -1,6 +1,8 @@ # based on https://github.com/kivy/python-for-android/blob/master/Dockerfile -FROM ubuntu:18.04 +FROM ubuntu:20.04 + +ENV DEBIAN_FRONTEND=noninteractive ENV ANDROID_HOME="/opt/android" @@ -18,7 +20,7 @@ RUN apt -y update -qq \ ENV ANDROID_NDK_HOME="${ANDROID_HOME}/android-ndk" -ENV ANDROID_NDK_VERSION="19b" +ENV ANDROID_NDK_VERSION="19c" ENV ANDROID_NDK_HOME_V="${ANDROID_NDK_HOME}-r${ANDROID_NDK_VERSION}" # get the latest version from https://developer.android.com/ndk/downloads/index.html @@ -38,10 +40,11 @@ RUN curl --location --progress-bar \ ENV ANDROID_SDK_HOME="${ANDROID_HOME}/android-sdk" # get the latest version from https://developer.android.com/studio/index.html -ENV ANDROID_SDK_TOOLS_VERSION="4333796" -ENV ANDROID_SDK_BUILD_TOOLS_VERSION="28.0.3" -ENV ANDROID_SDK_TOOLS_ARCHIVE="sdk-tools-linux-${ANDROID_SDK_TOOLS_VERSION}.zip" +ENV ANDROID_SDK_TOOLS_VERSION="6514223" +ENV ANDROID_SDK_BUILD_TOOLS_VERSION="29.0.3" +ENV ANDROID_SDK_TOOLS_ARCHIVE="commandlinetools-linux-${ANDROID_SDK_TOOLS_VERSION}_latest.zip" ENV ANDROID_SDK_TOOLS_DL_URL="https://dl.google.com/android/repository/${ANDROID_SDK_TOOLS_ARCHIVE}" +ENV ANDROID_SDK_MANAGER="${ANDROID_SDK_HOME}/tools/bin/sdkmanager --sdk_root=${ANDROID_SDK_HOME}" # download and install Android SDK RUN curl --location --progress-bar \ @@ -58,15 +61,15 @@ RUN mkdir --parents "${ANDROID_SDK_HOME}/.android/" \ # accept Android licenses (JDK necessary!) RUN apt -y update -qq \ - && apt -y install -qq --no-install-recommends openjdk-8-jdk \ + && apt -y install -qq --no-install-recommends openjdk-13-jdk \ && apt -y autoremove -RUN yes | "${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "build-tools;${ANDROID_SDK_BUILD_TOOLS_VERSION}" > /dev/null +RUN yes | ${ANDROID_SDK_MANAGER} --licenses > /dev/null # download platforms, API, build tools -RUN "${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "platforms;android-24" > /dev/null && \ - "${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "platforms;android-28" > /dev/null && \ - "${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "build-tools;${ANDROID_SDK_BUILD_TOOLS_VERSION}" > /dev/null && \ - "${ANDROID_SDK_HOME}/tools/bin/sdkmanager" "extras;android;m2repository" > /dev/null && \ +UN ${ANDROID_SDK_MANAGER} "platforms;android-24" > /dev/null && \ + ${ANDROID_SDK_MANAGER} "platforms;android-28" > /dev/null && \ + ${ANDROID_SDK_MANAGER} "build-tools;${ANDROID_SDK_BUILD_TOOLS_VERSION}" > /dev/null && \ + ${ANDROID_SDK_MANAGER} "extras;android;m2repository" > /dev/null && \ chmod +x "${ANDROID_SDK_HOME}/tools/bin/avdmanager" # download ANT @@ -92,18 +95,10 @@ ENV WORK_DIR="${HOME_DIR}/wspace" \ # install system dependencies RUN apt -y update -qq \ && apt -y install -qq --no-install-recommends \ - python3 virtualenv python3-pip python3-setuptools git wget lbzip2 patch sudo \ - software-properties-common \ + python3 python3-pip python3-setuptools git wget lbzip2 patch sudo \ + software-properties-common libssl-dev \ && apt -y autoremove -# install kivy -RUN add-apt-repository ppa:kivy-team/kivy \ - && apt -y update -qq \ - && apt -y install -qq --no-install-recommends python3-kivy \ - && apt -y autoremove \ - && apt -y clean -RUN python3 -m pip install image - # build dependencies # https://buildozer.readthedocs.io/en/latest/installation.html#android-on-ubuntu-16-04-64bit RUN dpkg --add-architecture i386 \ @@ -111,7 +106,7 @@ RUN dpkg --add-architecture i386 \ && apt -y install -qq --no-install-recommends \ build-essential ccache git python3 python3-dev \ libncurses5:i386 libstdc++6:i386 libgtk2.0-0:i386 \ - libpangox-1.0-0:i386 libpangoxft-1.0-0:i386 libidn11:i386 \ + libidn11:i386 \ zip zlib1g-dev zlib1g:i386 \ && apt -y autoremove \ && apt -y clean @@ -140,9 +135,12 @@ RUN chown ${USER} /opt USER ${USER} -RUN python3 -m pip install --upgrade cython==0.28.6 -RUN python3 -m pip install --upgrade pip -RUN python3 -m pip install --user wheel +RUN python3 -m pip install --user --upgrade pip +RUN python3 -m pip install --user --upgrade wheel +RUN python3 -m pip install --user --upgrade cython==0.29.19 +RUN python3 -m pip install --user --pre kivy +RUN python3 -m pip install --user image + # prepare git RUN git config --global user.name "John Doe" \ @@ -154,7 +152,8 @@ RUN cd /opt \ && cd buildozer \ && git remote add sombernight https://github.com/SomberNight/buildozer \ && git fetch --all \ - && git checkout 7578fea609d4445b3fed1f441813ab4c86ef0086 \ + # commit: kivy/buildozer "1.2.0" tag + && git checkout "94cfcb8d591c11d6ad0e11f129b08c1e27a161c5^{commit}" \ && python3 -m pip install --user -e . # install python-for-android @@ -163,7 +162,8 @@ RUN cd /opt \ && cd python-for-android \ && git remote add sombernight https://github.com/SomberNight/python-for-android \ && git fetch --all \ - && git checkout 9162ec6b4af464672960f6f9bb7c481af2d01802 \ + # commit: from branch sombernight/electrum_20200703 + && git checkout "0dd2ce87a8f380d20505ca5dc1e2d2357b4a08fc^{commit}" \ && python3 -m pip install --user -e . # build env vars diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index a07469a92..9a6e29b99 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -28,7 +28,7 @@ import signal import sys import traceback import threading -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, List try: @@ -92,6 +92,7 @@ class ElectrumGui(Logger): def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): set_language(config.get('language', get_default_language())) Logger.__init__(self) + self.logger.info(f"Qt GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}") # Uncomment this call to verify objects are being properly # GC-ed when windows are closed #network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer, @@ -105,7 +106,7 @@ class ElectrumGui(Logger): self.config = config self.daemon = daemon self.plugins = plugins - self.windows = [] + self.windows = [] # type: List[ElectrumWindow] self.efilter = OpenFileEventFilter(self.windows) self.app = QElectrumApplication(sys.argv) self.app.installEventFilter(self.efilter) @@ -200,12 +201,15 @@ class ElectrumGui(Logger): self.lightning_dialog.close() if self.watchtower_dialog: self.watchtower_dialog.close() + self.app.quit() def new_window(self, path, uri=None): # Use a signal as can be called from daemon thread self.app.new_window_signal.emit(path, uri) def show_lightning_dialog(self): + if not self.daemon.network.is_lightning_running(): + return if not self.lightning_dialog: self.lightning_dialog = LightningDialog(self) self.lightning_dialog.bring_to_top() @@ -300,7 +304,7 @@ class ElectrumGui(Logger): return window def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]: - wizard = InstallWizard(self.config, self.app, self.plugins) + wizard = InstallWizard(self.config, self.app, self.plugins, gui_object=self) try: path, storage = wizard.select_storage(path, self.daemon.get_wallet) # storage is None if file does not exist @@ -339,7 +343,7 @@ class ElectrumGui(Logger): # Show network dialog if config does not exist if self.daemon.network: if self.config.get('auto_connect') is None: - wizard = InstallWizard(self.config, self.app, self.plugins) + wizard = InstallWizard(self.config, self.app, self.plugins, gui_object=self) wizard.init_network(self.daemon.network) wizard.terminate() diff --git a/electrum/gui/qt/address_dialog.py b/electrum/gui/qt/address_dialog.py index 309d0bea0..f19acbade 100644 --- a/electrum/gui/qt/address_dialog.py +++ b/electrum/gui/qt/address_dialog.py @@ -64,7 +64,7 @@ class AddressDialog(WindowModalDialog): vbox = QVBoxLayout() self.setLayout(vbox) - vbox.addWidget(QLabel(_("Address:"))) + vbox.addWidget(QLabel(_("Address") + ":")) self.addr_e = ButtonsLineEdit(self.address) self.addr_e.addCopyButton(self.app) icon = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png" @@ -98,6 +98,16 @@ class AddressDialog(WindowModalDialog): witness_e.addCopyButton(self.app) vbox.addWidget(witness_e) + address_path_str = self.wallet.get_address_path_str(address) + if address_path_str: + vbox.addWidget(QLabel(_("Derivation path") + ':')) + der_path_e = ButtonsLineEdit(address_path_str) + der_path_e.addCopyButton(self.app) + der_path_e.setReadOnly(True) + vbox.addWidget(der_path_e) + + + vbox.addWidget(QLabel(_("History"))) addr_hist_model = AddressHistoryModel(self.parent, self.address) self.hw = HistoryList(self.parent, addr_hist_model) diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py index f4d29ce6d..5bb39e0af 100644 --- a/electrum/gui/qt/address_list.py +++ b/electrum/gui/qt/address_list.py @@ -1,4 +1,7 @@ -#!/usr/bin/env python +self.std_model = QStandardItemModel(self) +self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER) +self.proxy.setSourceModel(self.std_model) +self.setModel(self.proxy)#!/usr/bin/env python # # Electrum - lightweight Bitcoin client # Copyright (C) 2015 Thomas Voegtlin @@ -35,7 +38,7 @@ from electrum.plugin import run_hook from electrum.bitcoin import is_address from electrum.wallet import InternalAddressCorruption -from .util import MyTreeView, MONOSPACE_FONT, ColorScheme, webopen +from .util import MyTreeView, MONOSPACE_FONT, ColorScheme, webopen, MySortModel class AddressUsageStateFilter(IntEnum): @@ -78,6 +81,8 @@ class AddressList(MyTreeView): filter_columns = [Columns.TYPE, Columns.ADDRESS, Columns.LABEL, Columns.COIN_BALANCE] + ROLE_SORT_ORDER = Qt.UserRole + 1000 + def __init__(self, parent): super().__init__(parent, self.create_menu, stretch_column=self.Columns.LABEL) self.wallet = self.parent.wallet @@ -93,8 +98,12 @@ class AddressList(MyTreeView): self.used_button.currentIndexChanged.connect(self.toggle_used) for addr_usage_state in AddressUsageStateFilter.__members__.values(): # type: AddressUsageStateFilter self.used_button.addItem(addr_usage_state.ui_text()) - self.setModel(QStandardItemModel(self)) + self.std_model = QStandardItemModel(self) + self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER) + self.proxy.setSourceModel(self.std_model) + self.setModel(self.proxy) self.update() + self.sortByColumn(self.Columns.TYPE, Qt.AscendingOrder) def get_toolbar_buttons(self): return QLabel(_("Filter:")), self.change_button, self.used_button @@ -146,10 +155,12 @@ class AddressList(MyTreeView): addr_list = self.wallet.get_change_addresses() else: addr_list = self.wallet.get_addresses() - self.model().clear() + self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change + self.std_model.clear() self.refresh_headers() fx = self.parent.fx set_address = None + addresses_beyond_gap_limit = self.wallet.get_all_known_addresses_beyond_gap_limit() for address in addr_list: num = self.wallet.get_address_history_len(address) label = self.wallet.labels.get(address, '') @@ -186,15 +197,21 @@ class AddressList(MyTreeView): address_item[self.Columns.TYPE].setText(_('receiving')) address_item[self.Columns.TYPE].setBackground(ColorScheme.GREEN.as_color(True)) address_item[self.Columns.LABEL].setData(address, Qt.UserRole) + address_path = self.wallet.get_address_index(address) + address_item[self.Columns.TYPE].setData(address_path, self.ROLE_SORT_ORDER) + address_path_str = self.wallet.get_address_path_str(address) + if address_path_str is not None: + address_item[self.Columns.TYPE].setToolTip(address_path_str) + address_item[self.Columns.FIAT_BALANCE].setData(balance, self.ROLE_SORT_ORDER) # setup column 1 if self.wallet.is_frozen_address(address): address_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) - if self.wallet.is_beyond_limit(address): + if address in addresses_beyond_gap_limit: address_item[self.Columns.ADDRESS].setBackground(ColorScheme.RED.as_color(True)) # add item - count = self.model().rowCount() - self.model().insertRow(count, address_item) - address_idx = self.model().index(count, self.Columns.LABEL) + count = self.std_model.rowCount() + self.std_model.insertRow(count, address_item) + address_idx = self.std_model.index(count, self.Columns.LABEL) if address == current_address: set_address = QPersistentModelIndex(address_idx) self.set_current_idx(set_address) @@ -204,6 +221,7 @@ class AddressList(MyTreeView): else: self.hideColumn(self.Columns.FIAT_BALANCE) self.filter() + self.proxy.setDynamicSortFilter(True) def create_menu(self, position): from electrum.wallet import Multisig_Wallet @@ -213,17 +231,17 @@ class AddressList(MyTreeView): if not selected: return multi_select = len(selected) > 1 - addrs = [self.model().itemFromIndex(item).text() for item in selected] + addrs = [self.item_from_index(item).text() for item in selected] menu = QMenu() if not multi_select: idx = self.indexAt(position) if not idx.isValid(): return - item = self.model().itemFromIndex(idx) + item = self.item_from_index(idx) if not item: return addr = addrs[0] - addr_column_title = self.model().horizontalHeaderItem(self.Columns.LABEL).text() + addr_column_title = self.std_model.horizontalHeaderItem(self.Columns.LABEL).text() addr_idx = idx.sibling(idx.row(), self.Columns.LABEL) self.add_copy_menu(menu, idx) menu.addAction(_('Details'), lambda: self.parent.show_address(addr)) @@ -256,7 +274,7 @@ class AddressList(MyTreeView): def place_text_on_clipboard(self, text: str, *, title: str = None) -> None: if is_address(text): try: - self.wallet.check_address(text) + self.wallet.check_address_for_corruption(text) except InternalAddressCorruption as e: self.parent.show_error(str(e)) raise diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py index ffd3f0fab..0f0eceba0 100644 --- a/electrum/gui/qt/amountedit.py +++ b/electrum/gui/qt/amountedit.py @@ -7,7 +7,7 @@ from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtGui import QPalette, QPainter from PyQt5.QtWidgets import (QLineEdit, QStyle, QStyleOptionFrame) -from .util import char_width_in_lineedit +from .util import char_width_in_lineedit, ColorScheme from electrum.util import (format_satoshis_plain, decimal_point_to_base_unit_name, FEERATE_PRECISION, quantize_feerate) @@ -32,7 +32,6 @@ class AmountEdit(FreezableLineEdit): self.textChanged.connect(self.numbify) self.is_int = is_int self.is_shortcut = False - self.help_palette = QPalette() self.extra_precision = 0 def decimal_point(self): @@ -69,7 +68,7 @@ class AmountEdit(FreezableLineEdit): textRect = self.style().subElementRect(QStyle.SE_LineEditContents, panel, self) textRect.adjust(2, 0, -10, 0) painter = QPainter(self) - painter.setPen(self.help_palette.brush(QPalette.Disabled, QPalette.Text).color()) + painter.setPen(ColorScheme.GRAY.as_color()) painter.drawText(textRect, Qt.AlignRight | Qt.AlignVCenter, self.base_unit()) def get_amount(self) -> Union[None, Decimal, int]: @@ -106,11 +105,12 @@ class BTCAmountEdit(AmountEdit): amount = Decimal(max_prec_amount) / pow(10, self.max_precision()-self.decimal_point()) return Decimal(amount) if not self.is_int else int(amount) - def setAmount(self, amount): - if amount is None: - self.setText(" ") # Space forces repaint in case units changed + def setAmount(self, amount_sat): + if amount_sat is None: + self.setText(" ") # Space forces repaint in case units changed else: - self.setText(format_satoshis_plain(amount, self.decimal_point())) + self.setText(format_satoshis_plain(amount_sat, decimal_point=self.decimal_point())) + self.repaint() # macOS hack for #6269 class FeerateEdit(BTCAmountEdit): diff --git a/electrum/gui/qt/bip39_recovery_dialog.py b/electrum/gui/qt/bip39_recovery_dialog.py new file mode 100644 index 000000000..cf8cfd1eb --- /dev/null +++ b/electrum/gui/qt/bip39_recovery_dialog.py @@ -0,0 +1,73 @@ +# Copyright (C) 2020 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QGridLayout, QLabel, QListWidget, QListWidgetItem + +from electrum.i18n import _ +from electrum.network import Network +from electrum.bip39_recovery import account_discovery +from electrum.logging import get_logger + +from .util import WindowModalDialog, MessageBoxMixin, TaskThread, Buttons, CancelButton, OkButton + + +_logger = get_logger(__name__) + + +class Bip39RecoveryDialog(WindowModalDialog): + def __init__(self, parent: QWidget, get_account_xpub, on_account_select): + self.get_account_xpub = get_account_xpub + self.on_account_select = on_account_select + WindowModalDialog.__init__(self, parent, _('BIP39 Recovery')) + self.setMinimumWidth(400) + vbox = QVBoxLayout(self) + self.content = QVBoxLayout() + self.content.addWidget(QLabel(_('Scanning common paths for existing accounts...'))) + vbox.addLayout(self.content) + self.ok_button = OkButton(self) + self.ok_button.clicked.connect(self.on_ok_button_click) + self.ok_button.setEnabled(False) + vbox.addLayout(Buttons(CancelButton(self), self.ok_button)) + self.finished.connect(self.on_finished) + self.show() + self.thread = TaskThread(self) + self.thread.finished.connect(self.deleteLater) # see #3956 + self.thread.add(self.recovery, self.on_recovery_success, None, self.on_recovery_error) + + def on_finished(self): + self.thread.stop() + + def on_ok_button_click(self): + item = self.list.currentItem() + account = item.data(Qt.UserRole) + self.on_account_select(account) + + def recovery(self): + network = Network.get_instance() + coroutine = account_discovery(network, self.get_account_xpub) + return network.run_from_another_thread(coroutine) + + def on_recovery_success(self, accounts): + self.clear_content() + if len(accounts) == 0: + self.content.addWidget(QLabel(_('No existing accounts found.'))) + return + self.content.addWidget(QLabel(_('Choose an account to restore.'))) + self.list = QListWidget() + for account in accounts: + item = QListWidgetItem(account['description']) + item.setData(Qt.UserRole, account) + self.list.addItem(item) + self.list.clicked.connect(lambda: self.ok_button.setEnabled(True)) + self.content.addWidget(self.list) + + def on_recovery_error(self, exc_info): + self.clear_content() + self.content.addWidget(QLabel(_('Error: Account discovery failed.'))) + _logger.error(f"recovery error", exc_info=exc_info) + + def clear_content(self): + for i in reversed(range(self.content.count())): + self.content.itemAt(i).widget().setParent(None) diff --git a/electrum/gui/qt/channel_details.py b/electrum/gui/qt/channel_details.py index a92dc907a..47e6a7bf5 100644 --- a/electrum/gui/qt/channel_details.py +++ b/electrum/gui/qt/channel_details.py @@ -3,13 +3,18 @@ from typing import TYPE_CHECKING import PyQt5.QtGui as QtGui import PyQt5.QtWidgets as QtWidgets import PyQt5.QtCore as QtCore +from PyQt5.QtWidgets import QLabel, QLineEdit +from electrum import util from electrum.i18n import _ from electrum.util import bh2u, format_time from electrum.lnutil import format_short_channel_id, LOCAL, REMOTE, UpdateAddHtlc, Direction -from electrum.lnchannel import htlcsum +from electrum.lnchannel import htlcsum, Channel, AbstractChannel from electrum.lnaddr import LnAddr, lndecode from electrum.bitcoin import COIN +from electrum.wallet import Abstract_Wallet + +from .util import Buttons, CloseButton, ButtonsLineEdit if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -32,7 +37,7 @@ class LinkedLabel(QtWidgets.QLabel): class ChannelDetailsDialog(QtWidgets.QDialog): def make_htlc_item(self, i: UpdateAddHtlc, direction: Direction) -> HTLCItem: it = HTLCItem(_('Sent HTLC with ID {}' if Direction.SENT == direction else 'Received HTLC with ID {}').format(i.htlc_id)) - it.appendRow([HTLCItem(_('Amount')),HTLCItem(self.format(i.amount_msat))]) + it.appendRow([HTLCItem(_('Amount')),HTLCItem(self.format_msat(i.amount_msat))]) it.appendRow([HTLCItem(_('CLTV expiry')),HTLCItem(str(i.cltv_expiry))]) it.appendRow([HTLCItem(_('Payment hash')),HTLCItem(bh2u(i.payment_hash))]) return it @@ -41,7 +46,11 @@ class ChannelDetailsDialog(QtWidgets.QDialog): model = QtGui.QStandardItemModel(0, 2) model.setHorizontalHeaderLabels(['HTLC', 'Property value']) parentItem = model.invisibleRootItem() - folder_types = {'settled': _('Fulfilled HTLCs'), 'inflight': _('HTLCs in current commitment transaction'), 'failed': _('Failed HTLCs')} + folder_types = { + 'settled': _('Fulfilled HTLCs'), + 'inflight': _('HTLCs in current commitment transaction'), + 'failed': _('Failed HTLCs'), + } self.folders = {} self.keyname_rows = {} @@ -54,8 +63,8 @@ class ChannelDetailsDialog(QtWidgets.QDialog): self.folders[keyname] = folder mapping = {} num = 0 - for pay_hash, item in htlcs.items(): - chan_id, i, direction, status = item + for item in htlcs: + pay_hash, chan_id, i, direction, status = item if status != keyname: continue it = self.make_htlc_item(i, direction) @@ -73,29 +82,49 @@ class ChannelDetailsDialog(QtWidgets.QDialog): dest_mapping = self.keyname_rows[to] dest_mapping[payment_hash] = len(dest_mapping) - ln_payment_completed = QtCore.pyqtSignal(str, float, Direction, UpdateAddHtlc, bytes, bytes) - htlc_added = QtCore.pyqtSignal(str, UpdateAddHtlc, LnAddr, Direction) + ln_payment_completed = QtCore.pyqtSignal(str, bytes, bytes) + ln_payment_failed = QtCore.pyqtSignal(str, bytes, bytes) + htlc_added = QtCore.pyqtSignal(str, Channel, UpdateAddHtlc, Direction) + state_changed = QtCore.pyqtSignal(str, Abstract_Wallet, AbstractChannel) - @QtCore.pyqtSlot(str, UpdateAddHtlc, LnAddr, Direction) - def do_htlc_added(self, evtname, htlc, lnaddr, direction): + @QtCore.pyqtSlot(str, Abstract_Wallet, AbstractChannel) + def do_state_changed(self, wallet, chan): + if wallet != self.wallet: + return + if chan == self.chan: + self.update() + + @QtCore.pyqtSlot(str, Channel, UpdateAddHtlc, Direction) + def do_htlc_added(self, evtname, chan, htlc, direction): + if chan != self.chan: + return mapping = self.keyname_rows['inflight'] mapping[htlc.payment_hash] = len(mapping) self.folders['inflight'].appendRow(self.make_htlc_item(htlc, direction)) - @QtCore.pyqtSlot(str, float, Direction, UpdateAddHtlc, bytes, bytes) - def do_ln_payment_completed(self, evtname, date, direction, htlc, preimage, chan_id): + @QtCore.pyqtSlot(str, bytes, bytes) + def do_ln_payment_completed(self, evtname, payment_hash, chan_id): if chan_id != self.chan.channel_id: return - self.move('inflight', 'settled', htlc.payment_hash) - self.update_sent_received() + self.move('inflight', 'settled', payment_hash) + self.update() - def update_sent_received(self): - self.sent_label.setText(str(self.chan.total_msat(Direction.SENT))) - self.received_label.setText(str(self.chan.total_msat(Direction.RECEIVED))) + @QtCore.pyqtSlot(str, bytes, bytes) + def do_ln_payment_failed(self, evtname, payment_hash, chan_id): + if chan_id != self.chan.channel_id: + return + self.move('inflight', 'failed', payment_hash) + self.update() + + def update(self): + self.can_send_label.setText(self.format_msat(self.chan.available_to_spend(LOCAL))) + self.can_receive_label.setText(self.format_msat(self.chan.available_to_spend(REMOTE))) + self.sent_label.setText(self.format_msat(self.chan.total_msat(Direction.SENT))) + self.received_label.setText(self.format_msat(self.chan.total_msat(Direction.RECEIVED))) @QtCore.pyqtSlot(str) def show_tx(self, link_text: str): - funding_tx = self.window.wallet.db.get_transaction(self.chan.funding_outpoint.txid) + funding_tx = self.wallet.db.get_transaction(self.chan.funding_outpoint.txid) self.window.show_transaction(funding_tx, tx_desc=_('Funding Transaction')) def __init__(self, window: 'ElectrumWindow', chan_id: bytes): @@ -103,16 +132,21 @@ class ChannelDetailsDialog(QtWidgets.QDialog): # initialize instance fields self.window = window + self.wallet = window.wallet chan = self.chan = window.wallet.lnworker.channels[chan_id] - self.format = lambda msat: window.format_amount_and_units(msat / 1000) + self.format_msat = lambda msat: window.format_amount_and_units(msat / 1000) # connect signals with slots self.ln_payment_completed.connect(self.do_ln_payment_completed) + self.ln_payment_failed.connect(self.do_ln_payment_failed) + self.state_changed.connect(self.do_state_changed) self.htlc_added.connect(self.do_htlc_added) # register callbacks for updating - window.network.register_callback(self.ln_payment_completed.emit, ['ln_payment_completed']) - window.network.register_callback(self.htlc_added.emit, ['htlc_added']) + util.register_callback(self.ln_payment_completed.emit, ['ln_payment_completed']) + util.register_callback(self.ln_payment_failed.emit, ['ln_payment_failed']) + util.register_callback(self.htlc_added.emit, ['htlc_added']) + util.register_callback(self.state_changed.emit, ['channel']) # set attributes of QDialog self.setWindowTitle(_('Channel Details')) @@ -120,37 +154,51 @@ class ChannelDetailsDialog(QtWidgets.QDialog): # add layouts vbox = QtWidgets.QVBoxLayout(self) - form_layout = QtWidgets.QFormLayout(None) - vbox.addLayout(form_layout) - - # add form content - form_layout.addRow(_('Node ID:'), SelectableLabel(bh2u(chan.node_id))) - form_layout.addRow(_('Channel ID:'), SelectableLabel(bh2u(chan.channel_id))) + vbox.addWidget(QLabel(_('Remote Node ID:'))) + remote_id_e = ButtonsLineEdit(bh2u(chan.node_id)) + remote_id_e.addCopyButton(self.window.app) + remote_id_e.setReadOnly(True) + vbox.addWidget(remote_id_e) funding_label_text = f'{chan.funding_outpoint.txid}:{chan.funding_outpoint.output_index}' - form_layout.addRow(_('Funding Outpoint:'), LinkedLabel(funding_label_text, self.show_tx)) - form_layout.addRow(_('Short Channel ID:'), SelectableLabel(format_short_channel_id(chan.short_channel_id))) + vbox.addWidget(QLabel(_('Funding Outpoint:'))) + vbox.addWidget(LinkedLabel(funding_label_text, self.show_tx)) + + form_layout = QtWidgets.QFormLayout(None) + # add form content + form_layout.addRow(_('Channel ID:'), SelectableLabel(f"{chan.channel_id.hex()} (Short: {chan.short_channel_id})")) + form_layout.addRow(_('State:'), SelectableLabel(chan.get_state_for_GUI())) + self.initiator = 'Local' if chan.constraints.is_initiator else 'Remote' + form_layout.addRow(_('Initiator:'), SelectableLabel(self.initiator)) + self.capacity = self.window.format_amount_and_units(chan.constraints.capacity) + form_layout.addRow(_('Capacity:'), SelectableLabel(self.capacity)) + self.can_send_label = SelectableLabel() + self.can_receive_label = SelectableLabel() + form_layout.addRow(_('Can send:'), self.can_send_label) + form_layout.addRow(_('Can receive:'), self.can_receive_label) self.received_label = SelectableLabel() - form_layout.addRow(_('Received (mSAT):'), self.received_label) + form_layout.addRow(_('Received:'), self.received_label) self.sent_label = SelectableLabel() - form_layout.addRow(_('Sent (mSAT):'), self.sent_label) - self.htlc_minimum_msat = SelectableLabel(str(chan.config[REMOTE].htlc_minimum_msat)) - form_layout.addRow(_('Minimum HTLC value accepted by peer (mSAT):'), self.htlc_minimum_msat) - self.max_htlcs = SelectableLabel(str(chan.config[REMOTE].max_accepted_htlcs)) - form_layout.addRow(_('Maximum number of concurrent HTLCs accepted by peer:'), self.max_htlcs) - self.max_htlc_value = SelectableLabel(self.window.format_amount_and_units(chan.config[REMOTE].max_htlc_value_in_flight_msat / 1000)) - form_layout.addRow(_('Maximum value of in-flight HTLCs accepted by peer:'), self.max_htlc_value) + form_layout.addRow(_('Sent:'), self.sent_label) + #self.htlc_minimum_msat = SelectableLabel(str(chan.config[REMOTE].htlc_minimum_msat)) + #form_layout.addRow(_('Minimum HTLC value accepted by peer (mSAT):'), self.htlc_minimum_msat) + #self.max_htlcs = SelectableLabel(str(chan.config[REMOTE].max_accepted_htlcs)) + #form_layout.addRow(_('Maximum number of concurrent HTLCs accepted by peer:'), self.max_htlcs) + #self.max_htlc_value = SelectableLabel(self.window.format_amount_and_units(chan.config[REMOTE].max_htlc_value_in_flight_msat / 1000)) + #form_layout.addRow(_('Maximum value of in-flight HTLCs accepted by peer:'), self.max_htlc_value) self.dust_limit = SelectableLabel(self.window.format_amount_and_units(chan.config[REMOTE].dust_limit_sat)) form_layout.addRow(_('Remote dust limit:'), self.dust_limit) - self.reserve = SelectableLabel(self.window.format_amount_and_units(chan.config[REMOTE].reserve_sat)) - form_layout.addRow(_('Remote channel reserve:'), self.reserve) + self.remote_reserve = self.window.format_amount_and_units(chan.config[REMOTE].reserve_sat) + form_layout.addRow(_('Remote reserve:'), SelectableLabel(self.remote_reserve)) + vbox.addLayout(form_layout) # add htlc tree view to vbox (wouldn't scale correctly in QFormLayout) - form_layout.addRow(_('Payments (HTLCs):'), None) + vbox.addWidget(QLabel(_('Payments (HTLCs):'))) w = QtWidgets.QTreeView(self) htlc_dict = chan.get_payments() w.setModel(self.make_model(htlc_dict)) w.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) vbox.addWidget(w) + vbox.addLayout(Buttons(CloseButton(self))) # initialize sent/received fields - self.update_sent_received() + self.update() diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index be34aa9d7..b38148300 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -1,20 +1,24 @@ # -*- coding: utf-8 -*- import traceback from enum import IntEnum +from typing import Sequence, Optional from PyQt5 import QtCore, QtGui from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QMenu, QHBoxLayout, QLabel, QVBoxLayout, QGridLayout, QLineEdit +from PyQt5.QtWidgets import (QMenu, QHBoxLayout, QLabel, QVBoxLayout, QGridLayout, QLineEdit, + QPushButton, QAbstractItemView) +from PyQt5.QtGui import QFont, QStandardItem, QBrush from electrum.util import bh2u, NotEnoughFunds, NoDynamicFeeEstimates from electrum.i18n import _ -from electrum.lnchannel import Channel +from electrum.lnchannel import AbstractChannel, PeerState from electrum.wallet import Abstract_Wallet from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id, LN_MAX_FUNDING_SAT +from electrum.lnworker import LNWallet -from .util import MyTreeView, WindowModalDialog, Buttons, OkButton, CancelButton, EnterButton, WaitingDialog +from .util import (MyTreeView, WindowModalDialog, Buttons, OkButton, CancelButton, + EnterButton, WaitingDialog, MONOSPACE_FONT, ColorScheme) from .amountedit import BTCAmountEdit, FreezableLineEdit -from .channel_details import ChannelDetailsDialog ROLE_CHANNEL_ID = Qt.UserRole @@ -22,32 +26,47 @@ ROLE_CHANNEL_ID = Qt.UserRole class ChannelsList(MyTreeView): update_rows = QtCore.pyqtSignal(Abstract_Wallet) - update_single_row = QtCore.pyqtSignal(Channel) + update_single_row = QtCore.pyqtSignal(Abstract_Wallet, AbstractChannel) + gossip_db_loaded = QtCore.pyqtSignal() class Columns(IntEnum): SHORT_CHANID = 0 - NODE_ID = 1 + NODE_ALIAS = 1 LOCAL_BALANCE = 2 REMOTE_BALANCE = 3 CHANNEL_STATUS = 4 headers = { Columns.SHORT_CHANID: _('Short Channel ID'), - Columns.NODE_ID: _('Node ID'), + Columns.NODE_ALIAS: _('Node alias'), Columns.LOCAL_BALANCE: _('Local'), Columns.REMOTE_BALANCE: _('Remote'), Columns.CHANNEL_STATUS: _('Status'), } + filter_columns = [ + Columns.SHORT_CHANID, + Columns.NODE_ALIAS, + Columns.CHANNEL_STATUS, + ] + + _default_item_bg_brush = None # type: Optional[QBrush] + + + def __init__(self, parent): - super().__init__(parent, self.create_menu, stretch_column=self.Columns.NODE_ID, + super().__init__(parent, self.create_menu, stretch_column=self.Columns.NODE_ALIAS, editable_columns=[]) self.setModel(QtGui.QStandardItemModel(self)) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.main_window = parent + self.gossip_db_loaded.connect(self.on_gossip_db) self.update_rows.connect(self.do_update_rows) self.update_single_row.connect(self.do_update_single_row) self.network = self.parent.network self.lnworker = self.parent.wallet.lnworker + self.lnbackups = self.parent.wallet.lnbackups + self.setSortingEnabled(True) def format_fields(self, chan): labels = {} @@ -60,12 +79,18 @@ class ChannelsList(MyTreeView): if bal_other != bal_minus_htlcs_other: label += ' (+' + self.parent.format_amount(bal_other - bal_minus_htlcs_other) + ')' labels[subject] = label - status = self.lnworker.get_channel_status(chan) + status = chan.get_state_for_GUI() + closed = chan.is_closed() + if self.parent.network.is_lightning_running(): + node_info = self.parent.network.channel_db.get_node_info_for_node_id(chan.node_id) + node_alias = (node_info.alias if node_info else '') or '' + else: + node_alias = '' return [ - format_short_channel_id(chan.short_channel_id), - bh2u(chan.node_id), - labels[LOCAL], - labels[REMOTE], + chan.short_id_for_GUI(), + node_alias, + '' if closed else labels[LOCAL], + '' if closed else labels[REMOTE], status ] @@ -78,70 +103,200 @@ class ChannelsList(MyTreeView): self.main_window.show_error('Failed to close channel:\n{}'.format(repr(e))) def close_channel(self, channel_id): + msg = _('Close channel?') + if not self.parent.question(msg): + return def task(): coro = self.lnworker.close_channel(channel_id) return self.network.run_from_another_thread(coro) WaitingDialog(self, 'please wait..', task, self.on_success, self.on_failure) def force_close(self, channel_id): - def task(): - coro = self.lnworker.force_close_channel(channel_id) - return self.network.run_from_another_thread(coro) - if self.parent.question('Force-close channel?\nReclaimed funds will not be immediately available.'): + chan = self.lnworker.channels[channel_id] + to_self_delay = chan.config[REMOTE].to_self_delay + msg = _('Force-close channel?') + '\n\n'\ + + _('Funds retrieved from this channel will not be available before {} blocks after forced closure.').format(to_self_delay) + ' '\ + + _('After that delay, funds will be sent to an address derived from your wallet seed.') + '\n\n'\ + + _('In the meantime, channel funds will not be recoverable from your seed, and might be lost if you lose your wallet.') + ' '\ + + _('To prevent that, you should have a backup of this channel on another device.') + if self.parent.question(msg): + def task(): + coro = self.lnworker.force_close_channel(channel_id) + return self.network.run_from_another_thread(coro) WaitingDialog(self, 'please wait..', task, self.on_success, self.on_failure) def remove_channel(self, channel_id): if self.main_window.question(_('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')): self.lnworker.remove_channel(channel_id) + def remove_channel_backup(self, channel_id): + if self.main_window.question(_('Remove channel backup?')): + self.lnbackups.remove_channel_backup(channel_id) + + def export_channel_backup(self, channel_id): + msg = ' '.join([ + _("Channel backups can be imported in another instance of the same wallet, by scanning this QR code."), + _("Please note that channel backups cannot be used to restore your channels."), + _("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."), + ]) + data = self.lnworker.export_channel_backup(channel_id) + self.main_window.show_qrcode(data, 'channel backup', help_text=msg, + show_copy_text_btn=True) + + def request_force_close(self, channel_id): + def task(): + coro = self.lnbackups.request_force_close(channel_id) + return self.network.run_from_another_thread(coro) + def on_success(b): + self.main_window.show_message('success') + WaitingDialog(self, 'please wait..', task, on_success, self.on_failure) + + + def create_menu(self, position): menu = QMenu() - idx = self.selectionModel().currentIndex() + menu.setSeparatorsCollapsible(True) # consecutive separators are merged together + selected = self.selected_in_column(self.Columns.NODE_ALIAS) + if not selected: + menu.addAction(_("Import channel backup"), lambda: self.parent.do_process_from_text_channel_backup()) + menu.exec_(self.viewport().mapToGlobal(position)) + return + multi_select = len(selected) > 1 + if multi_select: + return + idx = self.indexAt(position) + if not idx.isValid(): + return item = self.model().itemFromIndex(idx) if not item: return - channel_id = idx.sibling(idx.row(), self.Columns.NODE_ID).data(ROLE_CHANNEL_ID) + channel_id = idx.sibling(idx.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID) + if channel_id in self.lnbackups.channel_backups: + menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id)) + menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id)) + menu.exec_(self.viewport().mapToGlobal(position)) + return chan = self.lnworker.channels[channel_id] - menu.addAction(_("Details..."), lambda: self.details(channel_id)) - self.add_copy_menu(menu, idx) + menu.addAction(_("Details..."), lambda: self.parent.show_channel(channel_id)) + cc = self.add_copy_menu(menu, idx) + cc.addAction(_("Node ID"), lambda: self.place_text_on_clipboard( + chan.node_id.hex(), title=_("Node ID"))) + cc.addAction(_("Long Channel ID"), lambda: self.place_text_on_clipboard( + channel_id.hex(), title=_("Long Channel ID"))) if not chan.is_closed(): - menu.addAction(_("Close channel"), lambda: self.close_channel(channel_id)) + if not chan.is_frozen_for_sending(): + menu.addAction(_("Freeze (for sending)"), lambda: chan.set_frozen_for_sending(True)) + else: + menu.addAction(_("Unfreeze (for sending)"), lambda: chan.set_frozen_for_sending(False)) + if not chan.is_frozen_for_receiving(): + menu.addAction(_("Freeze (for receiving)"), lambda: chan.set_frozen_for_receiving(True)) + else: + menu.addAction(_("Unfreeze (for receiving)"), lambda: chan.set_frozen_for_receiving(False)) + + funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid) + if funding_tx: + menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx)) + if not chan.is_closed(): + menu.addSeparator() + if chan.peer_state == PeerState.GOOD: + menu.addAction(_("Close channel"), lambda: self.close_channel(channel_id)) menu.addAction(_("Force-close channel"), lambda: self.force_close(channel_id)) else: - menu.addAction(_("Remove"), lambda: self.remove_channel(channel_id)) + item = chan.get_closing_height() + if item: + txid, height, timestamp = item + closing_tx = self.lnworker.lnwatcher.db.get_transaction(txid) + if closing_tx: + menu.addAction(_("View closing transaction"), lambda: self.parent.show_transaction(closing_tx)) + menu.addSeparator() + menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id)) + if chan.is_redeemed(): + menu.addSeparator() + menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id)) menu.exec_(self.viewport().mapToGlobal(position)) - def details(self, channel_id): - assert self.parent.wallet - ChannelDetailsDialog(self.parent, channel_id).show() - - @QtCore.pyqtSlot(Channel) - def do_update_single_row(self, chan): + @QtCore.pyqtSlot(Abstract_Wallet, AbstractChannel) + def do_update_single_row(self, wallet: Abstract_Wallet, chan: AbstractChannel): + if wallet != self.parent.wallet: + return for row in range(self.model().rowCount()): - item = self.model().item(row, self.Columns.NODE_ID) - if item.data(ROLE_CHANNEL_ID) == chan.channel_id: - for column, v in enumerate(self.format_fields(chan)): - self.model().item(row, column).setData(v, QtCore.Qt.DisplayRole) + item = self.model().item(row, self.Columns.NODE_ALIAS) + if item.data(ROLE_CHANNEL_ID) != chan.channel_id: + continue + for column, v in enumerate(self.format_fields(chan)): + self.model().item(row, column).setData(v, QtCore.Qt.DisplayRole) + items = [self.model().item(row, column) for column in self.Columns] + self._update_chan_frozen_bg(chan=chan, items=items) + if wallet.lnworker: + self.update_can_send(wallet.lnworker) + + @QtCore.pyqtSlot() + def on_gossip_db(self): + self.do_update_rows(self.parent.wallet) @QtCore.pyqtSlot(Abstract_Wallet) def do_update_rows(self, wallet): if wallet != self.parent.wallet: return - lnworker = self.parent.wallet.lnworker - if not lnworker: - return + channels = list(wallet.lnworker.channels.values()) if wallet.lnworker else [] + backups = list(wallet.lnbackups.channel_backups.values()) + if wallet.lnworker: + self.update_can_send(wallet.lnworker) self.model().clear() self.update_headers(self.headers) - for chan in lnworker.channels.values(): + for chan in channels + backups: items = [QtGui.QStandardItem(x) for x in self.format_fields(chan)] self.set_editability(items) - items[self.Columns.NODE_ID].setData(chan.channel_id, ROLE_CHANNEL_ID) + if self._default_item_bg_brush is None: + self._default_item_bg_brush = items[self.Columns.NODE_ALIAS].background() + items[self.Columns.NODE_ALIAS].setData(chan.channel_id, ROLE_CHANNEL_ID) + items[self.Columns.NODE_ALIAS].setFont(QFont(MONOSPACE_FONT)) + items[self.Columns.LOCAL_BALANCE].setFont(QFont(MONOSPACE_FONT)) + items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT)) + self._update_chan_frozen_bg(chan=chan, items=items) self.model().insertRow(0, items) + self.sortByColumn(self.Columns.SHORT_CHANID, Qt.DescendingOrder) + + def _update_chan_frozen_bg(self, *, chan: AbstractChannel, items: Sequence[QStandardItem]): + assert self._default_item_bg_brush is not None + # frozen for sending + item = items[self.Columns.LOCAL_BALANCE] + if chan.is_frozen_for_sending(): + item.setBackground(ColorScheme.BLUE.as_color(True)) + item.setToolTip(_("This channel is frozen for sending. It will not be used for outgoing payments.")) + else: + item.setBackground(self._default_item_bg_brush) + item.setToolTip("") + # frozen for receiving + item = items[self.Columns.REMOTE_BALANCE] + if chan.is_frozen_for_receiving(): + item.setBackground(ColorScheme.BLUE.as_color(True)) + item.setToolTip(_("This channel is frozen for receiving. It will not be included in invoices.")) + else: + item.setBackground(self._default_item_bg_brush) + item.setToolTip("") + + def update_can_send(self, lnworker: LNWallet): + msg = _('Can send') + ' ' + self.parent.format_amount(lnworker.num_sats_can_send())\ + + ' ' + self.parent.base_unit() + '; '\ + + _('can receive') + ' ' + self.parent.format_amount(lnworker.num_sats_can_receive())\ + + ' ' + self.parent.base_unit() + self.can_send_label.setText(msg) + + + def get_toolbar(self): h = QHBoxLayout() + self.can_send_label = QLabel('') + h.addWidget(self.can_send_label) h.addStretch() - h.addWidget(EnterButton(_('Open Channel'), self.new_channel_dialog)) + self.swap_button = EnterButton(_('Swap'), self.swap_dialog) + self.swap_button.setEnabled(self.parent.wallet.has_lightning()) + self.new_channel_button = EnterButton(_('Open Channel'), self.new_channel_dialog) + self.new_channel_button.setEnabled(self.parent.wallet.has_lightning()) + h.addWidget(self.new_channel_button) + h.addWidget(self.swap_button) return h @@ -194,24 +349,35 @@ class ChannelsList(MyTreeView): max_button = EnterButton(_("Max"), spend_max) max_button.setFixedWidth(100) max_button.setCheckable(True) + suggest_button = QPushButton(d, text=_('Suggest')) + def on_suggest(): + remote_nodeid.setText(bh2u(lnworker.suggest_peer() or b'')) + remote_nodeid.repaint() # macOS hack for #6269 + suggest_button.clicked.connect(on_suggest) + clear_button = QPushButton(d, text=_('Clear')) + def on_clear(): + amount_e.setText('') + amount_e.setFrozen(False) + amount_e.repaint() # macOS hack for #6269 + remote_nodeid.setText('') + remote_nodeid.repaint() # macOS hack for #6269 + max_button.setChecked(False) + max_button.repaint() # macOS hack for #6269 + clear_button.clicked.connect(on_clear) h = QGridLayout() h.addWidget(QLabel(_('Your Node ID')), 0, 0) - h.addWidget(local_nodeid, 0, 1) + h.addWidget(local_nodeid, 0, 1, 1, 3) h.addWidget(QLabel(_('Remote Node ID')), 1, 0) - h.addWidget(remote_nodeid, 1, 1) - h.addWidget(QLabel('Amount'), 2, 0) - hbox = QHBoxLayout() - hbox.addWidget(amount_e) - hbox.addWidget(max_button) - hbox.addStretch(1) - h.addLayout(hbox, 2, 1) + h.addWidget(remote_nodeid, 1, 1, 1, 3) + h.addWidget(suggest_button, 2, 1) + h.addWidget(clear_button, 2, 2) + h.addWidget(QLabel('Amount'), 3, 0) + h.addWidget(amount_e, 3, 1) + h.addWidget(max_button, 3, 2) vbox.addLayout(h) ok_button = OkButton(d) ok_button.setDefault(True) vbox.addLayout(Buttons(CancelButton(d), ok_button)) - suggestion = lnworker.suggest_peer() or b'' - remote_nodeid.setText(bh2u(suggestion)) - remote_nodeid.setCursorPosition(0) if not d.exec_(): return if max_button.isChecked() and amount_e.get_amount() < LN_MAX_FUNDING_SAT: @@ -222,4 +388,12 @@ class ChannelsList(MyTreeView): else: funding_sat = amount_e.get_amount() connect_str = str(remote_nodeid.text()).strip() + if not connect_str or not funding_sat: + return self.parent.open_channel(connect_str, funding_sat, 0) + + + def swap_dialog(self): + from .swap_dialog import SwapDialog + d = SwapDialog(self.parent) + d.run() diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index 3d274d9c8..ea937a51b 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -23,6 +23,7 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from decimal import Decimal from typing import TYPE_CHECKING, Optional, Union from PyQt5.QtWidgets import QVBoxLayout, QLabel, QGridLayout, QPushButton, QLineEdit @@ -31,12 +32,13 @@ from electrum.i18n import _ from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates from electrum.plugin import run_hook from electrum.transaction import Transaction, PartialTransaction -from electrum.simple_config import FEERATE_WARNING_HIGH_FEE +from electrum.simple_config import FEERATE_WARNING_HIGH_FEE, FEE_RATIO_HIGH_WARNING from electrum.wallet import InternalAddressCorruption -from .util import WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, BlockingWaitingDialog +from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, + BlockingWaitingDialog, PasswordLineEdit) -from .fee_slider import FeeSlider +from .fee_slider import FeeSlider, FeeComboBox if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -78,7 +80,7 @@ class TxEditor: def get_fee_estimator(self): return None - def update_tx(self): + def update_tx(self, *, fallback_to_zero_fee: bool = False): fee_estimator = self.get_fee_estimator() try: self.tx = self.make_tx(fee_estimator) @@ -87,7 +89,13 @@ class TxEditor: except NotEnoughFunds: self.not_enough_funds = True self.tx = None - return + if fallback_to_zero_fee: + try: + self.tx = self.make_tx(0) + except BaseException: + return + else: + return except NoDynamicFeeEstimates: self.no_dynfee_estimates = True self.tx = None @@ -103,7 +111,13 @@ class TxEditor: if use_rbf: self.tx.set_rbf(True) - + def have_enough_funds_assuming_zero_fees(self) -> bool: + try: + tx = self.make_tx(0) + except NotEnoughFunds: + return False + else: + return True @@ -138,14 +152,16 @@ class ConfirmTxDialog(TxEditor, WindowModalDialog): grid.addWidget(self.extra_fee_value, 2, 1) self.fee_slider = FeeSlider(self, self.config, self.fee_slider_callback) + self.fee_combo = FeeComboBox(self.fee_slider) + grid.addWidget(HelpLabel(_("Fee rate") + ": ", self.fee_combo.help_msg), 5, 0) grid.addWidget(self.fee_slider, 5, 1) + grid.addWidget(self.fee_combo, 5, 2) self.message_label = QLabel(self.default_message()) grid.addWidget(self.message_label, 6, 0, 1, -1) self.pw_label = QLabel(_('Password')) self.pw_label.setVisible(self.password_required) - self.pw = QLineEdit() - self.pw.setEchoMode(2) + self.pw = PasswordLineEdit() self.pw.setVisible(self.password_required) grid.addWidget(self.pw_label, 8, 0) grid.addWidget(self.pw, 8, 1, 1, -1) @@ -175,6 +191,7 @@ class ConfirmTxDialog(TxEditor, WindowModalDialog): password = self.pw.text() or None if self.password_required: if password is None: + self.main_window.show_error(_("Password required"), parent=self) return try: self.wallet.check_password(password) @@ -184,22 +201,32 @@ class ConfirmTxDialog(TxEditor, WindowModalDialog): self.is_send = True self.accept() - def disable(self, reason): - self.message_label.setStyleSheet(ColorScheme.RED.as_stylesheet()) - self.message_label.setText(reason) - self.pw.setEnabled(False) - self.send_button.setEnabled(False) + def toggle_send_button(self, enable: bool, *, message: str = None): + if message is None: + self.message_label.setStyleSheet(None) + self.message_label.setText(self.default_message()) + else: + self.message_label.setStyleSheet(ColorScheme.RED.as_stylesheet()) + self.message_label.setText(message) + self.pw.setEnabled(enable) + self.send_button.setEnabled(enable) - def enable(self): - self.message_label.setStyleSheet(None) - self.message_label.setText(self.default_message()) - self.pw.setEnabled(True) - self.send_button.setEnabled(True) + def _update_amount_label(self): + tx = self.tx + if self.output_value == '!': + if tx: + amount = tx.output_value() + amount_str = self.main_window.format_amount_and_units(amount) + else: + amount_str = "max" + else: + amount = self.output_value + amount_str = self.main_window.format_amount_and_units(amount) + self.amount_label.setText(amount_str) def update(self): tx = self.tx - amount = tx.output_value() if self.output_value == '!' else self.output_value - self.amount_label.setText(self.main_window.format_amount_and_units(amount)) + self._update_amount_label() if self.not_enough_funds: text = _("Not enough funds") @@ -208,7 +235,7 @@ class ConfirmTxDialog(TxEditor, WindowModalDialog): text += " ({} {} {})".format( self.main_window.format_amount(c + u + x).strip(), self.main_window.base_unit(), _("are frozen") ) - self.disable(text) + self.toggle_send_button(False, message=text) return if not tx: @@ -223,16 +250,22 @@ class ConfirmTxDialog(TxEditor, WindowModalDialog): self.extra_fee_value.setVisible(True) self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount)) - feerate_warning = FEERATE_WARNING_HIGH_FEE - low_fee = fee < self.wallet.relayfee() * tx.estimated_size() / 1000 - high_fee = fee > feerate_warning * tx.estimated_size() / 1000 - if low_fee: + amount = tx.output_value() if self.output_value == '!' else self.output_value + feerate = Decimal(fee) / tx.estimated_size() # sat/byte + fee_ratio = Decimal(fee) / amount if amount else 1 + if feerate < self.wallet.relayfee() / 1000: msg = '\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.") ]) - self.disable(msg) - elif high_fee: - self.disable(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) + self.toggle_send_button(False, message=msg) + elif fee_ratio >= FEE_RATIO_HIGH_WARNING: + self.toggle_send_button(True, + message=_('Warning') + ': ' + _("The fee for this transaction seems unusually high.") + + f'\n({fee_ratio*100:.2f}% of amount)') + elif feerate > FEERATE_WARNING_HIGH_FEE / 1000: + self.toggle_send_button(True, + message=_('Warning') + ': ' + _("The fee for this transaction seems unusually high.") + + f'\n(feerate: {feerate:.2f} sat/byte)') else: - self.enable() + self.toggle_send_button(True) diff --git a/electrum/gui/qt/console.py b/electrum/gui/qt/console.py index 3971ad696..88bc68f1f 100644 --- a/electrum/gui/qt/console.py +++ b/electrum/gui/qt/console.py @@ -44,7 +44,7 @@ class OverlayLabel(QtWidgets.QLabel): class Console(QtWidgets.QPlainTextEdit): - def __init__(self, prompt='>> ', startup_message='', parent=None): + def __init__(self, prompt='>>> ', parent=None): QtWidgets.QPlainTextEdit.__init__(self, parent) self.prompt = prompt @@ -56,7 +56,6 @@ class Console(QtWidgets.QPlainTextEdit): self.setWordWrapMode(QtGui.QTextOption.WrapAnywhere) self.setUndoRedoEnabled(False) self.document().setDefaultFont(QtGui.QFont(MONOSPACE_FONT, 10, QtGui.QFont.Normal)) - self.showMessage(startup_message) self.updateNamespace({'run':self.run_script}) self.set_json(False) @@ -91,13 +90,14 @@ class Console(QtWidgets.QPlainTextEdit): def showMessage(self, message): self.appendPlainText(message) - self.newPrompt() + self.newPrompt('') def clear(self): + curr_line = self.getCommand() self.setPlainText('') - self.newPrompt() + self.newPrompt(curr_line) - def newPrompt(self): + def newPrompt(self, curr_line): if self.construct: prompt = '.' * len(self.prompt) else: @@ -178,7 +178,7 @@ class Console(QtWidgets.QPlainTextEdit): def getHistory(self): return self.history - def setHisory(self, history): + def setHistory(self, history): self.history = history def addToHistory(self, command): @@ -244,7 +244,7 @@ class Console(QtWidgets.QPlainTextEdit): if type(self.namespace.get(command)) == type(lambda:None): self.appendPlainText("'{}' is a function. Type '{}()' to use it in the Python console." .format(command, command)) - self.newPrompt() + self.newPrompt('') return sys.stdout = stdoutProxy(self.appendPlainText) @@ -269,7 +269,7 @@ class Console(QtWidgets.QPlainTextEdit): traceback_lines.pop(i) self.appendPlainText('\n'.join(traceback_lines)) sys.stdout = tmp_stdout - self.newPrompt() + self.newPrompt('') self.set_json(False) @@ -346,17 +346,3 @@ class Console(QtWidgets.QPlainTextEdit): self.setCommand(beginning + p) else: self.show_completions(completions) - - -welcome_message = ''' - --------------------------------------------------------------- - Welcome to a primitive Python interpreter. - --------------------------------------------------------------- -''' - -if __name__ == '__main__': - app = QtWidgets.QApplication(sys.argv) - console = Console(startup_message=welcome_message) - console.updateNamespace({'myVar1' : app, 'myVar2' : 1234}) - console.show() - sys.exit(app.exec_()) diff --git a/electrum/gui/qt/contact_list.py b/electrum/gui/qt/contact_list.py index 8e57ec762..3cb579095 100644 --- a/electrum/gui/qt/contact_list.py +++ b/electrum/gui/qt/contact_list.py @@ -34,7 +34,7 @@ from electrum.bitcoin import is_address from electrum.util import block_explorer_URL from electrum.plugin import run_hook -from .util import MyTreeView, import_meta_gui, export_meta_gui, webopen +from .util import MyTreeView, webopen class ContactList(MyTreeView): @@ -63,12 +63,6 @@ class ContactList(MyTreeView): self.parent.set_contact(text, user_role) self.update() - def import_contacts(self): - import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.update) - - def export_contacts(self): - export_meta_gui(self.parent, _('contacts'), self.parent.contacts.export_file) - def create_menu(self, position): menu = QMenu() idx = self.indexAt(position) diff --git a/electrum/gui/qt/custom_model.py b/electrum/gui/qt/custom_model.py new file mode 100644 index 000000000..bca44be15 --- /dev/null +++ b/electrum/gui/qt/custom_model.py @@ -0,0 +1,94 @@ +# loosely based on +# http://trevorius.com/scrapbook/uncategorized/pyqt-custom-abstractitemmodel/ + +from PyQt5 import QtCore, QtWidgets + +class CustomNode: + + def __init__(self, model, data): + self.model = model + self._data = data + self._children = [] + self._parent = None + self._row = 0 + + def get_data(self): + return self._data + + def get_data_for_role(self, index, role): + # define in child class + raise NotImplementedError() + + def childCount(self): + return len(self._children) + + def child(self, row): + if row >= 0 and row < self.childCount(): + return self._children[row] + + def parent(self): + return self._parent + + def row(self): + return self._row + + def addChild(self, child): + child._parent = self + child._row = len(self._children) + self._children.append(child) + + + +class CustomModel(QtCore.QAbstractItemModel): + + def __init__(self, parent, columncount): + QtCore.QAbstractItemModel.__init__(self, parent) + self._root = CustomNode(self, None) + self._columncount = columncount + + def rowCount(self, index): + if index.isValid(): + return index.internalPointer().childCount() + return self._root.childCount() + + def columnCount(self, index): + return self._columncount + + def addChild(self, node, _parent): + if not _parent or not _parent.isValid(): + parent = self._root + else: + parent = _parent.internalPointer() + parent.addChild(self, node) + + def index(self, row, column, _parent=None): + if not _parent or not _parent.isValid(): + parent = self._root + else: + parent = _parent.internalPointer() + + if not QtCore.QAbstractItemModel.hasIndex(self, row, column, _parent): + return QtCore.QModelIndex() + + child = parent.child(row) + if child: + return QtCore.QAbstractItemModel.createIndex(self, row, column, child) + else: + return QtCore.QModelIndex() + + def parent(self, index): + if index.isValid(): + node = index.internalPointer() + if node: + p = node.parent() + if p: + return QtCore.QAbstractItemModel.createIndex(self, p.row(), 0, p) + else: + return QtCore.QModelIndex() + return QtCore.QModelIndex() + + def data(self, index, role): + if not index.isValid(): + return None + node = index.internalPointer() + return node.get_data_for_role(index, role) diff --git a/electrum/gui/qt/exception_window.py b/electrum/gui/qt/exception_window.py index ad60c48f6..97fde8c8b 100644 --- a/electrum/gui/qt/exception_window.py +++ b/electrum/gui/qt/exception_window.py @@ -22,6 +22,8 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import sys +import html +from typing import TYPE_CHECKING, Optional, Set from PyQt5.QtCore import QObject import PyQt5.QtCore as QtCore @@ -32,16 +34,24 @@ from electrum.i18n import _ from electrum.base_crash_reporter import BaseCrashReporter from electrum.logging import Logger from electrum import constants +from electrum.network import Network from .util import MessageBoxMixin, read_QIcon, WaitingDialog +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + from electrum.wallet import Abstract_Wallet + + + class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger): _active_window = None - def __init__(self, main_window, exctype, value, tb): + def __init__(self, config: 'SimpleConfig', exctype, value, tb): BaseCrashReporter.__init__(self, exctype, value, tb) - self.main_window = main_window + self.network = Network.get_instance() + self.config = config QWidget.__init__(self) self.setWindowTitle('Electrum - ' + _('An Error Occurred')) @@ -109,13 +119,13 @@ class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger): self.logger.error('There was a problem with the automatic reporting', exc_info=exc_info) self.show_critical(parent=self, msg=(_('There was a problem with the automatic reporting:') + '
' + - repr(e)[:120] + '
' + + repr(e)[:120] + '

' + _("Please report this issue manually") + f' on GitHub.'), rich_text=True) - proxy = self.main_window.network.proxy - task = lambda: BaseCrashReporter.send_report(self, self.main_window.network.asyncio_loop, proxy) + proxy = self.network.proxy + task = lambda: BaseCrashReporter.send_report(self, self.network.asyncio_loop, proxy) msg = _('Sending crash report...') WaitingDialog(self, msg, task, on_success, on_failure) @@ -124,7 +134,7 @@ class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger): self.close() def show_never(self): - self.main_window.config.set_key(BaseCrashReporter.config_key, False) + self.config.set_key(BaseCrashReporter.config_key, False) self.close() def closeEvent(self, event): @@ -135,7 +145,15 @@ class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger): return self.description_textfield.toPlainText() def get_wallet_type(self): - return self.main_window.wallet.wallet_type + wallet_types = Exception_Hook._INSTANCE.wallet_types_seen + return ",".join(wallet_types) + + def _get_traceback_str(self) -> str: + # The msg_box that shows the report uses rich_text=True, so + # if traceback contains special HTML characters, e.g. '<', + # they need to be escaped to avoid formatting issues. + traceback_str = super()._get_traceback_str() + return html.escape(traceback_str) def _show_window(*args): @@ -146,15 +164,29 @@ def _show_window(*args): class Exception_Hook(QObject, Logger): _report_exception = QtCore.pyqtSignal(object, object, object, object) - def __init__(self, main_window, *args, **kwargs): - QObject.__init__(self, *args, **kwargs) + _INSTANCE = None # type: Optional[Exception_Hook] # singleton + + def __init__(self, *, config: 'SimpleConfig'): + QObject.__init__(self) Logger.__init__(self) - if not main_window.config.get(BaseCrashReporter.config_key, default=True): - return - self.main_window = main_window + assert self._INSTANCE is None, "Exception_Hook is supposed to be a singleton" + self.config = config + self.wallet_types_seen = set() # type: Set[str] + + sys.excepthook = self.handler self._report_exception.connect(_show_window) + @classmethod + def maybe_setup(cls, *, config: 'SimpleConfig', wallet: 'Abstract_Wallet') -> None: + if not config.get(BaseCrashReporter.config_key, default=True): + return + if not cls._INSTANCE: + cls._INSTANCE = Exception_Hook(config=config) + cls._INSTANCE.wallet_types_seen.add(wallet.wallet_type) + + + def handler(self, *exc_info): self.logger.error('exception caught by crash reporter', exc_info=exc_info) - self._report_exception.emit(self.main_window, *exc_info) + self._report_exception.emit(self.config, *exc_info) diff --git a/electrum/gui/qt/fee_slider.py b/electrum/gui/qt/fee_slider.py index 25be72b86..403778270 100644 --- a/electrum/gui/qt/fee_slider.py +++ b/electrum/gui/qt/fee_slider.py @@ -2,10 +2,32 @@ import threading from PyQt5.QtGui import QCursor from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QSlider, QToolTip +from PyQt5.QtWidgets import QSlider, QToolTip, QComboBox from electrum.i18n import _ +class FeeComboBox(QComboBox): + + def __init__(self, fee_slider): + QComboBox.__init__(self) + self.config = fee_slider.config + self.fee_slider = fee_slider + self.addItems([_('Static'), _('ETA'), _('Mempool')]) + self.setCurrentIndex((2 if self.config.use_mempool_fees() else 1) if self.config.is_dynfee() else 0) + self.currentIndexChanged.connect(self.on_fee_type) + self.help_msg = '\n'.join([ + _('Static: the fee slider uses static values'), + _('ETA: fee rate is based on average confirmation time estimates'), + _('Mempool based: fee rate is targeting a depth in the memory pool') + ] + ) + + def on_fee_type(self, x): + self.config.set_key('mempool_fees', x==2) + self.config.set_key('dynamic_fees', x>0) + self.fee_slider.update() + + class FeeSlider(QSlider): diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index 010450f39..5ec3d42e2 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -43,9 +43,10 @@ from electrum.address_synchronizer import TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE from electrum.i18n import _ from electrum.util import (block_explorer_URL, profiler, TxMinedInfo, OrderedDictWithIndex, timestamp_to_datetime, - Satoshis, format_time) + Satoshis, Fiat, format_time) from electrum.logging import get_logger, Logger +from .custom_model import CustomNode, CustomModel from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton, filename_field, MyTreeView, AcceptFileDragDrop, WindowModalDialog, CloseButton, webopen) @@ -106,42 +107,19 @@ class HistorySortModel(QSortFilterProxyModel): def get_item_key(tx_item): return tx_item.get('txid') or tx_item['payment_hash'] -class HistoryModel(QAbstractItemModel, Logger): +class HistoryNode(CustomNode): - def __init__(self, parent: 'ElectrumWindow'): - QAbstractItemModel.__init__(self, parent) - Logger.__init__(self) - self.parent = parent - self.view = None # type: HistoryList - self.transactions = OrderedDictWithIndex() - self.tx_status_cache = {} # type: Dict[str, Tuple[int, str]] - - def set_view(self, history_list: 'HistoryList'): - # FIXME HistoryModel and HistoryList mutually depend on each other. - # After constructing both, this method needs to be called. - self.view = history_list # type: HistoryList - self.set_visibility_of_columns() - - def columnCount(self, parent: QModelIndex): - return len(HistoryColumns) - - def rowCount(self, parent: QModelIndex): - return len(self.transactions) - - def index(self, row: int, column: int, parent: QModelIndex): - return self.createIndex(row, column) - - def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: + def get_data_for_role(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: # note: this method is performance-critical. # it is called a lot, and so must run extremely fast. assert index.isValid() col = index.column() - tx_item = self.transactions.value_from_pos(index.row()) + window = self.model.parent + tx_item = self.get_data() is_lightning = tx_item.get('lightning', False) timestamp = tx_item['timestamp'] if is_lightning: status = 0 - txpos = tx_item['txpos'] if timestamp is None: status_str = 'unconfirmed' else: @@ -149,33 +127,25 @@ class HistoryModel(QAbstractItemModel, Logger): else: tx_hash = tx_item['txid'] conf = tx_item['confirmations'] - txpos = tx_item['txpos_in_block'] or 0 - height = tx_item['height'] try: - status, status_str = self.tx_status_cache[tx_hash] + status, status_str = self.model.tx_status_cache[tx_hash] except KeyError: - tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) - status, status_str = self.parent.wallet.get_tx_status(tx_hash, tx_mined_info) - - # we sort by timestamp - if timestamp is None: - timestamp = float("inf") + tx_mined_info = self.model.tx_mined_info_from_tx_item(tx_item) + status, status_str = window.wallet.get_tx_status(tx_hash, tx_mined_info) if role == Qt.UserRole: # for sorting d = { HistoryColumns.STATUS: - # height breaks ties for unverified txns - # txpos breaks ties for verified same block txns - (-timestamp, conf, -status, -height, -txpos) if not is_lightning else (-timestamp, 0,0,0,-txpos), + # respect sort order of self.transactions (wallet.get_full_history) + -index.row(), HistoryColumns.DESCRIPTION: tx_item['label'] if 'label' in tx_item else None, HistoryColumns.AMOUNT: (tx_item['bc_value'].value if 'bc_value' in tx_item else 0)\ + (tx_item['ln_value'].value if 'ln_value' in tx_item else 0), HistoryColumns.BALANCE: - (tx_item['balance'].value if 'balance' in tx_item else 0)\ - + (tx_item['balance_msat']//1000 if 'balance_msat'in tx_item else 0), + (tx_item['balance'].value if 'balance' in tx_item else 0), HistoryColumns.FIAT_VALUE: tx_item['fiat_value'].value if 'fiat_value' in tx_item else None, HistoryColumns.FIAT_ACQ_PRICE: @@ -190,11 +160,20 @@ class HistoryModel(QAbstractItemModel, Logger): icon = "lightning" if is_lightning else TX_ICONS[status] return QVariant(read_QIcon(icon)) elif col == HistoryColumns.STATUS and role == Qt.ToolTipRole: - msg = 'lightning transaction' if is_lightning else str(conf) + _(" confirmation" + ("s" if conf != 1 else "")) + if is_lightning: + msg = 'lightning transaction' + else: # on-chain + if tx_item['height'] == TX_HEIGHT_LOCAL: + # note: should we also explain double-spends? + msg = _("This transaction is only available on your local machine.\n" + "The currently connected server does not know about it.\n" + "You can either broadcast it now, or simply remove it.") + else: + msg = str(conf) + _(" confirmation" + ("s" if conf != 1 else "")) return QVariant(msg) elif col > HistoryColumns.DESCRIPTION and role == Qt.TextAlignmentRole: return QVariant(Qt.AlignRight | Qt.AlignVCenter) - elif col != HistoryColumns.STATUS and role == Qt.FontRole: + elif col > HistoryColumns.DESCRIPTION and role == Qt.FontRole: monospace_font = QFont(MONOSPACE_FONT) return QVariant(monospace_font) #elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole and not is_lightning\ @@ -217,37 +196,47 @@ class HistoryModel(QAbstractItemModel, Logger): bc_value = tx_item['bc_value'].value if 'bc_value' in tx_item else 0 ln_value = tx_item['ln_value'].value if 'ln_value' in tx_item else 0 value = bc_value + ln_value - v_str = self.parent.format_amount(value, is_diff=True, whitespaces=True) + v_str = window.format_amount(value, is_diff=True, whitespaces=True) return QVariant(v_str) elif col == HistoryColumns.BALANCE: balance = tx_item['balance'].value - balance_str = self.parent.format_amount(balance, whitespaces=True) + balance_str = window.format_amount(balance, whitespaces=True) return QVariant(balance_str) elif col == HistoryColumns.FIAT_VALUE and 'fiat_value' in tx_item: - value_str = self.parent.fx.format_fiat(tx_item['fiat_value'].value) + value_str = window.fx.format_fiat(tx_item['fiat_value'].value) return QVariant(value_str) elif col == HistoryColumns.FIAT_ACQ_PRICE and \ tx_item['value'].value < 0 and 'acquisition_price' in tx_item: # fixme: should use is_mine acq = tx_item['acquisition_price'].value - return QVariant(self.parent.fx.format_fiat(acq)) + return QVariant(window.fx.format_fiat(acq)) elif col == HistoryColumns.FIAT_CAP_GAINS and 'capital_gain' in tx_item: cg = tx_item['capital_gain'].value - return QVariant(self.parent.fx.format_fiat(cg)) + return QVariant(window.fx.format_fiat(cg)) elif col == HistoryColumns.TXID: return QVariant(tx_hash) if not is_lightning else QVariant('') return QVariant() - def parent(self, index: QModelIndex): - return QModelIndex() +class HistoryModel(CustomModel, Logger): - def hasChildren(self, index: QModelIndex): - return not index.isValid() + def __init__(self, parent: 'ElectrumWindow'): + CustomModel.__init__(self, parent, len(HistoryColumns)) + Logger.__init__(self) + self.parent = parent + self.view = None # type: HistoryList + self.transactions = OrderedDictWithIndex() + self.tx_status_cache = {} # type: Dict[str, Tuple[int, str]] - def update_label(self, row): - tx_item = self.transactions.value_from_pos(row) + def set_view(self, history_list: 'HistoryList'): + # FIXME HistoryModel and HistoryList mutually depend on each other. + # After constructing both, this method needs to be called. + self.view = history_list # type: HistoryList + self.set_visibility_of_columns() + + def update_label(self, index): + tx_item = index.internalPointer().get_data() tx_item['label'] = self.parent.wallet.get_label(get_item_key(tx_item)) - topLeft = bottomRight = self.createIndex(row, HistoryColumns.DESCRIPTION) + topLeft = bottomRight = self.createIndex(index.row(), HistoryColumns.DESCRIPTION) self.dataChanged.emit(topLeft, bottomRight, [Qt.DisplayRole]) self.parent.utxo_list.update() @@ -274,19 +263,64 @@ class HistoryModel(QAbstractItemModel, Logger): if fx: fx.history_used_spot = False wallet = self.parent.wallet self.set_visibility_of_columns() - transactions = wallet.get_full_history(self.parent.fx, - onchain_domain=self.get_domain(), - include_lightning=self.should_include_lightning_payments()) + transactions = wallet.get_full_history( + self.parent.fx, + onchain_domain=self.get_domain(), + include_lightning=self.should_include_lightning_payments()) if transactions == list(self.transactions.values()): return - old_length = len(self.transactions) + old_length = self._root.childCount() if old_length != 0: self.beginRemoveRows(QModelIndex(), 0, old_length) self.transactions.clear() + self._root = HistoryNode(self, None) self.endRemoveRows() - self.beginInsertRows(QModelIndex(), 0, len(transactions)-1) + parents = {} + for tx_item in transactions.values(): + node = HistoryNode(self, tx_item) + group_id = tx_item.get('group_id') + if group_id is None: + self._root.addChild(node) + else: + parent = parents.get(group_id) + if parent is None: + # create parent if it does not exist + self._root.addChild(node) + parents[group_id] = node + else: + # if parent has no children, create two children + if parent.childCount() == 0: + child_data = dict(parent.get_data()) + node1 = HistoryNode(self, child_data) + parent.addChild(node1) + parent._data['label'] = child_data.get('group_label') + parent._data['bc_value'] = child_data.get('bc_value', Satoshis(0)) + parent._data['ln_value'] = child_data.get('ln_value', Satoshis(0)) + # add child to parent + parent.addChild(node) + # update parent data + parent._data['balance'] = tx_item['balance'] + parent._data['value'] += tx_item['value'] + if 'group_label' in tx_item: + parent._data['label'] = tx_item['group_label'] + if 'bc_value' in tx_item: + parent._data['bc_value'] += tx_item['bc_value'] + if 'ln_value' in tx_item: + parent._data['ln_value'] += tx_item['ln_value'] + if 'fiat_value' in tx_item: + parent._data['fiat_value'] += tx_item['fiat_value'] + if tx_item.get('txid') == group_id: + parent._data['lightning'] = False + parent._data['txid'] = tx_item['txid'] + parent._data['timestamp'] = tx_item['timestamp'] + parent._data['height'] = tx_item['height'] + parent._data['confirmations'] = tx_item['confirmations'] + + new_length = self._root.childCount() + self.beginInsertRows(QModelIndex(), 0, new_length-1) self.transactions = transactions self.endInsertRows() + if selected_row: self.view.selectionModel().select(self.createIndex(selected_row, 0), QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent) self.view.filter() @@ -318,8 +352,8 @@ class HistoryModel(QAbstractItemModel, Logger): set_visible(HistoryColumns.FIAT_ACQ_PRICE, history and cap_gains) set_visible(HistoryColumns.FIAT_CAP_GAINS, history and cap_gains) - def update_fiat(self, row, idx): - tx_item = self.transactions.value_from_pos(row) + def update_fiat(self, idx): + tx_item = idx.internalPointer().get_data() key = tx_item['txid'] fee = tx_item.get('fee') value = tx_item['value'].value @@ -398,7 +432,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): def tx_item_from_proxy_row(self, proxy_row): hm_idx = self.model().mapToSource(self.model().index(proxy_row, 0)) - return self.hm.transactions.value_from_pos(hm_idx.row()) + return hm_idx.internalPointer().get_data() def should_hide(self, proxy_row): if self.start_timestamp and self.end_timestamp: @@ -426,7 +460,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): self.wallet = self.parent.wallet # type: Abstract_Wallet self.sortByColumn(HistoryColumns.STATUS, Qt.AscendingOrder) self.editable_columns |= {HistoryColumns.FIAT_VALUE} - + self.setRootIsDecorated(True) self.header().setStretchLastSection(False) for col in HistoryColumns: sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents @@ -562,18 +596,18 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): def on_edited(self, index, user_role, text): index = self.model().mapToSource(index) - row, column = index.row(), index.column() - tx_item = self.hm.transactions.value_from_pos(row) + tx_item = index.internalPointer().get_data() + column = index.column() key = get_item_key(tx_item) if column == HistoryColumns.DESCRIPTION: if self.wallet.set_label(key, text): #changed - self.hm.update_label(row) + self.hm.update_label(index) self.parent.update_completions() elif column == HistoryColumns.FIAT_VALUE: self.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, tx_item['value'].value) value = tx_item['value'].value if value is not None: - self.hm.update_fiat(row, index) + self.hm.update_fiat(index) else: assert False @@ -585,11 +619,14 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): if self.hm.flags(self.model().mapToSource(idx)) & Qt.ItemIsEditable: super().mouseDoubleClickEvent(event) else: - self.show_transaction(tx_item) + if tx_item.get('lightning'): + if tx_item['type'] == 'payment': + self.parent.show_lightning_transaction(tx_item) + return + tx_hash = tx_item['txid'] + self.show_transaction(tx_item, tx) - def show_transaction(self, tx_item): - if tx_item.get('lightning'): - return + def show_transaction(self, tx_item, tx): tx_hash = tx_item['txid'] tx = self.wallet.db.get_transaction(tx_hash) if not tx: @@ -605,9 +642,11 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): column_title = self.hm.headerData(column, Qt.Horizontal, Qt.DisplayRole) idx2 = idx.sibling(idx.row(), column) column_data = (self.hm.data(idx2, Qt.DisplayRole).value() or '').strip() - cc.addAction(column_title, - lambda text=column_data, title=column_title: - self.place_text_on_clipboard(text, title=title)) + cc.addAction( + column_title, + lambda text=column_data, title=column_title: + self.place_text_on_clipboard(text, title=title)) + return cc def create_menu(self, position: QPoint): org_idx: QModelIndex = self.indexAt(position) @@ -615,48 +654,61 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): if not idx.isValid(): # can happen e.g. before list is populated for the first time return - tx_item = self.hm.transactions.value_from_pos(idx.row()) - if tx_item.get('lightning'): + tx_item = idx.internalPointer().get_data() + if tx_item.get('lightning') and tx_item['type'] == 'payment': + menu = QMenu() + menu.addAction(_("View Payment"), lambda: self.parent.show_lightning_transaction(tx_item)) + cc = self.add_copy_menu(menu, idx) + cc.addAction(_("Payment Hash"), lambda: self.place_text_on_clipboard(tx_item['payment_hash'], title="Payment Hash")) + cc.addAction(_("Preimage"), lambda: self.place_text_on_clipboard(tx_item['preimage'], title="Preimage")) + menu.exec_(self.viewport().mapToGlobal(position)) return tx_hash = tx_item['txid'] tx = self.wallet.db.get_transaction(tx_hash) + if tx_item.get('lightning'): + tx = self.wallet.lnworker.lnwatcher.db.get_transaction(tx_hash) + else: + tx = self.wallet.db.get_transaction(tx_hash) if not tx: return tx_URL = block_explorer_URL(self.config, 'tx', tx_hash) - height = self.wallet.get_tx_height(tx_hash).height - is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) - is_unconfirmed = height <= 0 - invoice_keys = self.wallet._get_relevant_invoice_keys_for_tx(tx) + tx_details = self.wallet.get_tx_info(tx) + is_unconfirmed = tx_details.tx_mined_status.height <= 0 + invoice_keys = self.wallet.get_relevant_invoice_keys_for_tx(tx) menu = QMenu() - if height in [TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL]: + if tx_details.can_remove: menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash)) - menu.addAction(_("Copy Transaction ID"), lambda: self.place_text_on_clipboard(tx_hash, title="TXID")) - self.add_copy_menu(menu, idx) + cc = self.add_copy_menu(menu, idx) + cc.addAction(_("Transaction ID"), lambda: self.place_text_on_clipboard(tx_hash, title="TXID")) for c in self.editable_columns: if self.isColumnHidden(c): continue label = self.hm.headerData(c, Qt.Horizontal, Qt.DisplayRole) # TODO use siblingAtColumn when min Qt version is >=5.11 persistent = QPersistentModelIndex(org_idx.sibling(org_idx.row(), c)) menu.addAction(_("Edit {}").format(label), lambda p=persistent: self.edit(QModelIndex(p))) - menu.addAction(_("Details"), lambda: self.show_transaction(tx_item)) + menu.addAction(_("View Transaction"), lambda: self.show_transaction(tx_item, tx)) + channel_id = tx_item.get('channel_id') + if channel_id: + menu.addAction(_("View Channel"), lambda: self.parent.show_channel(bytes.fromhex(channel_id))) if is_unconfirmed and tx: # note: the current implementation of RBF *needs* the old tx fee - rbf = is_mine and not tx.is_final() and fee is not None - if rbf: + if tx_details.can_bump and tx_details.fee is not None: menu.addAction(_("Increase fee"), lambda: self.parent.bump_fee_dialog(tx)) else: child_tx = self.wallet.cpfp(tx, 0) if child_tx: menu.addAction(_("Child pays for parent"), lambda: self.parent.cpfp(tx, child_tx)) - if invoice_keys: - menu.addAction(read_QIcon("seal"), _("View invoice"), lambda: [self.parent.show_invoice(key) for key in invoice_keys]) + for key in invoice_keys: + invoice = self.parent.wallet.get_invoice(key) + if invoice: + menu.addAction(_("View invoice"), lambda: self.parent.show_onchain_invoice(invoice)) if tx_URL: menu.addAction(_("View on block explorer"), lambda: webopen(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) + def remove_local_tx(self, tx_hash: str): + to_delete = {tx_hash} + to_delete |= self.wallet.get_depending_transactions(tx_hash) 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?") @@ -739,7 +791,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): from electrum.util import json_encode f.write(json_encode(txns)) - def text_txid_from_coordinate(self, row, col): + def get_text_and_userrole_from_coordinate(self, row, col): idx = self.model().mapToSource(self.model().index(row, col)) - tx_item = self.hm.transactions.value_from_pos(idx.row()) + tx_item = idx.internalPointer().get_data() return self.hm.data(idx, Qt.DisplayRole).value(), get_item_key(tx_item) diff --git a/electrum/gui/qt/installwizard.py b/electrum/gui/qt/installwizard.py index 01990625e..4c1e087a2 100644 --- a/electrum/gui/qt/installwizard.py +++ b/electrum/gui/qt/installwizard.py @@ -19,18 +19,22 @@ from PyQt5.QtWidgets import (QWidget, QDialog, QLabel, QHBoxLayout, QMessageBox, from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import WalletStorage, StorageReadWriteError from electrum.util import UserCancelled, InvalidPassword, WalletFileException, get_new_wallet_name -from electrum.base_wizard import BaseWizard, HWD_SETUP_DECRYPT_WALLET, GoBack +from electrum.base_wizard import BaseWizard, HWD_SETUP_DECRYPT_WALLET, GoBack, ReRunDialog +from electrum.network import Network from electrum.i18n import _ from .seed_dialog import SeedLayout, KeysLayout from .network_dialog import NetworkChoiceLayout from .util import (MessageBoxMixin, Buttons, icon_path, ChoicesLayout, WWLabel, - InfoButton, char_width_in_lineedit) + InfoButton, char_width_in_lineedit, PasswordLineEdit) from .password_dialog import PasswordLayout, PasswordLayoutForHW, PW_NEW +from .bip39_recovery_dialog import Bip39RecoveryDialog from electrum.plugin import run_hook, Plugins if TYPE_CHECKING: from electrum.simple_config import SimpleConfig + from electrum.wallet_db import WalletDB + from . import ElectrumGui MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\ @@ -94,19 +98,41 @@ def wizard_dialog(func): def func_wrapper(*args, **kwargs): run_next = kwargs['run_next'] wizard = args[0] # type: InstallWizard - wizard.back_button.setText(_('Back') if wizard.can_go_back() else _('Cancel')) - try: - out = func(*args, **kwargs) - if type(out) is not tuple: - out = (out,) - run_next(*out) - except GoBack: - if wizard.can_go_back(): - wizard.go_back() - return - else: - wizard.close() + while True: + #wizard.logger.debug(f"dialog stack. len: {len(wizard._stack)}. stack: {wizard._stack}") + wizard.back_button.setText(_('Back') if wizard.can_go_back() else _('Cancel')) + # current dialog + try: + out = func(*args, **kwargs) + if type(out) is not tuple: + out = (out,) + except GoBack: + if not wizard.can_go_back(): + wizard.close() + # to go back from the current dialog, we just let the caller unroll the stack: raise + # next dialog + try: + while True: + try: + run_next(*out) + except ReRunDialog: + # restore state, and then let the loop re-run next + wizard.go_back(rerun_previous=False) + else: + break + except GoBack as e: + # to go back from the next dialog, we ask the wizard to restore state + wizard.go_back(rerun_previous=False) + # and we re-run the current dialog + if wizard.can_go_back(): + # also rerun any calculations that might have populated the inputs to the current dialog, + # by going back to just after the *previous* dialog finished + raise ReRunDialog() from e + else: + continue + else: + break return func_wrapper @@ -121,12 +147,13 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): accept_signal = pyqtSignal() - def __init__(self, config: 'SimpleConfig', app: QApplication, plugins: 'Plugins'): + def __init__(self, config: 'SimpleConfig', app: QApplication, plugins: 'Plugins', *, gui_object: 'ElectrumGui'): QDialog.__init__(self, None) BaseWizard.__init__(self, config, plugins) self.setWindowTitle('LBRY Vault - ' + _('Install Wizard')) self.app = app self.config = config + self.gui_thread = gui_object.gui_thread self.setMinimumSize(600, 400) self.accept_signal.connect(self.accept) self.title = QLabel() @@ -176,21 +203,20 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): vbox = QVBoxLayout() hbox = QHBoxLayout() hbox.addWidget(QLabel(_('Wallet') + ':')) - self.name_e = QLineEdit() - hbox.addWidget(self.name_e) + name_e = QLineEdit() + hbox.addWidget(name_e) button = QPushButton(_('Choose...')) hbox.addWidget(button) vbox.addLayout(hbox) - self.msg_label = WWLabel('') - vbox.addWidget(self.msg_label) + msg_label = WWLabel('') + vbox.addWidget(msg_label) hbox2 = QHBoxLayout() - self.pw_e = QLineEdit('', self) - self.pw_e.setFixedWidth(17 * char_width_in_lineedit()) - self.pw_e.setEchoMode(2) - self.pw_label = QLabel(_('Password') + ':') - hbox2.addWidget(self.pw_label) - hbox2.addWidget(self.pw_e) + pw_e = PasswordLineEdit('', self) + pw_e.setFixedWidth(17 * char_width_in_lineedit()) + pw_label = QLabel(_('Password') + ':') + hbox2.addWidget(pw_label) + hbox2.addWidget(pw_e) hbox2.addStretch() vbox.addLayout(hbox2) @@ -213,7 +239,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): def on_choose(): path, __ = QFileDialog.getOpenFileName(self, "Select your wallet file", wallet_folder) if path: - self.name_e.setText(path) + name_e.setText(path) def on_filename(filename): # FIXME? "filename" might contain ".." (etc) and hence sketchy path traversals are possible @@ -253,71 +279,82 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): + _("Press 'Next' to create/focus window.") if msg is None: msg = _('Cannot read file') - self.msg_label.setText(msg) + msg_label.setText(msg) widget_create_new.setVisible(bool(temp_storage and temp_storage.file_exists())) if user_needs_to_enter_password: - self.pw_label.show() - self.pw_e.show() - self.pw_e.setFocus() + pw_label.show() + pw_e.show() + pw_e.setFocus() else: - self.pw_label.hide() - self.pw_e.hide() + pw_label.hide() + pw_e.hide() button.clicked.connect(on_choose) button_create_new.clicked.connect( partial( - self.name_e.setText, + name_e.setText, get_new_wallet_name(wallet_folder))) - self.name_e.textChanged.connect(on_filename) - self.name_e.setText(os.path.basename(path)) + name_e.textChanged.connect(on_filename) + name_e.setText(os.path.basename(path)) - while True: - if self.loop.exec_() != 2: # 2 = next - raise UserCancelled - assert temp_storage - if temp_storage.file_exists() and not temp_storage.is_encrypted(): - break - if not temp_storage.file_exists(): - break - wallet_from_memory = get_wallet_from_daemon(temp_storage.path) - if wallet_from_memory: - raise WalletAlreadyOpenInMemory(wallet_from_memory) - if temp_storage.file_exists() and temp_storage.is_encrypted(): - if temp_storage.is_encrypted_with_user_pw(): - password = self.pw_e.text() - try: - temp_storage.decrypt(password) - break - except InvalidPassword as e: - self.show_message(title=_('Error'), msg=str(e)) - continue - except BaseException as e: - self.logger.exception('') - self.show_message(title=_('Error'), msg=repr(e)) - raise UserCancelled() - elif temp_storage.is_encrypted_with_hw_device(): - try: - self.run('choose_hw_device', HWD_SETUP_DECRYPT_WALLET, storage=temp_storage) - except InvalidPassword as e: - self.show_message(title=_('Error'), - msg=_('Failed to decrypt using this hardware device.') + '\n' + - _('If you use a passphrase, make sure it is correct.')) - self.reset_stack() - return self.select_storage(path, get_wallet_from_daemon) - except BaseException as e: - self.logger.exception('') - self.show_message(title=_('Error'), msg=repr(e)) - raise UserCancelled() - if temp_storage.is_past_initial_decryption(): - break + def run_user_interaction_loop(): + while True: + if self.loop.exec_() != 2: # 2 = next + raise UserCancelled() + assert temp_storage + if temp_storage.file_exists() and not temp_storage.is_encrypted(): + break + if not temp_storage.file_exists(): + break + wallet_from_memory = get_wallet_from_daemon(temp_storage.path) + if wallet_from_memory: + raise WalletAlreadyOpenInMemory(wallet_from_memory) + if temp_storage.file_exists() and temp_storage.is_encrypted(): + if temp_storage.is_encrypted_with_user_pw(): + password = pw_e.text() + try: + temp_storage.decrypt(password) + break + except InvalidPassword as e: + self.show_message(title=_('Error'), msg=str(e)) + continue + except BaseException as e: + self.logger.exception('') + self.show_message(title=_('Error'), msg=repr(e)) + raise UserCancelled() + elif temp_storage.is_encrypted_with_hw_device(): + try: + self.run('choose_hw_device', HWD_SETUP_DECRYPT_WALLET, storage=temp_storage) + except InvalidPassword as e: + self.show_message(title=_('Error'), + msg=_('Failed to decrypt using this hardware device.') + '\n' + + _('If you use a passphrase, make sure it is correct.')) + self.reset_stack() + return self.select_storage(path, get_wallet_from_daemon) + except (UserCancelled, GoBack): + raise + except BaseException as e: + self.logger.exception('') + self.show_message(title=_('Error'), msg=repr(e)) + raise UserCancelled() + if temp_storage.is_past_initial_decryption(): + break + else: + raise UserCancelled() else: - raise UserCancelled() - else: - raise Exception('Unexpected encryption version') + raise Exception('Unexpected encryption version') + + try: + run_user_interaction_loop() + finally: + try: + pw_e.clear() + except RuntimeError: # wrapped C/C++ object has been deleted. + pass # happens when decrypting with hw device return temp_storage.path, (temp_storage if temp_storage.file_exists() else None) - def run_upgrades(self, storage, db): + def run_upgrades(self, storage: WalletStorage, db: 'WalletDB') -> None: path = storage.path if db.requires_split(): self.hide() @@ -356,12 +393,6 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): if db.requires_upgrade(): self.upgrade_db(storage, db) - return db - - def finished(self): - """Called in hardware client wrapper, in order to close popups.""" - return - def on_error(self, exc_info): if not isinstance(exc_info[1], UserCancelled): self.logger.error("on_error", exc_info=exc_info) @@ -393,7 +424,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): self.set_layout(layout, title, next_enabled) result = self.loop.exec_() if not result and raise_on_cancel: - raise UserCancelled + raise UserCancelled() if result == 1: raise GoBack from None self.title.setVisible(False) @@ -476,8 +507,11 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): playout = PasswordLayout(msg=msg, kind=kind, OK_button=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() + try: + self.exec_layout(playout.layout()) + return playout.new_password(), playout.encrypt_cb.isChecked() + finally: + playout.clear_password_fields() @wizard_dialog def request_password(self, run_next, force_disable_encrypt_cb=False): @@ -530,6 +564,29 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): if on_finished: on_finished() + + def run_task_without_blocking_gui(self, task, *, msg=None): + assert self.gui_thread == threading.current_thread(), 'must be called from GUI thread' + if msg is None: + msg = _("Please wait...") + + exc = None # type: Optional[Exception] + res = None + def task_wrapper(): + nonlocal exc + nonlocal res + try: + res = task() + except Exception as e: + exc = e + self.waiting_dialog(task_wrapper, msg=msg) + if exc is None: + return res + else: + raise exc + + + @wizard_dialog def choice_dialog(self, title, message, choices, run_next): c_values = [x[0] for x in choices] @@ -550,10 +607,34 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): return clayout.selected_index() @wizard_dialog - def choice_and_line_dialog(self, title: str, message1: str, choices: List[Tuple[str, str, str]], - message2: str, test_text: Callable[[str], int], - run_next, default_choice_idx: int=0) -> Tuple[str, str]: + def derivation_and_script_type_gui_specific_dialog( + self, + *, + title: str, + message1: str, + choices: List[Tuple[str, str, str]], + message2: str, + test_text: Callable[[str], int], + run_next, + default_choice_idx: int = 0, + get_account_xpub=None + ) -> Tuple[str, str]: vbox = QVBoxLayout() + if get_account_xpub: + button = QPushButton(_("Detect Existing Accounts")) + def on_account_select(account): + script_type = account["script_type"] + if script_type == "p2pkh": + script_type = "standard" + button_index = c_values.index(script_type) + button = clayout.group.buttons()[button_index] + button.setChecked(True) + line.setText(account["derivation_path"]) + button.clicked.connect(lambda: Bip39RecoveryDialog(self, get_account_xpub, on_account_select)) + vbox.addWidget(button, alignment=Qt.AlignLeft) + vbox.addWidget(QLabel(_("Or"))) + + c_values = [x[0] for x in choices] c_titles = [x[1] for x in choices] @@ -565,7 +646,6 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): checked_index=default_choice_idx) vbox.addLayout(clayout.layout()) - vbox.addSpacing(50) vbox.addWidget(WWLabel(message2)) line = QLineEdit() @@ -622,7 +702,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): self.exec_layout(vbox, _('Master Public Key')) return None - def init_network(self, network): + def init_network(self, network: 'Network'): message = _("Electrum communicates with remote servers to get " "information about your transactions and addresses. The " "servers all fulfill the same purpose only differing in " @@ -639,6 +719,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): nlayout = NetworkChoiceLayout(network, self.config, wizard=True) if self.exec_layout(nlayout.layout()): nlayout.accept() + self.config.set_key('auto_connect', network.auto_connect, True) else: network.auto_connect = True self.config.set_key('auto_connect', True, True) @@ -664,18 +745,25 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): def on_m(m): m_label.setText(_('Require {0} signatures').format(m)) cw.set_m(m) + backup_warning_label.setVisible(cw.m != cw.n) def on_n(n): n_label.setText(_('From {0} cosigners').format(n)) cw.set_n(n) m_edit.setMaximum(n) + backup_warning_label.setVisible(cw.m != cw.n) n_edit.valueChanged.connect(on_n) m_edit.valueChanged.connect(on_m) - on_n(2) - on_m(2) vbox = QVBoxLayout() vbox.addWidget(cw) vbox.addWidget(WWLabel(_("Choose the number of signatures needed to unlock funds in your wallet:"))) vbox.addLayout(grid) + vbox.addSpacing(2 * char_width_in_lineedit()) + backup_warning_label = WWLabel(_("Warning: to be able to restore a multisig wallet, " + "you should include the master public key for each cosigner " + "in all of your backups.")) + vbox.addWidget(backup_warning_label) + on_n(2) + on_m(2) self.exec_layout(vbox, _("Multi-Signature Wallet")) m = int(m_edit.value()) n = int(n_edit.value()) diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index 80125ae63..e1a8b0d01 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -29,16 +29,14 @@ from typing import Sequence from PyQt5.QtCore import Qt, QItemSelectionModel from PyQt5.QtGui import QStandardItemModel, QStandardItem from PyQt5.QtWidgets import QAbstractItemView -from PyQt5.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem +from PyQt5.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView from electrum.i18n import _ -from electrum.util import format_time, PR_UNPAID, PR_PAID, PR_INFLIGHT -from electrum.util import get_request_status -from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN +from electrum.util import format_time +from electrum.invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_TYPE_ONCHAIN, PR_TYPE_LN from electrum.lnutil import PaymentAttemptLog -from .util import (MyTreeView, read_QIcon, - import_meta_gui, export_meta_gui, pr_icons) +from .util import MyTreeView, read_QIcon, MySortModel, pr_icons from .util import CloseButton, Buttons from .util import WindowModalDialog @@ -46,6 +44,7 @@ from .util import WindowModalDialog ROLE_REQUEST_TYPE = Qt.UserRole ROLE_REQUEST_ID = Qt.UserRole + 1 +ROLE_SORT_ORDER = Qt.UserRole + 2 class InvoiceList(MyTreeView): @@ -68,16 +67,16 @@ class InvoiceList(MyTreeView): super().__init__(parent, self.create_menu, stretch_column=self.Columns.DESCRIPTION, editable_columns=[]) + self.std_model = QStandardItemModel(self) + self.proxy = MySortModel(self, sort_role=ROLE_SORT_ORDER) + self.proxy.setSourceModel(self.std_model) + self.setModel(self.proxy) self.setSortingEnabled(True) - self.setModel(QStandardItemModel(self)) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.update() - def update_item(self, key, status): - req = self.parent.wallet.get_invoice(key) - if req is None: - return - model = self.model() + def update_item(self, key, invoice: Invoice): + model = self.std_model for row in range(0, model.rowCount()): item = model.item(row, 0) if item.data(ROLE_REQUEST_ID) == key: @@ -85,7 +84,8 @@ class InvoiceList(MyTreeView): else: return status_item = model.item(row, self.Columns.STATUS) - status, status_str = get_request_status(req) + status = self.parent.wallet.get_invoice_status(invoice) + status_str = invoice.get_status_str(status) if self.parent.wallet.lnworker: log = self.parent.wallet.lnworker.logs.get(key) if log and status == PR_INFLIGHT: @@ -95,29 +95,23 @@ class InvoiceList(MyTreeView): def update(self): # not calling maybe_defer_update() as it interferes with conditional-visibility - _list = self.parent.wallet.get_invoices() - # filter out paid invoices unless we have the log - lnworker_logs = self.parent.wallet.lnworker.logs if self.parent.wallet.lnworker else {} - _list = [x for x in _list - if x and (x.get('status') != PR_PAID or x.get('rhash') in lnworker_logs)] - self.model().clear() + self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change + self.std_model.clear() self.update_headers(self.__class__.headers) - for idx, item in enumerate(_list): - invoice_type = item['type'] - if invoice_type == PR_TYPE_LN: - key = item['rhash'] + for idx, item in enumerate(self.parent.wallet.get_invoices()): + if item.is_lightning(): + key = item.rhash icon_name = 'lightning.png' - elif invoice_type == PR_TYPE_ONCHAIN: - key = item['id'] - icon_name = 'bitcoin.png' - if item.get('bip70'): - icon_name = 'seal.png' else: - raise Exception('Unsupported type') - status, status_str = get_request_status(item) - message = item['message'] - amount = item['amount'] - timestamp = item.get('time', 0) + key = item.id + icon_name = 'bitcoin.png' + if item.bip70: + icon_name = 'seal.png' + status = self.parent.wallet.get_invoice_status(item) + status_str = item.get_status_str(status) + message = item.message + amount = item.get_amount_sat() + timestamp = item.time or 0 date_str = format_time(timestamp) if timestamp else _('Unknown') amount_str = self.parent.format_amount(amount, whitespaces=True) labels = [date_str, message, amount_str, status_str] @@ -126,84 +120,70 @@ class InvoiceList(MyTreeView): items[self.Columns.DATE].setIcon(read_QIcon(icon_name)) items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) items[self.Columns.DATE].setData(key, role=ROLE_REQUEST_ID) - items[self.Columns.DATE].setData(invoice_type, role=ROLE_REQUEST_TYPE) - self.model().insertRow(idx, items) - - self.selectionModel().select(self.model().index(0,0), QItemSelectionModel.SelectCurrent) + items[self.Columns.DATE].setData(item.type, role=ROLE_REQUEST_TYPE) + items[self.Columns.DATE].setData(timestamp, role=ROLE_SORT_ORDER) + self.std_model.insertRow(idx, items) + self.filter() + self.proxy.setDynamicSortFilter(True) # sort requests by date - self.sortByColumn(self.Columns.DATE, Qt.AscendingOrder) + self.sortByColumn(self.Columns.DATE, Qt.DescendingOrder) # hide list if empty if self.parent.isVisible(): - b = self.model().rowCount() > 0 + b = self.std_model.rowCount() > 0 self.setVisible(b) self.parent.invoices_label.setVisible(b) - self.filter() - - def import_invoices(self): - import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.update) - - def export_invoices(self): - export_meta_gui(self.parent, _('invoices'), self.parent.invoices.export_file) def create_menu(self, position): + wallet = self.parent.wallet items = self.selected_in_column(0) if len(items)>1: keys = [ item.data(ROLE_REQUEST_ID) for item in items] - invoices = [ self.parent.wallet.get_invoice(key) for key in keys] - invoices = [ invoice for invoice in invoices if invoice['status'] == PR_UNPAID and invoice['type'] == PR_TYPE_ONCHAIN] - if len(invoices) > 1: - menu = QMenu(self) - menu.addAction(_("Pay multiple invoices"), lambda: self.parent.pay_multiple_invoices(invoices)) - menu.exec_(self.viewport().mapToGlobal(position)) + invoices = [ wallet.invoices.get(key) for key in keys] + can_batch_pay = all([i.type == PR_TYPE_ONCHAIN and wallet.get_invoice_status(i) == PR_UNPAID for i in invoices]) + menu = QMenu(self) + if can_batch_pay: + menu.addAction(_("Batch pay invoices"), lambda: self.parent.pay_multiple_invoices(invoices)) + menu.addAction(_("Delete invoices"), lambda: self.parent.delete_invoices(keys)) + menu.exec_(self.viewport().mapToGlobal(position)) return idx = self.indexAt(position) - item = self.model().itemFromIndex(idx) - item_col0 = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) + item = self.item_from_index(idx) + item_col0 = self.item_from_index(idx.sibling(idx.row(), self.Columns.DATE)) if not item or not item_col0: return key = item_col0.data(ROLE_REQUEST_ID) - request_type = item_col0.data(ROLE_REQUEST_TYPE) + invoice = self.parent.wallet.get_invoice(key) menu = QMenu(self) self.add_copy_menu(menu, idx) - invoice = self.parent.wallet.get_invoice(key) - menu.addAction(_("Details"), lambda: self.parent.show_invoice(key)) - if invoice['status'] == PR_UNPAID: + if invoice.is_lightning(): + menu.addAction(_("Details"), lambda: self.parent.show_lightning_invoice(invoice)) + else: + if len(invoice.outputs) == 1: + menu.addAction(_("Copy Address"), lambda: self.parent.do_copy(invoice.get_address(), title='Bitcoin Address')) + menu.addAction(_("Details"), lambda: self.parent.show_onchain_invoice(invoice)) + status = wallet.get_invoice_status(invoice) + if status == PR_UNPAID: menu.addAction(_("Pay"), lambda: self.parent.do_pay_invoice(invoice)) + if status == PR_FAILED: + menu.addAction(_("Retry"), lambda: self.parent.do_pay_invoice(invoice)) if self.parent.wallet.lnworker: log = self.parent.wallet.lnworker.logs.get(key) if log: menu.addAction(_("View log"), lambda: self.show_log(key, log)) - menu.addAction(_("Delete"), lambda: self.parent.delete_invoice(key)) + menu.addAction(_("Delete"), lambda: self.parent.delete_invoices([key])) menu.exec_(self.viewport().mapToGlobal(position)) def show_log(self, key, log: Sequence[PaymentAttemptLog]): d = WindowModalDialog(self, _("Payment log")) - d.setMinimumWidth(800) + d.setMinimumWidth(600) vbox = QVBoxLayout(d) log_w = QTreeWidget() - log_w.setHeaderLabels([_('Route'), _('Channel ID'), _('Message'), _('Blacklist')]) + log_w.setHeaderLabels([_('Hops'), _('Channel ID'), _('Message')]) + log_w.header().setSectionResizeMode(2, QHeaderView.Stretch) + log_w.header().setSectionResizeMode(1, QHeaderView.ResizeToContents) for payment_attempt_log in log: - if not payment_attempt_log.exception: - route = payment_attempt_log.route - route_str = '%d'%len(route) - if not payment_attempt_log.success: - sender_idx = payment_attempt_log.failure_details.sender_idx - failure_msg = payment_attempt_log.failure_details.failure_msg - blacklist_msg = str(payment_attempt_log.failure_details.is_blacklisted) - short_channel_id = route[sender_idx+1].short_channel_id - data = failure_msg.data - message = repr(failure_msg.code) - else: - short_channel_id = route[-1].short_channel_id - message = _('Success') - blacklist_msg = str(False) - chan_str = str(short_channel_id) - else: - route_str = 'None' - chan_str = 'N/A' - message = str(payment_attempt_log.exception) - blacklist_msg = 'N/A' - x = QTreeWidgetItem([route_str, chan_str, message, blacklist_msg]) + route_str, chan_str, message = payment_attempt_log.formatted_tuple() + x = QTreeWidgetItem([route_str, chan_str, message]) log_w.addTopLevelItem(x) vbox.addWidget(log_w) vbox.addLayout(Buttons(CloseButton(d))) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 7db789662..38c75c046 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -45,51 +45,50 @@ from PyQt5.QtWidgets import (QMessageBox, QComboBox, QSystemTrayIcon, QTabWidget QVBoxLayout, QGridLayout, QLineEdit, QHBoxLayout, QPushButton, QScrollArea, QTextEdit, QShortcut, QMainWindow, QCompleter, QInputDialog, - QWidget, QSizePolicy, QStatusBar, QToolTip) + QWidget, QSizePolicy, QStatusBar, QToolTip, QDialog, + QMenu, QAction) import electrum from electrum import (keystore, ecc, constants, util, bitcoin, commands, paymentrequest) from electrum.bitcoin import COIN, is_address -from electrum.plugin import run_hook +from electrum.plugin import run_hook, BasePlugin from electrum.i18n import _ -from electrum.util import (format_time, format_satoshis, format_fee_satoshis, - format_satoshis_plain, +from electrum.util import (format_time, UserCancelled, profiler, - export_meta, import_meta, bh2u, bfh, InvalidPassword, - decimal_point_to_base_unit_name, - UnknownBaseUnit, DECIMAL_POINT_DEFAULT, UserFacingException, + bh2u, bfh, InvalidPassword, + UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter, InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds, NoDynamicFeeEstimates, MultipleSpendMaxTxOutputs) -from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN +from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING, Invoice +from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, LNInvoice, OnchainInvoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) from electrum.address_synchronizer import AddTransactionException from electrum.wallet import (Multisig_Wallet, CannotBumpFee, Abstract_Wallet, sweep_preparations, InternalAddressCorruption) from electrum.version import ELECTRUM_VERSION -from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed +from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed, UntrustedServerReturnedError from electrum.exchange_rate import FxThread from electrum.simple_config import SimpleConfig from electrum.logging import Logger -from electrum.util import PR_PAID, PR_FAILED -from electrum.util import pr_expiration_values from electrum.lnutil import ln_dummy_address +from electrum.lnaddr import lndecode, LnDecodeException from .exception_window import Exception_Hook from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit from .qrcodewidget import QRCodeWidget, QRDialog from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit from .transaction_dialog import show_transaction -#from .fee_slider import FeeSlider +from .fee_slider import FeeSlider, FeeComboBox from .util import (read_QIcon, ColorScheme, text_dialog, icon_path, WaitingDialog, WindowModalDialog, ChoicesLayout, HelpLabel, Buttons, OkButton, InfoButton, WWLabel, TaskThread, CancelButton, CloseButton, HelpButton, MessageBoxMixin, EnterButton, import_meta_gui, export_meta_gui, filename_field, address_field, char_width_in_lineedit, webopen, - TRANSACTION_FILE_EXTENSION_FILTER, MONOSPACE_FONT) + TRANSACTION_FILE_EXTENSION_FILTER_ANY, MONOSPACE_FONT) from .util import ButtonsTextEdit from .installwizard import WIF_HELP_TEXT from .history_list import HistoryList, HistoryModel @@ -158,6 +157,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): alias_received_signal = pyqtSignal() computing_privkeys_signal = pyqtSignal() show_privkeys_signal = pyqtSignal() + show_error_signal = pyqtSignal(str) + + payment_request: Optional[paymentrequest.PaymentRequest] def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet): QMainWindow.__init__(self) @@ -165,12 +167,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.gui_object = gui_object self.config = config = gui_object.config # type: SimpleConfig self.gui_thread = gui_object.gui_thread + assert wallet, "no wallet" + self.wallet = wallet self.setup_exception_hook() self.network = gui_object.daemon.network # type: Network - assert wallet, "no wallet" - self.wallet = wallet self.fx = gui_object.daemon.fx # type: FxThread self.contacts = wallet.contacts self.tray = gui_object.tray @@ -181,6 +183,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.checking_accounts = False self.qr_window = None self.pluginsdialog = None + self.showing_cert_mismatch_error = False self.tl_windows = [] Logger.__init__(self) @@ -189,12 +192,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.create_status_bar() self.need_update = threading.Event() - self.decimal_point = config.get('decimal_point', DECIMAL_POINT_DEFAULT) - try: - decimal_point_to_base_unit_name(self.decimal_point) - except UnknownBaseUnit: - self.decimal_point = DECIMAL_POINT_DEFAULT - self.num_zeros = int(config.get('num_zeros', 0)) self.completions = QStringListModel() @@ -207,7 +204,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.utxo_tab = self.create_utxo_tab() self.console_tab = self.create_console_tab() self.contacts_tab = self.create_contacts_tab() - self.channels_tab = self.create_channels_tab(wallet) + self.channels_tab = self.create_channels_tab() tabs.addTab(self.create_history_tab(), read_QIcon("tab_history.png"), _('History')) tabs.addTab(self.send_tab, read_QIcon("tab_send.png"), _('Send')) tabs.addTab(self.receive_tab, read_QIcon("tab_receive.png"), _('Receive')) @@ -221,8 +218,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): tabs.addTab(tab, icon, description.replace("&", "")) add_optional_tab(tabs, self.addresses_tab, read_QIcon("tab_addresses.png"), _("&Addresses"), "addresses") - if self.wallet.has_lightning(): - add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"), "channels") + add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"), "channels") add_optional_tab(tabs, self.utxo_tab, read_QIcon("tab_coins.png"), _("Co&ins"), "utxo") add_optional_tab(tabs, self.contacts_tab, read_QIcon("tab_contacts.png"), _("Con&tacts"), "contacts") add_optional_tab(tabs, self.console_tab, read_QIcon("tab_console.png"), _("Con&sole"), "console") @@ -256,6 +252,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.payment_request_ok_signal.connect(self.payment_request_ok) self.payment_request_error_signal.connect(self.payment_request_error) + self.show_error_signal.connect(self.show_error) self.history_list.setFocus(True) # network callbacks @@ -266,11 +263,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): 'banner', 'verified', 'fee', 'fee_histogram', 'on_quotes', 'on_history', 'channel', 'channels_updated', 'invoice_status', 'request_status'] + 'payment_failed', 'payment_succeeded', + 'invoice_status', 'request_status', 'ln_gossip_sync_progress', + 'cert_mismatch', 'gossip_db_loaded'] # To avoid leaking references to "self" that prevent the # window from being GC-ed when closed, callbacks should be # methods of this class only, and specifically not be # partials, lambdas or methods of subobjects. Hence... - self.network.register_callback(self.on_network, interests) + util.register_callback(self.on_network, interests) # set initial message self.console.showMessage(self.network.banner) @@ -295,12 +295,25 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.update_check_button.setText(_("Update to Electrum {} is available").format(v)) self.update_check_button.clicked.connect(lambda: self.show_update_check(v)) self.update_check_button.show() - self._update_check_thread = UpdateCheckThread(self) + self._update_check_thread = UpdateCheckThread() self._update_check_thread.checked.connect(on_version_received) self._update_check_thread.start() def setup_exception_hook(self): - Exception_Hook(self) + Exception_Hook.maybe_setup(config=self.config, + wallet=self.wallet) + + def run_coroutine_from_thread(self, coro, on_result=None): + def task(): + try: + f = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) + r = f.result() + if on_result: + on_result(r) + except Exception as e: + self.logger.exception("exception in coro scheduled via window.wallet") + self.show_error_signal.emit(str(e)) + self.wallet.thread.add(task) def on_fx_history(self): self.history_model.refresh('fx_history') @@ -409,15 +422,29 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.on_fx_quotes() elif event == 'on_history': self.on_fx_history() + elif event == 'gossip_db_loaded': + self.channels_list.gossip_db_loaded.emit(*args) elif event == 'channels_updated': - self.channels_list.update_rows.emit(*args) + wallet = args[0] + if wallet == self.wallet: + self.channels_list.update_rows.emit(*args) elif event == 'channel': - self.channels_list.update_single_row.emit(*args) - self.update_status() + wallet = args[0] + if wallet == self.wallet: + self.channels_list.update_single_row.emit(*args) + self.update_status() elif event == 'request_status': self.on_request_status(*args) elif event == 'invoice_status': self.on_invoice_status(*args) + elif event == 'payment_succeeded': + wallet = args[0] + if wallet == self.wallet: + self.on_payment_succeeded(*args) + elif event == 'payment_failed': + wallet = args[0] + if wallet == self.wallet: + self.on_payment_failed(*args) elif event == 'status': self.update_status() elif event == 'banner': @@ -430,6 +457,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): pass elif event == 'fee_histogram': self.history_model.on_fee_histogram() + elif event == 'ln_gossip_sync_progress': + self.update_lightning_icon() + elif event == 'cert_mismatch': + self.show_cert_mismatch_error() else: self.logger.info(f"unexpected network event: {event} {args}") @@ -448,14 +479,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def close_wallet(self): if self.wallet: self.logger.info(f'close_wallet {self.wallet.storage.path}') + self.wallet.thread = None run_hook('close_wallet', self.wallet) @profiler def load_wallet(self, wallet): wallet.thread = TaskThread(self, self.on_error) self.update_recently_visited(wallet.storage.path) - if wallet.lnworker and wallet.network: - wallet.network.trigger_callback('channels_updated', wallet) + if wallet.has_lightning(): + util.trigger_callback('channels_updated', wallet) self.need_update.set() # Once GUI has been initialized check if we want to announce something since the callback has been called before the GUI was initialized # update menus @@ -549,20 +581,46 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return self.gui_object.new_window(filename) + def select_backup_dir(self, b): + name = self.config.get('backup_dir', '') + dirname = QFileDialog.getExistingDirectory(self, "Select your wallet backup directory", name) + if dirname: + self.config.set_key('backup_dir', dirname) + self.backup_dir_e.setText(dirname) def backup_wallet(self): - path = self.wallet.storage.path - wallet_folder = os.path.dirname(path) - filename, __ = QFileDialog.getSaveFileName(self, _('Enter a filename for the copy of your wallet'), wallet_folder) - if not filename: + d = WindowModalDialog(self, _("File Backup")) + vbox = QVBoxLayout(d) + grid = QGridLayout() + backup_help = "" + backup_dir = self.config.get('backup_dir') + backup_dir_label = HelpLabel(_('Backup directory') + ':', backup_help) + msg = _('Please select a backup directory') + if self.wallet.has_lightning() and self.wallet.lnworker.channels: + msg += '\n\n' + ' '.join([ + _("Note that lightning channels will be converted to channel backups."), + _("You cannot use channel backups to perform lightning payments."), + _("Channel backups can only be used to request your channels to be closed.") + ]) + self.backup_dir_e = QPushButton(backup_dir) + self.backup_dir_e.clicked.connect(self.select_backup_dir) + grid.addWidget(backup_dir_label, 1, 0) + grid.addWidget(self.backup_dir_e, 1, 1) + vbox.addLayout(grid) + vbox.addWidget(WWLabel(msg)) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + if not d.exec_(): return - new_path = os.path.join(wallet_folder, filename) - if new_path != path: - try: - shutil.copy2(path, new_path) - self.show_message(_("A copy of your wallet file was created in")+" '%s'" % str(new_path), title=_("Wallet backup created")) - except BaseException as reason: - self.show_critical(_("Electrum was unable to copy your wallet file to the specified location.") + "\n" + str(reason), title=_("Unable to create backup")) + try: + new_path = self.wallet.save_backup() + except BaseException as reason: + self.show_critical(_("Electrum was unable to copy your wallet file to the specified location.") + "\n" + str(reason), title=_("Unable to create backup")) + return + if new_path: + msg = _("A copy of your wallet file was created in")+" '%s'" % str(new_path) + self.show_message(msg, title=_("Wallet backup created")) + else: + self.show_message(_("You need to configure a backup directory in your preferences"), title=_("Backup not created")) def update_recently_visited(self, filename): recent = self.config.get('recently_open', []) @@ -604,7 +662,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.recently_visited_menu = file_menu.addMenu(_("&Recently open")) file_menu.addAction(_("&Open"), self.open_wallet).setShortcut(QKeySequence.Open) file_menu.addAction(_("&New/Restore"), self.new_wallet).setShortcut(QKeySequence.New) - file_menu.addAction(_("&Save Copy"), self.backup_wallet).setShortcut(QKeySequence.SaveAs) + file_menu.addAction(_("&Save backup"), self.backup_wallet).setShortcut(QKeySequence.SaveAs) file_menu.addAction(_("Delete"), self.remove_wallet) file_menu.addSeparator() file_menu.addAction(_("&Quit"), self.close) @@ -633,11 +691,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): 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()) + contacts_menu.addAction(_("Import"), lambda: self.import_contacts()) + contacts_menu.addAction(_("Export"), lambda: self.export_contacts()) invoices_menu = wallet_menu.addMenu(_("Invoices")) - invoices_menu.addAction(_("Import"), lambda: self.invoice_list.import_invoices()) - invoices_menu.addAction(_("Export"), lambda: self.invoice_list.export_invoices()) + invoices_menu.addAction(_("Import"), lambda: self.import_invoices()) + invoices_menu.addAction(_("Export"), lambda: self.export_invoices()) + requests_menu = wallet_menu.addMenu(_("Requests")) + requests_menu.addAction(_("Import"), lambda: self.import_requests()) + requests_menu.addAction(_("Export"), lambda: self.export_requests()) wallet_menu.addSeparator() wallet_menu.addAction(_("Find"), self.toggle_search).setShortcut(QKeySequence("Ctrl+F")) @@ -650,15 +711,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): view_menu = menubar.addMenu(_("&View")) add_toggle_action(view_menu, self.addresses_tab) add_toggle_action(view_menu, self.utxo_tab) - if self.wallet.has_lightning(): - add_toggle_action(view_menu, self.channels_tab) + add_toggle_action(view_menu, self.channels_tab) add_toggle_action(view_menu, self.contacts_tab) add_toggle_action(view_menu, self.console_tab) - tools_menu = menubar.addMenu(_("&Tools")) + tools_menu = menubar.addMenu(_("&Tools")) # type: QMenu + preferences_action = tools_menu.addAction(_("Preferences"), self.settings_dialog) # type: QAction + if sys.platform == 'darwin': + # "Settings"/"Preferences" are all reserved keywords in macOS. + # preferences_action will get picked up based on name (and put into a standardized location, + # and given a standard reserved hotkey) + # Hence, this menu item will be at a "uniform location re macOS processes" + preferences_action.setMenuRole(QAction.PreferencesRole) # make sure OS recognizes it as preferences + # Add another preferences item, to also have a "uniform location for Electrum between different OSes" + tools_menu.addAction(_("Electrum preferences"), self.settings_dialog) - # Settings / Preferences are all reserved keywords in macOS using this as work around - tools_menu.addAction(_("Electrum preferences") if sys.platform == 'darwin' else _("Preferences"), self.settings_dialog) tools_menu.addAction(_("&Network"), self.gui_object.show_network_dialog).setEnabled(bool(self.network)) tools_menu.addAction(_("&Lightning Network"), self.gui_object.show_lightning_dialog).setEnabled(bool(self.wallet.has_lightning() and self.network)) tools_menu.addAction(_("Local &Watchtower"), self.gui_object.show_watchtower_dialog).setEnabled(bool(self.network and self.network.local_watchtower)) @@ -693,7 +760,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def donate_to_server(self): d = self.network.get_donation_address() if d: - host = self.network.get_parameters().host + host = self.network.get_parameters().server.host self.pay_to_URI('bitcoin:%s?message=donation for %s'%(d, host)) else: self.show_error(_('No donation address for this server')) @@ -709,7 +776,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): _("Uses icons from the Icons8 icon pack (icons8.com)."))) def show_update_check(self, version=None): - self.gui_object._update_check = UpdateCheck(self, version) + self.gui_object._update_check = UpdateCheck(latest_version=version) def show_report_bug(self): msg = ' '.join([ @@ -772,13 +839,27 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.config.set_key('io_dir', os.path.dirname(fileName), True) return fileName - def getSaveFileName(self, title, filename, filter = ""): + def getSaveFileName(self, title, filename, filter="", + *, default_extension: str = None, + default_filter: str = None) -> Optional[str]: directory = self.config.get('io_dir', os.path.expanduser('~')) - path = os.path.join( directory, filename ) - fileName, __ = QFileDialog.getSaveFileName(self, title, path, filter) - if fileName and directory != os.path.dirname(fileName): - self.config.set_key('io_dir', os.path.dirname(fileName), True) - return fileName + path = os.path.join(directory, filename) + + file_dialog = QFileDialog(self, title, path, filter) + file_dialog.setAcceptMode(QFileDialog.AcceptSave) + if default_extension: + # note: on MacOS, the selected filter's first extension seems to have priority over this... + file_dialog.setDefaultSuffix(default_extension) + if default_filter: + assert default_filter in filter, f"default_filter={default_filter!r} does not appear in filter={filter!r}" + file_dialog.selectNameFilter(default_filter) + if file_dialog.exec() != QDialog.Accepted: + return None + + selected_path = file_dialog.selectedFiles()[0] + if selected_path and directory != os.path.dirname(selected_path): + self.config.set_key('io_dir', os.path.dirname(selected_path), True) + return selected_path def timer_actions(self): self.request_list.refresh_status() @@ -795,21 +876,22 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.notify_transactions() def format_amount(self, x, is_diff=False, whitespaces=False): - return format_satoshis(x, self.num_zeros, self.decimal_point, is_diff=is_diff, whitespaces=whitespaces) + # x is in sats + return self.config.format_amount(x, is_diff=is_diff, whitespaces=whitespaces) def format_amount_and_units(self, amount): - text = self.format_amount(amount) + ' '+ self.base_unit() + # amount is in sats + text = self.config.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): - # fee_rate is in sat/kB - return format_fee_satoshis(fee_rate/1000, num_zeros=self.num_zeros) + ' sat/byte' + return self.config.format_fee_rate(fee_rate) def get_decimal_point(self): - return self.decimal_point + return self.config.get_decimal_point() def base_unit(self): return decimal_point_to_base_unit_name(self.decimal_point) @@ -881,9 +963,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): text += " [%s unconfirmed]"%(self.format_amount(u, is_diff=True).strip()) if x: text += " [%s unmatured]"%(self.format_amount(x, is_diff=True).strip()) - if self.wallet.lnworker: + if self.wallet.has_lightning(): l = self.wallet.lnworker.get_balance() - text += u' \U0001f5f2 %s'%(self.format_amount_and_units(l).strip()) + text += u' \U000026a1 %s'%(self.format_amount_and_units(l).strip()) # append fiat balance and price if self.fx.is_enabled(): @@ -924,7 +1006,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.channels_list.update_rows.emit(wallet) self.update_completions() - def create_channels_tab(self, wallet): + def create_channels_tab(self): self.channels_list = ChannelsList(self) t = self.channels_list.get_toolbar() return self.create_list_tab(self.channels_list, t) @@ -944,10 +1026,23 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): d = address_dialog.AddressDialog(self, addr) d.exec_() + def show_channel(self, channel_id): + from . import channel_details + channel_details.ChannelDetailsDialog(self, channel_id).show() + + + def show_transaction(self, tx, *, tx_desc=None): '''tx_desc is set only for txs created in the Send tab''' show_transaction(tx, parent=self, desc=tx_desc) + def show_lightning_transaction(self, tx_item): + from .lightning_tx_dialog import LightningTxDialog + d = LightningTxDialog(self, tx_item) + d.show() + + + def create_receive_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 @@ -977,7 +1072,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): evl = sorted(pr_expiration_values.items()) evl_keys = [i[0] for i in evl] evl_values = [i[1] for i in evl] - default_expiry = self.config.get('request_expiry', 3600) + default_expiry = self.config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING) try: i = evl_keys.index(default_expiry) except ValueError: @@ -994,7 +1089,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): _('Expired requests have to be deleted manually from your list, in order to free the corresponding LBRY Credits addresses.'), _('The LBRY Credits address never expires and will always be part of this electrum wallet.'), ]) - grid.addWidget(HelpLabel(_('Request expires'), msg), 2, 0) + grid.addWidget(HelpLabel(_('Expires after'), msg), 2, 0) grid.addWidget(self.expires_combo, 2, 1) self.expires_label = QLineEdit('') self.expires_label.setReadOnly(1) @@ -1004,21 +1099,25 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.clear_invoice_button = QPushButton(_('Clear')) self.clear_invoice_button.clicked.connect(self.clear_receive_tab) - self.create_invoice_button = QPushButton(_('On-chain')) + self.create_invoice_button = QPushButton(_('Request')) self.create_invoice_button.setIcon(read_QIcon("bitcoin.png")) + self.create_invoice_button.setToolTip('Create on-chain request') self.create_invoice_button.clicked.connect(lambda: self.create_invoice(False)) self.receive_buttons = buttons = QHBoxLayout() buttons.addStretch(1) buttons.addWidget(self.clear_invoice_button) buttons.addWidget(self.create_invoice_button) if self.wallet.has_lightning(): + self.create_invoice_button.setText(_('On-chain')) self.create_lightning_invoice_button = QPushButton(_('Lightning')) + self.create_lightning_invoice_button.setToolTip('Create lightning request') self.create_lightning_invoice_button.setIcon(read_QIcon("lightning.png")) self.create_lightning_invoice_button.clicked.connect(lambda: self.create_invoice(True)) buttons.addWidget(self.create_lightning_invoice_button) grid.addLayout(buttons, 4, 3, 1, 2) self.receive_payreq_e = ButtonsTextEdit() + self.receive_payreq_e.setFont(QFont(MONOSPACE_FONT)) self.receive_payreq_e.addCopyButton(self.app) self.receive_payreq_e.setReadOnly(True) self.receive_payreq_e.textChanged.connect(self.update_receive_qr) @@ -1029,21 +1128,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.receive_qr.enterEvent = lambda x: self.app.setOverrideCursor(QCursor(Qt.PointingHandCursor)) self.receive_qr.leaveEvent = lambda x: self.app.setOverrideCursor(QCursor(Qt.ArrowCursor)) - def on_receive_address_changed(): - addr = str(self.receive_address_e.text()) - self.receive_address_widgets.setVisible(bool(addr)) - - msg = _('LBRY Credits address where the payment should be received. Note that each payment request uses a different LBRY Credits address.') - receive_address_label = HelpLabel(_('Receiving address'), msg) - self.receive_address_e = ButtonsTextEdit() self.receive_address_e.setFont(QFont(MONOSPACE_FONT)) self.receive_address_e.addCopyButton(self.app) self.receive_address_e.setReadOnly(True) - self.receive_address_e.textChanged.connect(on_receive_address_changed) self.receive_address_e.textChanged.connect(self.update_receive_address_styling) - self.receive_address_e.setMinimumHeight(6 * char_width_in_lineedit()) - self.receive_address_e.setMaximumHeight(10 * char_width_in_lineedit()) + qr_show = lambda: self.show_qrcode(str(self.receive_address_e.text()), _('Receiving address'), parent=self) qr_icon = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png" self.receive_address_e.addButton(qr_icon, qr_show, _("Show as QR code")) @@ -1053,34 +1143,24 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): from .request_list import RequestList self.request_list = RequestList(self) + receive_tabs = QTabWidget() + receive_tabs.addTab(self.receive_address_e, _('Address')) + receive_tabs.addTab(self.receive_payreq_e, _('Request')) + receive_tabs.addTab(self.receive_qr, _('QR Code')) + receive_tabs.setCurrentIndex(self.config.get('receive_tabs_index', 0)) + receive_tabs.currentChanged.connect(lambda i: self.config.set_key('receive_tabs_index', i)) + + + # layout vbox_g = QVBoxLayout() vbox_g.addLayout(grid) vbox_g.addStretch() - receive_tabbed_widgets = QTabWidget() - receive_tabbed_widgets.addTab(self.receive_qr, 'QR Code') - receive_tabbed_widgets.addTab(self.receive_payreq_e, 'Text') - - vbox_receive_address = QVBoxLayout() - vbox_receive_address.setContentsMargins(0, 0, 0, 0) - vbox_receive_address.setSpacing(0) - vbox_receive_address.addWidget(receive_address_label) - vbox_receive_address.addWidget(self.receive_address_e) - self.receive_address_widgets = QWidget() - self.receive_address_widgets.setLayout(vbox_receive_address) - size_policy = self.receive_address_widgets.sizePolicy() - size_policy.setRetainSizeWhenHidden(True) - self.receive_address_widgets.setSizePolicy(size_policy) - - vbox_receive = QVBoxLayout() - vbox_receive.addWidget(receive_tabbed_widgets) - vbox_receive.addWidget(self.receive_address_widgets) - hbox = QHBoxLayout() hbox.addLayout(vbox_g) hbox.addStretch() - hbox.addLayout(vbox_receive) + hbox.addWidget(receive_tabs) w = QWidget() w.searchable_list = self.request_list @@ -1092,12 +1172,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): vbox.addWidget(self.request_list) vbox.setStretchFactor(self.request_list, 1000) - on_receive_address_changed() - return w - def delete_request(self, key): - self.wallet.delete_request(key) + def delete_requests(self, keys): + for key in keys: + self.wallet.delete_request(key) self.request_list.update() self.clear_receive_tab() @@ -1109,7 +1188,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def sign_payment_request(self, addr): alias = self.config.get('alias') - alias_privkey = None if alias and self.alias_info: alias_addr, alias_name, validated = self.alias_info if alias_addr: @@ -1131,32 +1209,44 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def create_invoice(self, is_lightning): amount = self.receive_amount_e.get_amount() message = self.receive_message_e.text() - expiry = self.config.get('request_expiry', 3600) + expiry = self.config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING) if is_lightning: key = self.wallet.lnworker.add_request(amount, message, expiry) else: key = self.create_bitcoin_request(amount, message, expiry) + if not key: + return self.address_list.update() + assert key is not None self.request_list.update() self.request_list.select_key(key) # clear request fields self.receive_amount_e.setText('') self.receive_message_e.setText('') - def create_bitcoin_request(self, amount, message, expiration): + # copy to clipboard + r = self.wallet.get_request(key) + content = r.invoice if r.is_lightning() else r.get_address() + title = _('Invoice') if is_lightning else _('Address') + self.do_copy(content, title=title) + + def create_bitcoin_request(self, amount, message, expiration) -> Optional[str]: addr = self.wallet.get_unused_address() if addr is None: - if not self.wallet.is_deterministic(): + if not self.wallet.is_deterministic(): # imported wallet msg = [ - _('No more addresses in your wallet.'), - _('You are using a non-deterministic wallet, which cannot create new addresses.'), - _('If you want to create new addresses, use a deterministic wallet instead.') + _('No more addresses in your wallet.'), ' ', + _('You are using a non-deterministic wallet, which cannot create new addresses.'), ' ', + _('If you want to create new addresses, use a deterministic wallet instead.'), '\n\n', + _('Creating a new payment request will reuse one of your addresses and overwrite an existing request. Continue anyway?'), ] - self.show_message(' '.join(msg)) - return - if not self.question(_("Warning: The next address will not be recovered automatically if you restore your wallet from seed; you may need to add it manually.\n\nThis occurs because you have too many unused addresses in your wallet. To avoid this situation, use the existing addresses first.\n\nCreate anyway?")): - return - addr = self.wallet.create_new_address(False) + if not self.question(''.join(msg)): + return + addr = self.wallet.get_receiving_address() + else: # deterministic wallet + if not self.question(_("Warning: The next address will not be recovered automatically if you restore your wallet from seed; you may need to add it manually.\n\nThis occurs because you have too many unused addresses in your wallet. To avoid this situation, use the existing addresses first.\n\nCreate anyway?")): + return + addr = self.wallet.create_new_address(False) req = self.wallet.make_payment_request(addr, amount, message, expiration) try: self.wallet.add_payment_request(req) @@ -1175,16 +1265,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): tooltip_text = _("{} copied to clipboard").format(title) QToolTip.showText(QCursor.pos(), tooltip_text, self) - def export_payment_request(self, addr): - r = self.wallet.receive_requests.get(addr) - pr = paymentrequest.serialize_request(r).SerializeToString() - name = r['id'] + '.bip70' - fileName = self.getSaveFileName(_("Select where to save your payment request"), name, "*.bip70") - if fileName: - with open(fileName, "wb+") as f: - f.write(util.to_bytes(pr)) - self.show_message(_("Request saved successfully")) - self.saved = True def clear_receive_tab(self): self.receive_payreq_e.setText('') @@ -1193,6 +1273,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.receive_amount_e.setAmount(None) self.expires_label.hide() self.expires_combo.show() + self.request_list.clearSelection() def toggle_qr_window(self): from . import qrwindow @@ -1416,10 +1497,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return False # no errors - def pay_lightning_invoice(self, invoice, amount_sat=None): + def pay_lightning_invoice(self, invoice: str, *, amount_msat: Optional[int]): + if amount_msat is None: + raise Exception("missing amount for LN invoice") + amount_sat = Decimal(amount_msat) / 1000 + # FIXME this is currently lying to user as we truncate to satoshis + msg = _("Pay lightning invoice?") + '\n\n' + _("This will send {}?").format(self.format_amount_and_units(amount_sat)) + if not self.question(msg): + return attempts = LN_NUM_PAYMENT_ATTEMPTS def task(): - self.wallet.lnworker.pay(invoice, amount_sat, attempts) + self.wallet.lnworker.pay(invoice, amount_msat=amount_msat, attempts=attempts) self.do_clear() self.wallet.thread.add(task) self.invoice_list.update() @@ -1431,43 +1519,49 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.notify(_('Payment received') + '\n' + key) self.need_update.set() - def on_invoice_status(self, key, status): - if key not in self.wallet.invoices: + def on_invoice_status(self, key): + req = self.wallet.get_invoice(key) + if req is None: return - self.invoice_list.update_item(key, status) - if status == PR_PAID: - self.show_message(_('Payment succeeded')) - self.need_update.set() - elif status == PR_FAILED: - self.show_error(_('Payment failed')) - else: - pass + self.invoice_list.update_item(key, req) + + def on_payment_succeeded(self, wallet, key): + description = self.wallet.get_label(key) + self.notify(_('Payment succeeded') + '\n\n' + description) + self.need_update.set() + + def on_payment_failed(self, wallet, key, reason): + self.show_error(_('Payment failed') + '\n\n' + reason) def read_invoice(self): if self.check_send_tab_payto_line_and_show_errors(): return if not self._is_onchain: - invoice = self.payto_e.lightning_invoice - if not invoice: + invoice_str = self.payto_e.lightning_invoice + if not invoice_str: return - if not self.wallet.lnworker: + if not self.wallet.has_lightning(): self.show_error(_('Lightning is disabled')) return - invoice_dict = self.wallet.lnworker.parse_bech32_invoice(invoice) - if invoice_dict.get('amount') is None: - amount = self.amount_e.get_amount() - if amount: - invoice_dict['amount'] = amount + invoice = LNInvoice.from_bech32(invoice_str) + if invoice.get_amount_msat() is None: + amount_sat = self.amount_e.get_amount() + if amount_sat: + invoice.amount_msat = int(amount_sat * 1000) else: self.show_error(_('No amount')) return - return invoice_dict + return invoice else: outputs = self.read_outputs() if self.check_send_tab_onchain_outputs_and_show_errors(outputs): return message = self.message_e.text() - return self.wallet.create_invoice(outputs, message, self.payment_request, self.payto_URI) + return self.wallet.create_invoice( + outputs=outputs, + message=message, + pr=self.payment_request, + URI=self.payto_URI) def do_save_invoice(self): invoice = self.read_invoice() @@ -1489,15 +1583,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def pay_multiple_invoices(self, invoices): outputs = [] for invoice in invoices: - outputs += invoice['outputs'] + outputs += invoice.outputs self.pay_onchain_dialog(self.get_coins(), outputs) - def do_pay_invoice(self, invoice): - if invoice['type'] == PR_TYPE_LN: - self.pay_lightning_invoice(invoice['invoice'], amount_sat=invoice['amount']) - elif invoice['type'] == PR_TYPE_ONCHAIN: - outputs = invoice['outputs'] - self.pay_onchain_dialog(self.get_coins(), outputs) + def do_pay_invoice(self, invoice: 'Invoice'): + if invoice.type == PR_TYPE_LN: + assert isinstance(invoice, LNInvoice) + self.pay_lightning_invoice(invoice.invoice, amount_msat=invoice.get_amount_msat()) + elif invoice.type == PR_TYPE_ONCHAIN: + assert isinstance(invoice, OnchainInvoice) + self.pay_onchain_dialog(self.get_coins(), invoice.outputs) else: raise Exception('unknown invoice type') @@ -1531,16 +1626,22 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if output_values.count('!') > 1: self.show_error(_("More than one output set to spend max")) return + + output_value = '!' if '!' in output_values else sum(output_values) + d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, is_sweep=is_sweep) + if d.not_enough_funds: + # Check if we had enough funds excluding fees, + # if so, still provide opportunity to set lower fees. + if not d.have_enough_funds_assuming_zero_fees(): + self.show_message(_('Not Enough Funds')) + return + + # shortcut to advanced preview (after "enough funds" check!) if self.config.get('advanced_preview'): self.preview_tx_dialog(make_tx=make_tx, external_keypairs=external_keypairs) return - output_value = '!' if '!' in output_values else sum(output_values) - d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, is_sweep=is_sweep) - if d.not_enough_funds: - self.show_message(_('Not Enough Funds')) - return cancelled, is_send, password, tx = d.run() if cancelled: return @@ -1646,6 +1747,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): # however, the user must not be allowed to broadcast early make_tx = self.mktx_for_open_channel(funding_sat) d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=funding_sat, is_sweep=False) + d.preview_button.setEnabled(False) cancelled, is_send, password, funding_tx = d.run() if not is_send: return @@ -1675,7 +1777,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def on_failure(exc_info): type_, e, traceback = exc_info - self.show_error(_('Could not open channel: {}').format(e)) + self.show_error(_('Could not open channel: {}').format(repr(e))) WaitingDialog(self, _('Opening channel...'), task, on_success, on_failure) def query_choice(self, msg, choices): @@ -1702,8 +1804,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.payto_e.setText(_("please wait...")) return True - def delete_invoice(self, key): - self.wallet.delete_invoice(key) + def delete_invoices(self, keys): + for key in keys: + self.wallet.delete_invoice(key) self.invoice_list.update() def payment_request_ok(self): @@ -1712,7 +1815,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return key = pr.get_id() invoice = self.wallet.get_invoice(key) - if invoice and invoice['status'] == PR_PAID: + if invoice and self.wallet.get_invoice_status(invoice) == PR_PAID: self.show_message("invoice already paid") self.do_clear() self.payment_request = None @@ -1723,7 +1826,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): else: self.payto_e.setExpired() self.payto_e.setText(pr.get_requestor()) - self.amount_e.setText(format_satoshis_plain(pr.get_amount(), self.decimal_point)) + self.amount_e.setAmount(pr.get_amount()) self.message_e.setText(pr.get_memo()) # signal to set fee self.amount_e.textEdited.emit("") @@ -1746,7 +1849,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def parse_lightning_invoice(self, invoice): """Parse ln invoice, and prepare the send tab for it.""" - from electrum.lnaddr import lndecode, LnDecodeException + try: lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) except Exception as e: @@ -1761,8 +1864,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.payto_e.setFrozen(True) self.payto_e.setText(pubkey) self.message_e.setText(description) - if lnaddr.amount is not None: - self.amount_e.setAmount(lnaddr.amount * COIN) + if lnaddr.get_amount_sat() is not None: + self.amount_e.setAmount(lnaddr.get_amount_sat()) #self.amount_e.textEdited.emit("") self.set_onchain(False) @@ -1854,8 +1957,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return self.create_list_tab(l) def remove_address(self, addr): - if self.question(_("Do you want to remove {} from your wallet?").format(addr)): + if not self.question(_("Do you want to remove {} from your wallet?").format(addr)): + return + try: self.wallet.delete_address(addr) + except UserFacingException as e: + self.show_error(str(e)) + else: self.need_update.set() # history, addresses, coins self.clear_receive_tab() @@ -1902,16 +2010,48 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.contact_list.update() self.update_completions() - def show_invoice(self, key): - invoice = self.wallet.get_invoice(key) - if invoice is None: - self.show_error('Cannot find payment request in wallet.') - return - bip70 = invoice.get('bip70') - if bip70: - pr = paymentrequest.PaymentRequest(bytes.fromhex(bip70)) + def show_onchain_invoice(self, invoice: OnchainInvoice): + amount_str = self.format_amount(invoice.amount_sat) + ' ' + self.base_unit() + d = WindowModalDialog(self, _("Onchain Invoice")) + vbox = QVBoxLayout(d) + grid = QGridLayout() + grid.addWidget(QLabel(_("Amount") + ':'), 1, 0) + grid.addWidget(QLabel(amount_str), 1, 1) + if len(invoice.outputs) == 1: + grid.addWidget(QLabel(_("Address") + ':'), 2, 0) + grid.addWidget(QLabel(invoice.get_address()), 2, 1) + else: + outputs_str = '\n'.join(map(lambda x: x.address + ' : ' + self.format_amount(x.value)+ self.base_unit(), invoice.outputs)) + grid.addWidget(QLabel(_("Outputs") + ':'), 2, 0) + grid.addWidget(QLabel(outputs_str), 2, 1) + grid.addWidget(QLabel(_("Description") + ':'), 3, 0) + grid.addWidget(QLabel(invoice.message), 3, 1) + if invoice.exp: + grid.addWidget(QLabel(_("Expires") + ':'), 4, 0) + grid.addWidget(QLabel(format_time(invoice.exp + invoice.time)), 4, 1) + if invoice.bip70: + pr = paymentrequest.PaymentRequest(bytes.fromhex(invoice.bip70)) pr.verify(self.contacts) - self.show_bip70_details(pr) + grid.addWidget(QLabel(_("Requestor") + ':'), 5, 0) + grid.addWidget(QLabel(pr.get_requestor()), 5, 1) + grid.addWidget(QLabel(_("Signature") + ':'), 6, 0) + grid.addWidget(QLabel(pr.get_verify_status()), 6, 1) + def do_export(): + key = pr.get_id() + name = str(key) + '.bip70' + fn = self.getSaveFileName(_("Save invoice to file"), name, filter="*.bip70") + if not fn: + return + with open(fn, 'wb') as f: + data = f.write(pr.raw) + self.show_message(_('BIP70 invoice saved as' + ' ' + fn)) + exportButton = EnterButton(_('Export'), do_export) + buttons = Buttons(exportButton, CloseButton(d)) + else: + buttons = Buttons(CloseButton(d)) + vbox.addLayout(grid) + vbox.addLayout(buttons) + d.exec_() def show_bip70_details(self, pr: 'paymentrequest.PaymentRequest'): key = pr.get_id()