mirror of
https://github.com/LBRYFoundation/LBRY-Vault.git
synced 2025-08-23 17:47:31 +00:00
Update and Bugfix
This commit is contained in:
parent
5716e49a35
commit
024a61ec56
76 changed files with 3841 additions and 1848 deletions
46
.travis.yml
46
.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
|
||||
|
|
44
README.rst
44
README.rst
|
@ -8,25 +8,45 @@ 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`.
|
||||
|
|
|
@ -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 && \
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
17
contrib/build-linux/appimage/sdist/Dockerfile
Normal file
17
contrib/build-linux/appimage/sdist/Dockerfile
Normal file
|
@ -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
|
52
contrib/build-linux/appimage/sdist/README.md
Normal file
52
contrib/build-linux/appimage/sdist/README.md
Normal file
|
@ -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`.
|
30
contrib/build-linux/appimage/sdist/build.sh
Normal file
30
contrib/build-linux/appimage/sdist/build.sh
Normal file
|
@ -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"/*
|
47
contrib/build-linux/appimage/sdist/make_tgz
Normal file
47
contrib/build-linux/appimage/sdist/make_tgz
Normal file
|
@ -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
|
||||
)
|
|
@ -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 \
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" \
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
17
contrib/deterministic-build/requirements-mac-build.txt
Normal file
17
contrib/deterministic-build/requirements-mac-build.txt
Normal file
|
@ -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
|
9
contrib/deterministic-build/requirements-sdist-build.txt
Normal file
9
contrib/deterministic-build/requirements-sdist-build.txt
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
}
|
23
contrib/osx/entitlements.plist
Normal file
23
contrib/osx/entitlements.plist
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- These are required for binaries built by PyInstaller -->
|
||||
<!-- see pyinstaller/pyinstaller#4629 -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
|
||||
<!-- These are required for USB HID access (hw wallets). -->
|
||||
<!-- see https://github.com/Electron-Cash/Electron-Cash/commit/5abec73eee0cdeb725e3c5a989621ec4ccfb92a0 -->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
<!-- Camera access, to read QR codes -->
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -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
|
||||
|
|
77
contrib/osx/notarize_app.sh
Normal file
77
contrib/osx/notarize_app.sh
Normal file
|
@ -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"
|
|
@ -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'
|
||||
},
|
||||
)
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
PyQt5<5.12
|
||||
PyQt5-sip<=4.19.13
|
||||
PyQt5<5.15
|
||||
pycryptodomex>=3.7
|
||||
|
|
|
@ -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
|
||||
|
|
6
contrib/requirements/requirements-mac-build.txt
Normal file
6
contrib/requirements/requirements-mac-build.txt
Normal file
|
@ -0,0 +1,6 @@
|
|||
pip
|
||||
setuptools
|
||||
pyinstaller>=3.6
|
||||
|
||||
# needed by pyinstaller:
|
||||
macholib
|
3
contrib/requirements/requirements-sdist-build.txt
Normal file
3
contrib/requirements/requirements-sdist-build.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# need modern versions of pip (and maybe other build tools), the one in apt had issues
|
||||
pip
|
||||
setuptools
|
|
@ -1,3 +1,3 @@
|
|||
tox
|
||||
python-coveralls
|
||||
coveralls
|
||||
tox-travis
|
||||
|
|
|
@ -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
|
||||
|
|
1
contrib/udev/53-hid-bitbox02.rules
Normal file
1
contrib/udev/53-hid-bitbox02.rules
Normal file
|
@ -0,0 +1 @@
|
|||
SUBSYSTEM=="usb", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02_%n", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403"
|
1
contrib/udev/54-hid-bitbox02.rules
Normal file
1
contrib/udev/54-hid-bitbox02.rules
Normal file
|
@ -0,0 +1 @@
|
|||
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02-%n"
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
75
electrum/bip39_recovery.py
Normal file
75
electrum/bip39_recovery.py
Normal file
|
@ -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"],
|
||||
}
|
80
electrum/bip39_wallet_formats.json
Normal file
80
electrum/bip39_wallet_formats.json
Normal file
|
@ -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
|
||||
}
|
||||
]
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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...
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
73
electrum/gui/qt/bip39_recovery_dialog.py
Normal file
73
electrum/gui/qt/bip39_recovery_dialog.py
Normal file
|
@ -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)
|
|
@ -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'<a href=click_destination>{chan.funding_outpoint.txid}</a>:{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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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_())
|
||||
|
|
|
@ -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)
|
||||
|
|
94
electrum/gui/qt/custom_model.py
Normal file
94
electrum/gui/qt/custom_model.py
Normal file
|
@ -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)
|
|
@ -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:') + '<br/>' +
|
||||
repr(e)[:120] + '<br/>' +
|
||||
repr(e)[:120] + '<br/><br/>' +
|
||||
_("Please report this issue manually") +
|
||||
f' <a href="{constants.GIT_REPO_ISSUES_URL}">on GitHub</a>.'),
|
||||
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)
|
||||
|
|
|
@ -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):
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Reference in a new issue